Files
omniai-web/src/features/script-tokens/ScriptTokensPage.tsx
T
stringadmin 4a298d205b
Web Quality / verify (push) Has been cancelled
chore: reduce frontend lint warnings
2026-06-09 12:02:30 +08:00

922 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
CheckCircleFilled,
CloseOutlined,
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<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
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<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 / 字幕等";
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<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;
}
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);
}
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): 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<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);
const fileInputRef = useRef<HTMLInputElement>(null);
const scoreFrameRef = useRef<number | null>(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<HTMLInputElement>) => {
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);
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);
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";
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>
) : (
<>
<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(); }}>
<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>
<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>
<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>
<div className="script-eval-v5-lp-bottom">
<button
type="button"
className="script-eval-v5-eval-btn"
disabled={loading || !hasContent}
onClick={() => void handleEvaluate()}
>
{loading ? <ShellIcon name="loading" /> : <ShellIcon name="thunderbolt" />}
<span>{loading ? "评测中..." : "开始评测"}</span>
</button>
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
<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()}>
<ShellIcon name="copy" />{copied ? "已复制" : "复制"}
</button>
<button type="button" className="script-eval-v5-action-btn" onClick={handleExportMarkdown}>
<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">
<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>
{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>
)}
</div>
)}
{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>
<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">
<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}
</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>
)}
</div>
<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>
</section>
);
}
export default ScriptTokensPage;