import { BarChartOutlined, CheckCircleFilled, CloseOutlined, CopyOutlined, DownloadOutlined, FileTextOutlined, LoadingOutlined, ThunderboltOutlined, UploadOutlined, } from "@ant-design/icons"; import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from "react"; import "../../styles/pages/script-tokens-v5.css"; import "../../styles/pages/script-tokens.css"; import { evaluateScript } from "../../api/scriptEvalClient"; import { buildApiUrl, getStoredToken } from "../../api/serverConnection"; import { ShellIcon } from "../../components/ShellIcon"; import { useSessionStore } from "../../stores"; interface ScoreDimension { key: string; label: string; maxScore: number; hint: string; detail: string; } interface EvalResult { totalScore: number; grade: string; dimensionScores: Record; subScores?: Record>; evidence?: Record; summary: string; issues: string[]; highlights: string[]; suggestions: string[]; } interface HistoryEntry { name: string; date: string; timestamp: number; score: number; grade: string; script?: string; result?: EvalResult; } function getGrade(score: number): string { if (score >= 97) return "S+"; if (score >= 93) return "S"; if (score >= 88) return "A+"; if (score >= 83) return "A"; if (score >= 78) return "B+"; if (score >= 70) return "B"; return "C"; } const HISTORY_KEY = "omniai:script-eval-history"; const TEXT_FILE_EXTENSIONS = [ ".txt", ".text", ".md", ".markdown", ".fountain", ".fdx", ".rtf", ".docx", ".doc", ".csv", ".tsv", ".json", ".jsonl", ".xml", ".html", ".htm", ".yaml", ".yml", ".toml", ".ini", ".conf", ".cfg", ".properties", ".log", ".srt", ".ass", ".ssa", ".vtt", ".sql", ".js", ".jsx", ".ts", ".tsx", ".py", ".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".php", ".rb", ".sh", ".bat", ".ps1", ".lua", ".swift", ".kt", ".kts", ] as const; const TEXT_FILE_EXTENSION_SET = new Set(TEXT_FILE_EXTENSIONS); const TEXT_FILE_ACCEPT = TEXT_FILE_EXTENSIONS.join(","); const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / DOCX / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等"; function loadHistory(): HistoryEntry[] { try { const raw = localStorage.getItem(HISTORY_KEY); return raw ? (JSON.parse(raw) as HistoryEntry[]).sort((a, b) => b.timestamp - a.timestamp) : []; } catch { return []; } } function saveHistory(entries: HistoryEntry[]) { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(entries.sort((a, b) => b.timestamp - a.timestamp).slice(0, 20))); } catch { /* quota exceeded */ } } function getFileExtension(filename: string): string { const dotIndex = filename.lastIndexOf("."); return dotIndex >= 0 ? filename.slice(dotIndex).toLowerCase() : ""; } function isReadableTextFile(file: File, ext: string): boolean { const mime = file.type.toLowerCase(); return ( TEXT_FILE_EXTENSION_SET.has(ext) || mime.startsWith("text/") || mime === "application/json" || mime === "application/xml" || mime === "application/xhtml+xml" || mime === "application/x-yaml" || mime === "application/yaml" ); } async function decodeTextFile(file: File): Promise { const bytes = await file.arrayBuffer(); const utf8 = new TextDecoder("utf-8", { fatal: false }).decode(bytes); if (!utf8.includes("\uFFFD")) return utf8; try { return new TextDecoder("gb18030", { fatal: false }).decode(bytes); } catch { return utf8; } } function normalizeUploadedText(raw: string, ext: string): string { if (ext === ".rtf") { const text = raw .replace(/\\par[d]?/gi, "\n") .replace(/\\line/gi, "\n") .replace(/\\'[0-9a-f]{2}/gi, "") .replace(/\\[a-z]+\d* ?/gi, "") .replace(/[{}]/g, "") .replace(/\n{3,}/g, "\n\n") .trim(); return text || raw; } if ([".html", ".htm", ".xml", ".fdx"].includes(ext) && typeof DOMParser !== "undefined") { try { const doc = new DOMParser().parseFromString(raw, ext === ".html" || ext === ".htm" ? "text/html" : "application/xml"); const text = doc.documentElement.textContent?.replace(/\n{3,}/g, "\n\n").trim(); return text || raw; } catch { return raw; } } return raw; } function formatFileSize(size: number): string { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; return `${(size / 1024 / 1024).toFixed(1)} MB`; } const SCORE_DIMENSIONS: ScoreDimension[] = [ { key: "hook", label: "钩子设计", maxScore: 20, hint: "开篇吸引力·悬念设置·黄金三秒", detail: "开篇即抛出高概念钩子,悬念设置紧凑有力。" }, { key: "character", label: "角色塑造", maxScore: 15, hint: "人物立体度·动机合理性·弧光设计", detail: "主角动机有铺垫,配角功能性较强,人物弧光尚可进一步深化。" }, { key: "plot", label: "剧情结构", maxScore: 20, hint: "起承转合·节奏把控·冲突设计", detail: "起承转合完整,节奏把控稳健,冲突设计有张力。" }, { key: "logic", label: "逻辑严密", maxScore: 15, hint: "世界观自洽·伏笔回收·因果链", detail: "世界观整体自洽,伏笔设置到位。" }, { key: "visual", label: "场景构建", maxScore: 15, hint: "空间描写·视听语言·画面想象力", detail: "视觉意象统一而强烈,场景描写极具画面感。" }, { key: "content", label: "内容深度", maxScore: 15, hint: "主题表达·情感共鸣·思想内核", detail: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" }, ]; const SUB_SCORE_LABELS: Record = { openingImpact: "开篇冲击", suspenseChain: "悬念链", sceneHook: "场内钩子", structure: "结构完整", rhythm: "节奏推进", conflict: "冲突强度", reversal: "反转效率", motivation: "动机清晰", arc: "人物弧光", voice: "语言辨识", relationship: "关系张力", causality: "因果链", worldRules: "世界规则", foreshadowing: "伏笔回收", continuity: "连续性", sceneDetail: "场景细节", shotPotential: "镜头潜力", aigcFeasibility: "AIGC 可实现", theme: "主题表达", emotion: "情感共鸣", marketFit: "市场匹配", originality: "原创性", }; function clampScore(score: unknown, maxScore: number): number { const numeric = Number(score); if (!Number.isFinite(numeric)) return 0; return Math.max(0, Math.min(maxScore, numeric)); } function getDimensionScore(result: EvalResult, dim: ScoreDimension): number { const value = result.dimensionScores[dim.key] ?? (dim.key === "logic" ? result.dimensionScores.dialogue : undefined); return clampScore(value, dim.maxScore); } function formatSubScoreLabel(key: string): string { return SUB_SCORE_LABELS[key] ?? key.replace(/([A-Z])/g, " $1").trim(); } function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[string, number]> { const scores = result.subScores?.[dim.key] ?? (dim.key === "logic" ? result.subScores?.dialogue : undefined); if (!scores) return []; return Object.entries(scores) .map(([key, value]) => [key, clampScore(value, dim.maxScore)] as [string, number]) .filter(([, value]) => value > 0) .slice(0, 5); } function normalizeEvidenceItems(evidence: unknown[] | undefined, limit: number): string[] { if (!Array.isArray(evidence)) return []; const items: string[] = []; for (const item of evidence) { const value = String(item).trim(); if (!value) continue; items.push(value); if (items.length >= limit) break; } return items; } function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] { const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined); return normalizeEvidenceItems(evidence, 3); } function formatReportMarkdown(result: EvalResult, script: string): string { const lines: string[] = []; lines.push(`# 剧本评测报告`); lines.push(""); lines.push(`**综合评分**: ${result.totalScore} / 100 · ${result.grade} 级`); lines.push(""); lines.push(`## 概要`); lines.push(result.summary); lines.push(""); lines.push(`## 六维评分`); for (const dim of SCORE_DIMENSIONS) { const score = getDimensionScore(result, dim); const pct = Math.round((score / dim.maxScore) * 100); const subScores = getDimensionSubScores(result, dim); const evidence = getDimensionEvidence(result, dim); const nestedReportLines = [ ...subScores.map(([key, value]) => ` - ${formatSubScoreLabel(key)}: ${value}`), ...evidence.map((item) => ` - 证据: ${item}`), ]; lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`); lines.push(...nestedReportLines); } if (result.highlights.length > 0) { lines.push(""); lines.push(`## 亮点`); result.highlights.forEach((h) => lines.push(`- ${h}`)); } if (result.issues.length > 0) { lines.push(""); lines.push(`## 扣分点`); result.issues.forEach((i) => lines.push(`- ${i}`)); } if (result.suggestions.length > 0) { lines.push(""); lines.push(`## 优化建议`); result.suggestions.forEach((s) => lines.push(`- ${s}`)); } lines.push(""); lines.push(`---`); lines.push(`*评测时间: ${new Date().toLocaleString("zh-CN")}*`); return lines.join("\n"); } function ScriptTokensPage() { const [script, setScript] = useState(""); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); const [evalError, setEvalError] = useState(null); const [uploadedFile, setUploadedFile] = useState<{ name: string; size: number } | null>(null); const [copied, setCopied] = useState(false); const [activeDim, setActiveDim] = useState(null); const [animatedScore, setAnimatedScore] = useState(0); const [activeHistoryIndex, setActiveHistoryIndex] = useState(0); const [history, setHistory] = useState(loadHistory); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); const scoreFrameRef = useRef(null); const session = useSessionStore((s) => s.session); const hasContent = Boolean(script.trim()); // Score animation useEffect(() => { if (!result) return; const start = performance.now(); const target = result.totalScore; const dur = 1400; function tick(now: number) { const t = Math.min((now - start) / dur, 1); const e = 1 - Math.pow(1 - t, 3); setAnimatedScore(Math.round(e * target)); if (t < 1) scoreFrameRef.current = requestAnimationFrame(tick); } scoreFrameRef.current = requestAnimationFrame(tick); return () => { if (scoreFrameRef.current) cancelAnimationFrame(scoreFrameRef.current); }; }, [result]); const processUploadedFile = async (file: File) => { const ext = getFileExtension(file.name); const readable = isReadableTextFile(file, ext); setUploadedFile({ name: file.name, size: file.size }); if (ext === ".docx" || ext === ".doc") { try { const formData = new FormData(); formData.append("file", file); const token = getStoredToken(); const resp = await fetch(buildApiUrl("files/extract-text"), { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: formData, }); if (resp.ok) { const { text } = await resp.json(); setScript(text || ""); } else { const err = await resp.json().catch(() => ({ error: "解析失败" })); setScript(`[已上传文件:${file.name}]\n\n${err.error || "文件解析失败,请尝试另存为 TXT 格式后重新上传。"}`); } } catch { setScript(`[已上传文件:${file.name}]\n\n文件解析请求失败,请检查网络连接后重试。`); } } else if (readable) { const text = normalizeUploadedText(await decodeTextFile(file), ext); setScript(text); } else { setScript(`[已上传文件:${file.name}]\n\n暂不支持解析 ${ext ? ext.toUpperCase() : "未知"} 格式,请上传常见文本类文件。`); } }; const handleFileUpload = async (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; await processUploadedFile(file); event.target.value = ""; }; const handleEvaluate = async () => { if (!hasContent) return; setLoading(true); setResult(null); setEvalError(null); setAnimatedScore(0); setActiveDim(null); try { const aiResult = await evaluateScript(script); setResult(aiResult); const g = getGrade(aiResult.totalScore); const entry: HistoryEntry = { name: uploadedFile?.name?.replace(/\.[^.]+$/, "") ?? `剧本 ${new Date().toLocaleDateString("zh-CN")}`, date: new Date().toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }), timestamp: Date.now(), score: aiResult.totalScore, grade: g, script, result: aiResult, }; const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)].sort( (a, b) => b.timestamp - a.timestamp, ); saveHistory(updated); setHistory(updated); } catch (err) { setEvalError(err instanceof Error ? err.message : "评测服务暂时不可用,请稍后重试"); } setLoading(false); }; const handleHistoryClick = (item: HistoryEntry, index: number) => { setActiveHistoryIndex(index); if (item.script) { setScript(item.script); setUploadedFile({ name: `${item.name}.txt`, size: item.script.length }); } if (item.result) { setResult(item.result); } else { setResult(null); } setEvalError(null); }; const handleReset = () => { setScript(""); setResult(null); setEvalError(null); setUploadedFile(null); setCopied(false); setAnimatedScore(0); setActiveDim(null); if (fileInputRef.current) fileInputRef.current.value = ""; }; const handleCopyReport = async () => { if (!result) return; const text = formatReportMarkdown(result, script); try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); setCopied(true); setTimeout(() => setCopied(false), 2000); } }; const handleExportMarkdown = () => { if (!result) return; const md = formatReportMarkdown(result, script); const blob = new Blob([md], { type: "text/markdown;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `剧本评测_${result.grade}级_${result.totalScore}分_${new Date().toISOString().slice(0, 10)}.md`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; const uploadKeyDown = (event: KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); fileInputRef.current?.click(); }; const handleDragOver = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) { setIsDragging(true); } }; const handleDragLeave = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) { setIsDragging(false); } }; const handleDrop = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); setIsDragging(false); const file = event.dataTransfer.files[0]; if (file) processUploadedFile(file); }; const grade = result ? getGrade(result.totalScore) : null; const beatPct = result ? (result.totalScore >= 95 ? 97 : result.totalScore >= 88 ? 92 : result.totalScore >= 80 ? 85 : 72) : 0; const compactTitle = uploadedFile?.name?.replace(/\.[^.]+$/, "") ?? "剧本评测"; const scriptMinutes = Math.max(8, Math.round(script.length / 460)); const reportDate = new Date().toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }); const statusClass = loading ? "is-loading" : result ? "is-complete" : hasContent ? "is-ready" : "is-idle"; return (
{/* Left Panel */} {/* Right Area */}
剧本评测 {uploadedFile && <> · {compactTitle}}
{result && ( <> )}
{loading ? (
AI 正在分析剧本...

正在调用模型进行六维评分,预计需要 15-30 秒

) : !result && (
fileInputRef.current?.click()} onKeyDown={uploadKeyDown} >
{uploadedFile ? "剧本已导入" : "上传剧本文件"}
{uploadedFile ? "如需更换,点击此处重新上传;完成后点击左侧开始评测。" : `${TEXT_FILE_HINT},上传后点击开始评测,AI 将识别剧本信息。`}
{evalError && (
评测失败{evalError}
)}
)} {result && (
{animatedScore} / 100 {grade}级
{result.totalScore >= 90 ? "优秀" : result.totalScore >= 80 ? "良好" : result.totalScore >= 70 ? "中等" : "待提升"},{result.totalScore >= 85 ? "具备商业开发潜力" : "建议针对性优化后再提交"}

{compactTitle}

{`剧本评测 · ${scriptMinutes} min · ${reportDate}`}

{result.summary}

维度拆解
得分 扣分
100% 80% 60% 40% 20% 0%
{SCORE_DIMENSIONS.map((dim, dimIndex) => { const score = getDimensionScore(result, dim); const pct = Math.max(0, Math.min(1, score / dim.maxScore)); const lossPct = 1 - pct; const isPerfect = score === dim.maxScore; const isActive = activeDim === null || activeDim === dimIndex; return ( ); })}
{activeDim === null ? "悬停维度可查看当前分项表现,优先从低分项制定改稿计划。" : `${SCORE_DIMENSIONS[activeDim].label}:${SCORE_DIMENSIONS[activeDim].detail}`}
{SCORE_DIMENSIONS.map((dim) => { const score = getDimensionScore(result, dim); const pct = Math.round((score / dim.maxScore) * 100); const subScores = getDimensionSubScores(result, dim); const evidence = getDimensionEvidence(result, dim); return (
{dim.label} {score}/{dim.maxScore}
{pct}%

{dim.hint}

{subScores.length > 0 ? (
{subScores.map(([key, value]) => { const subPct = Math.max(0, Math.min(100, Math.round((value / dim.maxScore) * 100))); return (
{formatSubScoreLabel(key)} {value}
); })}
) : (

等待模型返回更细的子项评分;当前先按主维度分数展示。

)} {evidence.length > 0 ? (
    {evidence.map((item, index) =>
  • {item}
  • )}
) : null}
); })}
{result.highlights.length > 0 ? (

亮点 {result.highlights.length}

{result.highlights.map((item, index) => (

{item}

))}
) : null} {result.issues.length > 0 ? (

扣分点 {result.issues.length}

{result.issues.map((item, index) => (

{item}

))}
) : null}
{result.suggestions.length > 0 ? (
优化路径
{result.suggestions.map((item, index) => { const high = index < 2; return ( ); })}
# 类型 优化路径 优先级
{String(index + 1).padStart(2, "0")} {high ? "核心" : "增强"} {item} {high ? "HIGH" : "MID"}
) : null}
)}
{loading ? "评测中..." : result ? "评测完成" : hasContent ? "待评测" : "等待上传"} {result ? `六维标准 · ${result.totalScore}分` : "六维标准"}
); } export default ScriptTokensPage;