mirror of https://github.com/djteang/OrangeTV.git
added:添加内置主题,支持用户自定义CSS
changed:优化搜索页面缓存机制 fixed:镜像健康检查问题,弹幕功能适配移动端
This commit is contained in:
parent
c1f86270ec
commit
167d328116
10
CHANGELOG
10
CHANGELOG
|
|
@ -1,3 +1,13 @@
|
|||
## [8.9.5] - 2025-09-21
|
||||
|
||||
### Added
|
||||
- 添加内置主题,支持用户自定义CSS
|
||||
### Changed
|
||||
- 优化搜索页面缓存机制
|
||||
### Fixed
|
||||
- 镜像健康检查问题
|
||||
- 弹幕功能适配移动端
|
||||
|
||||
## [8.9.0] - 2025-09-15
|
||||
|
||||
### Added
|
||||
|
|
|
|||
38
Dockerfile
38
Dockerfile
|
|
@ -95,9 +95,45 @@ USER nextjs
|
|||
# 暴露HTTP和WebSocket端口
|
||||
EXPOSE 3000 3001
|
||||
|
||||
# 创建健康检查脚本
|
||||
RUN echo '#!/usr/bin/env node\n\
|
||||
const http = require("http");\n\
|
||||
const options = {\n\
|
||||
hostname: "localhost",\n\
|
||||
port: 3000,\n\
|
||||
path: "/api/health",\n\
|
||||
method: "GET",\n\
|
||||
timeout: 5000\n\
|
||||
};\n\
|
||||
\n\
|
||||
const req = http.request(options, (res) => {\n\
|
||||
if (res.statusCode === 200) {\n\
|
||||
console.log("Health check passed");\n\
|
||||
process.exit(0);\n\
|
||||
} else {\n\
|
||||
console.log(`Health check failed with status: ${res.statusCode}`);\n\
|
||||
process.exit(1);\n\
|
||||
}\n\
|
||||
});\n\
|
||||
\n\
|
||||
req.on("error", (err) => {\n\
|
||||
console.log(`Health check error: ${err.message}`);\n\
|
||||
process.exit(1);\n\
|
||||
});\n\
|
||||
\n\
|
||||
req.on("timeout", () => {\n\
|
||||
console.log("Health check timeout");\n\
|
||||
req.destroy();\n\
|
||||
process.exit(1);\n\
|
||||
});\n\
|
||||
\n\
|
||||
req.setTimeout(5000);\n\
|
||||
req.end();' > /app/healthcheck.js && \
|
||||
chmod +x /app/healthcheck.js
|
||||
|
||||
# 添加健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/api/health || exit 1
|
||||
CMD node /app/healthcheck.js
|
||||
|
||||
# 设置WebSocket端口环境变量
|
||||
ENV WS_PORT=3001
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
8.9.0
|
||||
8.9.5
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// 主题初始化脚本 - 立即执行,避免主题闪烁
|
||||
(function () {
|
||||
try {
|
||||
// 应用主题函数
|
||||
function applyTheme(themeId, css) {
|
||||
const html = document.documentElement;
|
||||
|
||||
// 移除所有主题属性
|
||||
html.removeAttribute('data-theme');
|
||||
|
||||
// 应用主题
|
||||
if (themeId !== 'default') {
|
||||
html.setAttribute('data-theme', themeId);
|
||||
}
|
||||
|
||||
// 应用自定义CSS
|
||||
if (css) {
|
||||
let customStyleEl = document.getElementById('init-theme-css');
|
||||
if (!customStyleEl) {
|
||||
customStyleEl = document.createElement('style');
|
||||
customStyleEl.id = 'init-theme-css';
|
||||
document.head.appendChild(customStyleEl);
|
||||
}
|
||||
customStyleEl.textContent = css;
|
||||
}
|
||||
}
|
||||
|
||||
// 从localStorage获取保存的主题
|
||||
const savedTheme = localStorage.getItem('app-theme');
|
||||
const savedCustomCSS = localStorage.getItem('app-custom-css') || '';
|
||||
|
||||
// 立即应用已保存的主题(如果有)
|
||||
if (savedTheme) {
|
||||
applyTheme(savedTheme, savedCustomCSS);
|
||||
console.log('主题已初始化(本地设置):', savedTheme);
|
||||
} else {
|
||||
// 没有用户设置时,先应用默认主题
|
||||
applyTheme('default', '');
|
||||
console.log('主题已初始化(默认)');
|
||||
}
|
||||
|
||||
// 注意:GlobalThemeLoader会在React组件挂载后进一步处理全站配置
|
||||
} catch (error) {
|
||||
console.error('主题初始化失败:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ import {
|
|||
Users,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { GripVertical, Palette } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
|
@ -47,6 +47,7 @@ import { AdminConfig, AdminConfigResult } from '../../lib/admin.types';
|
|||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
import DataMigration from '@/components/DataMigration';
|
||||
import ThemeManager from '@/components/ThemeManager';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
|
||||
// 统一按钮样式系统
|
||||
|
|
@ -5210,6 +5211,7 @@ function AdminPageClient() {
|
|||
categoryConfig: false,
|
||||
configFile: false,
|
||||
dataMigration: false,
|
||||
themeManager: false,
|
||||
});
|
||||
|
||||
// 机器码管理状态
|
||||
|
|
@ -5447,6 +5449,21 @@ function AdminPageClient() {
|
|||
<DataMigration onRefreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
)}
|
||||
|
||||
{/* 主题定制标签 */}
|
||||
<CollapsibleTab
|
||||
title='主题定制'
|
||||
icon={
|
||||
<Palette
|
||||
size={20}
|
||||
className='text-gray-600 dark:text-gray-400'
|
||||
/>
|
||||
}
|
||||
isExpanded={expandedTabs.themeManager}
|
||||
onToggle={() => toggleTab('themeManager')}
|
||||
>
|
||||
<ThemeManager showAlert={showAlert} role={role} />
|
||||
</CollapsibleTab>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export async function POST(request: NextRequest) {
|
|||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
RequireDeviceCode,
|
||||
CustomTheme,
|
||||
} = body as {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
|
|
@ -52,6 +53,10 @@ export async function POST(request: NextRequest) {
|
|||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
RequireDeviceCode: boolean;
|
||||
CustomTheme?: {
|
||||
selectedTheme: string;
|
||||
customCSS: string;
|
||||
};
|
||||
};
|
||||
|
||||
// 参数校验
|
||||
|
|
@ -66,7 +71,11 @@ export async function POST(request: NextRequest) {
|
|||
typeof DoubanImageProxy !== 'string' ||
|
||||
typeof DisableYellowFilter !== 'boolean' ||
|
||||
typeof FluidSearch !== 'boolean' ||
|
||||
typeof RequireDeviceCode !== 'boolean'
|
||||
typeof RequireDeviceCode !== 'boolean' ||
|
||||
(CustomTheme && (
|
||||
typeof CustomTheme.selectedTheme !== 'string' ||
|
||||
typeof CustomTheme.customCSS !== 'string'
|
||||
))
|
||||
) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
|
@ -97,6 +106,7 @@ export async function POST(request: NextRequest) {
|
|||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
RequireDeviceCode,
|
||||
CustomTheme,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { AdminConfig } from '@/lib/admin.types';
|
||||
import { headers, cookies } from 'next/headers';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 创建一个模拟的NextRequest对象来使用getAuthInfoFromCookie
|
||||
const cookieStore = cookies();
|
||||
const authCookie = cookieStore.get('auth');
|
||||
|
||||
if (!authCookie) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
let authData;
|
||||
try {
|
||||
const decoded = decodeURIComponent(authCookie.value);
|
||||
authData = JSON.parse(decoded);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '认证信息无效' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await db.getAdminConfig();
|
||||
const themeConfig = config?.ThemeConfig || {
|
||||
defaultTheme: 'default' as const,
|
||||
customCSS: '',
|
||||
allowUserCustomization: true,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: themeConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取主题配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取主题配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// 获取认证信息
|
||||
const cookieStore = cookies();
|
||||
const authCookie = cookieStore.get('auth');
|
||||
|
||||
if (!authCookie) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
let authData;
|
||||
try {
|
||||
const decoded = decodeURIComponent(authCookie.value);
|
||||
authData = JSON.parse(decoded);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '认证信息无效' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
if (authData.role !== 'admin' && authData.role !== 'owner') {
|
||||
return NextResponse.json({ error: '权限不足,仅管理员可设置全局主题' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { defaultTheme, customCSS, allowUserCustomization } = body;
|
||||
|
||||
// 验证主题名称
|
||||
const validThemes = ['default', 'minimal', 'warm', 'fresh'];
|
||||
if (!validThemes.includes(defaultTheme)) {
|
||||
return NextResponse.json({ error: '无效的主题名称' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取当前配置
|
||||
const currentConfig = await db.getAdminConfig();
|
||||
|
||||
// 如果没有配置,创建一个基础配置
|
||||
let baseConfig: AdminConfig;
|
||||
if (!currentConfig) {
|
||||
baseConfig = {
|
||||
ConfigSubscribtion: {
|
||||
URL: "",
|
||||
AutoUpdate: false,
|
||||
LastCheck: "",
|
||||
},
|
||||
ConfigFile: "",
|
||||
SiteConfig: {
|
||||
SiteName: "OrangeTV",
|
||||
Announcement: "",
|
||||
SearchDownstreamMaxPage: 10,
|
||||
SiteInterfaceCacheTime: 30,
|
||||
DoubanProxyType: "direct",
|
||||
DoubanProxy: "",
|
||||
DoubanImageProxyType: "direct",
|
||||
DoubanImageProxy: "",
|
||||
DisableYellowFilter: false,
|
||||
FluidSearch: true,
|
||||
RequireDeviceCode: false,
|
||||
},
|
||||
UserConfig: {
|
||||
Users: [],
|
||||
},
|
||||
SourceConfig: [],
|
||||
CustomCategories: [],
|
||||
};
|
||||
} else {
|
||||
baseConfig = currentConfig;
|
||||
}
|
||||
|
||||
// 更新主题配置
|
||||
const updatedConfig: AdminConfig = {
|
||||
...baseConfig,
|
||||
ThemeConfig: {
|
||||
defaultTheme: defaultTheme as 'default' | 'minimal' | 'warm' | 'fresh',
|
||||
customCSS: customCSS || '',
|
||||
allowUserCustomization: allowUserCustomization !== false,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('保存主题配置:', updatedConfig.ThemeConfig);
|
||||
await db.saveAdminConfig(updatedConfig);
|
||||
console.log('主题配置保存成功');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '主题配置已更新',
|
||||
data: updatedConfig.ThemeConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新主题配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '更新主题配置失败', details: error instanceof Error ? error.message : '未知错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const config = await db.getAdminConfig();
|
||||
const themeConfig = config?.ThemeConfig || {
|
||||
defaultTheme: 'default' as const,
|
||||
customCSS: '',
|
||||
allowUserCustomization: true,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: themeConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取主题配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
data: {
|
||||
defaultTheme: 'default' as const,
|
||||
customCSS: '',
|
||||
allowUserCustomization: true,
|
||||
}
|
||||
},
|
||||
{ status: 200 } // 返回默认配置而不是错误
|
||||
);
|
||||
}
|
||||
}
|
||||
1376
src/app/globals.css
1376
src/app/globals.css
File diff suppressed because it is too large
Load Diff
|
|
@ -11,6 +11,7 @@ import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
|||
import { SiteProvider } from '../components/SiteProvider';
|
||||
import { ThemeProvider } from '../components/ThemeProvider';
|
||||
import { ToastProvider } from '../components/Toast';
|
||||
import GlobalThemeLoader from '../components/GlobalThemeLoader';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -110,6 +111,9 @@ export default async function RootLayout({
|
|||
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 主题初始化脚本 - 立即执行避免主题闪烁 */}
|
||||
<script src="/theme-init.js" />
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
|
||||
|
|
@ -122,6 +126,7 @@ export default async function RootLayout({
|
|||
>
|
||||
<ToastProvider>
|
||||
<SiteProvider siteName={siteName} announcement={announcement}>
|
||||
<GlobalThemeLoader />
|
||||
{children}
|
||||
<GlobalErrorIndicator />
|
||||
</SiteProvider>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import MachineCode from '@/lib/machine-code';
|
|||
|
||||
import { useSite } from '@/components/SiteProvider';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import GlobalThemeLoader from '@/components/GlobalThemeLoader';
|
||||
|
||||
// 版本显示组件
|
||||
function VersionDisplay() {
|
||||
|
|
@ -195,6 +196,7 @@ function LoginPageClient() {
|
|||
|
||||
return (
|
||||
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
|
||||
<GlobalThemeLoader />
|
||||
<div className='absolute top-4 right-4'>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { DoubanItem } from '@/lib/types';
|
|||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import ContinueWatching from '@/components/ContinueWatching';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import { useThemeInit } from '@/hooks/useTheme';
|
||||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
import { useSite } from '@/components/SiteProvider';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
|
@ -515,6 +516,8 @@ function HomeClient() {
|
|||
}
|
||||
|
||||
export default function Home() {
|
||||
// 初始化主题
|
||||
useThemeInit();
|
||||
return (
|
||||
<Suspense>
|
||||
<HomeClient />
|
||||
|
|
|
|||
|
|
@ -1797,6 +1797,37 @@ function PlayPageClient() {
|
|||
typeof window !== 'undefined' &&
|
||||
typeof (window as any).webkitConvertPointFromNodeToPage === 'function';
|
||||
|
||||
// 检测是否为移动端设备
|
||||
const isMobile = typeof window !== 'undefined' && (
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
window.innerWidth <= 768
|
||||
);
|
||||
|
||||
// 根据设备类型调整弹幕配置
|
||||
const getDanmuConfig = () => {
|
||||
if (isMobile) {
|
||||
return {
|
||||
fontSize: 20, // 移动端字体稍小
|
||||
margin: [5, '20%'], // 移动端边距更小
|
||||
minWidth: 150, // 移动端最小宽度更小
|
||||
maxWidth: 300, // 移动端最大宽度限制
|
||||
maxlength: 30, // 移动端字符长度限制
|
||||
placeholder: '发弹幕~', // 移动端简化提示文字
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
fontSize: 25, // 桌面端正常字体
|
||||
margin: [10, '25%'], // 桌面端正常边距
|
||||
minWidth: 200, // 桌面端最小宽度
|
||||
maxWidth: 500, // 桌面端最大宽度
|
||||
maxlength: 50, // 桌面端字符长度
|
||||
placeholder: '发个弹幕呗~', // 桌面端完整提示文字
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const danmuConfig = getDanmuConfig();
|
||||
|
||||
// 非WebKit浏览器且播放器已存在,使用switch方法切换
|
||||
if (!isWebkit && artPlayerRef.current) {
|
||||
artPlayerRef.current.switch = videoUrl;
|
||||
|
|
@ -1977,26 +2008,54 @@ function PlayPageClient() {
|
|||
return [];
|
||||
}
|
||||
},
|
||||
speed: 5,
|
||||
speed: isMobile ? 4 : 5, // 移动端弹幕速度稍慢
|
||||
opacity: 1,
|
||||
fontSize: 25,
|
||||
fontSize: danmuConfig.fontSize,
|
||||
color: '#FFFFFF',
|
||||
mode: 0,
|
||||
margin: [10, '25%'],
|
||||
margin: danmuConfig.margin,
|
||||
antiOverlap: true,
|
||||
useWorker: true,
|
||||
synchronousPlayback: false,
|
||||
filter: (danmu: any) => danmu.text.length < 50,
|
||||
lockTime: 5,
|
||||
maxLength: 100,
|
||||
minWidth: 200,
|
||||
maxWidth: 500,
|
||||
filter: (danmu: any) => danmu.text.length < (isMobile ? 30 : 50),
|
||||
lockTime: isMobile ? 3 : 5, // 移动端锁定时间更短
|
||||
maxLength: isMobile ? 80 : 100, // 移动端最大长度限制
|
||||
minWidth: danmuConfig.minWidth,
|
||||
maxWidth: danmuConfig.maxWidth,
|
||||
theme: 'dark',
|
||||
// 核心配置:启用弹幕发送功能
|
||||
panel: true, // 启用弹幕输入面板
|
||||
emit: true, // 启用弹幕发送
|
||||
placeholder: '发个弹幕呗~',
|
||||
maxlength: 50,
|
||||
placeholder: danmuConfig.placeholder,
|
||||
maxlength: danmuConfig.maxlength,
|
||||
// 移动端专用配置
|
||||
...(isMobile && {
|
||||
panelStyle: {
|
||||
fontSize: '14px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '20px',
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
color: '#ffffff',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
maxWidth: '280px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
buttonStyle: {
|
||||
fontSize: '12px',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
background: 'linear-gradient(45deg, #3b82f6, #1d4ed8)',
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '8px',
|
||||
minWidth: '50px',
|
||||
outline: 'none',
|
||||
},
|
||||
}),
|
||||
beforeVisible: (danmu: any) => {
|
||||
return !danmu.text.includes('广告');
|
||||
},
|
||||
|
|
@ -2021,7 +2080,7 @@ function PlayPageClient() {
|
|||
mode: danmu.mode || 0,
|
||||
time: (artPlayerRef.current?.currentTime || 0) + 0.5,
|
||||
border: false,
|
||||
size: 25
|
||||
size: isMobile ? 18 : 25 // 移动端弹幕字体更小
|
||||
};
|
||||
|
||||
// 手动触发弹幕显示(如果beforeEmit的返回值不能正常显示)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,229 @@ function SearchPageClient() {
|
|||
const groupRefs = useRef<Map<string, React.RefObject<VideoCardHandle>>>(new Map());
|
||||
const groupStatsRef = useRef<Map<string, { douban_id?: number; episodes?: number; source_names: string[] }>>(new Map());
|
||||
|
||||
// 执行搜索的通用函数
|
||||
const performSearch = (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// 更新搜索查询和状态
|
||||
setSearchQuery(trimmed);
|
||||
currentQueryRef.current = trimmed;
|
||||
|
||||
// 清理缓存标记,确保执行新搜索
|
||||
sessionStorage.removeItem('fromPlayPage');
|
||||
|
||||
// 清空旧的搜索结果和状态
|
||||
if (eventSourceRef.current) {
|
||||
try { eventSourceRef.current.close(); } catch { }
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setSearchResults([]);
|
||||
setTotalSources(0);
|
||||
setCompletedSources(0);
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
|
||||
// 清理聚合统计缓存和refs
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
|
||||
// 读取流式搜索设置
|
||||
let currentFluidSearch = useFluidSearch;
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||
if (savedFluidSearch !== null) {
|
||||
currentFluidSearch = JSON.parse(savedFluidSearch);
|
||||
} else {
|
||||
const defaultFluidSearch = (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
currentFluidSearch = defaultFluidSearch;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFluidSearch !== useFluidSearch) {
|
||||
setUseFluidSearch(currentFluidSearch);
|
||||
}
|
||||
|
||||
if (currentFluidSearch) {
|
||||
// 流式搜索
|
||||
const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
if (!event.data) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (currentQueryRef.current !== trimmed || eventSourceRef.current !== es) {
|
||||
console.warn('忽略过期的搜索响应:', payload.type, '当前查询:', currentQueryRef.current, '响应查询:', trimmed);
|
||||
return;
|
||||
}
|
||||
switch (payload.type) {
|
||||
case 'start':
|
||||
setTotalSources(payload.totalSources || 0);
|
||||
setCompletedSources(0);
|
||||
break;
|
||||
case 'source_result': {
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const incoming: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(payload.results as SearchResult[])
|
||||
: (payload.results as SearchResult[]);
|
||||
pendingResultsRef.current.push(...incoming);
|
||||
if (!flushTimerRef.current) {
|
||||
flushTimerRef.current = window.setTimeout(() => {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
flushTimerRef.current = null;
|
||||
}, 80);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'source_error':
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
break;
|
||||
case 'complete':
|
||||
setCompletedSources(payload.completedSources || totalSources);
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => {
|
||||
const newResults = prev.concat(toAppend);
|
||||
try {
|
||||
sessionStorage.setItem('cachedSearchQuery', trimmed);
|
||||
sessionStorage.setItem('cachedSearchResults', JSON.stringify(newResults));
|
||||
sessionStorage.setItem('cachedSearchState', JSON.stringify({
|
||||
totalSources: payload.completedSources || totalSources,
|
||||
completedSources: payload.completedSources || totalSources,
|
||||
}));
|
||||
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
|
||||
filterAll,
|
||||
filterAgg,
|
||||
}));
|
||||
sessionStorage.setItem('cachedViewMode', viewMode);
|
||||
} catch (error) {
|
||||
console.error('缓存搜索结果失败:', error);
|
||||
}
|
||||
return newResults;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setSearchResults((prev) => {
|
||||
try {
|
||||
sessionStorage.setItem('cachedSearchQuery', trimmed);
|
||||
sessionStorage.setItem('cachedSearchResults', JSON.stringify(prev));
|
||||
sessionStorage.setItem('cachedSearchState', JSON.stringify({
|
||||
totalSources: payload.completedSources || totalSources,
|
||||
completedSources: payload.completedSources || totalSources,
|
||||
}));
|
||||
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
|
||||
filterAll,
|
||||
filterAgg,
|
||||
}));
|
||||
sessionStorage.setItem('cachedViewMode', viewMode);
|
||||
} catch (error) {
|
||||
console.error('缓存搜索结果失败:', error);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
setIsLoading(false);
|
||||
try { es.close(); } catch { }
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch { }
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
setIsLoading(false);
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
}
|
||||
try { es.close(); } catch { }
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// 传统搜索
|
||||
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (currentQueryRef.current !== trimmed) {
|
||||
console.warn('忽略过期的搜索响应 (传统):', '当前查询:', currentQueryRef.current, '响应查询:', trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const results: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(data.results as SearchResult[])
|
||||
: (data.results as SearchResult[]);
|
||||
|
||||
setSearchResults(results);
|
||||
setTotalSources(1);
|
||||
setCompletedSources(1);
|
||||
|
||||
try {
|
||||
sessionStorage.setItem('cachedSearchQuery', trimmed);
|
||||
sessionStorage.setItem('cachedSearchResults', JSON.stringify(results));
|
||||
sessionStorage.setItem('cachedSearchState', JSON.stringify({
|
||||
totalSources: 1,
|
||||
completedSources: 1,
|
||||
}));
|
||||
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
|
||||
filterAll,
|
||||
filterAgg,
|
||||
}));
|
||||
sessionStorage.setItem('cachedViewMode', viewMode);
|
||||
} catch (error) {
|
||||
console.error('缓存搜索结果失败:', error);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
// 保存到搜索历史
|
||||
addSearchHistory(trimmed);
|
||||
|
||||
// 更新URL但不触发重新渲染
|
||||
const newUrl = `/search?q=${encodeURIComponent(trimmed)}`;
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
};
|
||||
|
||||
const getGroupRef = (key: string) => {
|
||||
let ref = groupRefs.current.get(key);
|
||||
if (!ref) {
|
||||
|
|
@ -136,6 +359,31 @@ function SearchPageClient() {
|
|||
});
|
||||
};
|
||||
|
||||
// 检查搜索结果与关键字的相关性
|
||||
const isRelevantResult = (item: SearchResult, query: string) => {
|
||||
if (!query.trim()) return true;
|
||||
|
||||
const searchTerms = query.trim().toLowerCase().split(/\s+/);
|
||||
const title = (item.title || '').toLowerCase();
|
||||
const typeName = (item.type_name || '').toLowerCase();
|
||||
|
||||
// 至少匹配一个搜索关键字
|
||||
return searchTerms.some(term => {
|
||||
// 标题包含关键字
|
||||
if (title.includes(term)) return true;
|
||||
// 类型名包含关键字
|
||||
if (typeName.includes(term)) return true;
|
||||
// 支持年份搜索
|
||||
if (term.match(/^\d{4}$/) && item.year === term) return true;
|
||||
// 支持模糊匹配(去除空格和标点符号后的匹配)
|
||||
const cleanTitle = title.replace(/[\s\-_\.]/g, '');
|
||||
const cleanTerm = term.replace(/[\s\-_\.]/g, '');
|
||||
if (cleanTitle.includes(cleanTerm)) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// 简化的年份排序:unknown/空值始终在最后
|
||||
const compareYear = (aYear: string, bYear: string, order: 'none' | 'asc' | 'desc') => {
|
||||
// 如果是无排序状态,返回0(保持原顺序)
|
||||
|
|
@ -209,13 +457,16 @@ function SearchPageClient() {
|
|||
});
|
||||
}, [aggregatedResults]);
|
||||
|
||||
// 构建筛选选项
|
||||
// 构建筛选选项 - 只基于相关的搜索结果
|
||||
const filterOptions = useMemo(() => {
|
||||
const sourcesSet = new Map<string, string>();
|
||||
const titlesSet = new Set<string>();
|
||||
const yearsSet = new Set<string>();
|
||||
|
||||
searchResults.forEach((item) => {
|
||||
// 只考虑与搜索关键字相关的结果来构建过滤选项
|
||||
const relevantResults = searchResults.filter(item => isRelevantResult(item, searchQuery));
|
||||
|
||||
relevantResults.forEach((item) => {
|
||||
if (item.source && item.source_name) {
|
||||
sourcesSet.set(item.source, item.source_name);
|
||||
}
|
||||
|
|
@ -266,6 +517,9 @@ function SearchPageClient() {
|
|||
const filteredAllResults = useMemo(() => {
|
||||
const { source, title, year, yearOrder } = filterAll;
|
||||
const filtered = searchResults.filter((item) => {
|
||||
// 首先检查相关性
|
||||
if (!isRelevantResult(item, searchQuery)) return false;
|
||||
// 然后应用其他过滤器
|
||||
if (source !== 'all' && item.source !== source) return false;
|
||||
if (title !== 'all' && item.title !== title) return false;
|
||||
if (year !== 'all' && item.year !== year) return false;
|
||||
|
|
@ -300,6 +554,10 @@ function SearchPageClient() {
|
|||
const filteredAggResults = useMemo(() => {
|
||||
const { source, title, year, yearOrder } = filterAgg as any;
|
||||
const filtered = aggregatedResults.filter(([_, group]) => {
|
||||
// 检查聚合组中是否至少有一个结果与搜索关键字相关
|
||||
const hasRelevantResult = group.some(item => isRelevantResult(item, searchQuery));
|
||||
if (!hasRelevantResult) return false;
|
||||
|
||||
const gTitle = group[0]?.title ?? '';
|
||||
const gYear = group[0]?.year ?? 'unknown';
|
||||
const hasSource = source === 'all' ? true : group.some((item) => item.source === source);
|
||||
|
|
@ -419,7 +677,61 @@ function SearchPageClient() {
|
|||
|
||||
if (query) {
|
||||
setSearchQuery(query);
|
||||
// 新搜索:关闭旧连接并清空结果
|
||||
|
||||
// 检查是否从播放页返回,如果是则尝试使用缓存
|
||||
const fromPlayPage = sessionStorage.getItem('fromPlayPage');
|
||||
const cachedQuery = sessionStorage.getItem('cachedSearchQuery');
|
||||
const cachedResults = sessionStorage.getItem('cachedSearchResults');
|
||||
const cachedState = sessionStorage.getItem('cachedSearchState');
|
||||
const cachedFilters = sessionStorage.getItem('cachedSearchFilters');
|
||||
const cachedViewMode = sessionStorage.getItem('cachedViewMode');
|
||||
|
||||
if (fromPlayPage === 'true' && cachedQuery === query.trim() && cachedResults && cachedState) {
|
||||
// 从播放页返回且有缓存,使用缓存的搜索结果
|
||||
console.log('使用缓存的搜索结果');
|
||||
|
||||
try {
|
||||
const results = JSON.parse(cachedResults);
|
||||
const state = JSON.parse(cachedState);
|
||||
|
||||
// 恢复缓存的过滤器和视图状态
|
||||
if (cachedFilters) {
|
||||
const filters = JSON.parse(cachedFilters);
|
||||
if (filters.filterAll) setFilterAll(filters.filterAll);
|
||||
if (filters.filterAgg) setFilterAgg(filters.filterAgg);
|
||||
}
|
||||
|
||||
if (cachedViewMode && ['agg', 'all'].includes(cachedViewMode)) {
|
||||
setViewMode(cachedViewMode as 'agg' | 'all');
|
||||
}
|
||||
|
||||
// 恢复搜索结果和状态
|
||||
setSearchResults(results);
|
||||
setTotalSources(state.totalSources || 0);
|
||||
setCompletedSources(state.completedSources || 0);
|
||||
setIsLoading(false);
|
||||
setShowResults(true);
|
||||
|
||||
// 清理导航标记,避免影响后续搜索
|
||||
sessionStorage.removeItem('fromPlayPage');
|
||||
|
||||
return; // 直接返回,不执行新搜索
|
||||
} catch (error) {
|
||||
console.error('恢复缓存的搜索结果失败:', error);
|
||||
// 缓存损坏,清理缓存并继续正常搜索
|
||||
sessionStorage.removeItem('cachedSearchQuery');
|
||||
sessionStorage.removeItem('cachedSearchResults');
|
||||
sessionStorage.removeItem('cachedSearchState');
|
||||
sessionStorage.removeItem('cachedSearchFilters');
|
||||
sessionStorage.removeItem('cachedViewMode');
|
||||
sessionStorage.removeItem('fromPlayPage');
|
||||
}
|
||||
}
|
||||
|
||||
// 执行新搜索 - 使用performSearch函数(不更新URL,因为URL已经由路由处理了)
|
||||
const trimmed = query.trim();
|
||||
|
||||
// 清空旧的搜索结果和状态
|
||||
if (eventSourceRef.current) {
|
||||
try { eventSourceRef.current.close(); } catch { }
|
||||
eventSourceRef.current = null;
|
||||
|
|
@ -427,22 +739,19 @@ function SearchPageClient() {
|
|||
setSearchResults([]);
|
||||
setTotalSources(0);
|
||||
setCompletedSources(0);
|
||||
// 清理缓冲
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
|
||||
// 清理聚合统计缓存和refs,防止数据污染
|
||||
// 清理聚合统计缓存和refs
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
|
||||
const trimmed = query.trim();
|
||||
|
||||
// 每次搜索时重新读取设置,确保使用最新的配置
|
||||
// 读取流式搜索设置
|
||||
let currentFluidSearch = useFluidSearch;
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||
|
|
@ -454,13 +763,12 @@ function SearchPageClient() {
|
|||
}
|
||||
}
|
||||
|
||||
// 如果读取的配置与当前状态不同,更新状态
|
||||
if (currentFluidSearch !== useFluidSearch) {
|
||||
setUseFluidSearch(currentFluidSearch);
|
||||
}
|
||||
|
||||
if (currentFluidSearch) {
|
||||
// 流式搜索:打开新的流式连接
|
||||
// 流式搜索
|
||||
const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
|
|
@ -468,7 +776,6 @@ function SearchPageClient() {
|
|||
if (!event.data) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
// 强化竞态条件检查:确保是当前查询的响应
|
||||
if (currentQueryRef.current !== trimmed || eventSourceRef.current !== es) {
|
||||
console.warn('忽略过期的搜索响应:', payload.type, '当前查询:', currentQueryRef.current, '响应查询:', trimmed);
|
||||
return;
|
||||
|
|
@ -481,7 +788,6 @@ function SearchPageClient() {
|
|||
case 'source_result': {
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
||||
// 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const incoming: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
|
|
@ -506,7 +812,6 @@ function SearchPageClient() {
|
|||
break;
|
||||
case 'complete':
|
||||
setCompletedSources(payload.completedSources || totalSources);
|
||||
// 完成前确保将缓冲写入
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
|
|
@ -515,8 +820,47 @@ function SearchPageClient() {
|
|||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
setSearchResults((prev) => {
|
||||
const newResults = prev.concat(toAppend);
|
||||
try {
|
||||
sessionStorage.setItem('cachedSearchQuery', trimmed);
|
||||
sessionStorage.setItem('cachedSearchResults', JSON.stringify(newResults));
|
||||
sessionStorage.setItem('cachedSearchState', JSON.stringify({
|
||||
totalSources: payload.completedSources || totalSources,
|
||||
completedSources: payload.completedSources || totalSources,
|
||||
}));
|
||||
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
|
||||
filterAll,
|
||||
filterAgg,
|
||||
}));
|
||||
sessionStorage.setItem('cachedViewMode', viewMode);
|
||||
} catch (error) {
|
||||
console.error('缓存搜索结果失败:', error);
|
||||
}
|
||||
return newResults;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setSearchResults((prev) => {
|
||||
try {
|
||||
sessionStorage.setItem('cachedSearchQuery', trimmed);
|
||||
sessionStorage.setItem('cachedSearchResults', JSON.stringify(prev));
|
||||
sessionStorage.setItem('cachedSearchState', JSON.stringify({
|
||||
totalSources: payload.completedSources || totalSources,
|
||||
completedSources: payload.completedSources || totalSources,
|
||||
}));
|
||||
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
|
||||
filterAll,
|
||||
filterAgg,
|
||||
}));
|
||||
sessionStorage.setItem('cachedViewMode', viewMode);
|
||||
} catch (error) {
|
||||
console.error('缓存搜索结果失败:', error);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
setIsLoading(false);
|
||||
try { es.close(); } catch { }
|
||||
|
|
@ -530,7 +874,6 @@ function SearchPageClient() {
|
|||
|
||||
es.onerror = () => {
|
||||
setIsLoading(false);
|
||||
// 错误时也清空缓冲
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
|
|
@ -548,11 +891,10 @@ function SearchPageClient() {
|
|||
}
|
||||
};
|
||||
} else {
|
||||
// 传统搜索:使用普通接口
|
||||
// 传统搜索
|
||||
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// 强化竞态条件检查:确保是当前查询的响应
|
||||
if (currentQueryRef.current !== trimmed) {
|
||||
console.warn('忽略过期的搜索响应 (传统):', '当前查询:', currentQueryRef.current, '响应查询:', trimmed);
|
||||
return;
|
||||
|
|
@ -568,6 +910,22 @@ function SearchPageClient() {
|
|||
setSearchResults(results);
|
||||
setTotalSources(1);
|
||||
setCompletedSources(1);
|
||||
|
||||
try {
|
||||
sessionStorage.setItem('cachedSearchQuery', trimmed);
|
||||
sessionStorage.setItem('cachedSearchResults', JSON.stringify(results));
|
||||
sessionStorage.setItem('cachedSearchState', JSON.stringify({
|
||||
totalSources: 1,
|
||||
completedSources: 1,
|
||||
}));
|
||||
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
|
||||
filterAll,
|
||||
filterAgg,
|
||||
}));
|
||||
sessionStorage.setItem('cachedViewMode', viewMode);
|
||||
} catch (error) {
|
||||
console.error('缓存搜索结果失败:', error);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
|
|
@ -575,10 +933,10 @@ function SearchPageClient() {
|
|||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
setShowSuggestions(false);
|
||||
|
||||
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||
addSearchHistory(query);
|
||||
setShowSuggestions(false);
|
||||
// 保存到搜索历史
|
||||
addSearchHistory(trimmed);
|
||||
} else {
|
||||
setShowResults(false);
|
||||
setShowSuggestions(false);
|
||||
|
|
@ -630,38 +988,15 @@ function SearchPageClient() {
|
|||
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||
if (!trimmed) return;
|
||||
|
||||
// 清理所有状态和缓存,确保搜索结果干净
|
||||
setSearchResults([]);
|
||||
pendingResultsRef.current = [];
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
|
||||
// 回显搜索框
|
||||
setSearchQuery(trimmed);
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
setShowSuggestions(false);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
// 其余由 searchParams 变化的 effect 处理
|
||||
// 直接调用搜索函数
|
||||
performSearch(trimmed);
|
||||
};
|
||||
|
||||
const handleSuggestionSelect = (suggestion: string) => {
|
||||
// 清理所有状态和缓存,确保搜索结果干净
|
||||
setSearchResults([]);
|
||||
pendingResultsRef.current = [];
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
|
||||
setSearchQuery(suggestion);
|
||||
setShowSuggestions(false);
|
||||
|
||||
// 自动执行搜索
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(suggestion)}`);
|
||||
// 其余由 searchParams 变化的 effect 处理
|
||||
// 直接调用搜索函数
|
||||
performSearch(suggestion);
|
||||
};
|
||||
|
||||
// 返回顶部功能
|
||||
|
|
@ -724,19 +1059,9 @@ function SearchPageClient() {
|
|||
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||
if (!trimmed) return;
|
||||
|
||||
// 清理所有状态和缓存,确保搜索结果干净
|
||||
setSearchResults([]);
|
||||
pendingResultsRef.current = [];
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
|
||||
// 回显搜索框
|
||||
setSearchQuery(trimmed);
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
setShowSuggestions(false);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
// 直接调用搜索函数
|
||||
performSearch(trimmed);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -893,16 +1218,8 @@ function SearchPageClient() {
|
|||
<div key={item} className='relative group'>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 清理所有状态和缓存,确保搜索结果干净
|
||||
setSearchResults([]);
|
||||
pendingResultsRef.current = [];
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
|
||||
setSearchQuery(item);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}`
|
||||
);
|
||||
// 直接调用搜索函数
|
||||
performSearch(item.trim());
|
||||
}}
|
||||
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1240,8 +1240,8 @@ export function ChatModal({
|
|||
{selectedConversation ? (
|
||||
<>
|
||||
{/* 聊天头部 */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 移动端返回按钮 */}
|
||||
{isMobile && (
|
||||
<button
|
||||
|
|
@ -1289,10 +1289,10 @@ export function ChatModal({
|
|||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate text-sm">
|
||||
{selectedConversation.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{selectedConversation.participants.length === 2 ? (
|
||||
// 私人对话:显示在线状态
|
||||
(() => {
|
||||
|
|
@ -1437,10 +1437,10 @@ export function ChatModal({
|
|||
</div>
|
||||
|
||||
{/* 消息输入区域 */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900">
|
||||
{/* 表情选择器 */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 relative">
|
||||
{/* 表情选择器 - 绝对定位,不占据文档流空间 */}
|
||||
{showEmojiPicker && (
|
||||
<div className="emoji-picker-container mx-4 mt-3 mb-2 p-3 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-2xl shadow-xl">
|
||||
<div className="emoji-picker-container absolute left-4 right-4 bottom-full mb-2 p-3 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-2xl shadow-xl z-50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">选择表情</h3>
|
||||
<button
|
||||
|
|
@ -1466,16 +1466,16 @@ export function ChatModal({
|
|||
)}
|
||||
|
||||
{/* 主输入区域 */}
|
||||
<div className={`${isMobile ? 'p-3' : 'p-4'} pb-safe`}>
|
||||
<div className={`${isMobile ? 'p-2' : 'p-3'} pb-safe`}>
|
||||
<div className="bg-white dark:bg-gray-700 rounded-2xl shadow-sm border border-gray-200/80 dark:border-gray-600/80 backdrop-blur-sm">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-600">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-gray-100 dark:border-gray-600">
|
||||
{/* 左侧功能按钮组 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* 表情按钮 */}
|
||||
<button
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
className={`emoji-picker-container p-2.5 rounded-xl transition-all duration-200 transform hover:scale-105 ${showEmojiPicker
|
||||
className={`emoji-picker-container p-2 rounded-xl transition-all duration-200 transform hover:scale-105 ${showEmojiPicker
|
||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20'
|
||||
}`}
|
||||
|
|
@ -1488,7 +1488,7 @@ export function ChatModal({
|
|||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
className="p-2.5 text-gray-500 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-xl transition-all duration-200 transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-xl transition-all duration-200 transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
title="上传图片"
|
||||
>
|
||||
{uploadingImage ? (
|
||||
|
|
@ -1509,7 +1509,7 @@ export function ChatModal({
|
|||
|
||||
{/* 附件按钮(预留) */}
|
||||
<button
|
||||
className="p-2.5 text-gray-400 hover:text-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-xl transition-all duration-200 transform hover:scale-105"
|
||||
className="p-2 text-gray-400 hover:text-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-xl transition-all duration-200 transform hover:scale-105"
|
||||
disabled
|
||||
title="附件(即将开放)"
|
||||
>
|
||||
|
|
@ -1538,13 +1538,13 @@ export function ChatModal({
|
|||
</div>
|
||||
|
||||
{/* 消息输入区域 */}
|
||||
<div className="p-4">
|
||||
<div className="p-3">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="输入消息内容... 按Enter发送,Shift+Enter换行"
|
||||
className="w-full px-4 py-3 pr-16 bg-gray-50 dark:bg-gray-600 border-0 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:bg-white dark:focus:bg-gray-500 placeholder-gray-400 dark:placeholder-gray-400 resize-none min-h-[48px] max-h-32 transition-all duration-200"
|
||||
className="w-full px-3 py-2 pr-14 bg-gray-50 dark:bg-gray-600 border-0 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:bg-white dark:focus:bg-gray-500 placeholder-gray-400 dark:placeholder-gray-400 resize-none min-h-[40px] max-h-28 transition-all duration-200"
|
||||
rows={1}
|
||||
maxLength={1000}
|
||||
style={{ height: 'auto' }}
|
||||
|
|
@ -1575,7 +1575,7 @@ export function ChatModal({
|
|||
</div>
|
||||
|
||||
{/* 底部信息栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 dark:bg-gray-600/50 rounded-b-2xl border-t border-gray-100 dark:border-gray-600">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 dark:bg-gray-600/50 rounded-b-2xl border-t border-gray-100 dark:border-gray-600">
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center space-x-1">
|
||||
<span>📝</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// 全局主题加载器组件
|
||||
const GlobalThemeLoader = () => {
|
||||
useEffect(() => {
|
||||
const loadGlobalTheme = async () => {
|
||||
try {
|
||||
// 获取全局主题配置
|
||||
const response = await fetch('/api/theme');
|
||||
const result = await response.json();
|
||||
|
||||
console.log('获取到全站主题配置:', result);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const { defaultTheme, customCSS, allowUserCustomization } = result.data;
|
||||
|
||||
// 检查用户是否有自定义设置
|
||||
const userTheme = localStorage.getItem('app-theme');
|
||||
const userCustomCSS = localStorage.getItem('app-custom-css') || '';
|
||||
|
||||
console.log('当前用户设置:', { userTheme, userCustomCSS });
|
||||
console.log('全站配置:', { defaultTheme, customCSS, allowUserCustomization });
|
||||
|
||||
// 如果不允许用户自定义,强制应用全局配置
|
||||
if (!allowUserCustomization) {
|
||||
localStorage.setItem('app-theme', defaultTheme);
|
||||
localStorage.setItem('app-custom-css', customCSS);
|
||||
applyTheme(defaultTheme, customCSS);
|
||||
console.log('强制应用全站配置:', defaultTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
// 智能决定使用哪个主题
|
||||
let finalTheme = defaultTheme;
|
||||
let finalCSS = customCSS;
|
||||
|
||||
// 检查是否需要强制应用全站主题
|
||||
// 如果localStorage中存储的主题与全站默认不同,说明可能是过期的设置,需要更新
|
||||
const shouldForceGlobalTheme = !userTheme || userTheme !== defaultTheme;
|
||||
|
||||
if (shouldForceGlobalTheme) {
|
||||
// 强制应用全站默认配置
|
||||
finalTheme = defaultTheme;
|
||||
finalCSS = customCSS;
|
||||
localStorage.setItem('app-theme', defaultTheme);
|
||||
if (customCSS) {
|
||||
localStorage.setItem('app-custom-css', customCSS);
|
||||
} else {
|
||||
localStorage.removeItem('app-custom-css');
|
||||
}
|
||||
console.log('强制应用全站默认主题:', defaultTheme, '(替换过期设置:', userTheme, ')');
|
||||
} else {
|
||||
// 用户设置与全站默认一致,使用现有设置
|
||||
finalTheme = userTheme;
|
||||
finalCSS = userCustomCSS || customCSS;
|
||||
console.log('保持一致的主题设置:', userTheme);
|
||||
}
|
||||
|
||||
// 应用最终主题
|
||||
applyTheme(finalTheme, finalCSS);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载全局主题配置失败:', error);
|
||||
// 失败时使用本地设置或默认设置
|
||||
const savedTheme = localStorage.getItem('app-theme') || 'default';
|
||||
const savedCustomCSS = localStorage.getItem('app-custom-css') || '';
|
||||
applyTheme(savedTheme, savedCustomCSS);
|
||||
}
|
||||
};
|
||||
|
||||
// 应用主题函数
|
||||
const applyTheme = (themeId: string, css: string = '') => {
|
||||
const html = document.documentElement;
|
||||
|
||||
// 移除所有主题class
|
||||
html.removeAttribute('data-theme');
|
||||
|
||||
// 应用新主题
|
||||
if (themeId !== 'default') {
|
||||
html.setAttribute('data-theme', themeId);
|
||||
}
|
||||
|
||||
// 应用自定义CSS
|
||||
let customStyleEl = document.getElementById('global-theme-css');
|
||||
if (!customStyleEl) {
|
||||
customStyleEl = document.createElement('style');
|
||||
customStyleEl.id = 'global-theme-css';
|
||||
document.head.appendChild(customStyleEl);
|
||||
}
|
||||
customStyleEl.textContent = css;
|
||||
};
|
||||
|
||||
// 立即加载,不延迟
|
||||
loadGlobalTheme();
|
||||
}, []);
|
||||
|
||||
return null; // 这是一个逻辑组件,不渲染任何内容
|
||||
};
|
||||
|
||||
export default GlobalThemeLoader;
|
||||
|
|
@ -0,0 +1,749 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronUp, Palette, Eye, Check } from 'lucide-react';
|
||||
|
||||
// CSS模板配置
|
||||
const cssTemplates = [
|
||||
{
|
||||
id: 'gradient-bg',
|
||||
name: '渐变背景',
|
||||
description: '为页面添加漂亮的渐变背景',
|
||||
preview: 'body {\n background: linear-gradient(135deg, \n #667eea 0%, #764ba2 100%);\n}',
|
||||
css: `/* 渐变背景主题 */
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* 确保内容可读性 */
|
||||
.admin-panel, .bg-theme-surface {
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .admin-panel, .dark .bg-theme-surface {
|
||||
background: rgba(0, 0, 0, 0.8) !important;
|
||||
}`
|
||||
},
|
||||
{
|
||||
id: 'image-bg',
|
||||
name: '图片背景',
|
||||
description: '使用自定义图片作为背景',
|
||||
preview: 'body {\n background-image: url("图片链接");\n background-size: cover;\n}',
|
||||
css: `/* 图片背景主题 */
|
||||
body {
|
||||
background-image: url("https://images.unsplash.com/photo-1519681393784-d120c3b3fd60?ixlib=rb-4.0.3");
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* 添加遮罩层确保可读性 */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 调整内容区域透明度 */
|
||||
.admin-panel, .bg-theme-surface {
|
||||
backdrop-filter: blur(15px);
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dark .admin-panel, .dark .bg-theme-surface {
|
||||
background: rgba(0, 0, 0, 0.85) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}`
|
||||
},
|
||||
{
|
||||
id: 'sidebar-glow',
|
||||
name: '发光侧边栏',
|
||||
description: '为侧边栏添加发光效果',
|
||||
preview: '.sidebar {\n box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);\n border-radius: 15px;\n}',
|
||||
css: `/* 发光侧边栏效果 */
|
||||
.sidebar, [data-sidebar] {
|
||||
box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);
|
||||
border-radius: 15px;
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* 侧边栏项目悬停效果 */
|
||||
.sidebar a:hover, [data-sidebar] a:hover {
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
transform: translateX(5px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 活动项目发光 */
|
||||
.sidebar [data-active="true"], [data-sidebar] [data-active="true"] {
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
box-shadow: inset 0 0 10px rgba(14, 165, 233, 0.2);
|
||||
border-radius: 8px;
|
||||
}`
|
||||
},
|
||||
{
|
||||
id: 'card-animations',
|
||||
name: '卡片动画',
|
||||
description: '为视频卡片添加动画效果',
|
||||
preview: '.video-card:hover {\n transform: scale(1.05);\n box-shadow: 0 10px 25px rgba(0,0,0,0.2);\n}',
|
||||
css: `/* 卡片动画效果 */
|
||||
.video-card, [data-video-card] {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.video-card:hover, [data-video-card]:hover {
|
||||
transform: translateY(-5px) scale(1.02);
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 图片悬停效果 */
|
||||
.video-card img, [data-video-card] img {
|
||||
transition: transform 0.3s ease;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.video-card:hover img, [data-video-card]:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 按钮动画 */
|
||||
.video-card button, [data-video-card] button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.video-card button:hover, [data-video-card] button:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
}`
|
||||
},
|
||||
{
|
||||
id: 'glass-theme',
|
||||
name: '毛玻璃主题',
|
||||
description: '现代毛玻璃风格界面',
|
||||
preview: '.glass-effect {\n backdrop-filter: blur(20px);\n background: rgba(255, 255, 255, 0.1);\n}',
|
||||
css: `/* 毛玻璃主题 */
|
||||
body {
|
||||
background: linear-gradient(45deg,
|
||||
rgba(59, 130, 246, 0.1) 0%,
|
||||
rgba(147, 51, 234, 0.1) 50%,
|
||||
rgba(236, 72, 153, 0.1) 100%);
|
||||
}
|
||||
|
||||
/* 所有面板使用毛玻璃效果 */
|
||||
.admin-panel, .bg-theme-surface, [data-panel] {
|
||||
backdrop-filter: blur(20px);
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .admin-panel, .dark .bg-theme-surface, .dark [data-panel] {
|
||||
background: rgba(0, 0, 0, 0.3) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 按钮毛玻璃效果 */
|
||||
button {
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
backdrop-filter: blur(15px);
|
||||
transform: translateY(-1px);
|
||||
}`
|
||||
},
|
||||
{
|
||||
id: 'neon-accents',
|
||||
name: '霓虹强调',
|
||||
description: '添加炫酷的霓虹发光效果',
|
||||
preview: '.neon-glow {\n box-shadow: 0 0 20px currentColor;\n text-shadow: 0 0 10px currentColor;\n}',
|
||||
css: `/* 霓虹发光主题 */
|
||||
:root {
|
||||
--neon-color: #00ff88;
|
||||
--neon-glow: 0 0 20px var(--neon-color);
|
||||
}
|
||||
|
||||
/* 主要标题霓虹效果 */
|
||||
h1, h2, h3 {
|
||||
text-shadow: 0 0 10px var(--neon-color);
|
||||
color: var(--neon-color);
|
||||
}
|
||||
|
||||
/* 按钮霓虹效果 */
|
||||
button:hover, .btn-primary {
|
||||
box-shadow: var(--neon-glow);
|
||||
border: 1px solid var(--neon-color);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 输入框聚焦霓虹效果 */
|
||||
input:focus, textarea:focus {
|
||||
box-shadow: var(--neon-glow);
|
||||
border-color: var(--neon-color);
|
||||
}
|
||||
|
||||
/* 卡片边框霓虹效果 */
|
||||
.card-hover:hover {
|
||||
box-shadow: var(--neon-glow);
|
||||
border: 1px solid var(--neon-color);
|
||||
}
|
||||
|
||||
/* 侧边栏活动项霓虹效果 */
|
||||
[data-active="true"] {
|
||||
box-shadow: inset var(--neon-glow);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
}`
|
||||
}
|
||||
];
|
||||
|
||||
// 主题配置
|
||||
const themes = [
|
||||
{
|
||||
id: 'default',
|
||||
name: '默认主题',
|
||||
description: '现代蓝色主题,清新优雅',
|
||||
preview: {
|
||||
bg: '#ffffff',
|
||||
surface: '#f9fafb',
|
||||
accent: '#0ea5e9',
|
||||
text: '#111827',
|
||||
border: '#e5e7eb'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'minimal',
|
||||
name: '极简主题',
|
||||
description: '简约黑白,专注内容',
|
||||
preview: {
|
||||
bg: '#ffffff',
|
||||
surface: '#fcfcfc',
|
||||
accent: '#525252',
|
||||
text: '#171717',
|
||||
border: '#e5e5e5'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'warm',
|
||||
name: '暖色主题',
|
||||
description: '温暖橙调,舒适护眼',
|
||||
preview: {
|
||||
bg: '#fffdf7',
|
||||
surface: '#fefaf0',
|
||||
accent: '#ea580c',
|
||||
text: '#7c2d12',
|
||||
border: '#fde68a'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'fresh',
|
||||
name: '清新主题',
|
||||
description: '自然绿色,清新活力',
|
||||
preview: {
|
||||
bg: '#f7fdf9',
|
||||
surface: '#f0fdf4',
|
||||
accent: '#3fcc71',
|
||||
text: '#14532d',
|
||||
border: '#bbf7d0'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
interface ThemeManagerProps {
|
||||
showAlert: (config: any) => void;
|
||||
role?: 'user' | 'admin' | 'owner' | null;
|
||||
}
|
||||
|
||||
const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
|
||||
const [currentTheme, setCurrentTheme] = useState('default');
|
||||
const [customCSS, setCustomCSS] = useState('');
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [showCustomEditor, setShowCustomEditor] = useState(false);
|
||||
const [globalThemeConfig, setGlobalThemeConfig] = useState<{
|
||||
defaultTheme: string;
|
||||
customCSS: string;
|
||||
allowUserCustomization: boolean;
|
||||
} | null>(null);
|
||||
const [isGlobalMode, setIsGlobalMode] = useState(false);
|
||||
|
||||
const isAdmin = role === 'admin' || role === 'owner';
|
||||
|
||||
// 加载全局主题配置
|
||||
const loadGlobalThemeConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/theme');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setGlobalThemeConfig(result.data);
|
||||
return result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载全局主题配置失败:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 保存全局主题配置
|
||||
const saveGlobalThemeConfig = async (config: {
|
||||
defaultTheme: string;
|
||||
customCSS: string;
|
||||
allowUserCustomization: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/theme', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setGlobalThemeConfig(result.data);
|
||||
showAlert({
|
||||
type: 'success',
|
||||
title: '全局主题配置已保存',
|
||||
message: '所有用户将使用新的主题配置',
|
||||
timer: 3000
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.error || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert({
|
||||
type: 'error',
|
||||
title: '保存全局主题配置失败',
|
||||
message: error instanceof Error ? error.message : '未知错误',
|
||||
timer: 3000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 从localStorage加载当前主题
|
||||
useEffect(() => {
|
||||
// 确保在客户端环境中执行
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const initTheme = async () => {
|
||||
// 加载全局配置
|
||||
const globalConfig = await loadGlobalThemeConfig();
|
||||
|
||||
if (isGlobalMode && globalConfig) {
|
||||
// 使用全局配置
|
||||
setCurrentTheme(globalConfig.defaultTheme);
|
||||
setCustomCSS(globalConfig.customCSS);
|
||||
applyTheme(globalConfig.defaultTheme, globalConfig.customCSS);
|
||||
} else {
|
||||
// 使用本地配置
|
||||
const savedTheme = localStorage.getItem('app-theme') || globalConfig?.defaultTheme || 'default';
|
||||
const savedCustomCSS = localStorage.getItem('app-custom-css') || '';
|
||||
setCurrentTheme(savedTheme);
|
||||
setCustomCSS(savedCustomCSS);
|
||||
applyTheme(savedTheme, savedCustomCSS);
|
||||
}
|
||||
};
|
||||
|
||||
initTheme();
|
||||
}, [isGlobalMode]);
|
||||
|
||||
// 应用主题
|
||||
const applyTheme = (themeId: string, css: string = '') => {
|
||||
const html = document.documentElement;
|
||||
|
||||
// 移除所有主题class
|
||||
html.removeAttribute('data-theme');
|
||||
|
||||
// 应用新主题
|
||||
if (themeId !== 'default') {
|
||||
html.setAttribute('data-theme', themeId);
|
||||
}
|
||||
|
||||
// 应用自定义CSS
|
||||
let customStyleEl = document.getElementById('custom-theme-css');
|
||||
if (!customStyleEl) {
|
||||
customStyleEl = document.createElement('style');
|
||||
customStyleEl.id = 'custom-theme-css';
|
||||
document.head.appendChild(customStyleEl);
|
||||
}
|
||||
customStyleEl.textContent = css;
|
||||
};
|
||||
|
||||
// 切换主题
|
||||
const handleThemeChange = async (themeId: string) => {
|
||||
setCurrentTheme(themeId);
|
||||
applyTheme(themeId, customCSS);
|
||||
|
||||
if (isGlobalMode && isAdmin) {
|
||||
// 保存到全局配置
|
||||
const success = await saveGlobalThemeConfig({
|
||||
defaultTheme: themeId,
|
||||
customCSS: customCSS,
|
||||
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
|
||||
});
|
||||
|
||||
// 如果保存成功,立即更新本地全局配置状态
|
||||
if (success) {
|
||||
setGlobalThemeConfig({
|
||||
defaultTheme: themeId,
|
||||
customCSS: customCSS,
|
||||
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 保存到本地
|
||||
localStorage.setItem('app-theme', themeId);
|
||||
}
|
||||
|
||||
const theme = themes.find(t => t.id === themeId);
|
||||
showAlert({
|
||||
type: 'success',
|
||||
title: isGlobalMode ? '全局主题已设置' : '主题已切换',
|
||||
message: `已切换到${theme?.name}`,
|
||||
timer: 2000
|
||||
});
|
||||
};
|
||||
|
||||
// 预览主题
|
||||
const handleThemePreview = (themeId: string) => {
|
||||
if (!previewMode) {
|
||||
setPreviewMode(true);
|
||||
applyTheme(themeId, customCSS);
|
||||
|
||||
// 3秒后恢复原主题
|
||||
setTimeout(() => {
|
||||
setPreviewMode(false);
|
||||
applyTheme(currentTheme, customCSS);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 应用自定义CSS
|
||||
const handleCustomCSSApply = async () => {
|
||||
try {
|
||||
applyTheme(currentTheme, customCSS);
|
||||
|
||||
if (isGlobalMode && isAdmin) {
|
||||
// 保存到全局配置
|
||||
const success = await saveGlobalThemeConfig({
|
||||
defaultTheme: currentTheme,
|
||||
customCSS: customCSS,
|
||||
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
|
||||
});
|
||||
|
||||
// 如果保存成功,立即更新本地全局配置状态
|
||||
if (success) {
|
||||
setGlobalThemeConfig({
|
||||
defaultTheme: currentTheme,
|
||||
customCSS: customCSS,
|
||||
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 保存到本地
|
||||
localStorage.setItem('app-custom-css', customCSS);
|
||||
showAlert({
|
||||
type: 'success',
|
||||
title: '自定义样式已应用',
|
||||
message: '您的自定义CSS已生效',
|
||||
timer: 2000
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert({
|
||||
type: 'error',
|
||||
title: '样式应用失败',
|
||||
message: 'CSS语法可能有误,请检查后重试',
|
||||
timer: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 重置自定义CSS
|
||||
const handleCustomCSSReset = () => {
|
||||
setCustomCSS('');
|
||||
applyTheme(currentTheme, '');
|
||||
localStorage.removeItem('app-custom-css');
|
||||
|
||||
showAlert({
|
||||
type: 'success',
|
||||
title: '自定义样式已重置',
|
||||
timer: 2000
|
||||
});
|
||||
};
|
||||
|
||||
// 应用模板CSS
|
||||
const handleApplyTemplate = (templateCSS: string, templateName: string) => {
|
||||
setCustomCSS(templateCSS);
|
||||
showAlert({
|
||||
type: 'success',
|
||||
title: '模板已复制',
|
||||
message: `${templateName}模板已复制到编辑器`,
|
||||
timer: 2000
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 管理员控制面板 */}
|
||||
{isAdmin && (
|
||||
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
管理员主题设置
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-theme-text">配置模式</label>
|
||||
<p className="text-xs text-theme-text-secondary mt-1">
|
||||
选择设置个人主题还是全站默认主题
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="configMode"
|
||||
checked={!isGlobalMode}
|
||||
onChange={() => setIsGlobalMode(false)}
|
||||
className="w-4 h-4 text-theme-accent"
|
||||
/>
|
||||
<span className="text-sm text-theme-text">个人设置</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="configMode"
|
||||
checked={isGlobalMode}
|
||||
onChange={() => setIsGlobalMode(true)}
|
||||
className="w-4 h-4 text-theme-accent"
|
||||
/>
|
||||
<span className="text-sm text-theme-text">全站默认</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{globalThemeConfig && (
|
||||
<div className="p-3 bg-theme-accent/5 border border-theme-accent/20 rounded-lg">
|
||||
<div className="text-sm text-theme-text">
|
||||
<strong>当前全站配置:</strong>
|
||||
</div>
|
||||
<div className="text-xs text-theme-text-secondary mt-1">
|
||||
默认主题: {themes.find(t => t.id === globalThemeConfig.defaultTheme)?.name || globalThemeConfig.defaultTheme}
|
||||
{globalThemeConfig.customCSS && ' | 包含自定义CSS'}
|
||||
{!globalThemeConfig.allowUserCustomization && ' | 禁止用户自定义'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGlobalMode && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-700">
|
||||
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
|
||||
<span className="text-sm font-medium">⚠️ 全站模式</span>
|
||||
</div>
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
在此模式下的更改将影响所有用户的默认主题配置
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主题选择器 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
{isGlobalMode && isAdmin ? '全站默认主题' : '主题选择'}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{themes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className={`relative p-4 border-2 rounded-xl cursor-pointer transition-all ${currentTheme === theme.id
|
||||
? 'border-theme-accent bg-theme-accent/5'
|
||||
: 'border-theme-border bg-theme-surface hover:border-theme-accent/50'
|
||||
}`}
|
||||
onClick={() => handleThemeChange(theme.id)}
|
||||
>
|
||||
{/* 主题预览 */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: theme.preview.bg }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: theme.preview.surface }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: theme.preview.accent }} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleThemePreview(theme.id);
|
||||
}}
|
||||
className="p-1 text-theme-text-secondary hover:text-theme-accent transition-colors"
|
||||
title="预览主题"
|
||||
disabled={previewMode}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
{currentTheme === theme.id && (
|
||||
<Check className="h-4 w-4 text-theme-accent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-theme-text">{theme.name}</h4>
|
||||
<p className="text-sm text-theme-text-secondary mt-1">{theme.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{previewMode && (
|
||||
<div className="mt-4 p-3 bg-theme-info/10 border border-theme-info/20 rounded-lg">
|
||||
<p className="text-sm text-theme-info">正在预览主题,3秒后将自动恢复...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 自定义CSS编辑器 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-theme-text flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
自定义样式
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowCustomEditor(!showCustomEditor)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-surface border border-theme-border rounded-lg hover:bg-theme-accent/5 transition-colors"
|
||||
>
|
||||
{showCustomEditor ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
{showCustomEditor ? '收起编辑器' : '展开编辑器'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCustomEditor && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-theme-text-secondary bg-theme-surface p-3 rounded-lg border border-theme-border">
|
||||
<p className="mb-2">💡 <strong>使用提示:</strong></p>
|
||||
<ul className="space-y-1 text-xs">
|
||||
<li>• 使用CSS变量覆盖主题颜色:<code className="bg-theme-bg px-1 rounded">--color-theme-accent: 255, 0, 0;</code></li>
|
||||
<li>• 使用Tailwind类名:<code className="bg-theme-bg px-1 rounded">{`.my-class { @apply bg-red-500; }`}</code></li>
|
||||
<li>• 自定义组件样式:<code className="bg-theme-bg px-1 rounded">{`.admin-panel { border-radius: 20px; }`}</code></li>
|
||||
<li>• 修改会实时生效,请谨慎使用</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={customCSS}
|
||||
onChange={(e) => setCustomCSS(e.target.value)}
|
||||
placeholder="/* 在此输入您的自定义CSS */
|
||||
:root {
|
||||
--color-theme-accent: 255, 0, 0; /* 红色主题色 */
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* 使用Tailwind类名 */
|
||||
.custom-button {
|
||||
@apply bg-gradient-to-r from-purple-500 to-pink-500 text-white px-6 py-3 rounded-xl;
|
||||
}"
|
||||
className="w-full h-64 p-4 bg-theme-surface border border-theme-border rounded-lg text-sm font-mono text-theme-text placeholder-theme-text-secondary resize-none focus:outline-none focus:ring-2 focus:ring-theme-accent/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleCustomCSSApply}
|
||||
className="px-4 py-2 bg-theme-accent text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
应用样式
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCustomCSSReset}
|
||||
className="px-4 py-2 bg-theme-surface border border-theme-border text-theme-text rounded-lg hover:bg-theme-accent/5 transition-colors"
|
||||
>
|
||||
重置样式
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CSS 模板库 */}
|
||||
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-theme-text mb-3 flex items-center gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
🎨 样式模板库
|
||||
</h4>
|
||||
<p className="text-sm text-theme-text-secondary mb-4">选择预设模板快速应用炫酷效果,也可以在此基础上进行自定义修改</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{cssTemplates.map((template) => (
|
||||
<div key={template.id} className="p-3 border border-theme-border rounded-lg hover:bg-theme-accent/5 transition-colors group">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h5 className="text-sm font-medium text-theme-text">{template.name}</h5>
|
||||
<button
|
||||
onClick={() => handleApplyTemplate(template.css, template.name)}
|
||||
className="text-xs px-2 py-1 bg-theme-accent text-white rounded hover:opacity-90 transition-opacity opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-theme-text-secondary mb-2">{template.description}</p>
|
||||
<div className="text-xs bg-theme-bg rounded p-2 max-h-16 overflow-y-auto">
|
||||
<code className="whitespace-pre-wrap text-theme-text-secondary">{template.preview}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-theme-accent/5 border border-theme-accent/20 rounded-lg">
|
||||
<p className="text-xs text-theme-text-secondary">
|
||||
<strong>💡 使用提示:</strong> 点击模板的"应用"按钮将代码复制到自定义CSS编辑器,然后可以在此基础上进行修改。记得点击"应用样式"按钮生效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-theme-text mb-2">📖 主题定制指南</h4>
|
||||
<div className="text-sm text-theme-text-secondary space-y-2">
|
||||
<p><strong>内置主题:</strong>选择预设主题即可一键切换整体风格</p>
|
||||
<p><strong>自定义CSS:</strong>通过CSS变量或直接样式实现个性化定制</p>
|
||||
<p><strong>样式模板:</strong>使用预设模板快速实现炫酷效果</p>
|
||||
<p><strong>主题变量:</strong></p>
|
||||
<ul className="text-xs space-y-1 ml-4 mt-1">
|
||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-bg</code> - 背景色</li>
|
||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-surface</code> - 卡片背景</li>
|
||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-accent</code> - 主题色</li>
|
||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-text</code> - 主文本色</li>
|
||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-border</code> - 边框色</li>
|
||||
</ul>
|
||||
<p><strong>常用技巧:</strong></p>
|
||||
<ul className="text-xs space-y-1 ml-4 mt-1">
|
||||
<li>• 修改背景:<code className="bg-theme-bg px-1 rounded">{`body { background: linear-gradient(...); }`}</code></li>
|
||||
<li>• 使用Tailwind:<code className="bg-theme-bg px-1 rounded">{`.my-class { @apply bg-red-500; }`}</code></li>
|
||||
<li>• 组合多个模板效果获得独特样式</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeManager;
|
||||
|
|
@ -94,22 +94,27 @@ export function ThemeToggle() {
|
|||
});
|
||||
};
|
||||
|
||||
// 检查是否在登录页面
|
||||
const isLoginPage = pathname === '/login';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`flex items-center ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
|
||||
{/* 聊天按钮 */}
|
||||
<button
|
||||
onClick={() => setIsChatModalOpen(true)}
|
||||
className={`${isMobile ? 'w-8 h-8 p-1.5' : 'w-10 h-10 p-2'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors relative`}
|
||||
aria-label='Open chat'
|
||||
>
|
||||
<MessageCircle className='w-full h-full' />
|
||||
{messageCount > 0 && (
|
||||
<span className={`absolute ${isMobile ? '-top-0.5 -right-0.5 w-4 h-4 text-xs' : '-top-1 -right-1 w-5 h-5 text-xs'} bg-red-500 text-white rounded-full flex items-center justify-center`}>
|
||||
{messageCount > 99 ? '99+' : messageCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* 聊天按钮 - 在登录页面不显示 */}
|
||||
{!isLoginPage && (
|
||||
<button
|
||||
onClick={() => setIsChatModalOpen(true)}
|
||||
className={`${isMobile ? 'w-8 h-8 p-1.5' : 'w-10 h-10 p-2'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors relative`}
|
||||
aria-label='Open chat'
|
||||
>
|
||||
<MessageCircle className='w-full h-full' />
|
||||
{messageCount > 0 && (
|
||||
<span className={`absolute ${isMobile ? '-top-0.5 -right-0.5 w-4 h-4 text-xs' : '-top-1 -right-1 w-5 h-5 text-xs'} bg-red-500 text-white rounded-full flex items-center justify-center`}>
|
||||
{messageCount > 99 ? '99+' : messageCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
|
|
@ -125,14 +130,16 @@ export function ThemeToggle() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* 聊天模态框 */}
|
||||
<ChatModal
|
||||
isOpen={isChatModalOpen}
|
||||
onClose={() => setIsChatModalOpen(false)}
|
||||
onMessageCountChange={handleMessageCountFromModal}
|
||||
onChatCountReset={handleChatCountReset}
|
||||
onFriendRequestCountReset={handleFriendRequestCountReset}
|
||||
/>
|
||||
{/* 聊天模态框 - 在登录页面不渲染 */}
|
||||
{!isLoginPage && (
|
||||
<ChatModal
|
||||
isOpen={isChatModalOpen}
|
||||
onClose={() => setIsChatModalOpen(false)}
|
||||
onMessageCountChange={handleMessageCountFromModal}
|
||||
onChatCountReset={handleChatCountReset}
|
||||
onFriendRequestCountReset={handleFriendRequestCountReset}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,6 +228,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// 如果从搜索页面点击,设置标记以便返回时使用缓存
|
||||
if (from === 'search' && typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('fromPlayPage', 'true');
|
||||
}
|
||||
|
||||
if (origin === 'live' && actualSource && actualId) {
|
||||
// 直播内容跳转到直播页面
|
||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
||||
|
|
@ -270,6 +275,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
|
||||
// 新标签页播放处理函数
|
||||
const handlePlayInNewTab = useCallback(() => {
|
||||
// 如果从搜索页面点击,设置标记以便返回时使用缓存
|
||||
if (from === 'search' && typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('fromPlayPage', 'true');
|
||||
}
|
||||
|
||||
if (origin === 'live' && actualSource && actualId) {
|
||||
// 直播内容跳转到直播页面
|
||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
// 全局主题Hook - 用于在任何组件中初始化和使用主题
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useThemeInit = () => {
|
||||
useEffect(() => {
|
||||
// 确保在客户端环境中执行
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
// 从localStorage获取保存的主题
|
||||
const savedTheme = localStorage.getItem('app-theme') || 'default';
|
||||
const savedCustomCSS = localStorage.getItem('app-custom-css') || '';
|
||||
|
||||
// 立即应用主题到HTML元素
|
||||
const html = document.documentElement;
|
||||
|
||||
// 移除所有主题属性
|
||||
html.removeAttribute('data-theme');
|
||||
|
||||
// 应用保存的主题
|
||||
if (savedTheme !== 'default') {
|
||||
html.setAttribute('data-theme', savedTheme);
|
||||
}
|
||||
|
||||
// 应用自定义CSS
|
||||
if (savedCustomCSS) {
|
||||
let customStyleEl = document.getElementById('custom-theme-css');
|
||||
if (!customStyleEl) {
|
||||
customStyleEl = document.createElement('style');
|
||||
customStyleEl.id = 'custom-theme-css';
|
||||
document.head.appendChild(customStyleEl);
|
||||
}
|
||||
customStyleEl.textContent = savedCustomCSS;
|
||||
}
|
||||
|
||||
console.log(`主题已初始化: ${savedTheme}`);
|
||||
} catch (error) {
|
||||
console.error('主题初始化失败:', error);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const applyTheme = (themeId: string, css: string = '') => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const html = document.documentElement;
|
||||
|
||||
// 移除所有主题class
|
||||
html.removeAttribute('data-theme');
|
||||
|
||||
// 应用新主题
|
||||
if (themeId !== 'default') {
|
||||
html.setAttribute('data-theme', themeId);
|
||||
}
|
||||
|
||||
// 应用自定义CSS
|
||||
let customStyleEl = document.getElementById('custom-theme-css');
|
||||
if (!customStyleEl) {
|
||||
customStyleEl = document.createElement('style');
|
||||
customStyleEl.id = 'custom-theme-css';
|
||||
document.head.appendChild(customStyleEl);
|
||||
}
|
||||
customStyleEl.textContent = css;
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('app-theme', themeId);
|
||||
localStorage.setItem('app-custom-css', css);
|
||||
};
|
||||
|
||||
const getCurrentTheme = () => {
|
||||
if (typeof window === 'undefined') return 'default';
|
||||
return localStorage.getItem('app-theme') || 'default';
|
||||
};
|
||||
|
||||
const getCurrentCustomCSS = () => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return localStorage.getItem('app-custom-css') || '';
|
||||
};
|
||||
|
||||
return {
|
||||
applyTheme,
|
||||
getCurrentTheme,
|
||||
getCurrentCustomCSS
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -17,6 +17,15 @@ export interface AdminConfig {
|
|||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
RequireDeviceCode: boolean;
|
||||
CustomTheme?: {
|
||||
selectedTheme: string;
|
||||
customCSS: string;
|
||||
};
|
||||
};
|
||||
ThemeConfig?: {
|
||||
defaultTheme: 'default' | 'minimal' | 'warm' | 'fresh';
|
||||
customCSS: string;
|
||||
allowUserCustomization: boolean;
|
||||
};
|
||||
UserConfig: {
|
||||
Users: {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,20 @@ export interface ChangelogEntry {
|
|||
}
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: "8.9.5",
|
||||
date: "2025-09-21",
|
||||
added: [
|
||||
"添加内置主题,支持用户自定义CSS"
|
||||
],
|
||||
changed: [
|
||||
"优化搜索页面缓存机制"
|
||||
],
|
||||
fixed: [
|
||||
"镜像健康检查问题",
|
||||
"弹幕功能适配移动端"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "8.9.0",
|
||||
date: "2025-09-15",
|
||||
|
|
|
|||
|
|
@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean {
|
|||
// 配置middleware匹配规则
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config).*)',
|
||||
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config|api/theme).*)',
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,18 +20,30 @@ const config: Config = {
|
|||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--color-primary-100) / <alpha-value>)',
|
||||
200: 'rgb(var(--color-primary-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--color-primary-300) / <alpha-value>)',
|
||||
400: 'rgb(var(--color-primary-400) / <alpha-value>)',
|
||||
500: 'rgb(var(--color-primary-500) / <alpha-value>)',
|
||||
600: 'rgb(var(--color-primary-600) / <alpha-value>)',
|
||||
700: 'rgb(var(--color-primary-700) / <alpha-value>)',
|
||||
800: 'rgb(var(--color-primary-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--color-primary-900) / <alpha-value>)',
|
||||
},
|
||||
dark: '#222222',
|
||||
dark: 'rgb(var(--color-dark) / <alpha-value>)',
|
||||
// 主题颜色系统
|
||||
'theme-bg': 'rgb(var(--color-theme-bg) / <alpha-value>)',
|
||||
'theme-surface': 'rgb(var(--color-theme-surface) / <alpha-value>)',
|
||||
'theme-accent': 'rgb(var(--color-theme-accent) / <alpha-value>)',
|
||||
'theme-text': 'rgb(var(--color-theme-text) / <alpha-value>)',
|
||||
'theme-text-secondary': 'rgb(var(--color-theme-text-secondary) / <alpha-value>)',
|
||||
'theme-border': 'rgb(var(--color-theme-border) / <alpha-value>)',
|
||||
// 扩展主题颜色
|
||||
'theme-success': 'rgb(var(--color-theme-success) / <alpha-value>)',
|
||||
'theme-warning': 'rgb(var(--color-theme-warning) / <alpha-value>)',
|
||||
'theme-error': 'rgb(var(--color-theme-error) / <alpha-value>)',
|
||||
'theme-info': 'rgb(var(--color-theme-info) / <alpha-value>)',
|
||||
},
|
||||
keyframes: {
|
||||
flicker: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue