mirror of https://github.com/djteang/OrangeTV.git
Compare commits
15 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
2194a3d6ad | |
|
|
3fd6211697 | |
|
|
7357131005 | |
|
|
49d1d3b8b8 | |
|
|
668146f414 | |
|
|
b07b4ef36a | |
|
|
7707ba5414 | |
|
|
3457a7c565 | |
|
|
c462e1c2b5 | |
|
|
a5e9ce41f1 | |
|
|
f3138e9681 | |
|
|
167d328116 | |
|
|
c1f86270ec | |
|
|
7b73c7c71d | |
|
|
091ca9d2ff |
54
CHANGELOG
54
CHANGELOG
|
|
@ -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
|
||||
|
|
|
|||
54
Dockerfile
54
Dockerfile
|
|
@ -16,6 +16,21 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制所有文件
|
||||
COPY . .
|
||||
|
||||
# 然后检查文件
|
||||
RUN echo "文件列表:" && ls -la && \
|
||||
echo "检查 tsconfig.json:" && \
|
||||
if [ -f "tsconfig.json" ]; then \
|
||||
echo "tsconfig.json 存在"; \
|
||||
else \
|
||||
echo "tsconfig.json 不存在"; \
|
||||
echo "查找所有文件:"; \
|
||||
find . -type f -name "*tsconfig*"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# 安装所有依赖
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
|
@ -74,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
|
||||
|
||||
|
|
@ -82,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
8.8.8
|
||||
8.9.5
|
||||
|
|
@ -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';
|
||||
|
||||
// 统一按钮样式系统
|
||||
|
|
@ -496,6 +497,7 @@ interface SiteConfig {
|
|||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
RequireDeviceCode: boolean;
|
||||
}
|
||||
|
||||
// 视频源数据类型
|
||||
|
|
@ -2295,6 +2297,7 @@ const VideoSourceConfig = ({
|
|||
const { isLoading, withLoading } = useLoadingState();
|
||||
const [sources, setSources] = useState<DataSource[]>([]);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingSource, setEditingSource] = useState<DataSource | null>(null);
|
||||
const [orderChanged, setOrderChanged] = useState(false);
|
||||
const [newSource, setNewSource] = useState<DataSource>({
|
||||
name: '',
|
||||
|
|
@ -2340,6 +2343,32 @@ const VideoSourceConfig = ({
|
|||
resultCount: number;
|
||||
}>>([]);
|
||||
|
||||
// 单个视频源验证状态
|
||||
const [singleValidationResult, setSingleValidationResult] = useState<{
|
||||
status: 'valid' | 'invalid' | 'no_results' | 'validating' | null;
|
||||
message: string;
|
||||
details?: {
|
||||
responseTime?: number;
|
||||
resultCount?: number;
|
||||
error?: string;
|
||||
searchKeyword?: string;
|
||||
};
|
||||
}>({ status: null, message: '' });
|
||||
const [isSingleValidating, setIsSingleValidating] = useState(false);
|
||||
|
||||
// 新增视频源验证状态
|
||||
const [newSourceValidationResult, setNewSourceValidationResult] = useState<{
|
||||
status: 'valid' | 'invalid' | 'no_results' | 'validating' | null;
|
||||
message: string;
|
||||
details?: {
|
||||
responseTime?: number;
|
||||
resultCount?: number;
|
||||
error?: string;
|
||||
searchKeyword?: string;
|
||||
};
|
||||
}>({ status: null, message: '' });
|
||||
const [isNewSourceValidating, setIsNewSourceValidating] = useState(false);
|
||||
|
||||
// dnd-kit 传感器
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
|
@ -2422,11 +2451,42 @@ const VideoSourceConfig = ({
|
|||
from: 'custom',
|
||||
});
|
||||
setShowAddForm(false);
|
||||
// 清除检测结果
|
||||
clearNewSourceValidation();
|
||||
}).catch(() => {
|
||||
console.error('操作失败', 'add', newSource);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditSource = () => {
|
||||
if (!editingSource || !editingSource.name || !editingSource.api) return;
|
||||
withLoading('editSource', async () => {
|
||||
await callSourceApi({
|
||||
action: 'edit',
|
||||
key: editingSource.key,
|
||||
name: editingSource.name,
|
||||
api: editingSource.api,
|
||||
detail: editingSource.detail,
|
||||
});
|
||||
setEditingSource(null);
|
||||
}).catch(() => {
|
||||
console.error('操作失败', 'edit', editingSource);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingSource(null);
|
||||
// 清除单个源的检测结果
|
||||
setSingleValidationResult({ status: null, message: '' });
|
||||
setIsSingleValidating(false);
|
||||
};
|
||||
|
||||
// 清除新增视频源检测结果
|
||||
const clearNewSourceValidation = () => {
|
||||
setNewSourceValidationResult({ status: null, message: '' });
|
||||
setIsNewSourceValidating(false);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: any) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
|
@ -2450,7 +2510,7 @@ const VideoSourceConfig = ({
|
|||
// 有效性检测函数
|
||||
const handleValidateSources = async () => {
|
||||
if (!searchKeyword.trim()) {
|
||||
showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空' });
|
||||
showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空', showConfirm: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2524,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 });
|
||||
};
|
||||
|
||||
// 设置超时,防止长时间等待
|
||||
|
|
@ -2532,18 +2592,161 @@ 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 通用视频源有效性检测函数
|
||||
const handleValidateSource = async (
|
||||
api: string,
|
||||
name: string,
|
||||
isNewSource: boolean = false
|
||||
) => {
|
||||
if (!api.trim()) {
|
||||
showAlert({ type: 'warning', title: 'API地址不能为空', message: '请输入有效的API地址', showConfirm: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const validationKey = isNewSource ? 'validateNewSource' : 'validateSingleSource';
|
||||
const setValidating = isNewSource ? setIsNewSourceValidating : setIsSingleValidating;
|
||||
const setResult = isNewSource ? setNewSourceValidationResult : setSingleValidationResult;
|
||||
|
||||
await withLoading(validationKey, async () => {
|
||||
setValidating(true);
|
||||
setResult({ status: 'validating', message: '检测中...' });
|
||||
|
||||
const startTime = Date.now();
|
||||
const testKeyword = '灵笼';
|
||||
|
||||
try {
|
||||
// 构建检测 URL,使用临时 API 地址
|
||||
const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(testKeyword)}&tempApi=${encodeURIComponent(api.trim())}&tempName=${encodeURIComponent(name)}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
switch (data.type) {
|
||||
case 'start':
|
||||
console.log(`开始检测视频源: ${name}`);
|
||||
break;
|
||||
|
||||
case 'source_result':
|
||||
case 'source_error':
|
||||
if (data.source === 'temp') {
|
||||
let message = '';
|
||||
let details: any = {
|
||||
responseTime,
|
||||
searchKeyword: testKeyword
|
||||
};
|
||||
|
||||
if (data.status === 'valid') {
|
||||
message = '搜索正常';
|
||||
details.resultCount = data.resultCount || 0;
|
||||
} else if (data.status === 'no_results') {
|
||||
message = '无法搜索到结果';
|
||||
details.resultCount = 0;
|
||||
} else {
|
||||
message = '连接失败';
|
||||
details.error = data.error || '未知错误';
|
||||
}
|
||||
|
||||
setResult({
|
||||
status: data.status,
|
||||
message,
|
||||
details
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
console.log(`检测完成: ${name}`);
|
||||
eventSource.close();
|
||||
setValidating(false);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析EventSource数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('EventSource错误:', error);
|
||||
eventSource.close();
|
||||
setValidating(false);
|
||||
const responseTime = Date.now() - startTime;
|
||||
setResult({
|
||||
status: 'invalid',
|
||||
message: '连接错误,请重试',
|
||||
details: {
|
||||
responseTime,
|
||||
error: '网络连接失败',
|
||||
searchKeyword: testKeyword
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 设置超时,防止长时间等待
|
||||
setTimeout(() => {
|
||||
if (eventSource.readyState === EventSource.OPEN) {
|
||||
eventSource.close();
|
||||
setValidating(false);
|
||||
const responseTime = Date.now() - startTime;
|
||||
setResult({
|
||||
status: 'invalid',
|
||||
message: '检测超时,请重试',
|
||||
details: {
|
||||
responseTime,
|
||||
error: '请求超时(30秒)',
|
||||
searchKeyword: testKeyword
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 30000); // 30秒超时
|
||||
|
||||
} catch (error) {
|
||||
setValidating(false);
|
||||
const responseTime = Date.now() - startTime;
|
||||
setResult({
|
||||
status: 'invalid',
|
||||
message: error instanceof Error ? error.message : '未知错误',
|
||||
details: {
|
||||
responseTime,
|
||||
error: error instanceof Error ? error.message : '未知错误',
|
||||
searchKeyword: testKeyword
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 单个视频源有效性检测函数
|
||||
const handleValidateSingleSource = async () => {
|
||||
if (!editingSource) {
|
||||
showAlert({ type: 'warning', title: '没有可检测的视频源', message: '请确保正在编辑视频源', showConfirm: true });
|
||||
return;
|
||||
}
|
||||
await handleValidateSource(editingSource.api, editingSource.name, false);
|
||||
};
|
||||
|
||||
// 新增视频源有效性检测函数
|
||||
const handleValidateNewSource = async () => {
|
||||
if (!newSource.name.trim()) {
|
||||
showAlert({ type: 'warning', title: '视频源名称不能为空', message: '请输入视频源名称', showConfirm: true });
|
||||
return;
|
||||
}
|
||||
await handleValidateSource(newSource.api, newSource.name, true);
|
||||
};
|
||||
|
||||
// 获取有效性状态显示
|
||||
const getValidationStatus = (sourceKey: string) => {
|
||||
const result = validationResults.find(r => r.key === sourceKey);
|
||||
|
|
@ -2671,15 +2874,27 @@ const VideoSourceConfig = ({
|
|||
>
|
||||
{!source.disabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
{source.from !== 'config' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSource(source);
|
||||
// 清除之前的检测结果
|
||||
setSingleValidationResult({ status: null, message: '' });
|
||||
setIsSingleValidating(false);
|
||||
}}
|
||||
disabled={isLoading(`editSource_${source.key}`)}
|
||||
className={`${buttonStyles.roundedPrimary} ${isLoading(`editSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title='编辑此视频源'
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(source.key)}
|
||||
disabled={isLoading(`deleteSource_${source.key}`)}
|
||||
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title='删除此视频源'
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
@ -2711,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;
|
||||
}
|
||||
|
||||
|
|
@ -2746,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: () => { } });
|
||||
},
|
||||
|
|
@ -2824,7 +3039,13 @@ const VideoSourceConfig = ({
|
|||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
onClick={() => {
|
||||
setShowAddForm(!showAddForm);
|
||||
// 切换表单时清除检测结果
|
||||
if (!showAddForm) {
|
||||
clearNewSourceValidation();
|
||||
}
|
||||
}}
|
||||
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
|
||||
>
|
||||
{showAddForm ? '取消' : '添加视频源'}
|
||||
|
|
@ -2873,11 +3094,62 @@ const VideoSourceConfig = ({
|
|||
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
|
||||
{/* 新增视频源有效性检测结果显示 */}
|
||||
{newSourceValidationResult.status && (
|
||||
<div className='p-3 rounded-lg border'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>检测结果:</span>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full ${newSourceValidationResult.status === 'valid'
|
||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
: newSourceValidationResult.status === 'validating'
|
||||
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
|
||||
: newSourceValidationResult.status === 'no_results'
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
|
||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{newSourceValidationResult.status === 'valid' && '✓ '}
|
||||
{newSourceValidationResult.status === 'validating' && '⏳ '}
|
||||
{newSourceValidationResult.status === 'no_results' && '⚠️ '}
|
||||
{newSourceValidationResult.status === 'invalid' && '✗ '}
|
||||
{newSourceValidationResult.message}
|
||||
</span>
|
||||
</div>
|
||||
{newSourceValidationResult.details && (
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400 space-y-1'>
|
||||
{newSourceValidationResult.details.searchKeyword && (
|
||||
<div>测试关键词: {newSourceValidationResult.details.searchKeyword}</div>
|
||||
)}
|
||||
{newSourceValidationResult.details.responseTime && (
|
||||
<div>响应时间: {newSourceValidationResult.details.responseTime}ms</div>
|
||||
)}
|
||||
{newSourceValidationResult.details.resultCount !== undefined && (
|
||||
<div>搜索结果数: {newSourceValidationResult.details.resultCount}</div>
|
||||
)}
|
||||
{newSourceValidationResult.details.error && (
|
||||
<div className='text-red-600 dark:text-red-400'>错误信息: {newSourceValidationResult.details.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<button
|
||||
onClick={handleValidateNewSource}
|
||||
disabled={!newSource.api || isNewSourceValidating || isLoading('validateNewSource')}
|
||||
className={`px-4 py-2 ${!newSource.api || isNewSourceValidating || isLoading('validateNewSource') ? buttonStyles.disabled : buttonStyles.primary}`}
|
||||
>
|
||||
{isNewSourceValidating || isLoading('validateNewSource') ? '检测中...' : '有效性检测'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddSource}
|
||||
disabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')}
|
||||
className={`w-full sm:w-auto px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`}
|
||||
className={`px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`}
|
||||
>
|
||||
{isLoading('addSource') ? '添加中...' : '添加'}
|
||||
</button>
|
||||
|
|
@ -2885,6 +3157,140 @@ const VideoSourceConfig = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑视频源表单 */}
|
||||
{editingSource && (
|
||||
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
编辑视频源: {editingSource.name}
|
||||
</h5>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className='text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||
名称
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={editingSource.name}
|
||||
onChange={(e) =>
|
||||
setEditingSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null)
|
||||
}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||
Key (不可编辑)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={editingSource.key}
|
||||
disabled
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||
API 地址
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={editingSource.api}
|
||||
onChange={(e) =>
|
||||
setEditingSource((prev) => prev ? ({ ...prev, api: e.target.value }) : null)
|
||||
}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||
Detail 地址(选填)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={editingSource.detail || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSource((prev) => prev ? ({ ...prev, detail: e.target.value }) : null)
|
||||
}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 有效性检测结果显示 */}
|
||||
{singleValidationResult.status && (
|
||||
<div className='col-span-full mt-4 p-3 rounded-lg border'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>检测结果:</span>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full ${singleValidationResult.status === 'valid'
|
||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
: singleValidationResult.status === 'validating'
|
||||
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
|
||||
: singleValidationResult.status === 'no_results'
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
|
||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{singleValidationResult.status === 'valid' && '✓ '}
|
||||
{singleValidationResult.status === 'validating' && '⏳ '}
|
||||
{singleValidationResult.status === 'no_results' && '⚠️ '}
|
||||
{singleValidationResult.status === 'invalid' && '✗ '}
|
||||
{singleValidationResult.message}
|
||||
</span>
|
||||
</div>
|
||||
{singleValidationResult.details && (
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400 space-y-1'>
|
||||
{singleValidationResult.details.searchKeyword && (
|
||||
<div>测试关键词: {singleValidationResult.details.searchKeyword}</div>
|
||||
)}
|
||||
{singleValidationResult.details.responseTime && (
|
||||
<div>响应时间: {singleValidationResult.details.responseTime}ms</div>
|
||||
)}
|
||||
{singleValidationResult.details.resultCount !== undefined && (
|
||||
<div>搜索结果数: {singleValidationResult.details.resultCount}</div>
|
||||
)}
|
||||
{singleValidationResult.details.error && (
|
||||
<div className='text-red-600 dark:text-red-400'>错误信息: {singleValidationResult.details.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className={buttonStyles.secondary}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleValidateSingleSource}
|
||||
disabled={!editingSource.api || isSingleValidating || isLoading('validateSingleSource')}
|
||||
className={`${!editingSource.api || isSingleValidating || isLoading('validateSingleSource') ? buttonStyles.disabled : buttonStyles.primary}`}
|
||||
>
|
||||
{isSingleValidating || isLoading('validateSingleSource') ? '检测中...' : '有效性检测'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditSource}
|
||||
disabled={!editingSource.name || !editingSource.api || isLoading('editSource')}
|
||||
className={`${!editingSource.name || !editingSource.api || isLoading('editSource') ? buttonStyles.disabled : buttonStyles.success}`}
|
||||
>
|
||||
{isLoading('editSource') ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* 视频源表格 */}
|
||||
|
|
@ -3657,6 +4063,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||
DoubanImageProxy: '',
|
||||
DisableYellowFilter: false,
|
||||
FluidSearch: true,
|
||||
RequireDeviceCode: true,
|
||||
});
|
||||
|
||||
// 豆瓣数据源相关状态
|
||||
|
|
@ -3719,6 +4126,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
||||
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
||||
FluidSearch: config.SiteConfig.FluidSearch || true,
|
||||
RequireDeviceCode: config.SiteConfig.RequireDeviceCode !== undefined ? config.SiteConfig.RequireDeviceCode : true,
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
|
@ -4103,6 +4511,40 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 启用设备码验证 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
启用设备码验证
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
RequireDeviceCode: !prev.RequireDeviceCode,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${siteSettings.RequireDeviceCode
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${siteSettings.RequireDeviceCode
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
启用后用户登录时需要绑定设备码,提升账户安全性。禁用后用户可以直接登录而无需绑定设备码。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 禁用黄色过滤器 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
|
|
@ -4769,6 +5211,7 @@ function AdminPageClient() {
|
|||
categoryConfig: false,
|
||||
configFile: false,
|
||||
dataMigration: false,
|
||||
themeManager: false,
|
||||
});
|
||||
|
||||
// 机器码管理状态
|
||||
|
|
@ -5006,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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export async function POST(request: NextRequest) {
|
|||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
RequireDeviceCode,
|
||||
CustomTheme,
|
||||
} = body as {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
|
|
@ -50,6 +52,11 @@ export async function POST(request: NextRequest) {
|
|||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
RequireDeviceCode: boolean;
|
||||
CustomTheme?: {
|
||||
selectedTheme: string;
|
||||
customCSS: string;
|
||||
};
|
||||
};
|
||||
|
||||
// 参数校验
|
||||
|
|
@ -63,7 +70,12 @@ export async function POST(request: NextRequest) {
|
|||
typeof DoubanImageProxyType !== 'string' ||
|
||||
typeof DoubanImageProxy !== 'string' ||
|
||||
typeof DisableYellowFilter !== 'boolean' ||
|
||||
typeof FluidSearch !== 'boolean'
|
||||
typeof FluidSearch !== 'boolean' ||
|
||||
typeof RequireDeviceCode !== 'boolean' ||
|
||||
(CustomTheme && (
|
||||
typeof CustomTheme.selectedTheme !== 'string' ||
|
||||
typeof CustomTheme.customCSS !== 'string'
|
||||
))
|
||||
) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
|
@ -93,6 +105,8 @@ export async function POST(request: NextRequest) {
|
|||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
RequireDeviceCode,
|
||||
CustomTheme,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { db } from '@/lib/db';
|
|||
export const runtime = 'nodejs';
|
||||
|
||||
// 支持的操作类型
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'edit' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
|
||||
|
||||
interface BaseBody {
|
||||
action?: Action;
|
||||
|
|
@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
|||
const username = authInfo.username;
|
||||
|
||||
// 基础校验
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'edit', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
|
||||
if (!username || !action || !ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
|
@ -99,6 +99,26 @@ export async function POST(request: NextRequest) {
|
|||
entry.disabled = false;
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
const { key, name, api, detail } = body as {
|
||||
key?: string;
|
||||
name?: string;
|
||||
api?: string;
|
||||
detail?: string;
|
||||
};
|
||||
if (!key || !name || !api) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (!entry) {
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
}
|
||||
// 更新字段(除了 key 和 from)
|
||||
entry.name = name;
|
||||
entry.api = api;
|
||||
entry.detail = detail || '';
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const { key } = body as { key?: string };
|
||||
if (!key)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const searchKeyword = searchParams.get('q');
|
||||
const sourceKey = searchParams.get('source'); // 支持单个源验证
|
||||
const tempApi = searchParams.get('tempApi'); // 临时 API 地址
|
||||
const tempName = searchParams.get('tempName'); // 临时源名称
|
||||
|
||||
if (!searchKeyword) {
|
||||
return new Response(
|
||||
|
|
@ -30,7 +33,34 @@ export async function GET(request: NextRequest) {
|
|||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const apiSites = config.SourceConfig;
|
||||
let apiSites = config.SourceConfig;
|
||||
|
||||
// 如果提供了临时 API 地址,创建临时源进行验证
|
||||
if (tempApi && tempName) {
|
||||
apiSites = [{
|
||||
key: 'temp',
|
||||
name: tempName,
|
||||
api: tempApi,
|
||||
detail: '',
|
||||
disabled: false,
|
||||
from: 'custom' as const
|
||||
}];
|
||||
} else if (sourceKey) {
|
||||
// 如果指定了特定源,只验证该源
|
||||
const targetSite = apiSites.find(site => site.key === sourceKey);
|
||||
if (!targetSite) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: '指定的视频源不存在' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
apiSites = [targetSite];
|
||||
}
|
||||
|
||||
// 共享状态
|
||||
let streamClosed = false;
|
||||
|
|
@ -94,6 +124,7 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
// 检查结果是否有效
|
||||
let status: 'valid' | 'no_results' | 'invalid';
|
||||
let resultCount = 0;
|
||||
if (
|
||||
data &&
|
||||
data.list &&
|
||||
|
|
@ -108,11 +139,14 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
if (validResults.length > 0) {
|
||||
status = 'valid';
|
||||
resultCount = validResults.length;
|
||||
} else {
|
||||
status = 'no_results';
|
||||
resultCount = 0;
|
||||
}
|
||||
} else {
|
||||
status = 'no_results';
|
||||
resultCount = 0;
|
||||
}
|
||||
|
||||
// 发送该源的验证结果
|
||||
|
|
@ -122,7 +156,8 @@ export async function GET(request: NextRequest) {
|
|||
const sourceEvent = `data: ${JSON.stringify({
|
||||
type: 'source_result',
|
||||
source: site.key,
|
||||
status
|
||||
status,
|
||||
resultCount
|
||||
})}\n\n`;
|
||||
|
||||
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||||
|
|
@ -145,7 +180,9 @@ export async function GET(request: NextRequest) {
|
|||
const errorEvent = `data: ${JSON.stringify({
|
||||
type: 'source_error',
|
||||
source: site.key,
|
||||
status: 'invalid'
|
||||
status: 'invalid',
|
||||
error: error instanceof Error ? error.message : '未知错误',
|
||||
resultCount: 0
|
||||
})}\n\n`;
|
||||
|
||||
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,248 +1,98 @@
|
|||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
function buildCorsHeaders(contentType?: string, extra?: Record<string, string>) {
|
||||
const headers = new Headers();
|
||||
if (contentType) headers.set('Content-Type', contentType);
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges, Content-Type');
|
||||
headers.set('Cache-Control', 'no-cache');
|
||||
if (extra) {
|
||||
Object.entries(extra).forEach(([k, v]) => headers.set(k, v));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function forwardRequest(url: string, method: 'GET' | 'HEAD', reqHeaders: Headers) {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
|
||||
// 透传范围请求和必要请求头
|
||||
const fetchHeaders: Record<string, string> = {};
|
||||
const range = reqHeaders.get('Range');
|
||||
if (range) fetchHeaders['Range'] = range;
|
||||
const accept = reqHeaders.get('Accept');
|
||||
if (accept) fetchHeaders['Accept'] = accept;
|
||||
|
||||
// 统一 UA,部分源(如 quark drive)需要浏览器 UA 才能返回
|
||||
fetchHeaders['User-Agent'] =
|
||||
reqHeaders.get('User-Agent') ||
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
|
||||
|
||||
const upstream = await fetch(decodedUrl, {
|
||||
method,
|
||||
headers: fetchHeaders,
|
||||
redirect: 'follow',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return upstream;
|
||||
}
|
||||
|
||||
export async function HEAD(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
|
||||
const upstream = await forwardRequest(url, 'HEAD', request.headers);
|
||||
const headers = buildCorsHeaders(upstream.headers.get('Content-Type') || undefined, {
|
||||
'Accept-Ranges': upstream.headers.get('Accept-Ranges') || 'bytes',
|
||||
'Content-Length': upstream.headers.get('Content-Length') || '',
|
||||
'Content-Range': upstream.headers.get('Content-Range') || '',
|
||||
});
|
||||
const status = upstream.status === 206 ? 206 : upstream.status;
|
||||
return new Response(null, { status, headers });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: 'Proxy HEAD failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Proxy video request for URL:', url);
|
||||
|
||||
let response: Response | null = null;
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
console.log('Decoded URL:', decodedUrl);
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||
|
||||
// 为短剧视频文件设置合适的请求头,避免403错误
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'identity',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Sec-Fetch-Dest': 'video',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
const upstream = await forwardRequest(url, 'GET', request.headers);
|
||||
if (!upstream.ok && upstream.status !== 206) {
|
||||
return NextResponse.json({ error: `Upstream error ${upstream.status}` }, { status: upstream.status });
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('Content-Type') || 'application/octet-stream';
|
||||
const extra: Record<string, string> = {
|
||||
'Accept-Ranges': upstream.headers.get('Accept-Ranges') || 'bytes',
|
||||
};
|
||||
const contentLength = upstream.headers.get('Content-Length');
|
||||
if (contentLength) extra['Content-Length'] = contentLength;
|
||||
const contentRange = upstream.headers.get('Content-Range');
|
||||
if (contentRange) extra['Content-Range'] = contentRange;
|
||||
|
||||
// 对于夸克网盘等,设置更精确的请求头
|
||||
if (decodedUrl.includes('quark.cn') || decodedUrl.includes('drive.quark.cn')) {
|
||||
headers['Referer'] = 'https://pan.quark.cn/';
|
||||
headers['Origin'] = 'https://pan.quark.cn';
|
||||
// 移除可能导致问题的header
|
||||
delete headers['Sec-Fetch-Dest'];
|
||||
delete headers['Sec-Fetch-Mode'];
|
||||
delete headers['Sec-Fetch-Site'];
|
||||
} else if (decodedUrl.includes('dl-c-')) {
|
||||
// 对于CDN链接,使用更简单的请求头
|
||||
headers['Referer'] = '';
|
||||
delete headers['Origin'];
|
||||
}
|
||||
|
||||
// 处理Range请求,支持视频拖拽播放
|
||||
const rangeHeader = request.headers.get('Range');
|
||||
if (rangeHeader) {
|
||||
headers['Range'] = rangeHeader;
|
||||
}
|
||||
|
||||
response = await fetch(decodedUrl, {
|
||||
headers,
|
||||
// 添加超时设置
|
||||
signal: AbortSignal.timeout(30000), // 30秒超时
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch video: ${response.status} ${response.statusText}`);
|
||||
console.error('Request headers were:', JSON.stringify(headers, null, 2));
|
||||
|
||||
// 返回具有正确CORS头的错误响应
|
||||
const errorHeaders = new Headers();
|
||||
errorHeaders.set('Access-Control-Allow-Origin', '*');
|
||||
errorHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
errorHeaders.set('Access-Control-Allow-Headers', 'Range, Content-Type, Accept, Origin, Authorization');
|
||||
|
||||
return NextResponse.json({
|
||||
error: `Failed to fetch video: ${response.status}`,
|
||||
details: response.statusText,
|
||||
url: decodedUrl
|
||||
}, {
|
||||
status: response.status >= 400 ? response.status : 500,
|
||||
headers: errorHeaders
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Successfully fetched video: ${response.status} ${response.statusText}`);
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
|
||||
// 设置内容类型
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType) {
|
||||
responseHeaders.set('Content-Type', contentType);
|
||||
} else {
|
||||
// 默认为MP4
|
||||
responseHeaders.set('Content-Type', 'video/mp4');
|
||||
}
|
||||
|
||||
// 完整的CORS头设置
|
||||
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
||||
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
responseHeaders.set('Access-Control-Allow-Headers', 'Range, Content-Type, Accept, Origin, Authorization, X-Requested-With');
|
||||
responseHeaders.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges, Content-Type');
|
||||
responseHeaders.set('Access-Control-Allow-Credentials', 'false');
|
||||
|
||||
// 支持Range请求
|
||||
responseHeaders.set('Accept-Ranges', 'bytes');
|
||||
|
||||
// 传递内容长度和Range响应头
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
responseHeaders.set('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
const contentRange = response.headers.get('content-range');
|
||||
if (contentRange) {
|
||||
responseHeaders.set('Content-Range', contentRange);
|
||||
}
|
||||
|
||||
// 缓存控制
|
||||
responseHeaders.set('Cache-Control', 'public, max-age=3600, must-revalidate');
|
||||
|
||||
// 使用流式传输,支持大文件播放
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
if (!response?.body) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
reader = response.body.getReader();
|
||||
let isCancelled = false;
|
||||
|
||||
function pump() {
|
||||
if (isCancelled || !reader) {
|
||||
return;
|
||||
}
|
||||
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (done) {
|
||||
controller.close();
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
controller.enqueue(value);
|
||||
pump();
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error('Stream error:', error);
|
||||
controller.error(error);
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (!isCancelled) {
|
||||
console.error('Reader error:', error);
|
||||
controller.error(error);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
isCancelled = true;
|
||||
if (reader && reader.releaseLock) {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
const headers = buildCorsHeaders(contentType, extra);
|
||||
const status = upstream.status === 206 ? 206 : 200;
|
||||
return new Response(upstream.body, { status, headers });
|
||||
} catch (e) {
|
||||
// reader 可能已经被释放,忽略错误
|
||||
}
|
||||
reader = null;
|
||||
console.error('Proxy video failed:', e);
|
||||
return NextResponse.json({ error: 'Proxy failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
pump();
|
||||
},
|
||||
cancel() {
|
||||
// 当流被取消时,确保释放所有资源
|
||||
if (reader && reader.releaseLock) {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
// reader 可能已经被释放,忽略错误
|
||||
}
|
||||
reader = null;
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: buildCorsHeaders() });
|
||||
}
|
||||
|
||||
if (response?.body) {
|
||||
try {
|
||||
response.body.cancel();
|
||||
} catch (e) {
|
||||
// 忽略取消时的错误
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
headers: responseHeaders
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Proxy video error:', error);
|
||||
|
||||
// 确保在错误情况下也释放资源
|
||||
if (reader && typeof (reader as any)?.releaseLock === 'function') {
|
||||
try {
|
||||
(reader as ReadableStreamDefaultReader<Uint8Array>).releaseLock();
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
if (response?.body) {
|
||||
try {
|
||||
response.body.cancel();
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
error: 'Failed to proxy video',
|
||||
details: error instanceof Error ? error.message : String(error)
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 支持OPTIONS请求用于CORS预检
|
||||
export async function OPTIONS(_request: Request) {
|
||||
console.log('CORS preflight request received');
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS, POST',
|
||||
'Access-Control-Allow-Headers': 'Range, Content-Type, Accept, Origin, Authorization, X-Requested-With',
|
||||
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges, Content-Type',
|
||||
'Access-Control-Allow-Credentials': 'false',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] || '',
|
||||
})),
|
||||
|
|
|
|||
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';
|
||||
|
|
@ -55,6 +56,7 @@ export default async function RootLayout({
|
|||
let disableYellowFilter =
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
||||
let requireDeviceCode = process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false';
|
||||
let customCategories = [] as {
|
||||
name: string;
|
||||
type: 'movie' | 'tv';
|
||||
|
|
@ -78,6 +80,7 @@ export default async function RootLayout({
|
|||
query: category.query,
|
||||
}));
|
||||
fluidSearch = config.SiteConfig.FluidSearch;
|
||||
requireDeviceCode = config.SiteConfig.RequireDeviceCode;
|
||||
}
|
||||
|
||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||
|
|
@ -90,6 +93,7 @@ export default async function RootLayout({
|
|||
DISABLE_YELLOW_FILTER: disableYellowFilter,
|
||||
CUSTOM_CATEGORIES: customCategories,
|
||||
FLUID_SEARCH: fluidSearch,
|
||||
REQUIRE_DEVICE_CODE: requireDeviceCode,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -107,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`}
|
||||
|
|
@ -119,6 +177,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() {
|
||||
|
|
@ -85,18 +86,23 @@ function LoginPageClient() {
|
|||
const [machineCodeGenerated, setMachineCodeGenerated] = useState(false);
|
||||
const [, setShowBindOption] = useState(false);
|
||||
const [bindMachineCode, setBindMachineCode] = useState(false);
|
||||
const [deviceCodeEnabled, setDeviceCodeEnabled] = useState(true); // 站点是否启用设备码功能
|
||||
|
||||
const { siteName } = useSite();
|
||||
|
||||
// 在客户端挂载后设置配置并生成机器码
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE;
|
||||
setShouldAskUsername(storageType && storageType !== 'localstorage');
|
||||
const runtimeConfig = (window as any).RUNTIME_CONFIG;
|
||||
const storageType = runtimeConfig?.STORAGE_TYPE;
|
||||
const requireDeviceCode = runtimeConfig?.REQUIRE_DEVICE_CODE;
|
||||
|
||||
// 生成机器码和设备信息
|
||||
setShouldAskUsername(storageType && storageType !== 'localstorage');
|
||||
setDeviceCodeEnabled(requireDeviceCode !== false); // 默认启用,除非明确设置为 false
|
||||
|
||||
// 只有在启用设备码功能时才生成机器码和设备信息
|
||||
const generateMachineInfo = async () => {
|
||||
if (MachineCode.isSupported()) {
|
||||
if (requireDeviceCode !== false && MachineCode.isSupported()) {
|
||||
try {
|
||||
const code = await MachineCode.generateMachineCode();
|
||||
const info = await MachineCode.getDeviceInfo();
|
||||
|
|
@ -128,8 +134,8 @@ function LoginPageClient() {
|
|||
...(shouldAskUsername ? { username } : {}),
|
||||
};
|
||||
|
||||
// 如果需要机器码或用户选择绑定,则发送机器码
|
||||
if ((requireMachineCode || bindMachineCode) && machineCode) {
|
||||
// 只有在启用设备码功能时才处理机器码逻辑
|
||||
if (deviceCodeEnabled && (requireMachineCode || bindMachineCode) && machineCode) {
|
||||
requestData.machineCode = machineCode;
|
||||
}
|
||||
|
||||
|
|
@ -142,8 +148,8 @@ function LoginPageClient() {
|
|||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (res.ok) {
|
||||
// 登录成功,如果用户选择绑定机器码,则绑定
|
||||
if (bindMachineCode && machineCode && shouldAskUsername) {
|
||||
// 登录成功,如果启用设备码功能且用户选择绑定机器码,则绑定
|
||||
if (deviceCodeEnabled && bindMachineCode && machineCode && shouldAskUsername) {
|
||||
try {
|
||||
await fetch('/api/machine-code', {
|
||||
method: 'POST',
|
||||
|
|
@ -190,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>
|
||||
|
|
@ -242,8 +249,8 @@ function LoginPageClient() {
|
|||
</label>
|
||||
</div>
|
||||
|
||||
{/* 机器码信息显示 */}
|
||||
{machineCodeGenerated && shouldAskUsername && (
|
||||
{/* 机器码信息显示 - 只有在启用设备码功能时才显示 */}
|
||||
{deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && (
|
||||
<div className='space-y-4'>
|
||||
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
||||
<div className='flex items-center space-x-2 mb-2'>
|
||||
|
|
@ -294,7 +301,7 @@ function LoginPageClient() {
|
|||
!password ||
|
||||
loading ||
|
||||
(shouldAskUsername && !username) ||
|
||||
(machineCodeGenerated && shouldAskUsername && !requireMachineCode && !bindMachineCode)
|
||||
(deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && !requireMachineCode && !bindMachineCode)
|
||||
}
|
||||
className='inline-flex w-full justify-center rounded-lg bg-blue-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-blue-600 hover:to-blue-700 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1969,40 +2000,127 @@ function PlayPageClient() {
|
|||
plugins: danmuEnabled ? [
|
||||
artplayerPluginDanmuku({
|
||||
danmuku: async () => {
|
||||
return await loadDanmuData(currentVideoId);
|
||||
try {
|
||||
const danmuData = await loadDanmuData(currentVideoId);
|
||||
return danmuData;
|
||||
} catch (error) {
|
||||
console.error('加载弹幕失败:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
speed: 5, // 弹幕速度
|
||||
opacity: 1, // 透明度
|
||||
fontSize: 25, // 字体大小
|
||||
color: '#FFFFFF', // 默认颜色
|
||||
mode: 0, // 弹幕模式
|
||||
margin: [10, '25%'], // 边距
|
||||
antiOverlap: true, // 防重叠
|
||||
useWorker: true, // 使用 WebWorker
|
||||
synchronousPlayback: false, // 非同步播放
|
||||
filter: (danmu: any) => danmu.text.length < 50, // 过滤长弹幕
|
||||
lockTime: 5, // 锁定时间
|
||||
maxLength: 100, // 最大长度
|
||||
minWidth: 200, // 最小宽度
|
||||
maxWidth: 500, // 最大宽度
|
||||
theme: 'dark', // 主题
|
||||
speed: isMobile ? 4 : 5, // 移动端弹幕速度稍慢
|
||||
opacity: 1,
|
||||
fontSize: danmuConfig.fontSize,
|
||||
color: '#FFFFFF',
|
||||
mode: 0,
|
||||
margin: danmuConfig.margin,
|
||||
antiOverlap: true,
|
||||
useWorker: true,
|
||||
synchronousPlayback: false,
|
||||
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: 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('广告');
|
||||
},
|
||||
beforeEmit: async (danmu: any) => {
|
||||
// 发送弹幕前的处理
|
||||
try {
|
||||
await sendDanmu(currentVideoId, {
|
||||
const result = await sendDanmu(currentVideoId, {
|
||||
text: danmu.text,
|
||||
color: danmu.color || '#FFFFFF',
|
||||
mode: danmu.mode || 0,
|
||||
time: artPlayerRef.current?.currentTime || 0
|
||||
});
|
||||
return danmu;
|
||||
|
||||
// 显示成功提示
|
||||
if (artPlayerRef.current?.notice) {
|
||||
artPlayerRef.current.notice.show = '✅ 弹幕发送成功!';
|
||||
}
|
||||
|
||||
// 创建符合插件要求的弹幕对象
|
||||
const danmuObject = {
|
||||
text: danmu.text,
|
||||
color: danmu.color || '#FFFFFF',
|
||||
mode: danmu.mode || 0,
|
||||
time: (artPlayerRef.current?.currentTime || 0) + 0.5,
|
||||
border: false,
|
||||
size: isMobile ? 18 : 25 // 移动端弹幕字体更小
|
||||
};
|
||||
|
||||
// 手动触发弹幕显示(如果beforeEmit的返回值不能正常显示)
|
||||
// 这是一个备用方案
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const danmakuPlugin = artPlayerRef.current?.plugins?.artplayerPluginDanmuku;
|
||||
if (danmakuPlugin) {
|
||||
// 确保弹幕未被隐藏
|
||||
try {
|
||||
if (danmakuPlugin.isHide && danmakuPlugin.show) {
|
||||
danmakuPlugin.show();
|
||||
}
|
||||
} catch { }
|
||||
|
||||
// 尝试不同的方法来添加弹幕
|
||||
if (danmakuPlugin.emit) {
|
||||
danmakuPlugin.emit(danmuObject);
|
||||
} else if (danmakuPlugin.add) {
|
||||
danmakuPlugin.add(danmuObject);
|
||||
} else if (danmakuPlugin.send) {
|
||||
danmakuPlugin.send(danmuObject);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ 手动添加弹幕失败:', err);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// 返回弹幕对象让插件自动处理
|
||||
return danmuObject;
|
||||
} catch (error) {
|
||||
console.error('发送弹幕失败:', error);
|
||||
artPlayerRef.current?.notice?.show?.('发送弹幕失败:' + (error as any).message);
|
||||
|
||||
// 显示错误提示
|
||||
if (artPlayerRef.current?.notice) {
|
||||
artPlayerRef.current.notice.show = '❌ 发送弹幕失败:' + (error as any).message;
|
||||
}
|
||||
|
||||
// 阻止弹幕显示
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -2061,6 +2179,7 @@ function PlayPageClient() {
|
|||
return newVal ? '弹幕已开启' : '弹幕已关闭';
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: '跳过片头片尾',
|
||||
html: '跳过片头片尾',
|
||||
|
|
@ -2159,6 +2278,11 @@ function PlayPageClient() {
|
|||
});
|
||||
}
|
||||
|
||||
// 检查弹幕插件是否正确加载
|
||||
if (danmuEnabled) {
|
||||
// 弹幕启用,无需调试日志
|
||||
}
|
||||
|
||||
// 播放器就绪后,如果正在播放则请求 Wake Lock
|
||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||
requestWakeLock();
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export function ChatModal({
|
|||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const [dragStartPosition, setDragStartPosition] = useState({ x: 0, y: 0 });
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -58,19 +59,55 @@ export function ChatModal({
|
|||
});
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
setIsDragging(true);
|
||||
setDragStartPosition({
|
||||
x: touch.clientX - dragPosition.x,
|
||||
y: touch.clientY - dragPosition.y
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const newX = e.clientX - dragStartPosition.x;
|
||||
const newY = e.clientY - dragStartPosition.y;
|
||||
|
||||
// 限制拖动范围,确保模态框不会完全移出视口
|
||||
const maxX = window.innerWidth - 400; // 模态框最小宽度
|
||||
const maxY = window.innerHeight - 200; // 模态框最小高度
|
||||
// 允许在全屏范围内拖动,保留边距避免完全移出
|
||||
const edgePadding = 40;
|
||||
const maxX = window.innerWidth - edgePadding;
|
||||
const minX = - (window.innerWidth - edgePadding);
|
||||
const maxY = window.innerHeight - edgePadding;
|
||||
const minY = - (window.innerHeight - edgePadding);
|
||||
|
||||
setDragPosition({
|
||||
x: Math.max(-200, Math.min(maxX, newX)),
|
||||
y: Math.max(0, Math.min(maxY, newY))
|
||||
x: Math.max(minX, Math.min(maxX, newX)),
|
||||
y: Math.max(minY, Math.min(maxY, newY))
|
||||
});
|
||||
}, [isDragging, dragStartPosition]);
|
||||
|
||||
const handleTouchMove = useCallback((e: TouchEvent) => {
|
||||
if (!isDragging) return;
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const newX = touch.clientX - dragStartPosition.x;
|
||||
const newY = touch.clientY - dragStartPosition.y;
|
||||
|
||||
const edgePadding = 40;
|
||||
const maxX = window.innerWidth - edgePadding;
|
||||
const minX = - (window.innerWidth - edgePadding);
|
||||
const maxY = window.innerHeight - edgePadding;
|
||||
const minY = - (window.innerHeight - edgePadding);
|
||||
|
||||
// 阻止页面滚动
|
||||
e.preventDefault();
|
||||
|
||||
setDragPosition({
|
||||
x: Math.max(minX, Math.min(maxX, newX)),
|
||||
y: Math.max(minY, Math.min(maxY, newY))
|
||||
});
|
||||
}, [isDragging, dragStartPosition]);
|
||||
|
||||
|
|
@ -78,11 +115,31 @@ export function ChatModal({
|
|||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// 添加全局鼠标事件监听
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// 检测屏幕大小
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 添加全局鼠标/触摸事件监听
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
|
@ -90,10 +147,12 @@ export function ChatModal({
|
|||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove as any);
|
||||
document.removeEventListener('touchend', handleTouchEnd as any);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
||||
|
||||
// 实时搜索功能
|
||||
useEffect(() => {
|
||||
|
|
@ -772,18 +831,38 @@ export function ChatModal({
|
|||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[99999] flex items-center justify-center bg-black bg-opacity-50" style={{ zIndex: '99999' }}>
|
||||
<div
|
||||
className="w-full max-w-6xl h-[80vh] bg-white dark:bg-gray-900 rounded-lg shadow-xl flex relative"
|
||||
className={`z-[2147483647] ${isMobile
|
||||
? 'fixed top-0 left-0 right-0 bottom-0 bg-white dark:bg-gray-900'
|
||||
: 'fixed inset-0 flex items-center justify-center bg-black bg-opacity-50'
|
||||
}`}
|
||||
style={{
|
||||
transform: `translate(${dragPosition.x}px, ${dragPosition.y}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s ease-out'
|
||||
zIndex: '2147483647',
|
||||
...(isMobile && {
|
||||
paddingTop: '56px', // 减少顶部padding
|
||||
paddingBottom: '72px' // 减少底部padding
|
||||
})
|
||||
}}
|
||||
>
|
||||
{/* 拖动头部 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-8 bg-gray-100 dark:bg-gray-800 rounded-t-lg cursor-grab active:cursor-grabbing flex items-center justify-center"
|
||||
className={`${isMobile
|
||||
? 'w-full bg-white dark:bg-gray-900 flex flex-col'
|
||||
: 'w-full max-w-6xl h-[80vh] bg-white dark:bg-gray-900 rounded-lg shadow-xl flex flex-row relative'
|
||||
}`}
|
||||
style={{
|
||||
transform: !isMobile ? `translate(${dragPosition.x}px, ${dragPosition.y}px)` : 'none',
|
||||
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
|
||||
...(isMobile && {
|
||||
height: 'calc(100vh - 128px)', // 调整为新的padding总和
|
||||
minHeight: 'calc(100vh - 128px)'
|
||||
})
|
||||
}}
|
||||
>
|
||||
{/* 拖动头部 - 仅桌面端显示 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-8 bg-gray-100 dark:bg-gray-800 rounded-t-lg cursor-grab active:cursor-grabbing hidden md:flex items-center justify-center"
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||
>
|
||||
<div className="flex space-x-1">
|
||||
|
|
@ -793,7 +872,16 @@ export function ChatModal({
|
|||
</div>
|
||||
</div>
|
||||
{/* 左侧面板 */}
|
||||
<div className="w-1/3 border-r border-gray-200 dark:border-gray-700 flex flex-col mt-8">
|
||||
<div className={`${isMobile
|
||||
? `w-full flex flex-col ${selectedConversation ? 'hidden' : 'flex'}`
|
||||
: `w-1/3 border-r border-gray-200 dark:border-gray-700 flex flex-col mt-8 h-auto ${selectedConversation ? 'block' : 'block'}`
|
||||
}`}
|
||||
style={{
|
||||
...(isMobile && {
|
||||
height: '100%',
|
||||
maxHeight: '100%'
|
||||
})
|
||||
}}>
|
||||
{/* 头部 */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
|
@ -1139,12 +1227,32 @@ export function ChatModal({
|
|||
</div>
|
||||
|
||||
{/* 右侧聊天区域 */}
|
||||
<div className="flex-1 flex flex-col mt-8">
|
||||
<div className={`${isMobile
|
||||
? `w-full flex flex-col ${selectedConversation ? 'flex' : 'hidden'}`
|
||||
: `flex-1 flex flex-col mt-8 ${selectedConversation ? 'block' : 'block'}`
|
||||
}`}
|
||||
style={{
|
||||
...(isMobile && {
|
||||
height: '100%',
|
||||
maxHeight: '100%'
|
||||
})
|
||||
}}>
|
||||
{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
|
||||
onClick={() => setSelectedConversation(null)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* 对话头像(显示对方用户的头像,如果是群聊则显示群组图标) */}
|
||||
<div className="flex-shrink-0">
|
||||
{selectedConversation.participants.length === 2 ? (
|
||||
|
|
@ -1181,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 ? (
|
||||
// 私人对话:显示在线状态
|
||||
(() => {
|
||||
|
|
@ -1207,7 +1315,8 @@ export function ChatModal({
|
|||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gradient-to-b from-gray-50/30 to-white/50 dark:from-gray-800/30 dark:to-gray-900/50">
|
||||
<div className={`flex-1 overflow-y-auto space-y-4 bg-gradient-to-b from-gray-50/30 to-white/50 dark:from-gray-800/30 dark:to-gray-900/50 ${isMobile ? 'p-3' : 'p-6'
|
||||
}`}>
|
||||
{messages.map((message, index) => {
|
||||
const isOwnMessage = message.sender_id === currentUser?.username;
|
||||
const prevMessage = index > 0 ? messages[index - 1] : null;
|
||||
|
|
@ -1328,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
|
||||
|
|
@ -1357,16 +1466,16 @@ export function ChatModal({
|
|||
)}
|
||||
|
||||
{/* 主输入区域 */}
|
||||
<div className="p-4">
|
||||
<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'
|
||||
}`}
|
||||
|
|
@ -1379,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 ? (
|
||||
|
|
@ -1400,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="附件(即将开放)"
|
||||
>
|
||||
|
|
@ -1429,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' }}
|
||||
|
|
@ -1466,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>
|
||||
|
|
@ -1498,9 +1607,11 @@ export function ChatModal({
|
|||
</div>
|
||||
</>
|
||||
) : (
|
||||
!isMobile && (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
选择一个对话开始聊天
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -17,10 +17,10 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
|||
<header className='md:hidden fixed top-0 left-0 right-0 z-[999] w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'>
|
||||
<div className='h-12 flex items-center justify-between px-4'>
|
||||
{/* 左侧:搜索按钮、返回按钮和设置按钮 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Link
|
||||
href='/search'
|
||||
className='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'
|
||||
className='w-8 h-8 p-1.5 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'
|
||||
>
|
||||
<svg
|
||||
className='w-full h-full'
|
||||
|
|
@ -41,7 +41,7 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
|||
</div>
|
||||
|
||||
{/* 右侧按钮 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -16,6 +16,7 @@ export function ThemeToggle() {
|
|||
const [messageCount, setMessageCount] = useState(0);
|
||||
const [chatCount, setChatCount] = useState(0);
|
||||
const [friendRequestCount, setFriendRequestCount] = useState(0);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
|
||||
|
|
@ -54,6 +55,17 @@ export function ThemeToggle() {
|
|||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听主题变化和路由变化,确保主题色始终同步
|
||||
|
|
@ -82,27 +94,32 @@ export function ThemeToggle() {
|
|||
});
|
||||
};
|
||||
|
||||
// 检查是否在登录页面
|
||||
const isLoginPage = pathname === '/login';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 聊天按钮 */}
|
||||
<div className={`flex items-center ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
|
||||
{/* 聊天按钮 - 在登录页面不显示 */}
|
||||
{!isLoginPage && (
|
||||
<button
|
||||
onClick={() => setIsChatModalOpen(true)}
|
||||
className='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'
|
||||
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 -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
|
||||
<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
|
||||
onClick={toggleTheme}
|
||||
className='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'
|
||||
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`}
|
||||
aria-label='Toggle theme'
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
|
|
@ -113,7 +130,8 @@ export function ThemeToggle() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* 聊天模态框 */}
|
||||
{/* 聊天模态框 - 在登录页面不渲染 */}
|
||||
{!isLoginPage && (
|
||||
<ChatModal
|
||||
isOpen={isChatModalOpen}
|
||||
onClose={() => setIsChatModalOpen(false)}
|
||||
|
|
@ -121,6 +139,7 @@ export function ThemeToggle() {
|
|||
onChatCountReset={handleChatCountReset}
|
||||
onFriendRequestCountReset={handleFriendRequestCountReset}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,9 +39,21 @@ interface ToastProviderProps {
|
|||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
|
|
@ -86,16 +98,16 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
|||
};
|
||||
|
||||
const getToastIcon = (type: ToastType) => {
|
||||
const iconProps = { className: 'w-5 h-5 flex-shrink-0' };
|
||||
const iconSize = isMobile ? 'w-4 h-4' : 'w-5 h-5';
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle {...iconProps} className="w-5 h-5 flex-shrink-0 text-green-500" />;
|
||||
return <CheckCircle className={`${iconSize} flex-shrink-0 text-green-500`} />;
|
||||
case 'error':
|
||||
return <XCircle {...iconProps} className="w-5 h-5 flex-shrink-0 text-red-500" />;
|
||||
return <XCircle className={`${iconSize} flex-shrink-0 text-red-500`} />;
|
||||
case 'warning':
|
||||
return <AlertCircle {...iconProps} className="w-5 h-5 flex-shrink-0 text-yellow-500" />;
|
||||
return <AlertCircle className={`${iconSize} flex-shrink-0 text-yellow-500`} />;
|
||||
case 'info':
|
||||
return <Info {...iconProps} className="w-5 h-5 flex-shrink-0 text-blue-500" />;
|
||||
return <Info className={`${iconSize} flex-shrink-0 text-blue-500`} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -113,29 +125,36 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
|||
};
|
||||
|
||||
const toastContainer = mounted && toasts.length > 0 && (
|
||||
<div className="fixed top-4 right-4 z-[9999] space-y-2 max-w-sm w-full">
|
||||
<div
|
||||
className={`fixed ${isMobile ? 'space-y-1' : 'space-y-2'} ${isMobile
|
||||
? 'top-14 left-3 right-3 max-w-none z-[2147483648]'
|
||||
: 'top-4 right-4 max-w-sm w-full z-[9999]'
|
||||
}`}
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`
|
||||
flex items-start gap-3 p-4 rounded-lg border shadow-lg
|
||||
flex items-start gap-3 rounded-lg border shadow-lg
|
||||
transform transition-all duration-300 ease-out
|
||||
animate-in slide-in-from-right-2
|
||||
${isMobile ? 'p-3 text-sm' : 'p-4'}
|
||||
${isMobile ? 'animate-in slide-in-from-top-2' : 'animate-in slide-in-from-right-2'}
|
||||
${getToastStyles(toast.type)}
|
||||
`}
|
||||
>
|
||||
{getToastIcon(toast.type)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium">{toast.title}</h4>
|
||||
<h4 className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{toast.title}</h4>
|
||||
{toast.message && (
|
||||
<p className="text-sm opacity-90 mt-1">{toast.message}</p>
|
||||
<p className={`opacity-90 mt-1 ${isMobile ? 'text-xs' : 'text-sm'}`}>{toast.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="flex-shrink-0 text-current opacity-50 hover:opacity-100 transition-opacity"
|
||||
className={`flex-shrink-0 text-current opacity-50 hover:opacity-100 transition-opacity ${isMobile ? 'p-1' : ''
|
||||
}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className={isMobile ? 'w-3 h-3' : 'w-4 h-4'} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export const UserMenu: React.FC = () => {
|
|||
const [mounted, setMounted] = useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string>('');
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 裁剪相关状态
|
||||
|
|
@ -137,6 +138,17 @@ export const UserMenu: React.FC = () => {
|
|||
// 确保组件已挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取认证信息、存储类型和头像
|
||||
|
|
@ -1319,7 +1331,7 @@ export const UserMenu: React.FC = () => {
|
|||
<div className='relative'>
|
||||
<button
|
||||
onClick={handleMenuClick}
|
||||
className='w-10 h-10 p-0.5 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 overflow-hidden'
|
||||
className={`${isMobile ? 'w-8 h-8 p-0.5' : 'w-10 h-10 p-0.5'} 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 overflow-hidden`}
|
||||
aria-label='User Menu'
|
||||
>
|
||||
{avatarUrl ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,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: () => ''
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -16,6 +16,16 @@ export interface AdminConfig {
|
|||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
RequireDeviceCode: boolean;
|
||||
CustomTheme?: {
|
||||
selectedTheme: string;
|
||||
customCSS: string;
|
||||
};
|
||||
};
|
||||
ThemeConfig?: {
|
||||
defaultTheme: 'default' | 'minimal' | 'warm' | 'fresh';
|
||||
customCSS: string;
|
||||
allowUserCustomization: boolean;
|
||||
};
|
||||
UserConfig: {
|
||||
Users: {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,54 @@ export interface ChangelogEntry {
|
|||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: "8.8.8",
|
||||
version: "8.9.5",
|
||||
date: "2025-09-21",
|
||||
added: [
|
||||
"添加内置主题,支持用户自定义CSS"
|
||||
],
|
||||
changed: [
|
||||
"优化搜索页面缓存机制"
|
||||
],
|
||||
fixed: [
|
||||
"镜像健康检查问题",
|
||||
"弹幕功能适配移动端"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "8.9.0",
|
||||
date: "2025-09-15",
|
||||
added: [
|
||||
"机器识别码设定开关",
|
||||
"配置文件去重添加",
|
||||
"视频源编辑",
|
||||
"单个视频源进行有效性检测"
|
||||
],
|
||||
changed: [
|
||||
"聊天页面适配移动端"
|
||||
],
|
||||
fixed: [
|
||||
"弹幕发送问题",
|
||||
"播放页测速问题",
|
||||
"测速问题"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "8.8.9",
|
||||
date: "2025-09-14",
|
||||
added: [
|
||||
"聊天,好友等功能",
|
||||
"支持arm架构镜像"
|
||||
],
|
||||
changed: [
|
||||
|
||||
],
|
||||
fixed: [
|
||||
"播放页面500问题"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "8.8.8",
|
||||
date: "2025-09-12",
|
||||
added: [
|
||||
"短剧类目聚合",
|
||||
"支持短剧类目搜索",
|
||||
|
|
|
|||
|
|
@ -80,15 +80,30 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
|||
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
|
||||
);
|
||||
|
||||
// 用于跟踪已存在的API地址,避免重复
|
||||
const existingApiUrls = new Set(
|
||||
Array.from(currentApiSites.values()).map(s => s.api.toLowerCase().trim())
|
||||
);
|
||||
|
||||
apiSitesFromFile.forEach(([key, site]) => {
|
||||
const existingSource = currentApiSites.get(key);
|
||||
const normalizedApiUrl = site.api.toLowerCase().trim();
|
||||
|
||||
if (existingSource) {
|
||||
// 如果已存在,只覆盖 name、api、detail 和 from
|
||||
existingSource.name = site.name;
|
||||
existingSource.api = site.api;
|
||||
existingSource.detail = site.detail;
|
||||
existingSource.from = 'config';
|
||||
// 更新API地址记录
|
||||
existingApiUrls.add(normalizedApiUrl);
|
||||
} else {
|
||||
// 检查API地址是否已存在
|
||||
if (existingApiUrls.has(normalizedApiUrl)) {
|
||||
console.warn(`跳过重复的API地址: ${site.api} (key: ${key})`);
|
||||
return; // 跳过重复的API地址
|
||||
}
|
||||
|
||||
// 如果不存在,创建新条目
|
||||
currentApiSites.set(key, {
|
||||
key,
|
||||
|
|
@ -98,6 +113,7 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
|||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
existingApiUrls.add(normalizedApiUrl);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -226,6 +242,8 @@ async function getInitConfig(configFile: string, subConfig: {
|
|||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||
FluidSearch:
|
||||
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
||||
RequireDeviceCode:
|
||||
process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false',
|
||||
},
|
||||
UserConfig: {
|
||||
Users: [],
|
||||
|
|
@ -339,6 +357,37 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||
adminConfig.LiveConfig = [];
|
||||
}
|
||||
|
||||
// 确保 SiteConfig 及其属性存在
|
||||
if (!adminConfig.SiteConfig) {
|
||||
adminConfig.SiteConfig = {
|
||||
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV',
|
||||
Announcement: process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
SearchDownstreamMaxPage: Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||
SiteInterfaceCacheTime: 7200,
|
||||
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||
DoubanImageProxyType: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
||||
DisableYellowFilter: process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||
FluidSearch: process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
||||
RequireDeviceCode: process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
// 确保 RequireDeviceCode 属性存在
|
||||
if (adminConfig.SiteConfig.RequireDeviceCode === undefined) {
|
||||
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;
|
||||
|
||||
|
|
@ -477,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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除申请详情
|
||||
|
|
|
|||
|
|
@ -72,38 +72,18 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
|||
pingTime: number; // 网络延迟(毫秒)
|
||||
}> {
|
||||
try {
|
||||
// 检查是否需要使用代理
|
||||
const needsProxy = m3u8Url.includes('quark.cn') ||
|
||||
m3u8Url.includes('drive.quark.cn') ||
|
||||
m3u8Url.includes('dl-c-zb-') ||
|
||||
m3u8Url.includes('dl-c-') ||
|
||||
m3u8Url.match(/https?:\/\/[^/]*\.drive\./) ||
|
||||
// 添加更多可能需要代理的域名
|
||||
m3u8Url.includes('ffzy-online') ||
|
||||
m3u8Url.includes('bfikuncdn.com') ||
|
||||
m3u8Url.includes('vip.') ||
|
||||
!m3u8Url.includes('localhost');
|
||||
|
||||
const finalM3u8Url = needsProxy
|
||||
? `/api/proxy/video?url=${encodeURIComponent(m3u8Url)}`
|
||||
: m3u8Url;
|
||||
|
||||
if (needsProxy) {
|
||||
console.log('Using proxy for M3U8 resolution detection:', m3u8Url);
|
||||
}
|
||||
|
||||
// 直接使用m3u8 URL作为视频源,避免CORS问题
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.preload = 'metadata';
|
||||
|
||||
// 测量网络延迟(ping时间)
|
||||
// 测量网络延迟(ping时间) - 使用m3u8 URL而不是ts文件
|
||||
const pingStart = performance.now();
|
||||
let pingTime = 0;
|
||||
|
||||
// 测量ping时间(如果使用代理,则测试代理URL的响应时间)
|
||||
const pingUrl = needsProxy ? `/api/proxy/video/test?url=${encodeURIComponent(m3u8Url)}` : m3u8Url;
|
||||
fetch(pingUrl, { method: 'HEAD', mode: needsProxy ? 'cors' : 'no-cors' })
|
||||
// 测量ping时间(使用m3u8 URL)
|
||||
fetch(m3u8Url, { method: 'HEAD', mode: 'no-cors' })
|
||||
.then(() => {
|
||||
pingTime = performance.now() - pingStart;
|
||||
})
|
||||
|
|
@ -209,31 +189,17 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
|||
}
|
||||
});
|
||||
|
||||
hls.loadSource(finalM3u8Url);
|
||||
hls.loadSource(m3u8Url);
|
||||
hls.attachMedia(video);
|
||||
|
||||
// 监听hls.js错误
|
||||
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
|
||||
// 只在开发环境下打印详细错误,生产环境下简化错误信息
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Video resolution detection failed:', {
|
||||
url: needsProxy ? 'via proxy' : m3u8Url,
|
||||
error: data.details,
|
||||
type: data.type
|
||||
});
|
||||
}
|
||||
|
||||
console.error('HLS错误:', data);
|
||||
if (data.fatal) {
|
||||
clearTimeout(timeout);
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
|
||||
// 对于CORS相关错误,提供更友好的错误信息
|
||||
if (data.details === 'manifestLoadError' || data.type === 'networkError') {
|
||||
reject(new Error('Network access restricted'));
|
||||
} else {
|
||||
reject(new Error(`Video analysis failed: ${data.type}`));
|
||||
}
|
||||
reject(new Error(`HLS播放失败: ${data.type}`));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable no-console */
|
||||
|
||||
const CURRENT_VERSION = '8.8.8';
|
||||
const CURRENT_VERSION = '8.9.5';
|
||||
|
||||
// 导出当前版本号供其他地方使用
|
||||
export { CURRENT_VERSION };
|
||||
|
|
|
|||
|
|
@ -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.8.8' === 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}`);
|
||||
|
|
|
|||
|
|
@ -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