From 091ca9d2ff0342fe03505dde124dd9b3f4e9830b Mon Sep 17 00:00:00 2001 From: djteang <935037887@qq.com> Date: Mon, 15 Sep 2025 20:11:25 +0800 Subject: [PATCH] =?UTF-8?q?add:=E6=9C=BA=E5=99=A8=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E7=A0=81=E8=AE=BE=E5=AE=9A=E5=BC=80=E5=85=B3;=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=87=E4=BB=B6=E5=8E=BB=E9=87=8D=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?;=E8=A7=86=E9=A2=91=E6=BA=90=E7=BC=96=E8=BE=91;=E5=8D=95?= =?UTF-8?q?=E4=B8=AA=E8=A7=86=E9=A2=91=E6=BA=90=E8=BF=9B=E8=A1=8C=E6=9C=89?= =?UTF-8?q?=E6=95=88=E6=80=A7=E6=A3=80=E6=B5=8B=20update:=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E9=A1=B5=E9=9D=A2=E9=80=82=E9=85=8D=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=20fixed:=E5=BC=B9=E5=B9=95=E5=8F=91=E9=80=81=E9=97=AE?= =?UTF-8?q?=E9=A2=98;=E6=92=AD=E6=94=BE=E9=A1=B5=E6=B5=8B=E9=80=9F?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 15 + src/app/admin/page.tsx | 465 ++++++++++++++++++++- src/app/api/admin/site/route.ts | 6 +- src/app/api/admin/source/route.ts | 24 +- src/app/api/admin/source/validate/route.ts | 43 +- src/app/api/proxy/video/route.ts | 324 ++++---------- src/app/layout.tsx | 3 + src/app/login/page.tsx | 27 +- src/app/play/page.tsx | 107 ++++- src/components/ChatModal.tsx | 151 ++++++- src/components/MobileHeader.tsx | 6 +- src/components/ThemeToggle.tsx | 20 +- src/components/Toast.tsx | 43 +- src/components/UserMenu.tsx | 14 +- src/lib/admin.types.ts | 1 + src/lib/changelog.ts | 34 +- src/lib/config.ts | 40 ++ src/lib/utils.ts | 50 +-- src/lib/version.ts | 2 +- src/lib/version_check.ts | 2 +- 20 files changed, 1005 insertions(+), 372 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5cae983..ac2cfc9 100644 --- a/Dockerfile +++ b/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 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 9b68ddf..27c8f01 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -496,6 +496,7 @@ interface SiteConfig { DoubanImageProxy: string; DisableYellowFilter: boolean; FluidSearch: boolean; + RequireDeviceCode: boolean; } // 视频源数据类型 @@ -2295,6 +2296,7 @@ const VideoSourceConfig = ({ const { isLoading, withLoading } = useLoadingState(); const [sources, setSources] = useState([]); const [showAddForm, setShowAddForm] = useState(false); + const [editingSource, setEditingSource] = useState(null); const [orderChanged, setOrderChanged] = useState(false); const [newSource, setNewSource] = useState({ name: '', @@ -2340,6 +2342,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 +2450,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; @@ -2544,6 +2603,149 @@ const VideoSourceConfig = ({ }); }; + // 通用视频源有效性检测函数 + const handleValidateSource = async ( + api: string, + name: string, + isNewSource: boolean = false + ) => { + if (!api.trim()) { + showAlert({ type: 'warning', title: 'API地址不能为空', message: '请输入有效的API地址' }); + 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: '请确保正在编辑视频源' }); + return; + } + await handleValidateSource(editingSource.api, editingSource.name, false); + }; + + // 新增视频源有效性检测函数 + const handleValidateNewSource = async () => { + if (!newSource.name.trim()) { + showAlert({ type: 'warning', title: '视频源名称不能为空', message: '请输入视频源名称' }); + return; + } + await handleValidateSource(newSource.api, newSource.name, true); + }; + // 获取有效性状态显示 const getValidationStatus = (sourceKey: string) => { const result = validationResults.find(r => r.key === sourceKey); @@ -2671,15 +2873,27 @@ const VideoSourceConfig = ({ > {!source.disabled ? '禁用' : '启用'} - {source.from !== 'config' && ( - - )} + + ); @@ -2824,7 +3038,13 @@ const VideoSourceConfig = ({ )} @@ -2885,6 +3156,140 @@ const VideoSourceConfig = ({ )} + {/* 编辑视频源表单 */} + {editingSource && ( +
+
+
+ 编辑视频源: {editingSource.name} +
+ +
+
+
+ + + 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' + /> +
+
+ + +
+
+ + + 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' + /> +
+
+ + + 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' + /> +
+ + {/* 有效性检测结果显示 */} + {singleValidationResult.status && ( +
+
+
+ 检测结果: + + {singleValidationResult.status === 'valid' && '✓ '} + {singleValidationResult.status === 'validating' && '⏳ '} + {singleValidationResult.status === 'no_results' && '⚠️ '} + {singleValidationResult.status === 'invalid' && '✗ '} + {singleValidationResult.message} + +
+ {singleValidationResult.details && ( +
+ {singleValidationResult.details.searchKeyword && ( +
测试关键词: {singleValidationResult.details.searchKeyword}
+ )} + {singleValidationResult.details.responseTime && ( +
响应时间: {singleValidationResult.details.responseTime}ms
+ )} + {singleValidationResult.details.resultCount !== undefined && ( +
搜索结果数: {singleValidationResult.details.resultCount}
+ )} + {singleValidationResult.details.error && ( +
错误信息: {singleValidationResult.details.error}
+ )} +
+ )} +
+
+ )} +
+
+ + + +
+
+ )} + {/* 视频源表格 */} @@ -3657,6 +4062,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | DoubanImageProxy: '', DisableYellowFilter: false, FluidSearch: true, + RequireDeviceCode: true, }); // 豆瓣数据源相关状态 @@ -3719,6 +4125,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 +4510,40 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | /> + {/* 启用设备码验证 */} +
+
+ + +
+

