From b66d6076757accd6bc4a3b9bb2c3c99b414199c8 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 21 Mar 2026 11:47:22 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=BA=90=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 616 ++++++++++++++++++++++- src/app/api/admin/source-script/route.ts | 130 +++++ src/lib/source-script.ts | 606 ++++++++++++++++++++++ 3 files changed, 1351 insertions(+), 1 deletion(-) create mode 100644 src/app/api/admin/source-script/route.ts create mode 100644 src/lib/source-script.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 07dbe5a..3346c7f 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -45,7 +45,7 @@ import { Video, } from 'lucide-react'; import { GripVertical } from 'lucide-react'; -import { memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; @@ -317,6 +317,23 @@ const useLoadingState = () => { return { loadingStates, setLoading, isLoading, withLoading }; }; +interface StandaloneSourceScript { + id: string; + key: string; + name: string; + description?: string; + enabled: boolean; + version: string; + code: string; + createdAt: number; + updatedAt: number; + history: Array<{ + version: string; + code: string; + updatedAt: number; + }>; +} + // 新增站点配置类型 interface SiteConfig { SiteName: string; @@ -5945,6 +5962,591 @@ const CategoryConfig = ({ )} {/* 通用弹窗组件 */} + + + ); +}; + +const VideoSourceScriptLab = () => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [scripts, setScripts] = useState([]); + const [loadingScripts, setLoadingScripts] = useState(true); + const [template, setTemplate] = useState(''); + const [selectedScriptId, setSelectedScriptId] = useState(null); + const [editor, setEditor] = useState<{ + id?: string; + key: string; + name: string; + description: string; + code: string; + enabled: boolean; + history: StandaloneSourceScript['history']; + version?: string; + updatedAt?: number; + }>({ + key: '', + name: '', + description: '', + code: '', + enabled: true, + history: [], + }); + const [testHook, setTestHook] = useState<'search' | 'detail' | 'resolvePlayUrl'>('search'); + const [testPayload, setTestPayload] = useState( + JSON.stringify({ keyword: '凡人修仙传', page: 1 }, null, 2) + ); + const [testOutput, setTestOutput] = useState(''); + const importInputRef = useRef(null); + + const applyEditorFromScript = (script: StandaloneSourceScript | null) => { + if (!script) { + setEditor({ + key: '', + name: '', + description: '', + code: template, + enabled: true, + history: [], + }); + setSelectedScriptId(null); + return; + } + + setEditor({ + id: script.id, + key: script.key, + name: script.name, + description: script.description || '', + code: script.code, + enabled: script.enabled, + history: script.history || [], + version: script.version, + updatedAt: script.updatedAt, + }); + setSelectedScriptId(script.id); + }; + + const loadScripts = async (preferId?: string | null) => { + setLoadingScripts(true); + try { + const response = await fetch('/api/admin/source-script', { + cache: 'no-store', + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '加载脚本失败'); + } + + const nextScripts = (data.items || []) as StandaloneSourceScript[]; + setScripts(nextScripts); + setTemplate(data.template || ''); + + const targetId = + preferId || + selectedScriptId || + nextScripts[0]?.id || + null; + + const selected = nextScripts.find((item) => item.id === targetId) || null; + if (selected) { + applyEditorFromScript(selected); + } else { + setEditor({ + key: '', + name: '', + description: '', + code: data.template || '', + enabled: true, + history: [], + }); + setSelectedScriptId(null); + } + } catch (error) { + showError(error instanceof Error ? error.message : '加载脚本失败', showAlert); + } finally { + setLoadingScripts(false); + } + }; + + useEffect(() => { + loadScripts(); + }, []); + + const handleCreateNew = () => { + setSelectedScriptId(null); + setEditor({ + key: '', + name: '', + description: '', + code: template, + enabled: true, + history: [], + }); + setTestOutput(''); + }; + + const handleExportCurrent = () => { + if (!editor.key || !editor.name || !editor.code) { + showError('当前没有可导出的脚本', showAlert); + return; + } + + const payload = { + key: editor.key, + name: editor.name, + description: editor.description, + code: editor.code, + enabled: editor.enabled, + }; + + const blob = new Blob([JSON.stringify(payload, null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${editor.key}.json`; + link.click(); + URL.revokeObjectURL(url); + }; + + const handleImportFile = async ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const raw = await file.text(); + const parsed = JSON.parse(raw); + const items = Array.isArray(parsed) ? parsed : [parsed]; + + await withLoading('importSourceScript', async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'import', + items, + }), + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '导入失败'); + } + + showSuccess(`已导入 ${data.items?.length || 0} 个脚本`, showAlert); + await loadScripts(data.items?.[0]?.id || null); + }); + } catch (error) { + showError(error instanceof Error ? error.message : '导入失败', showAlert); + } finally { + event.target.value = ''; + } + }; + + const handleSave = async () => { + if (!editor.key || !editor.name || !editor.code) { + showError('请填写脚本 Key、名称和代码', showAlert); + return; + } + + await withLoading('saveSourceScript', async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + id: editor.id, + key: editor.key, + name: editor.name, + description: editor.description, + code: editor.code, + enabled: editor.enabled, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '保存失败'); + } + + showSuccess('脚本已保存', showAlert); + await loadScripts(data.item?.id || editor.id || null); + }).catch((error) => { + showError(error instanceof Error ? error.message : '保存失败', showAlert); + }); + }; + + const handleDelete = async () => { + if (!editor.id) { + handleCreateNew(); + return; + } + + showAlert({ + type: 'warning', + title: '删除脚本', + message: `确定要删除脚本 "${editor.name}" 吗?`, + showConfirm: true, + onConfirm: async () => { + hideAlert(); + await withLoading('deleteSourceScript', async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'delete', + id: editor.id, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '删除失败'); + } + showSuccess('脚本已删除', showAlert); + await loadScripts(null); + }).catch((error) => { + showError(error instanceof Error ? error.message : '删除失败', showAlert); + }); + }, + }); + }; + + const handleToggleEnabled = async (id: string) => { + await withLoading(`toggleSourceScript_${id}`, async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'toggle_enabled', + id, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '更新失败'); + } + await loadScripts(id); + }).catch((error) => { + showError(error instanceof Error ? error.message : '更新失败', showAlert); + }); + }; + + const handleRestore = async (version: string) => { + if (!editor.id) return; + + await withLoading(`restoreSourceScript_${editor.id}`, async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'restore', + id: editor.id, + version, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '回滚失败'); + } + showSuccess('已回滚到历史版本', showAlert); + await loadScripts(editor.id); + }).catch((error) => { + showError(error instanceof Error ? error.message : '回滚失败', showAlert); + }); + }; + + const handleTest = async () => { + let payload = {}; + try { + payload = testPayload.trim() ? JSON.parse(testPayload) : {}; + } catch { + showError('测试输入必须是合法 JSON', showAlert); + return; + } + + await withLoading('testSourceScript', async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'test', + key: editor.key || 'test-script', + name: editor.name || '测试脚本', + code: editor.code, + hook: testHook, + payload, + }), + }); + const data = await response.json().catch(() => ({})); + setTestOutput(JSON.stringify(data, null, 2)); + if (!response.ok) { + throw new Error(data.error || data.message || '测试失败'); + } + showSuccess('测试执行完成', showAlert); + }).catch((error) => { + showError(error instanceof Error ? error.message : '测试失败', showAlert); + }); + }; + + useEffect(() => { + setTestPayload( + testHook === 'search' + ? JSON.stringify({ keyword: '凡人修仙传', page: 1 }, null, 2) + : testHook === 'detail' + ? JSON.stringify({ id: 'demo-id' }, null, 2) + : JSON.stringify( + { + sourceId: 'demo-id', + playUrl: 'https://example.com/video.m3u8', + episodeIndex: 0, + }, + null, + 2 + ) + ); + }, [testHook]); + + return ( +
+
+
+
+

+ 脚本列表 +

+
+ + + + +
+
+ +
+ {loadingScripts ? ( +
加载中...
+ ) : scripts.length === 0 ? ( +
+ 还没有脚本,点右上角新建一个。 +
+ ) : ( + scripts.map((script) => ( + +
+ + )) + )} +
+
+ +
+
+ setEditor((prev) => ({ ...prev, name: e.target.value }))} + 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' + /> + setEditor((prev) => ({ ...prev, key: e.target.value }))} + 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' + /> +
+ +