Compare commits

...

14 Commits
8.9.0 ... main

Author SHA1 Message Date
djteang 2194a3d6ad fixed:TypeError: Cannot read properties of null (reading 'large') 2025-09-25 11:08:27 +08:00
djteang 3fd6211697 fixed:更新文档 2025-09-25 10:15:30 +08:00
djteang 7357131005 fixed:TypeError: Cannot read properties of null (reading 'large') 2025-09-25 10:01:46 +08:00
djteang 49d1d3b8b8 fixed:自定义主题应用所有人 2025-09-25 00:07:07 +08:00
djteang 668146f414 fixed:自定义主题应用所有人 2025-09-24 22:12:34 +08:00
djteang b07b4ef36a fixed:自定义主题应用所有人 2025-09-23 16:37:49 +08:00
djteang 7707ba5414 fixed:自定义主题应用所有人 2025-09-23 09:20:31 +08:00
djteang 3457a7c565 fixed:自定义主题应用所有人 2025-09-22 17:20:02 +08:00
djteang c462e1c2b5 fixed:版本检查问题 2025-09-21 09:30:41 +08:00
djteang a5e9ce41f1 fixed:版本检查问题 2025-09-21 09:16:01 +08:00
djteang f3138e9681 fixed:镜像健康检查问题 2025-09-21 01:23:06 +08:00
djteang 167d328116 added:添加内置主题,支持用户自定义CSS
changed:优化搜索页面缓存机制
fixed:镜像健康检查问题,弹幕功能适配移动端
2025-09-21 01:19:44 +08:00
djteang c1f86270ec fixed:修复弹窗提示无法关闭问题 2025-09-16 10:17:10 +08:00
djteang 7b73c7c71d fixed:恢复版本检测功能 2025-09-16 09:06:24 +08:00
30 changed files with 3474 additions and 257 deletions

View File

@ -1,3 +1,57 @@
## [8.9.5] - 2025-09-21
### Added
- 添加内置主题支持用户自定义CSS
### Changed
- 优化搜索页面缓存机制
### Fixed
- 镜像健康检查问题
- 弹幕功能适配移动端
## [8.9.0] - 2025-09-15
### Added
- 机器识别码设定开关
- 配置文件去重添加
- 视频源编辑
- 单个视频源进行有效性检测
### Changed
- 聊天页面适配移动端
### Fixed
- 弹幕发送问题
- 播放页测速问题
## [8.8.9] - 2025-09-14
### Added
- 聊天,好友等功能
- 支持arm架构镜像
### Fixed
- 播放页面500问题
### Added
- 聊天,好友等功能
- 支持arm架构镜像
### Fixed
- 播放页面500问题
## [8.8.8] - 2025-09-12
### Added
- 新增短剧类目聚合
- 支持短剧类目搜索
- 弹幕功能
- 用户头像上传
- 设备识别码绑定用户
### Changed
- 美化界面
- 修改图标和标题
### Fixed
- 停用版本检查功能
## [100.0.0] - 2025-08-26
### Added

View File

@ -89,6 +89,43 @@ RUN corepack enable && corepack prepare pnpm@latest --activate && \
# 清理安装缓存减小镜像大小
pnpm store prune
# 创建健康检查脚本在切换用户之前以root权限创建
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 && \
chown nextjs:nodejs /app/healthcheck.js
# 切回非特权用户
USER nextjs
@ -97,7 +134,7 @@ EXPOSE 3000 3001
# 添加健康检查
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

View File

@ -26,7 +26,7 @@
- ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集
@ -77,6 +77,7 @@ services:
restart: on-failure
ports:
- '3000:3000'
- '3001:3001'
environment:
- USERNAME=admin
- PASSWORD=orange
@ -111,6 +112,7 @@ services:
restart: on-failure
ports:
- '3000:3000'
- '3001:3001'
environment:
- USERNAME=admin
- PASSWORD=orange
@ -147,6 +149,7 @@ services:
restart: on-failure
ports:
- '3000:3000'
- '3001:3001'
environment:
- USERNAME=admin
- PASSWORD=orange

View File

@ -1 +1 @@
8.8.8
8.9.5

View File

@ -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';
// 统一按钮样式系统
@ -2509,7 +2510,7 @@ const VideoSourceConfig = ({
// 有效性检测函数
const handleValidateSources = async () => {
if (!searchKeyword.trim()) {
showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空' });
showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空', showConfirm: true });
return;
}
@ -2583,7 +2584,7 @@ const VideoSourceConfig = ({
console.error('EventSource错误:', error);
eventSource.close();
setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试' });
showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试', showConfirm: true });
};
// 设置超时,防止长时间等待
@ -2591,13 +2592,13 @@ const VideoSourceConfig = ({
if (eventSource.readyState === EventSource.OPEN) {
eventSource.close();
setIsValidating(false);
showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试' });
showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试', showConfirm: true });
}
}, 60000); // 60秒超时
} catch (error) {
setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误' });
showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误', showConfirm: true });
throw error;
}
});
@ -2610,7 +2611,7 @@ const VideoSourceConfig = ({
isNewSource: boolean = false
) => {
if (!api.trim()) {
showAlert({ type: 'warning', title: 'API地址不能为空', message: '请输入有效的API地址' });
showAlert({ type: 'warning', title: 'API地址不能为空', message: '请输入有效的API地址', showConfirm: true });
return;
}
@ -2731,7 +2732,7 @@ const VideoSourceConfig = ({
// 单个视频源有效性检测函数
const handleValidateSingleSource = async () => {
if (!editingSource) {
showAlert({ type: 'warning', title: '没有可检测的视频源', message: '请确保正在编辑视频源' });
showAlert({ type: 'warning', title: '没有可检测的视频源', message: '请确保正在编辑视频源', showConfirm: true });
return;
}
await handleValidateSource(editingSource.api, editingSource.name, false);
@ -2740,7 +2741,7 @@ const VideoSourceConfig = ({
// 新增视频源有效性检测函数
const handleValidateNewSource = async () => {
if (!newSource.name.trim()) {
showAlert({ type: 'warning', title: '视频源名称不能为空', message: '请输入视频源名称' });
showAlert({ type: 'warning', title: '视频源名称不能为空', message: '请输入视频源名称', showConfirm: true });
return;
}
await handleValidateSource(newSource.api, newSource.name, true);
@ -2925,7 +2926,7 @@ const VideoSourceConfig = ({
// 批量操作
const handleBatchOperation = async (action: 'batch_enable' | 'batch_disable' | 'batch_delete') => {
if (selectedSources.size === 0) {
showAlert({ type: 'warning', title: '请先选择要操作的视频源', message: '请选择至少一个视频源' });
showAlert({ type: 'warning', title: '请先选择要操作的视频源', message: '请选择至少一个视频源', showConfirm: true });
return;
}
@ -2960,7 +2961,7 @@ const VideoSourceConfig = ({
// 重置选择状态
setSelectedSources(new Set());
} catch (err) {
showAlert({ type: 'error', title: `${actionName}失败`, message: err instanceof Error ? err.message : '操作失败' });
showAlert({ type: 'error', title: `${actionName}失败`, message: err instanceof Error ? err.message : '操作失败', showConfirm: true });
}
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
},
@ -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>

View File

@ -20,41 +20,74 @@ export async function GET(request: NextRequest) {
}
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
const username = authInfo?.username;
try {
const config = await getConfig();
const result: AdminConfigResult = {
Role: 'owner',
Config: config,
};
// 检查用户权限
let userRole = 'guest'; // 未登录用户为 guest
let isAdmin = false;
if (username === process.env.USERNAME) {
result.Role = 'owner';
} else {
userRole = 'owner';
isAdmin = true;
} else if (username) {
const user = config.UserConfig.Users.find((u) => u.username === username);
if (user && user.role === 'admin' && !user.banned) {
result.Role = 'admin';
userRole = 'admin';
isAdmin = true;
} else if (user && !user.banned) {
userRole = 'user';
} else if (user && user.banned) {
userRole = 'banned';
} else {
return NextResponse.json(
{ error: '你是管理员吗你就访问?' },
{ status: 401 }
);
// 认证了但用户不存在,可能是数据不同步
userRole = 'unknown';
}
}
// 根据用户权限返回不同的配置信息
if (isAdmin) {
// 管理员返回完整配置
const result: AdminConfigResult = {
Role: userRole as 'admin' | 'owner',
Config: config,
};
return NextResponse.json(result, {
headers: {
'Cache-Control': 'no-store', // 管理员配置不缓存
},
});
} else {
// 普通用户或未登录用户只返回公开配置
const publicConfig = {
ThemeConfig: config.ThemeConfig,
SiteConfig: {
SiteName: config.SiteConfig.SiteName,
Announcement: config.SiteConfig.Announcement,
// 其他公开的站点配置可以在这里添加
}
};
const result = {
Role: userRole,
Config: publicConfig,
};
console.log('返回公开配置给', userRole, ',包含主题配置:', !!publicConfig.ThemeConfig);
return NextResponse.json(result, {
headers: {
'Cache-Control': 'public, max-age=60', // 公开配置可以缓存1分钟
},
});
}
} catch (error) {
console.error('获取管理员配置失败:', error);
console.error('获取配置失败:', error);
return NextResponse.json(
{
error: '获取管理员配置失败',
error: '获取配置失败',
details: (error as Error).message,
},
{ status: 500 }

View File

@ -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,
};
// 写入数据库

View File

@ -0,0 +1,116 @@
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';
import { getConfig, setCachedConfig, clearCachedConfig } from '@/lib/config';
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 getConfig();
const themeConfig = config.ThemeConfig;
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 baseConfig = await getConfig();
// 更新主题配置
const updatedConfig: AdminConfig = {
...baseConfig,
ThemeConfig: {
defaultTheme: defaultTheme as 'default' | 'minimal' | 'warm' | 'fresh',
customCSS: customCSS || '',
allowUserCustomization: allowUserCustomization !== false,
},
};
console.log('=== 保存主题配置 ===');
console.log('请求参数:', { defaultTheme, customCSS, allowUserCustomization });
console.log('当前存储类型:', process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage');
console.log('待保存配置:', updatedConfig.ThemeConfig);
console.log('完整配置对象:', JSON.stringify(updatedConfig, null, 2));
await db.saveAdminConfig(updatedConfig);
console.log('主题配置保存成功');
// 直接更新缓存,确保缓存与数据库同步
await setCachedConfig(updatedConfig);
console.log('已更新配置缓存');
// 立即验证缓存中的配置
const cachedConfig = await getConfig();
console.log('保存后验证缓存中的配置:', cachedConfig.ThemeConfig);
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 }
);
}
}

View File

@ -298,11 +298,12 @@ function DoubanPageClient() {
id: item.id?.toString() || '',
title: item.name_cn || item.name,
poster:
item.images.large ||
item.images.common ||
item.images.medium ||
item.images.small ||
item.images.grid,
item.images?.large ||
item.images?.common ||
item.images?.medium ||
item.images?.small ||
item.images?.grid ||
'', // 空字符串,让 VideoCard 组件处理图片加载失败
rate: item.rating?.score?.toFixed(1) || '',
year: item.air_date?.split('-')?.[0] || '',
})),

File diff suppressed because it is too large Load Diff

View File

@ -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,60 @@ export default async function RootLayout({
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
}}
/>
{/* 立即从缓存应用主题,避免闪烁 */}
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
// 从localStorage立即获取缓存的主题配置
const cachedTheme = localStorage.getItem('theme-cache');
if (cachedTheme) {
try {
const themeConfig = JSON.parse(cachedTheme);
console.log('应用缓存主题配置:', themeConfig);
// 立即应用缓存的主题,避免闪烁
const html = document.documentElement;
// 清除现有主题
html.removeAttribute('data-theme');
// 应用缓存的主题
if (themeConfig.defaultTheme && themeConfig.defaultTheme !== 'default') {
html.setAttribute('data-theme', themeConfig.defaultTheme);
}
// 应用缓存的自定义CSS
if (themeConfig.customCSS) {
let customStyleEl = document.getElementById('custom-theme-css');
if (!customStyleEl) {
customStyleEl = document.createElement('style');
customStyleEl.id = 'custom-theme-css';
document.head.appendChild(customStyleEl);
}
customStyleEl.textContent = themeConfig.customCSS;
}
console.log('缓存主题已应用:', themeConfig.defaultTheme);
} catch (parseError) {
console.warn('解析缓存主题配置失败:', parseError);
localStorage.removeItem('theme-cache'); // 清除无效缓存
}
} else {
console.log('未找到缓存主题等待API获取');
}
} catch (error) {
console.error('应用缓存主题失败:', error);
}
})();
`,
}}
/>
</head>
<body
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
@ -122,6 +177,7 @@ export default async function RootLayout({
>
<ToastProvider>
<SiteProvider siteName={siteName} announcement={announcement}>
<GlobalThemeLoader />
{children}
<GlobalErrorIndicator />
</SiteProvider>

View File

@ -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>

View File

@ -372,20 +372,21 @@ function HomeClient() {
return todayAnimes.map((anime, index) => (
<div
key={`${anime.id}-${index}`}
key={`${anime.id || 0}-${index}`}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={anime.name_cn || anime.name}
title={anime.name_cn || anime.name || '未知标题'}
poster={
anime.images.large ||
anime.images.common ||
anime.images.medium ||
anime.images.small ||
anime.images.grid
anime.images?.large ||
anime.images?.common ||
anime.images?.medium ||
anime.images?.small ||
anime.images?.grid ||
'' // 空字符串,让 VideoCard 组件处理图片加载失败
}
douban_id={anime.id}
douban_id={anime.id || 0}
rate={anime.rating?.score?.toFixed(1) || ''}
year={anime.air_date?.split('-')?.[0] || ''}
isBangumi={true}

View File

@ -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的返回值不能正常显示

View File

@ -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'
>

View File

@ -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>

View File

@ -176,6 +176,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
type: 'error',
title: '错误',
message: '请输入加密密码',
showConfirm: true
});
return;
}
@ -231,6 +232,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
type: 'error',
title: '导出失败',
message: error instanceof Error ? error.message : '导出过程中发生错误',
showConfirm: true,
});
} finally {
setIsExporting(false);
@ -252,6 +254,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
type: 'error',
title: '错误',
message: '请选择备份文件',
showConfirm: true
});
return;
}
@ -261,6 +264,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
type: 'error',
title: '错误',
message: '请输入解密密码',
showConfirm: true
});
return;
}
@ -319,6 +323,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
type: 'error',
title: '导入失败',
message: error instanceof Error ? error.message : '导入过程中发生错误',
showConfirm: true
});
} finally {
setIsImporting(false);

View File

@ -0,0 +1,130 @@
'use client';
import { useEffect } from 'react';
// 全局主题加载器组件 - 从API同步最新配置确保缓存与服务端一致
const GlobalThemeLoader = () => {
useEffect(() => {
const syncThemeWithAPI = async () => {
try {
console.log('从API同步主题配置...');
const response = await fetch('/api/admin/config');
const result = await response.json();
if (result?.Config?.ThemeConfig) {
const themeConfig = result.Config.ThemeConfig;
const { defaultTheme, customCSS, allowUserCustomization } = themeConfig;
console.log('API返回主题配置:', {
defaultTheme,
customCSS,
allowUserCustomization
});
// 获取当前缓存的主题配置
const cachedTheme = getCachedTheme();
// 比较API配置与缓存配置
const configChanged = !cachedTheme ||
cachedTheme.defaultTheme !== defaultTheme ||
cachedTheme.customCSS !== customCSS;
if (configChanged) {
console.log('检测到主题配置变更,更新应用:', {
from: cachedTheme,
to: { defaultTheme, customCSS }
});
applyAndCacheTheme(defaultTheme, customCSS);
} else {
console.log('主题配置无变化,保持当前设置');
}
// 将配置存储到运行时配置中供ThemeManager使用
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig) {
runtimeConfig.THEME_CONFIG = themeConfig;
}
} else {
console.log('无法获取主题配置,使用默认配置:', result);
// API失败时如果有缓存就保持没有缓存就用默认
const cachedTheme = getCachedTheme();
if (!cachedTheme) {
console.log('无缓存,应用默认主题');
applyAndCacheTheme('default', '');
} else {
console.log('保持缓存主题:', cachedTheme);
}
}
} catch (error) {
console.error('API同步失败:', error);
// 错误时如果有缓存就保持,没有缓存就用默认
const cachedTheme = getCachedTheme();
if (!cachedTheme) {
console.log('无缓存且请求失败,应用默认主题');
applyAndCacheTheme('default', '');
} else {
console.log('请求失败,保持缓存主题:', cachedTheme);
}
}
};
// 获取缓存的主题配置
const getCachedTheme = () => {
try {
const cached = localStorage.getItem('theme-cache');
return cached ? JSON.parse(cached) : null;
} catch (error) {
console.warn('读取主题缓存失败:', error);
localStorage.removeItem('theme-cache');
return null;
}
};
// 应用主题并缓存
const applyAndCacheTheme = (themeId: string, css: string = '') => {
applyTheme(themeId, css);
// 缓存主题配置
const themeConfig = { defaultTheme: themeId, customCSS: css };
try {
localStorage.setItem('theme-cache', JSON.stringify(themeConfig));
console.log('主题配置已缓存:', themeConfig);
} catch (error) {
console.warn('缓存主题配置失败:', error);
}
};
// 应用主题函数
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;
};
// 延迟一点时间确保页面缓存主题已应用然后同步API配置
const timer = setTimeout(() => {
syncThemeWithAPI();
}, 100);
return () => clearTimeout(timer);
}, []);
return null; // 这是一个逻辑组件,不渲染任何内容
};
export default GlobalThemeLoader;

View File

@ -0,0 +1,801 @@
'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 isAdmin = role === 'admin' || role === 'owner';
// 更新主题缓存的辅助函数
const updateThemeCache = (themeId: string, css: string) => {
try {
const themeConfig = {
defaultTheme: themeId,
customCSS: css
};
localStorage.setItem('theme-cache', JSON.stringify(themeConfig));
console.log('主题配置已缓存:', themeConfig);
} catch (error) {
console.warn('缓存主题配置失败:', error);
}
};
// 从API加载主题配置唯一数据源
const loadGlobalThemeConfig = async () => {
try {
console.log('从API获取主题配置...');
const response = await fetch('/api/admin/config');
const result = await response.json();
if (result?.Config?.ThemeConfig) {
const themeConfig = result.Config.ThemeConfig;
console.log('API返回的主题配置:', themeConfig);
setGlobalThemeConfig(themeConfig);
// 更新运行时配置,保持同步
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig) {
runtimeConfig.THEME_CONFIG = themeConfig;
}
return themeConfig;
} else {
console.log('无法获取主题配置,可能未登录或权限不足:', result);
}
} catch (error) {
console.error('从API加载主题配置失败:', 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);
// 更新运行时配置,确保同步
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig) {
runtimeConfig.THEME_CONFIG = result.data;
console.log('已更新运行时主题配置:', result.data);
}
// 立即应用新的主题配置,确保当前页面也能看到更改
applyTheme(result.data.defaultTheme, result.data.customCSS);
// 更新本地缓存
updateThemeCache(result.data.defaultTheme, result.data.customCSS);
console.log('已立即应用新主题配置:', result.data.defaultTheme);
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 (globalConfig) {
// 使用全局配置
setCurrentTheme(globalConfig.defaultTheme);
setCustomCSS(globalConfig.customCSS);
applyTheme(globalConfig.defaultTheme, globalConfig.customCSS);
} else {
// 如果没有全局配置,使用默认值
const defaultTheme = 'default';
const defaultCSS = '';
setCurrentTheme(defaultTheme);
setCustomCSS(defaultCSS);
applyTheme(defaultTheme, defaultCSS);
}
};
initTheme();
}, []);
// 应用主题
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 (isAdmin) {
// 保存到全局配置
const success = await saveGlobalThemeConfig({
defaultTheme: themeId,
customCSS: customCSS,
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
});
// 如果保存成功,立即更新本地全局配置状态
if (success) {
setGlobalThemeConfig({
defaultTheme: themeId,
customCSS: customCSS,
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
});
}
}
const theme = themes.find(t => t.id === themeId);
showAlert({
type: 'success',
title: '全站主题已设置',
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 (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 {
showAlert({
type: 'warning',
title: '权限不足',
message: '仅管理员可以设置全站主题',
timer: 2000
});
}
} catch (error) {
showAlert({
type: 'error',
title: '样式应用失败',
message: 'CSS语法可能有误请检查后重试',
timer: 3000
});
}
};
// 重置自定义CSS
const handleCustomCSSReset = async () => {
setCustomCSS('');
applyTheme(currentTheme, '');
if (isAdmin) {
// 保存到全局配置
await saveGlobalThemeConfig({
defaultTheme: currentTheme,
customCSS: '',
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
});
setGlobalThemeConfig({
defaultTheme: currentTheme,
customCSS: '',
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
});
// 更新运行时配置
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig) {
runtimeConfig.THEME_CONFIG = {
defaultTheme: currentTheme,
customCSS: '',
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
};
}
// 更新本地缓存
updateThemeCache(currentTheme, '');
}
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 && globalThemeConfig && (
<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="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>
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-900/20 dark:border-blue-700">
<div className="flex items-center gap-2 text-blue-800 dark:text-blue-200">
<span className="text-sm font-medium"> </span>
</div>
<p className="text-xs text-blue-700 dark:text-blue-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" />
</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 transition-all ${currentTheme === theme.id
? 'border-theme-accent bg-theme-accent/5'
: 'border-theme-border bg-theme-surface'
} ${isAdmin ? 'cursor-pointer hover:border-theme-accent/50' : 'cursor-not-allowed opacity-60'}`}
onClick={() => isAdmin && 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();
if (isAdmin) handleThemePreview(theme.id);
}}
className={`p-1 transition-colors ${isAdmin ? 'text-theme-text-secondary hover:text-theme-accent' : 'text-theme-text-secondary opacity-50 cursor-not-allowed'}`}
title={isAdmin ? "预览主题" : "仅管理员可预览"}
disabled={previewMode || !isAdmin}
>
<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>
{isAdmin ? (
<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 className="text-sm text-theme-text-secondary">
</div>
)}
</div>
{!isAdmin && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-700 mb-4">
<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>
)}
{isAdmin && 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 模板库 */}
{isAdmin && (
<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>{isAdmin ? '选择预设主题即可一键切换全站整体风格' : '由管理员设置的全站预设主题'}</p>
{isAdmin && <p><strong>CSS</strong>CSS变量或直接样式实现全站个性化定制</p>}
{isAdmin && <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>
{isAdmin && (
<>
<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;

View File

@ -94,10 +94,14 @@ export function ThemeToggle() {
});
};
// 检查是否在登录页面
const isLoginPage = pathname === '/login';
return (
<>
<div className={`flex items-center ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
{/* 聊天按钮 */}
{/* 聊天按钮 - 在登录页面不显示 */}
{!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`}
@ -110,6 +114,7 @@ export function ThemeToggle() {
</span>
)}
</button>
)}
{/* 主题切换按钮 */}
<button
@ -125,7 +130,8 @@ export function ThemeToggle() {
</button>
</div>
{/* 聊天模态框 */}
{/* 聊天模态框 - 在登录页面不渲染 */}
{!isLoginPage && (
<ChatModal
isOpen={isChatModalOpen}
onClose={() => setIsChatModalOpen(false)}
@ -133,6 +139,7 @@ export function ThemeToggle() {
onChatCountReset={handleChatCountReset}
onFriendRequestCountReset={handleFriendRequestCountReset}
/>
)}
</>
);
}

View File

@ -81,16 +81,24 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
const fetchRemoteChangelog = async () => {
try {
const response = await fetch(
'https://raw.githubusercontent.com/MoonTechLab/LunaTV/main/CHANGELOG'
'https://raw.githubusercontent.com/djteang/OrangeTV/refs/heads/main/CHANGELOG'
);
if (response.ok) {
const content = await response.text();
const parsed = parseChangelog(content);
setRemoteChangelog(parsed);
// 检查是否有更新
// 检查是否有更新 - 基于日期而非版本号数字大小来确定最新版本
if (parsed.length > 0) {
const latest = parsed[0];
// 按日期排序,找到真正的最新版本
const sortedByDate = [...parsed].sort((a, b) => {
// 解析日期进行比较
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime(); // 降序排列,最新的在前
});
const latest = sortedByDate[0];
setLatestVersion(latest.version);
setIsHasUpdate(
compareVersions(latest.version) === UpdateStatus.HAS_UPDATE
@ -363,7 +371,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
</div>
</div>
<a
href='https://github.com/MoonTechLab/LunaTV'
href='https://github.com/djteang/OrangeTV'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
@ -441,6 +449,12 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
);
return !localVersions.includes(entry.version);
})
.sort((a, b) => {
// 按日期排序,确保最新的版本在前面显示
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime(); // 降序排列,最新的在前
})
.map((entry, index) => (
<div
key={index}

View File

@ -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_', '')}`;

20
src/hooks/useTheme.ts Normal file
View File

@ -0,0 +1,20 @@
// 全局主题Hook - 已弃用,主题现在由 GlobalThemeLoader 统一管理
// 保留此文件是为了向后兼容性,但不再使用
export const useThemeInit = () => {
// 不再执行任何操作,主题由 GlobalThemeLoader 处理
console.warn('useThemeInit is deprecated. Theme is now managed by GlobalThemeLoader.');
};
export const useTheme = () => {
// 已弃用:主题现在由 GlobalThemeLoader 和 ThemeManager 统一管理
console.warn('useTheme is deprecated. Use ThemeManager component for theme management.');
return {
applyTheme: () => console.warn('applyTheme is deprecated'),
getCurrentTheme: () => 'default',
getCurrentCustomCSS: () => ''
};
};

View File

@ -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: {

View File

@ -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",

View File

@ -379,6 +379,15 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
adminConfig.SiteConfig.RequireDeviceCode = process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false';
}
// 确保 ThemeConfig 存在
if (!adminConfig.ThemeConfig) {
adminConfig.ThemeConfig = {
defaultTheme: 'default',
customCSS: '',
allowUserCustomization: true,
};
}
// 站长变更自检
const ownerUser = process.env.USERNAME;
@ -517,3 +526,7 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
export async function setCachedConfig(config: AdminConfig) {
cachedConfig = config;
}
export function clearCachedConfig() {
cachedConfig = undefined as any;
}

View File

@ -72,7 +72,14 @@ export class UpstashRedisStorage implements IStorage {
const val = await withRetry(() =>
this.client.get(this.prKey(userName, key))
);
return val ? (val as PlayRecord) : null;
if (!val) return null;
try {
return typeof val === 'string' ? JSON.parse(val) : val as PlayRecord;
} catch (error) {
console.error('解析播放记录失败:', error);
return null;
}
}
async setPlayRecord(
@ -80,7 +87,7 @@ export class UpstashRedisStorage implements IStorage {
key: string,
record: PlayRecord
): Promise<void> {
await withRetry(() => this.client.set(this.prKey(userName, key), record));
await withRetry(() => this.client.set(this.prKey(userName, key), JSON.stringify(record)));
}
async getAllPlayRecords(
@ -94,9 +101,14 @@ export class UpstashRedisStorage implements IStorage {
for (const fullKey of keys) {
const value = await withRetry(() => this.client.get(fullKey));
if (value) {
try {
// 截取 source+id 部分
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
result[keyPart] = value as PlayRecord;
const record = typeof value === 'string' ? JSON.parse(value) : value as PlayRecord;
result[keyPart] = record;
} catch (error) {
console.error('解析播放记录失败:', error, 'key:', fullKey);
}
}
}
return result;
@ -115,7 +127,14 @@ export class UpstashRedisStorage implements IStorage {
const val = await withRetry(() =>
this.client.get(this.favKey(userName, key))
);
return val ? (val as Favorite) : null;
if (!val) return null;
try {
return typeof val === 'string' ? JSON.parse(val) : val as Favorite;
} catch (error) {
console.error('解析收藏失败:', error);
return null;
}
}
async setFavorite(
@ -124,7 +143,7 @@ export class UpstashRedisStorage implements IStorage {
favorite: Favorite
): Promise<void> {
await withRetry(() =>
this.client.set(this.favKey(userName, key), favorite)
this.client.set(this.favKey(userName, key), JSON.stringify(favorite))
);
}
@ -137,8 +156,13 @@ export class UpstashRedisStorage implements IStorage {
for (const fullKey of keys) {
const value = await withRetry(() => this.client.get(fullKey));
if (value) {
try {
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
result[keyPart] = value as Favorite;
const favorite = typeof value === 'string' ? JSON.parse(value) : value as Favorite;
result[keyPart] = favorite;
} catch (error) {
console.error('解析收藏失败:', error, 'key:', fullKey);
}
}
}
return result;
@ -270,11 +294,24 @@ export class UpstashRedisStorage implements IStorage {
async getAdminConfig(): Promise<AdminConfig | null> {
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
return val ? (val as AdminConfig) : null;
if (!val) return null;
try {
// 尝试解析JSON字符串兼容BaseRedisStorage格式
if (typeof val === 'string') {
return JSON.parse(val) as AdminConfig;
}
// 如果已经是对象直接返回Upstash自动反序列化的情况
return val as AdminConfig;
} catch (error) {
console.error('解析管理员配置失败:', error);
return null;
}
}
async setAdminConfig(config: AdminConfig): Promise<void> {
await withRetry(() => this.client.set(this.adminConfigKey(), config));
// 统一使用JSON字符串格式存储与BaseRedisStorage保持一致
await withRetry(() => this.client.set(this.adminConfigKey(), JSON.stringify(config)));
}
// ---------- 跳过片头片尾配置 ----------
@ -290,7 +327,14 @@ export class UpstashRedisStorage implements IStorage {
const val = await withRetry(() =>
this.client.get(this.skipConfigKey(userName, source, id))
);
return val ? (val as SkipConfig) : null;
if (!val) return null;
try {
return typeof val === 'string' ? JSON.parse(val) : val as SkipConfig;
} catch (error) {
console.error('解析跳过配置失败:', error);
return null;
}
}
async setSkipConfig(
@ -300,7 +344,7 @@ export class UpstashRedisStorage implements IStorage {
config: SkipConfig
): Promise<void> {
await withRetry(() =>
this.client.set(this.skipConfigKey(userName, source, id), config)
this.client.set(this.skipConfigKey(userName, source, id), JSON.stringify(config))
);
}
@ -332,11 +376,16 @@ export class UpstashRedisStorage implements IStorage {
keys.forEach((key, index) => {
const value = values[index];
if (value) {
try {
// 从key中提取source+id
const match = key.match(/^u:.+?:skip:(.+)$/);
if (match) {
const sourceAndId = match[1];
configs[sourceAndId] = value as SkipConfig;
const config = typeof value === 'string' ? JSON.parse(value) : value as SkipConfig;
configs[sourceAndId] = config;
}
} catch (error) {
console.error('解析跳过配置失败:', error, 'key:', key);
}
}
});
@ -373,7 +422,16 @@ export class UpstashRedisStorage implements IStorage {
async getDanmu(videoId: string): Promise<any[]> {
const val = await withRetry(() => this.client.lrange(this.danmuKey(videoId), 0, -1));
return val ? val.map(item => JSON.parse(ensureString(item))) : [];
if (!val || !Array.isArray(val)) return [];
return val.map(item => {
try {
return typeof item === 'string' ? JSON.parse(item) : item;
} catch (error) {
console.error('解析弹幕数据失败:', error);
return null;
}
}).filter(item => item !== null);
}
async saveDanmu(videoId: string, userName: string, danmu: {
@ -425,9 +483,11 @@ export class UpstashRedisStorage implements IStorage {
if (!val) return null;
try {
const data = JSON.parse(ensureString(val));
// 处理不同的序列化格式
const data = typeof val === 'string' ? JSON.parse(val) : val;
return data.machineCode || null;
} catch {
} catch (error) {
console.error('解析用户机器码失败:', error);
return null;
}
}
@ -439,7 +499,7 @@ export class UpstashRedisStorage implements IStorage {
bindTime: Date.now()
};
// 保存用户的机器码
// 保存用户的机器码 - 统一使用JSON序列化
await withRetry(() =>
this.client.set(this.machineCodeKey(userName), JSON.stringify(data))
);
@ -481,10 +541,11 @@ export class UpstashRedisStorage implements IStorage {
if (val) {
try {
const data = JSON.parse(ensureString(val));
// 处理不同的序列化格式
const data = typeof val === 'string' ? JSON.parse(val) : val;
result[userName] = data;
} catch {
// 忽略解析错误
} catch (error) {
console.error('解析机器码用户数据失败:', error, 'key:', key);
}
}
}
@ -536,9 +597,9 @@ export class UpstashRedisStorage implements IStorage {
// 消息管理
async saveMessage(message: ChatMessage): Promise<void> {
// 保存消息详情
// 保存消息详情 - 使用JSON序列化
await withRetry(() =>
this.client.set(this.messageKey(message.id), message)
this.client.set(this.messageKey(message.id), JSON.stringify(message))
);
// 将消息ID添加到对话的消息列表中按时间排序
@ -560,7 +621,12 @@ export class UpstashRedisStorage implements IStorage {
for (const messageId of messageIds) {
const messageData = await withRetry(() => this.client.get(this.messageKey(messageId as string)));
if (messageData) {
messages.push(messageData as ChatMessage);
try {
const message = typeof messageData === 'string' ? JSON.parse(messageData) : messageData;
messages.push(message as ChatMessage);
} catch (error) {
console.error('解析消息失败:', error);
}
}
}
@ -570,11 +636,15 @@ export class UpstashRedisStorage implements IStorage {
async markMessageAsRead(messageId: string): Promise<void> {
const messageData = await withRetry(() => this.client.get(this.messageKey(messageId)));
if (messageData) {
const message = messageData as ChatMessage;
try {
const message = typeof messageData === 'string' ? JSON.parse(messageData) : messageData as ChatMessage;
message.is_read = true;
await withRetry(() =>
this.client.set(this.messageKey(messageId), message)
this.client.set(this.messageKey(messageId), JSON.stringify(message))
);
} catch (error) {
console.error('标记消息为已读失败:', error);
}
}
}
@ -601,13 +671,20 @@ export class UpstashRedisStorage implements IStorage {
this.client.get(this.conversationKey(conversationId))
);
return conversationData ? (conversationData as Conversation) : null;
if (!conversationData) return null;
try {
return typeof conversationData === 'string' ? JSON.parse(conversationData) : conversationData as Conversation;
} catch (error) {
console.error('解析对话失败:', error);
return null;
}
}
async createConversation(conversation: Conversation): Promise<void> {
// 保存对话详情
// 保存对话详情 - 使用JSON序列化
await withRetry(() =>
this.client.set(this.conversationKey(conversation.id), conversation)
this.client.set(this.conversationKey(conversation.id), JSON.stringify(conversation))
);
// 将对话ID添加到每个参与者的对话列表中
@ -623,7 +700,7 @@ export class UpstashRedisStorage implements IStorage {
if (conversation) {
Object.assign(conversation, updates);
await withRetry(() =>
this.client.set(this.conversationKey(conversationId), conversation)
this.client.set(this.conversationKey(conversationId), JSON.stringify(conversation))
);
}
}
@ -656,7 +733,12 @@ export class UpstashRedisStorage implements IStorage {
for (const friendId of friendIds) {
const friendData = await withRetry(() => this.client.get(this.friendKey(friendId)));
if (friendData) {
friends.push(friendData as Friend);
try {
const friend = typeof friendData === 'string' ? JSON.parse(friendData) : friendData;
friends.push(friend as Friend);
} catch (error) {
console.error('解析好友数据失败:', error);
}
}
}
@ -664,9 +746,9 @@ export class UpstashRedisStorage implements IStorage {
}
async addFriend(userName: string, friend: Friend): Promise<void> {
// 保存好友详情
// 保存好友详情 - 使用JSON序列化
await withRetry(() =>
this.client.set(this.friendKey(friend.id), friend)
this.client.set(this.friendKey(friend.id), JSON.stringify(friend))
);
// 将好友ID添加到用户的好友列表中
@ -688,11 +770,15 @@ export class UpstashRedisStorage implements IStorage {
async updateFriendStatus(friendId: string, status: Friend['status']): Promise<void> {
const friendData = await withRetry(() => this.client.get(this.friendKey(friendId)));
if (friendData) {
const friend = friendData as Friend;
try {
const friend = typeof friendData === 'string' ? JSON.parse(friendData) : friendData as Friend;
friend.status = status;
await withRetry(() =>
this.client.set(this.friendKey(friendId), friend)
this.client.set(this.friendKey(friendId), JSON.stringify(friend))
);
} catch (error) {
console.error('更新好友状态失败:', error);
}
}
}
@ -706,11 +792,15 @@ export class UpstashRedisStorage implements IStorage {
for (const requestId of requestIds) {
const requestData = await withRetry(() => this.client.get(this.friendRequestKey(requestId)));
if (requestData) {
const request = requestData as FriendRequest;
try {
const request = typeof requestData === 'string' ? JSON.parse(requestData) : requestData as FriendRequest;
// 只返回相关的申请(发送给该用户的或该用户发送的)
if (request.to_user === userName || request.from_user === userName) {
requests.push(request);
}
} catch (error) {
console.error('解析好友申请失败:', error);
}
}
}
@ -718,9 +808,9 @@ export class UpstashRedisStorage implements IStorage {
}
async createFriendRequest(request: FriendRequest): Promise<void> {
// 保存申请详情
// 保存申请详情 - 使用JSON序列化
await withRetry(() =>
this.client.set(this.friendRequestKey(request.id), request)
this.client.set(this.friendRequestKey(request.id), JSON.stringify(request))
);
// 将申请ID添加到双方的申请列表中
@ -735,19 +825,24 @@ export class UpstashRedisStorage implements IStorage {
async updateFriendRequest(requestId: string, status: FriendRequest['status']): Promise<void> {
const requestData = await withRetry(() => this.client.get(this.friendRequestKey(requestId)));
if (requestData) {
const request = requestData as FriendRequest;
try {
const request = typeof requestData === 'string' ? JSON.parse(requestData) : requestData as FriendRequest;
request.status = status;
request.updated_at = Date.now();
await withRetry(() =>
this.client.set(this.friendRequestKey(requestId), request)
this.client.set(this.friendRequestKey(requestId), JSON.stringify(request))
);
} catch (error) {
console.error('更新好友申请失败:', error);
}
}
}
async deleteFriendRequest(requestId: string): Promise<void> {
const requestData = await withRetry(() => this.client.get(this.friendRequestKey(requestId)));
if (requestData) {
const request = requestData as FriendRequest;
try {
const request = typeof requestData === 'string' ? JSON.parse(requestData) : requestData as FriendRequest;
// 从双方的申请列表中移除
await withRetry(() =>
@ -756,6 +851,9 @@ export class UpstashRedisStorage implements IStorage {
await withRetry(() =>
this.client.srem(this.userFriendRequestsKey(request.to_user), requestId)
);
} catch (error) {
console.error('删除好友申请失败:', error);
}
}
// 删除申请详情

View File

@ -1,6 +1,6 @@
/* eslint-disable no-console */
const CURRENT_VERSION = '8.9.0';
const CURRENT_VERSION = '8.9.5';
// 导出当前版本号供其他地方使用
export { CURRENT_VERSION };

View File

@ -2,7 +2,7 @@
'use client';
import { CURRENT_VERSION } from "@/lib/version";
import { CURRENT_VERSION } from '@/lib/version';
// 版本检查结果枚举
export enum UpdateStatus {
@ -13,7 +13,7 @@ export enum UpdateStatus {
// 远程版本检查URL配置
const VERSION_CHECK_URLS = [
'https://raw.githubusercontent.com/MoonTechLab/LunaTV/main/VERSION.txt',
'https://raw.githubusercontent.com/djteang/OrangeTV/refs/heads/main/VERSION.txt',
];
/**
@ -89,13 +89,15 @@ async function fetchVersionFromUrl(url: string): Promise<string | null> {
*/
export function compareVersions(remoteVersion: string): UpdateStatus {
// 如果版本号相同,无需更新
if ('8.9.0' === CURRENT_VERSION) {
if (remoteVersion === CURRENT_VERSION) {
return UpdateStatus.NO_UPDATE;
}
try {
// 解析版本号为数字数组 [X, Y, Z]
const currentParts = (CURRENT_VERSION as string).split('.').map((part: string) => {
const currentParts = (CURRENT_VERSION as string)
.split('.')
.map((part: string) => {
const num = parseInt(part, 10);
if (isNaN(num) || num < 0) {
throw new Error(`无效的版本号格式: ${CURRENT_VERSION}`);

View File

@ -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: {