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 ( + + + + + + 脚本列表 + + + + importInputRef.current?.click()} + disabled={isLoading('importSourceScript')} + className={isLoading('importSourceScript') ? buttonStyles.disabledSmall : buttonStyles.primarySmall} + > + 导入 + + loadScripts(selectedScriptId)} + disabled={loadingScripts} + className={loadingScripts ? buttonStyles.disabledSmall : buttonStyles.secondarySmall} + > + 刷新 + + + 新建 + + + + + + {loadingScripts ? ( + 加载中... + ) : scripts.length === 0 ? ( + + 还没有脚本,点右上角新建一个。 + + ) : ( + scripts.map((script) => ( + { + applyEditorFromScript(script); + setTestOutput(''); + }} + className={`w-full text-left p-4 rounded-xl border transition-colors ${ + selectedScriptId === script.id + ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' + : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900' + }`} + > + + + + {script.name} + + + {script.key} + + + + {script.enabled ? '启用' : '停用'} + + + + {new Date(script.updatedAt).toLocaleString('zh-CN')} + { + e.stopPropagation(); + handleToggleEnabled(script.id); + }} + disabled={isLoading(`toggleSourceScript_${script.id}`)} + className={script.enabled ? buttonStyles.warningSmall : buttonStyles.successSmall} + > + {script.enabled ? '停用' : '启用'} + + + + )) + )} + + + + + + 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' + /> + + + setEditor((prev) => ({ ...prev, description: e.target.value }))} + rows={2} + 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' + /> + + + + + 脚本代码 + + + {editor.version ? `当前版本: ${editor.version}` : '未保存'} + + + setEditor((prev) => ({ ...prev, code: e.target.value }))} + rows={24} + spellCheck={false} + className='w-full px-3 py-3 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-950 text-gray-100' + /> + + + + + {isLoading('saveSourceScript') ? '保存中...' : '保存脚本'} + + + {isLoading('testSourceScript') ? '测试中...' : '运行测试'} + + + 导出当前脚本 + + + {editor.id ? '删除脚本' : '清空编辑器'} + + + + + + + + 测试 Hook + + setTestHook(e.target.value as 'search' | 'detail' | 'resolvePlayUrl')} + 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' + > + search + detail + resolvePlayUrl + + + setTestPayload(e.target.value)} + rows={10} + spellCheck={false} + className='w-full px-3 py-3 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> + + + + + 测试输出 + + + {testOutput || '运行测试后会显示结果、日志和错误信息'} + + + + + {editor.history.length > 0 && ( + + + 历史版本 + + + {editor.history.map((history) => ( + + + {history.version} + + {new Date(history.updatedAt).toLocaleString('zh-CN')} + + + handleRestore(history.version)} + disabled={isLoading(`restoreSourceScript_${editor.id}`)} + className={buttonStyles.secondarySmall} + > + 回滚到此版本 + + + ))} + + + )} + + + ({ userConfig: false, videoSource: false, + sourceScriptLab: false, mediaLibrary: false, openListConfig: false, embyConfig: false, @@ -12306,6 +12909,17 @@ function AdminPageClient() { + + } + isExpanded={expandedTabs.sourceScriptLab} + onToggle={() => toggleTab('sourceScriptLab')} + > + + + {/* 电视直播源配置标签 */} ; + const action = body.action as string; + + switch (action) { + case 'save': { + const saved = await saveSourceScript({ + id: body.id, + key: body.key, + name: body.name, + description: body.description, + code: body.code, + enabled: body.enabled, + }); + return NextResponse.json({ ok: true, item: saved }); + } + case 'delete': { + await deleteSourceScript(body.id); + return NextResponse.json({ ok: true }); + } + case 'toggle_enabled': { + const item = await toggleSourceScriptEnabled(body.id); + return NextResponse.json({ ok: true, item }); + } + case 'restore': { + const item = await restoreSourceScriptHistory(body.id, body.version); + return NextResponse.json({ ok: true, item }); + } + case 'test': { + const result = await testSourceScript({ + code: body.code, + hook: body.hook, + payload: body.payload || {}, + name: body.name, + key: body.key, + configValues: body.configValues, + }); + + if (!result.ok) { + return NextResponse.json(result, { status: 400 }); + } + + return NextResponse.json(result); + } + case 'import': { + const imported = await importSourceScripts( + Array.isArray(body.items) ? body.items : [] + ); + return NextResponse.json({ ok: true, items: imported }); + } + default: + return NextResponse.json({ error: '未知操作' }, { status: 400 }); + } + } catch (error) { + return NextResponse.json( + { + error: (error as Error).message || '脚本操作失败', + }, + { status: 500 } + ); + } +} diff --git a/src/lib/source-script.ts b/src/lib/source-script.ts new file mode 100644 index 0000000..19ebba2 --- /dev/null +++ b/src/lib/source-script.ts @@ -0,0 +1,606 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as cheerio from 'cheerio'; +import { nanoid } from 'nanoid'; + +import { db } from '@/lib/db'; + +const SOURCE_SCRIPT_REGISTRY_KEY = 'source-script:registry'; +const MAX_HISTORY_ITEMS = 10; +const DEFAULT_TIMEOUT_MS = 20000; + +export interface SourceScriptVersion { + version: string; + code: string; + updatedAt: number; +} + +export interface SourceScriptRecord { + id: string; + key: string; + name: string; + description?: string; + enabled: boolean; + version: string; + code: string; + createdAt: number; + updatedAt: number; + history: SourceScriptVersion[]; +} + +export interface SourceScriptImportItem { + key: string; + name: string; + description?: string; + code: string; + enabled?: boolean; +} + +export interface SourceScriptRegistry { + items: SourceScriptRecord[]; +} + +export interface SourceScriptTestResult { + ok: boolean; + durationMs: number; + logs: string[]; + meta?: Record; + result?: any; + error?: string; +} + +const DEFAULT_SCRIPT_TEMPLATE = `return { + meta: { + name: '示例脚本', + author: 'admin' + }, + + async search(ctx, { keyword, page }) { + ctx.log.info('search', keyword, page); + return { + list: [], + page, + pageCount: 1, + total: 0 + }; + }, + + async detail(ctx, { id }) { + ctx.log.info('detail', id); + return { + id, + title: '', + poster: '', + year: '', + desc: '', + episodes: [], + episodes_titles: [] + }; + }, + + async resolvePlayUrl(ctx, { playUrl, sourceId, episodeIndex }) { + ctx.log.info('resolvePlayUrl', sourceId, episodeIndex, playUrl); + return { + url: playUrl, + type: 'auto', + headers: {} + }; + } +};`; + +function getNowVersion() { + return new Date().toISOString(); +} + +function buildEmptyRegistry(): SourceScriptRegistry { + return { items: [] }; +} + +async function loadRegistry(): Promise { + const raw = await db.getGlobalValue(SOURCE_SCRIPT_REGISTRY_KEY); + if (!raw) { + return buildEmptyRegistry(); + } + + try { + const parsed = JSON.parse(raw) as SourceScriptRegistry; + if (!parsed || !Array.isArray(parsed.items)) { + return buildEmptyRegistry(); + } + return parsed; + } catch { + return buildEmptyRegistry(); + } +} + +async function saveRegistry(registry: SourceScriptRegistry) { + await db.setGlobalValue( + SOURCE_SCRIPT_REGISTRY_KEY, + JSON.stringify(registry) + ); +} + +function assertScriptKey(key: string) { + if (!/^[a-zA-Z0-9_-]+$/.test(key)) { + throw new Error('脚本 Key 仅支持字母、数字、下划线和中划线'); + } +} + +function assertSafeCode(code: string) { + const blockedPatterns = [ + /\brequire\s*\(/, + /\bprocess\./, + /\bchild_process\b/, + /\bfs\b/, + /\bimport\s*\(/, + /\beval\s*\(/, + /\bnew\s+Function\b/, + ]; + + for (const pattern of blockedPatterns) { + if (pattern.test(code)) { + throw new Error(`脚本包含不允许的用法: ${pattern}`); + } + } +} + +function createLogCollector() { + const logs: string[] = []; + + const push = (level: string, args: any[]) => { + const rendered = args + .map((arg) => { + if (typeof arg === 'string') return arg; + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + }) + .join(' '); + + logs.push(`[${level}] ${rendered}`); + if (logs.length > 50) { + logs.shift(); + } + }; + + return { + logs, + log: { + info: (...args: any[]) => push('info', args), + warn: (...args: any[]) => push('warn', args), + error: (...args: any[]) => push('error', args), + }, + }; +} + +function withTimeout(promise: Promise, timeoutMs = DEFAULT_TIMEOUT_MS) { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`执行超时(${timeoutMs}ms)`)), timeoutMs); + }), + ]); +} + +function createCacheHelpers(scriptId: string) { + const prefix = `source-script-cache:${scriptId}:`; + + return { + async get(key: string) { + const raw = await db.getGlobalValue(`${prefix}${key}`); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as { value: string; expiresAt: number }; + if (parsed.expiresAt && parsed.expiresAt < Date.now()) { + await db.deleteGlobalValue(`${prefix}${key}`); + return null; + } + return parsed.value ?? null; + } catch { + return raw; + } + }, + async set(key: string, value: string, ttlSec = 300) { + await db.setGlobalValue( + `${prefix}${key}`, + JSON.stringify({ + value, + expiresAt: Date.now() + ttlSec * 1000, + }) + ); + }, + async del(key: string) { + await db.deleteGlobalValue(`${prefix}${key}`); + }, + }; +} + +function createUtils() { + return { + buildUrl(base: string, query?: Record) { + const url = new URL(base); + Object.entries(query || {}).forEach(([key, value]) => { + url.searchParams.set(key, String(value)); + }); + return url.toString(); + }, + joinUrl(base: string, path: string) { + return new URL(path, base).toString(); + }, + randomUA() { + return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'; + }, + sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + }, + base64Encode(value: string) { + return Buffer.from(value, 'utf8').toString('base64'); + }, + base64Decode(value: string) { + return Buffer.from(value, 'base64').toString('utf8'); + }, + now() { + return Date.now(); + }, + }; +} + +function createScriptFactory(code: string) { + assertSafeCode(code); + + return new Function( + `"use strict"; +const process = undefined; +const require = undefined; +const module = undefined; +const exports = undefined; +const fetch = undefined; +${code}` + ) as () => any; +} + +async function createScriptContext(script: SourceScriptRecord, configValues?: Record) { + const { logs, log } = createLogCollector(); + const cache = createCacheHelpers(script.id); + + const fetcher = async (input: { + url: string; + method?: string; + headers?: Record; + query?: Record; + body?: string; + json?: unknown; + timeoutMs?: number; + }) => { + const url = new URL(input.url); + Object.entries(input.query || {}).forEach(([key, value]) => { + url.searchParams.set(key, String(value)); + }); + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`不支持的协议: ${url.protocol}`); + } + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + input.timeoutMs || DEFAULT_TIMEOUT_MS + ); + + try { + const response = await fetch(url.toString(), { + method: input.method || 'GET', + headers: { + ...(input.json ? { 'Content-Type': 'application/json' } : {}), + ...(input.headers || {}), + }, + body: input.json !== undefined ? JSON.stringify(input.json) : input.body, + signal: controller.signal, + }); + + return { + status: response.status, + ok: response.ok, + url: response.url, + headers: Object.fromEntries(response.headers.entries()), + text: () => response.text(), + json: () => response.json() as Promise, + arrayBuffer: () => response.arrayBuffer(), + }; + } finally { + clearTimeout(timeoutId); + } + }; + + return { + ctx: Object.freeze({ + fetch: fetcher, + request: { + get: (url: string, options?: Omit[0], 'url' | 'method'>) => + fetcher({ url, method: 'GET', ...(options || {}) }), + post: (url: string, options?: Omit[0], 'url' | 'method'>) => + fetcher({ url, method: 'POST', ...(options || {}) }), + async getHtml(url: string, options?: Omit[0], 'url' | 'method'>) { + const response = await fetcher({ url, method: 'GET', ...(options || {}) }); + const text = await response.text(); + return cheerio.load(text); + }, + async getJson(url: string, options?: Omit[0], 'url' | 'method'>) { + const response = await fetcher({ url, method: 'GET', ...(options || {}) }); + return response.json(); + }, + }, + html: { + load: (html: string) => cheerio.load(html), + }, + json: { + parse(text: string, fallback?: T) { + try { + return JSON.parse(text) as T; + } catch { + return fallback as T; + } + }, + stringify(value: unknown) { + return JSON.stringify(value); + }, + }, + utils: createUtils(), + cache, + log, + config: { + get: (key: string) => configValues?.[key], + require: (key: string) => { + const value = configValues?.[key]; + if (!value) { + throw new Error(`缺少脚本配置: ${key}`); + } + return value; + }, + all: () => ({ ...(configValues || {}) }), + }, + runtime: { + scriptId: script.id, + sourceKey: script.key, + sourceName: script.name, + version: script.version, + }, + }), + logs, + }; +} + +function normalizeScript(script: any) { + if (!script || typeof script !== 'object') { + throw new Error('脚本必须返回对象'); + } + return script; +} + +export async function listSourceScripts() { + const registry = await loadRegistry(); + return registry.items.sort((a, b) => b.updatedAt - a.updatedAt); +} + +export async function getSourceScript(id: string) { + const registry = await loadRegistry(); + return registry.items.find((item) => item.id === id) || null; +} + +export async function saveSourceScript(input: { + id?: string; + key: string; + name: string; + description?: string; + code: string; + enabled?: boolean; +}) { + assertScriptKey(input.key); + assertSafeCode(input.code); + + const registry = await loadRegistry(); + const now = Date.now(); + const existing = input.id + ? registry.items.find((item) => item.id === input.id) + : undefined; + + if (!existing && registry.items.some((item) => item.key === input.key)) { + throw new Error('脚本 Key 已存在'); + } + + if (existing) { + existing.history.unshift({ + version: existing.version, + code: existing.code, + updatedAt: existing.updatedAt, + }); + existing.history = existing.history.slice(0, MAX_HISTORY_ITEMS); + existing.key = input.key; + existing.name = input.name; + existing.description = input.description || ''; + existing.code = input.code; + existing.enabled = input.enabled ?? existing.enabled; + existing.updatedAt = now; + existing.version = getNowVersion(); + await saveRegistry(registry); + return existing; + } + + const created: SourceScriptRecord = { + id: nanoid(), + key: input.key, + name: input.name, + description: input.description || '', + code: input.code, + enabled: input.enabled ?? true, + version: getNowVersion(), + createdAt: now, + updatedAt: now, + history: [], + }; + + registry.items.unshift(created); + await saveRegistry(registry); + return created; +} + +export async function importSourceScripts(items: SourceScriptImportItem[]) { + const registry = await loadRegistry(); + const now = Date.now(); + const imported: SourceScriptRecord[] = []; + + for (const item of items) { + if (!item?.key || !item?.name || !item?.code) { + throw new Error('导入脚本缺少必要字段: key/name/code'); + } + + assertScriptKey(item.key); + assertSafeCode(item.code); + + const existing = registry.items.find((record) => record.key === item.key); + + if (existing) { + existing.history.unshift({ + version: existing.version, + code: existing.code, + updatedAt: existing.updatedAt, + }); + existing.history = existing.history.slice(0, MAX_HISTORY_ITEMS); + existing.name = item.name; + existing.description = item.description || ''; + existing.code = item.code; + existing.enabled = item.enabled ?? existing.enabled; + existing.updatedAt = now; + existing.version = getNowVersion(); + imported.push(existing); + continue; + } + + const created: SourceScriptRecord = { + id: nanoid(), + key: item.key, + name: item.name, + description: item.description || '', + code: item.code, + enabled: item.enabled ?? true, + version: getNowVersion(), + createdAt: now, + updatedAt: now, + history: [], + }; + registry.items.unshift(created); + imported.push(created); + } + + await saveRegistry(registry); + return imported; +} + +export async function deleteSourceScript(id: string) { + const registry = await loadRegistry(); + const nextItems = registry.items.filter((item) => item.id !== id); + if (nextItems.length === registry.items.length) { + throw new Error('脚本不存在'); + } + registry.items = nextItems; + await saveRegistry(registry); +} + +export async function toggleSourceScriptEnabled(id: string) { + const registry = await loadRegistry(); + const target = registry.items.find((item) => item.id === id); + if (!target) { + throw new Error('脚本不存在'); + } + target.enabled = !target.enabled; + target.updatedAt = Date.now(); + await saveRegistry(registry); + return target; +} + +export async function restoreSourceScriptHistory(id: string, version: string) { + const registry = await loadRegistry(); + const target = registry.items.find((item) => item.id === id); + if (!target) { + throw new Error('脚本不存在'); + } + + const history = target.history.find((item) => item.version === version); + if (!history) { + throw new Error('历史版本不存在'); + } + + target.history.unshift({ + version: target.version, + code: target.code, + updatedAt: target.updatedAt, + }); + target.history = target.history.slice(0, MAX_HISTORY_ITEMS); + target.code = history.code; + target.version = getNowVersion(); + target.updatedAt = Date.now(); + await saveRegistry(registry); + return target; +} + +export async function testSourceScript(input: { + code: string; + hook: 'search' | 'detail' | 'resolvePlayUrl'; + payload: Record; + name?: string; + key?: string; + configValues?: Record; +}): Promise { + const startedAt = Date.now(); + try { + const tempScript: SourceScriptRecord = { + id: 'test-script', + key: input.key || 'test-script', + name: input.name || '测试脚本', + description: '', + enabled: true, + version: 'test', + code: input.code, + createdAt: startedAt, + updatedAt: startedAt, + history: [], + }; + + const factory = createScriptFactory(input.code); + const compiled = normalizeScript(factory()); + const hook = compiled[input.hook]; + if (typeof hook !== 'function') { + throw new Error(`脚本未实现 ${input.hook} hook`); + } + + const { ctx, logs } = await createScriptContext(tempScript, input.configValues); + const result = await withTimeout( + Promise.resolve(hook(ctx, input.payload)), + DEFAULT_TIMEOUT_MS + ); + + return { + ok: true, + durationMs: Date.now() - startedAt, + logs, + meta: compiled.meta, + result, + }; + } catch (error) { + return { + ok: false, + durationMs: Date.now() - startedAt, + logs: [], + error: (error as Error).message, + }; + } +} + +export function getDefaultSourceScriptTemplate() { + return DEFAULT_SCRIPT_TEMPLATE; +}
+ {testOutput || '运行测试后会显示结果、日志和错误信息'} +