Files
omniai-web/src/features/script-tokens/ScriptTokensPage.tsx
T

922 lines
38 KiB
TypeScript
Raw Normal View History

import {
CheckCircleFilled,
CloseOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from "react";
2026-06-05 17:19:38 +08:00
import "../../styles/pages/script-tokens-v5.css";
import "../../styles/pages/script-tokens.css";
2026-06-02 12:38:01 +08:00
import { evaluateScript } from "../../api/scriptEvalClient";
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
2026-06-05 20:42:34 +08:00
import { ShellIcon } from "../../components/ShellIcon";
import { useSessionStore } from "../../stores";
2026-06-02 12:38:01 +08:00
interface ScoreDimension {
key: string;
label: string;
maxScore: number;
hint: string;
detail: string;
2026-06-02 12:38:01 +08:00
}
interface EvalResult {
totalScore: number;
grade: string;
dimensionScores: Record<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
2026-06-02 12:38:01 +08:00
summary: string;
issues: string[];
highlights: string[];
suggestions: string[];
}
interface HistoryEntry {
name: string;
date: string;
timestamp: number;
score: number;
grade: string;
script?: string;
result?: EvalResult;
}
2026-06-02 12:38:01 +08:00
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";
2026-06-02 12:38:01 +08:00
}
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<string>(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 / 字幕等";
2026-06-02 12:38:01 +08:00
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 []; }
2026-06-02 12:38:01 +08:00
}
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<string> {
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;
2026-06-02 12:38:01 +08:00
}
2026-06-03 20:23:27 +08:00
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<string, string> = {
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);
}
2026-06-05 18:27:08 +08:00
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);
2026-06-05 18:27:08 +08:00
return normalizeEvidenceItems(evidence, 3);
}
2026-06-09 12:02:30 +08:00
function formatReportMarkdown(result: EvalResult): string {
2026-06-02 12:38:01 +08:00
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);
2026-06-02 12:38:01 +08:00
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);
2026-06-02 12:38:01 +08:00
}
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<EvalResult | null>(null);
const [evalError, setEvalError] = useState<string | null>(null);
const [uploadedFile, setUploadedFile] = useState<{ name: string; size: number } | null>(null);
const [copied, setCopied] = useState(false);
const [activeDim, setActiveDim] = useState<number | null>(null);
const [animatedScore, setAnimatedScore] = useState(0);
const [activeHistoryIndex, setActiveHistoryIndex] = useState<number>(0);
const [history, setHistory] = useState<HistoryEntry[]>(loadHistory);
const [isDragging, setIsDragging] = useState(false);
2026-06-02 12:38:01 +08:00
const fileInputRef = useRef<HTMLInputElement>(null);
const scoreFrameRef = useRef<number | null>(null);
2026-06-02 12:38:01 +08:00
const session = useSessionStore((s) => s.session);
2026-06-02 12:38:01 +08:00
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]);
2026-06-02 12:38:01 +08:00
const processUploadedFile = async (file: File) => {
const ext = getFileExtension(file.name);
const readable = isReadableTextFile(file, ext);
2026-06-02 12:38:01 +08:00
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);
2026-06-02 12:38:01 +08:00
} else {
setScript(`[已上传文件:${file.name}]\n\n暂不支持解析 ${ext ? ext.toUpperCase() : "未知"} 格式,请上传常见文本类文件。`);
2026-06-02 12:38:01 +08:00
}
};
const handleFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
await processUploadedFile(file);
2026-06-02 12:38:01 +08:00
event.target.value = "";
};
const handleEvaluate = async () => {
if (!hasContent) return;
setLoading(true);
setResult(null);
setEvalError(null);
setAnimatedScore(0);
setActiveDim(null);
2026-06-02 12:38:01 +08:00
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);
2026-06-02 12:38:01 +08:00
} 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);
};
2026-06-02 12:38:01 +08:00
const handleReset = () => {
setScript("");
setResult(null);
setEvalError(null);
2026-06-02 12:38:01 +08:00
setUploadedFile(null);
setCopied(false);
setAnimatedScore(0);
setActiveDim(null);
2026-06-02 12:38:01 +08:00
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleCopyReport = async () => {
if (!result) return;
2026-06-09 12:02:30 +08:00
const text = formatReportMarkdown(result);
2026-06-02 12:38:01 +08:00
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;
2026-06-09 12:02:30 +08:00
const md = formatReportMarkdown(result);
2026-06-02 12:38:01 +08:00
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<HTMLDivElement>) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
fileInputRef.current?.click();
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) {
setIsDragging(true);
}
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
setIsDragging(false);
}
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
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";
2026-06-02 12:38:01 +08:00
return (
<section className={`script-eval-v5 page-motion ${statusClass}`}>
<div className="script-eval-v5-page">
{/* Left Panel */}
<aside className="script-eval-v5-left">
<div className="script-eval-v5-left-main">
<div className="script-eval-v5-lp-section">
<div className="script-eval-v5-lp-label"></div>
<div
className={`script-eval-v5-upload-zone${isDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={uploadKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging ? (
<div className="script-eval-v5-upload-drop-overlay">
<UploadOutlined />
<span></span>
</div>
) : null}
{uploadedFile ? (
<div className="script-eval-v5-upload-done is-show">
<button
type="button"
className="script-eval-v5-upload-delete"
onClick={(e) => { e.stopPropagation(); handleReset(); }}
aria-label="删除文件"
>
<CloseOutlined />
</button>
<CheckCircleFilled />
<span className="script-eval-v5-uf-meta">
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
<span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span>
</span>
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}>
</span>
</div>
) : (
<>
2026-06-05 20:42:34 +08:00
<div className="script-eval-v5-upload-icon"><ShellIcon name="upload" /></div>
<div className="script-eval-v5-upload-text"></div>
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
2026-06-05 20:42:34 +08:00
<ShellIcon name="upload" />
</button>
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
</>
)}
</div>
<input ref={fileInputRef} type="file" accept={TEXT_FILE_ACCEPT} style={{ display: "none" }} onChange={handleFileUpload} />
</div>
2026-06-02 12:38:01 +08:00
<div className="script-eval-v5-lp-section">
<div className="script-eval-v5-lp-label">AI </div>
<div className="script-eval-v5-info-grid">
{!result ? (
<div className="script-eval-v5-info-empty"></div>
) : (
<>
<div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val"><span className="script-eval-v5-info-tag">{result.totalScore} · {grade}</span></span>
</div>
<div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{script.length} </span>
</div>
<div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{new Date().toLocaleDateString("zh-CN")}</span>
</div>
<div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{beatPct}%</span>
</div>
</>
)}
</div>
</div>
2026-06-02 12:38:01 +08:00
<div className="script-eval-v5-lp-section is-fill">
<div className="script-eval-v5-lp-label"></div>
<div className="script-eval-v5-history-list">
{!session ? (
<div className="script-eval-v5-history-empty"></div>
) : history.length === 0 ? (
<div className="script-eval-v5-history-empty"></div>
) : (
history.map((item, i) => (
<div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`}
onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0}
onKeyDown={(e) => { if ((e as React.KeyboardEvent).key === "Enter") handleHistoryClick(item, i); }}>
<div className="script-eval-v5-hi-left">
<div className="script-eval-v5-hi-name">{item.name}</div>
<div className="script-eval-v5-hi-date">{item.date}</div>
<div className="script-eval-v5-hi-bar">
<div className="script-eval-v5-hi-bar-fill" style={{ width: `${Math.min(92, (item.score / 100) * 100)}%` }} />
</div>
</div>
<div className="script-eval-v5-hi-right">
<div className={`script-eval-v5-hi-score${item.score >= 90 ? " is-green" : ""}`}>{item.score}</div>
<div className="script-eval-v5-hi-grade">{item.grade}</div>
</div>
</div>
))
)}
</div>
</div>
2026-06-02 12:38:01 +08:00
<div className="script-eval-v5-lp-bottom">
<button
type="button"
className="script-eval-v5-eval-btn"
disabled={loading || !hasContent}
onClick={() => void handleEvaluate()}
>
2026-06-05 20:42:34 +08:00
{loading ? <ShellIcon name="loading" /> : <ShellIcon name="thunderbolt" />}
<span>{loading ? "评测中..." : "开始评测"}</span>
</button>
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
2026-06-05 20:42:34 +08:00
<ShellIcon name="download" />
<span></span>
</button>
</div>
</div>
</aside>
{/* Right Area */}
<div className="script-eval-v5-right">
<div className="script-eval-v5-right-topbar">
<div className="script-eval-v5-right-title">
<span className="script-eval-v5-rt-green"></span>
{uploadedFile && <> · {compactTitle}</>}
</div>
<div className="script-eval-v5-right-actions">
{result && (
<>
<button type="button" className="script-eval-v5-action-btn" onClick={() => void handleCopyReport()}>
2026-06-05 20:42:34 +08:00
<ShellIcon name="copy" />{copied ? "已复制" : "复制"}
</button>
<button type="button" className="script-eval-v5-action-btn" onClick={handleExportMarkdown}>
2026-06-05 20:42:34 +08:00
<ShellIcon name="download" />
</button>
</>
)}
</div>
</div>
<div className={`script-eval-v5-right-content${result ? " is-report" : ""}`}>
{loading ? (
<div className="script-eval-v5-input-section">
<div className="script-eval-v5-illustration" aria-label="评测中">
<div className="script-eval-v5-loading">
<div className="page-loading-spinner" />
<strong>AI ...</strong>
<p> 15-30 </p>
<div className="script-eval-v5-loading-steps" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
) : !result && (
<div className="script-eval-v5-input-section">
<div className={`script-eval-v5-illustration${isDragging ? " is-dragging" : ""}`} aria-label="上传剧本并开始评测">
<div
className="script-eval-v5-illustration-hit"
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={uploadKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging && (
<div className="script-eval-v5-upload-drop-overlay">
<UploadOutlined />
<span></span>
</div>
)}
<div className="script-eval-v5-upload-card-icon">
2026-06-05 20:42:34 +08:00
<ShellIcon name="file-text" />
</div>
<div className="script-eval-v5-upload-card-title">
{uploadedFile ? "剧本已导入" : "上传剧本文件"}
</div>
<div className="script-eval-v5-upload-card-desc">
{uploadedFile
? "如需更换,点击此处重新上传;完成后点击左侧开始评测。"
: `${TEXT_FILE_HINT},上传后点击开始评测,AI 将识别剧本信息。`}
</div>
</div>
</div>
2026-06-02 12:38:01 +08:00
{evalError && (
<div className="script-eval-v5-error" role="alert">
<span></span><span>{evalError}</span>
<button type="button" className="script-eval-v5-retry-btn" onClick={() => void handleEvaluate()} disabled={!hasContent}>
</button>
</div>
)}
2026-06-02 12:38:01 +08:00
</div>
)}
2026-06-02 12:38:01 +08:00
{result && (
<section className="script-eval-report script-eval-report--inside">
<div className="script-eval-report__body">
<header className="script-eval-report__hero">
<div className="script-eval-report__score-block">
<div className="script-eval-report__score-row">
<span className="script-eval-report__score">{animatedScore}</span>
<span className="script-eval-report__score-total">/ 100</span>
<span className="script-eval-report__grade">
<i />
{grade}
</span>
</div>
<div className="script-eval-report__score-line">
<span style={{ width: `${animatedScore}%` }} />
</div>
<div className="script-eval-report__beat">{result.totalScore >= 90 ? "优秀" : result.totalScore >= 80 ? "良好" : result.totalScore >= 70 ? "中等" : "待提升"}{result.totalScore >= 85 ? "具备商业开发潜力" : "建议针对性优化后再提交"}</div>
</div>
2026-06-02 12:38:01 +08:00
<div className="script-eval-report__summary">
<div className="script-eval-report__title-line">
<div>
<h1>{compactTitle}</h1>
<p>{`剧本评测 · ${scriptMinutes} min · ${reportDate}`}</p>
</div>
</div>
<p className="script-eval-report__desc">{result.summary}</p>
</div>
</header>
<section className="script-eval-report__chart-card" aria-label="维度拆解">
<div className="script-eval-report__card-head">
<span><i /></span>
<div className="script-eval-report__legend">
<span><i className="is-score" /></span>
<span><i className="is-loss" /></span>
</div>
</div>
<div className="script-eval-report__chart">
<div className="script-eval-report__axis">
<span>100%</span>
<span>80%</span>
<span>60%</span>
<span>40%</span>
<span>20%</span>
<span>0%</span>
</div>
<div className="script-eval-report__chart-grid">
{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 (
<button
key={dim.key}
type="button"
className={`script-eval-report__bar-col${isActive ? "" : " is-dimmed"}`}
onMouseEnter={() => setActiveDim(dimIndex)}
onFocus={() => setActiveDim(dimIndex)}
onMouseLeave={() => setActiveDim(null)}
onBlur={() => setActiveDim(null)}
aria-label={`${dim.label} ${score}/${dim.maxScore}${dim.hint}`}
>
<div className="script-eval-report__bar-score">
<b>{score}</b><small>/{dim.maxScore}</small>{isPerfect ? <em>*</em> : null}
</div>
<div className="script-eval-report__bar-box">
{lossPct > 0 ? <div className="script-eval-report__bar-loss" style={{ height: `${lossPct * 100}%` }} /> : null}
<div className="script-eval-report__bar-fill" style={{ height: `${pct * 100}%` }} />
</div>
<strong>{dim.label}</strong>
<span>{dim.hint}</span>
</button>
);
})}
</div>
</div>
<div className="script-eval-report__chart-note">
2026-06-05 20:42:34 +08:00
<ShellIcon name="bar-chart" />
<span>
{activeDim === null
? "悬停维度可查看当前分项表现,优先从低分项制定改稿计划。"
: `${SCORE_DIMENSIONS[activeDim].label}${SCORE_DIMENSIONS[activeDim].detail}`}
</span>
</div>
</section>
<div className="script-eval-report__detail-grid">
{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 (
<section className="script-eval-report__detail-card" key={dim.key}>
<header className="script-eval-report__detail-head">
<div>
<span>{dim.label}</span>
<strong>{score}<small>/{dim.maxScore}</small></strong>
</div>
<em>{pct}%</em>
</header>
<p className="script-eval-report__detail-hint">{dim.hint}</p>
{subScores.length > 0 ? (
<div className="script-eval-report__subscore-list">
{subScores.map(([key, value]) => {
const subPct = Math.max(0, Math.min(100, Math.round((value / dim.maxScore) * 100)));
return (
<div className="script-eval-report__subscore-row" key={key}>
<span>{formatSubScoreLabel(key)}</span>
<div className="script-eval-report__subscore-bar" aria-hidden="true">
<i style={{ width: `${subPct}%` }} />
</div>
<b>{value}</b>
</div>
);
})}
</div>
) : (
<p className="script-eval-report__detail-empty"></p>
)}
{evidence.length > 0 ? (
<ul className="script-eval-report__evidence-list">
{evidence.map((item, index) => <li key={index}>{item}</li>)}
</ul>
) : null}
</section>
);
})}
</div>
<div className="script-eval-report__findings">
{result.highlights.length > 0 ? (
<section className="script-eval-report__finding-group is-highlight">
<h2> <span>{result.highlights.length}</span></h2>
<div>
{result.highlights.map((item, index) => (
<p key={index}>{item}</p>
))}
</div>
</section>
) : null}
{result.issues.length > 0 ? (
<section className="script-eval-report__finding-group is-issue">
<h2> <span>{result.issues.length}</span></h2>
<div>
{result.issues.map((item, index) => (
<p key={index}>{item}</p>
))}
</div>
</section>
) : null}
2026-06-02 12:38:01 +08:00
</div>
{result.suggestions.length > 0 ? (
<section className="script-eval-report__path-card">
<div className="script-eval-report__card-head">
<span><i /></span>
</div>
<table className="script-eval-report__path-table">
<thead>
<tr>
<th>#</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{result.suggestions.map((item, index) => {
const high = index < 2;
return (
<tr key={index}>
<td>{String(index + 1).padStart(2, "0")}</td>
<td>{high ? "核心" : "增强"}</td>
<td>{item}</td>
<td><span className={high ? "is-high" : "is-mid"}>{high ? "HIGH" : "MID"}</span></td>
</tr>
);
})}
</tbody>
</table>
</section>
) : null}
</div>
</section>
2026-06-02 12:38:01 +08:00
)}
</div>
2026-06-02 12:38:01 +08:00
<div className="script-eval-v5-statusbar">
<div className="script-eval-v5-status-dot" />
<span>{loading ? "评测中..." : result ? "评测完成" : hasContent ? "待评测" : "等待上传"}</span>
<span className="script-eval-v5-sb-right">{result ? `六维标准 · ${result.totalScore}` : "六维标准"}</span>
</div>
</div>
</div>
2026-06-02 12:38:01 +08:00
</section>
);
}
export default ScriptTokensPage;