+ 启用后用户登录时需要绑定设备码,提升账户安全性。禁用后用户可以直接登录而无需绑定设备码。 +

+
+ {/* 禁用黄色过滤器 */}
diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts index b06539a..aedbe73 100644 --- a/src/app/api/admin/site/route.ts +++ b/src/app/api/admin/site/route.ts @@ -39,6 +39,7 @@ export async function POST(request: NextRequest) { DoubanImageProxy, DisableYellowFilter, FluidSearch, + RequireDeviceCode, } = body as { SiteName: string; Announcement: string; @@ -50,6 +51,7 @@ export async function POST(request: NextRequest) { DoubanImageProxy: string; DisableYellowFilter: boolean; FluidSearch: boolean; + RequireDeviceCode: boolean; }; // 参数校验 @@ -63,7 +65,8 @@ export async function POST(request: NextRequest) { typeof DoubanImageProxyType !== 'string' || typeof DoubanImageProxy !== 'string' || typeof DisableYellowFilter !== 'boolean' || - typeof FluidSearch !== 'boolean' + typeof FluidSearch !== 'boolean' || + typeof RequireDeviceCode !== 'boolean' ) { return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); } @@ -93,6 +96,7 @@ export async function POST(request: NextRequest) { DoubanImageProxy, DisableYellowFilter, FluidSearch, + RequireDeviceCode, }; // 写入数据库 diff --git a/src/app/api/admin/source/route.ts b/src/app/api/admin/source/route.ts index dafacfb..4c75850 100644 --- a/src/app/api/admin/source/route.ts +++ b/src/app/api/admin/source/route.ts @@ -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) diff --git a/src/app/api/admin/source/validate/route.ts b/src/app/api/admin/source/validate/route.ts index 12e63e4..bb4ad9d 100644 --- a/src/app/api/admin/source/validate/route.ts +++ b/src/app/api/admin/source/validate/route.ts @@ -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))) { diff --git a/src/app/api/proxy/video/route.ts b/src/app/api/proxy/video/route.ts index 4091343..1ba51f8 100644 --- a/src/app/api/proxy/video/route.ts +++ b/src/app/api/proxy/video/route.ts @@ -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) { - const { searchParams } = new URL(request.url); - const url = searchParams.get('url'); - - if (!url) { - return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 }); - } - - console.log('Proxy video request for URL:', url); - - let response: Response | null = null; - let reader: ReadableStreamDefaultReader | null = null; - - try { - const decodedUrl = decodeURIComponent(url); - console.log('Decoded URL:', decodedUrl); - - // 为短剧视频文件设置合适的请求头,避免403错误 - const headers: Record = { - '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', - }; - - // 对于夸克网盘等,设置更精确的请求头 - 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(); - } catch (e) { - // reader 可能已经被释放,忽略错误 - } - reader = null; - } - } - - pump(); - }, - cancel() { - // 当流被取消时,确保释放所有资源 - if (reader && reader.releaseLock) { - try { - reader.releaseLock(); - } catch (e) { - // reader 可能已经被释放,忽略错误 - } - reader = null; - } - - 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).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 }); +function buildCorsHeaders(contentType?: string, extra?: Record) { + 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; } -// 支持OPTIONS请求用于CORS预检 -export async function OPTIONS(_request: Request) { - console.log('CORS preflight request received'); +async function forwardRequest(url: string, method: 'GET' | 'HEAD', reqHeaders: Headers) { + const decodedUrl = decodeURIComponent(url); - 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', - }, + // 透传范围请求和必要请求头 + const fetchHeaders: Record = {}; + 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 }); + + 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 }); + } +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 }); + + 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 = { + '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; + + const headers = buildCorsHeaders(contentType, extra); + const status = upstream.status === 206 ? 206 : 200; + return new Response(upstream.body, { status, headers }); + } catch (e) { + console.error('Proxy video failed:', e); + return NextResponse.json({ error: 'Proxy failed' }, { status: 500 }); + } +} + +export async function OPTIONS() { + return new Response(null, { status: 204, headers: buildCorsHeaders() }); +} + + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 277e01f..8fb5d66 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -55,6 +55,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 +79,7 @@ export default async function RootLayout({ query: category.query, })); fluidSearch = config.SiteConfig.FluidSearch; + requireDeviceCode = config.SiteConfig.RequireDeviceCode; } // 将运行时配置注入到全局 window 对象,供客户端在运行时读取 @@ -90,6 +92,7 @@ export default async function RootLayout({ DISABLE_YELLOW_FILTER: disableYellowFilter, CUSTOM_CATEGORIES: customCategories, FLUID_SEARCH: fluidSearch, + REQUIRE_DEVICE_CODE: requireDeviceCode, }; return ( diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 15d96ca..2805ccb 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -85,18 +85,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 +133,8 @@ function LoginPageClient() { ...(shouldAskUsername ? { username } : {}), }; - // 如果需要机器码或用户选择绑定,则发送机器码 - if ((requireMachineCode || bindMachineCode) && machineCode) { + // 只有在启用设备码功能时才处理机器码逻辑 + if (deviceCodeEnabled && (requireMachineCode || bindMachineCode) && machineCode) { requestData.machineCode = machineCode; } @@ -142,8 +147,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', @@ -242,8 +247,8 @@ function LoginPageClient() {
- {/* 机器码信息显示 */} - {machineCodeGenerated && shouldAskUsername && ( + {/* 机器码信息显示 - 只有在启用设备码功能时才显示 */} + {deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && (
@@ -294,7 +299,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' > diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 9d98031..a8da66b 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1969,40 +1969,99 @@ 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: 5, + opacity: 1, + fontSize: 25, + color: '#FFFFFF', + mode: 0, + margin: [10, '25%'], + antiOverlap: true, + useWorker: true, + synchronousPlayback: false, + filter: (danmu: any) => danmu.text.length < 50, + lockTime: 5, + maxLength: 100, + minWidth: 200, + maxWidth: 500, + theme: 'dark', + // 核心配置:启用弹幕发送功能 + panel: true, // 启用弹幕输入面板 + emit: true, // 启用弹幕发送 + placeholder: '发个弹幕呗~', + maxlength: 50, 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: 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 +2120,7 @@ function PlayPageClient() { return newVal ? '弹幕已开启' : '弹幕已关闭'; }, }, + { name: '跳过片头片尾', html: '跳过片头片尾', @@ -2159,6 +2219,11 @@ function PlayPageClient() { }); } + // 检查弹幕插件是否正确加载 + if (danmuEnabled) { + // 弹幕启用,无需调试日志 + } + // 播放器就绪后,如果正在播放则请求 Wake Lock if (artPlayerRef.current && !artPlayerRef.current.paused) { requestWakeLock(); diff --git a/src/components/ChatModal.tsx b/src/components/ChatModal.tsx index cc39f8d..6a4cce0 100644 --- a/src/components/ChatModal.tsx +++ b/src/components/ChatModal.tsx @@ -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(null); const fileInputRef = useRef(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 ( -
+
- {/* 拖动头部 */} + {/* 拖动头部 - 仅桌面端显示 */}
@@ -793,7 +872,16 @@ export function ChatModal({
{/* 左侧面板 */} -
+
{/* 头部 */}
@@ -1139,12 +1227,32 @@ export function ChatModal({
{/* 右侧聊天区域 */} -
+
{selectedConversation ? ( <> {/* 聊天头部 */}
+ {/* 移动端返回按钮 */} + {isMobile && ( + + )} {/* 对话头像(显示对方用户的头像,如果是群聊则显示群组图标) */}
{selectedConversation.participants.length === 2 ? ( @@ -1207,7 +1315,8 @@ export function ChatModal({
{/* 消息列表 */} -
+
{messages.map((message, index) => { const isOwnMessage = message.sender_id === currentUser?.username; const prevMessage = index > 0 ? messages[index - 1] : null; @@ -1357,7 +1466,7 @@ export function ChatModal({ )} {/* 主输入区域 */} -
+
{/* 顶部工具栏 */}
@@ -1498,9 +1607,11 @@ export function ChatModal({
) : ( -
- 选择一个对话开始聊天 -
+ !isMobile && ( +
+ 选择一个对话开始聊天 +
+ ) )}
diff --git a/src/components/MobileHeader.tsx b/src/components/MobileHeader.tsx index b63949d..ae7896c 100644 --- a/src/components/MobileHeader.tsx +++ b/src/components/MobileHeader.tsx @@ -17,10 +17,10 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
{/* 左侧:搜索按钮、返回按钮和设置按钮 */} -
+
{
{/* 右侧按钮 */} -
+
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 017a5bd..fe6ce0e 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -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); + }; }, []); // 监听主题变化和路由变化,确保主题色始终同步 @@ -84,16 +96,16 @@ export function ThemeToggle() { return ( <> -
+
{/* 聊天按钮 */}
))} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index c54a2a4..513cdf8 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -48,6 +48,7 @@ export const UserMenu: React.FC = () => { const [mounted, setMounted] = useState(false); const [avatarUrl, setAvatarUrl] = useState(''); const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); + const [isMobile, setIsMobile] = useState(false); const fileInputRef = useRef(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 = () => {