2026-06-02 18:58:13 +08:00
|
|
|
import {
|
|
|
|
|
CheckCircleFilled,
|
|
|
|
|
CopyOutlined,
|
|
|
|
|
DownloadOutlined,
|
|
|
|
|
FileTextOutlined,
|
|
|
|
|
UploadOutlined,
|
|
|
|
|
} from "@ant-design/icons";
|
|
|
|
|
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
2026-06-02 12:38:01 +08:00
|
|
|
import { evaluateScript } from "../../api/scriptEvalClient";
|
2026-06-02 18:58:13 +08:00
|
|
|
import { useSessionStore } from "../../stores";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
|
|
|
|
interface ScoreDimension {
|
|
|
|
|
key: string;
|
|
|
|
|
label: string;
|
|
|
|
|
maxScore: number;
|
2026-06-02 18:58:13 +08:00
|
|
|
hint: string;
|
|
|
|
|
detail: string;
|
2026-06-02 12:38:01 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EvalResult {
|
|
|
|
|
totalScore: number;
|
|
|
|
|
grade: string;
|
|
|
|
|
dimensionScores: Record<string, number>;
|
|
|
|
|
summary: string;
|
|
|
|
|
issues: string[];
|
|
|
|
|
highlights: string[];
|
|
|
|
|
suggestions: string[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
interface HistoryEntry {
|
|
|
|
|
name: string;
|
|
|
|
|
date: string;
|
|
|
|
|
timestamp: number;
|
|
|
|
|
score: number;
|
|
|
|
|
grade: string;
|
|
|
|
|
}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-02 18:58:13 +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
|
|
|
}
|
|
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
const HISTORY_KEY = "omniai:script-eval-history";
|
2026-06-02 21:36:44 +08:00
|
|
|
const TEXT_FILE_EXTENSIONS = [
|
|
|
|
|
".txt",
|
|
|
|
|
".text",
|
|
|
|
|
".md",
|
|
|
|
|
".markdown",
|
|
|
|
|
".fountain",
|
|
|
|
|
".fdx",
|
|
|
|
|
".rtf",
|
|
|
|
|
".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 / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
function loadHistory(): HistoryEntry[] {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(HISTORY_KEY);
|
2026-06-02 21:36:44 +08:00
|
|
|
return raw ? (JSON.parse(raw) as HistoryEntry[]).sort((a, b) => b.timestamp - a.timestamp) : [];
|
2026-06-02 18:58:13 +08:00
|
|
|
} catch { return []; }
|
2026-06-02 12:38:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
function saveHistory(entries: HistoryEntry[]) {
|
2026-06-02 21:36:44 +08:00
|
|
|
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-02 18:58:13 +08:00
|
|
|
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: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" },
|
|
|
|
|
];
|
|
|
|
|
|
2026-06-02 12:38:01 +08:00
|
|
|
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(`## 六维评分`);
|
2026-06-02 18:58:13 +08:00
|
|
|
for (const dim of SCORE_DIMENSIONS) {
|
2026-06-02 12:38:01 +08:00
|
|
|
const score = result.dimensionScores[dim.key] ?? 0;
|
|
|
|
|
const pct = Math.round((score / dim.maxScore) * 100);
|
2026-06-02 18:58:13 +08:00
|
|
|
lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`);
|
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);
|
2026-06-02 18:58:13 +08:00
|
|
|
const [activeDim, setActiveDim] = useState<number | null>(null);
|
|
|
|
|
const [animatedScore, setAnimatedScore] = useState(0);
|
|
|
|
|
const [history, setHistory] = useState<HistoryEntry[]>(loadHistory);
|
2026-06-02 12:38:01 +08:00
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
2026-06-02 18:58:13 +08:00
|
|
|
const scoreFrameRef = useRef<number | null>(null);
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
const session = useSessionStore((s) => s.session);
|
2026-06-02 12:38:01 +08:00
|
|
|
const hasContent = Boolean(script.trim());
|
|
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
// 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 handleFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const file = event.target.files?.[0];
|
|
|
|
|
if (!file) return;
|
2026-06-02 21:36:44 +08:00
|
|
|
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 (readable) {
|
2026-06-02 21:36:44 +08:00
|
|
|
const text = normalizeUploadedText(await decodeTextFile(file), ext);
|
|
|
|
|
setScript(text);
|
2026-06-02 12:38:01 +08:00
|
|
|
} else {
|
2026-06-02 21:36:44 +08:00
|
|
|
setScript(`[已上传文件:${file.name}]\n\n暂不支持解析 ${ext ? ext.toUpperCase() : "未知"} 格式,请上传常见文本类文件。`);
|
2026-06-02 12:38:01 +08:00
|
|
|
}
|
|
|
|
|
event.target.value = "";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleEvaluate = async () => {
|
|
|
|
|
if (!hasContent) return;
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setResult(null);
|
|
|
|
|
setEvalError(null);
|
2026-06-02 18:58:13 +08:00
|
|
|
setAnimatedScore(0);
|
|
|
|
|
setActiveDim(null);
|
2026-06-02 12:38:01 +08:00
|
|
|
try {
|
|
|
|
|
const aiResult = await evaluateScript(script);
|
|
|
|
|
setResult(aiResult);
|
2026-06-02 18:58:13 +08:00
|
|
|
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,
|
|
|
|
|
};
|
2026-06-02 21:36:44 +08:00
|
|
|
const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)].sort(
|
|
|
|
|
(a, b) => b.timestamp - a.timestamp,
|
|
|
|
|
);
|
2026-06-02 18:58:13 +08:00
|
|
|
saveHistory(updated);
|
|
|
|
|
setHistory(updated);
|
2026-06-02 12:38:01 +08:00
|
|
|
} catch (err) {
|
|
|
|
|
setEvalError(err instanceof Error ? err.message : "评测服务暂时不可用,请稍后重试");
|
|
|
|
|
}
|
|
|
|
|
setLoading(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleReset = () => {
|
|
|
|
|
setScript("");
|
|
|
|
|
setResult(null);
|
2026-06-02 18:58:13 +08:00
|
|
|
setEvalError(null);
|
2026-06-02 12:38:01 +08:00
|
|
|
setUploadedFile(null);
|
|
|
|
|
setCopied(false);
|
2026-06-02 18:58:13 +08:00
|
|
|
setAnimatedScore(0);
|
|
|
|
|
setActiveDim(null);
|
2026-06-02 12:38:01 +08:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
const uploadKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
|
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
fileInputRef.current?.click();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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(/\.[^.]+$/, "") ?? "剧本评测";
|
2026-06-02 21:36:44 +08:00
|
|
|
const scriptMinutes = Math.max(8, Math.round(script.length / 460));
|
|
|
|
|
const reportDate = new Date().toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
|
2026-06-02 12:38:01 +08:00
|
|
|
|
|
|
|
|
return (
|
2026-06-02 18:58:13 +08:00
|
|
|
<section className="script-eval-v5 page-motion">
|
|
|
|
|
<div className="script-eval-v5-page">
|
|
|
|
|
{/* Left Panel */}
|
|
|
|
|
<aside className="script-eval-v5-left">
|
|
|
|
|
<div className="script-eval-v5-lp-section">
|
|
|
|
|
<div className="script-eval-v5-lp-label">上传剧本</div>
|
|
|
|
|
<div
|
|
|
|
|
className="script-eval-v5-upload-zone"
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
onKeyDown={uploadKeyDown}
|
|
|
|
|
>
|
|
|
|
|
{uploadedFile ? (
|
|
|
|
|
<div className="script-eval-v5-upload-done is-show">
|
|
|
|
|
<CheckCircleFilled />
|
|
|
|
|
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
|
|
|
|
|
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}>
|
|
|
|
|
重新上传
|
|
|
|
|
</span>
|
2026-06-02 12:38:01 +08:00
|
|
|
</div>
|
2026-06-02 18:58:13 +08:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="script-eval-v5-upload-icon"><UploadOutlined /></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(); }}>
|
|
|
|
|
+ 上传剧本
|
|
|
|
|
</button>
|
2026-06-02 21:36:44 +08:00
|
|
|
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
|
2026-06-02 18:58:13 +08:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-06-02 21:36:44 +08:00
|
|
|
<input ref={fileInputRef} type="file" accept={TEXT_FILE_ACCEPT} style={{ display: "none" }} onChange={handleFileUpload} />
|
2026-06-02 18:58:13 +08:00
|
|
|
</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-02 18:58:13 +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
|
|
|
|
2026-06-02 18:58:13 +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 === 0 ? " is-active" : ""}`}>
|
|
|
|
|
<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>
|
2026-06-02 12:38:01 +08:00
|
|
|
</div>
|
|
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
<div className="script-eval-v5-lp-bottom">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="script-eval-v5-eval-btn"
|
|
|
|
|
disabled={loading || !hasContent}
|
|
|
|
|
onClick={() => void handleEvaluate()}
|
|
|
|
|
>
|
|
|
|
|
{loading ? "◆ 评测中..." : "◆ 开始评测"}
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
|
|
|
|
|
导出评测报告
|
|
|
|
|
</button>
|
|
|
|
|
</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()}>
|
|
|
|
|
<CopyOutlined />{copied ? "已复制" : "复制"}
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" className="script-eval-v5-action-btn" onClick={handleExportMarkdown}>
|
|
|
|
|
<DownloadOutlined />导出
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-02 21:36:44 +08:00
|
|
|
<div className={`script-eval-v5-right-content${result ? " is-report" : ""}`}>
|
2026-06-02 18:58:13 +08:00
|
|
|
{!result && (
|
|
|
|
|
<div className="script-eval-v5-input-section">
|
2026-06-02 21:36:44 +08:00
|
|
|
<div className="script-eval-v5-illustration" aria-label="上传剧本并开始评测">
|
|
|
|
|
<div
|
|
|
|
|
className="script-eval-v5-illustration-hit"
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
onKeyDown={uploadKeyDown}
|
|
|
|
|
>
|
|
|
|
|
<div className="script-eval-v5-upload-card-icon">
|
|
|
|
|
<FileTextOutlined />
|
|
|
|
|
</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>
|
2026-06-02 18:58:13 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
{evalError && (
|
|
|
|
|
<div className="script-eval-v5-error" role="alert">
|
|
|
|
|
<span>⚠</span><span>{evalError}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-02 12:38:01 +08:00
|
|
|
</div>
|
2026-06-02 18:58:13 +08:00
|
|
|
)}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
{result && (
|
2026-06-02 21:36:44 +08:00
|
|
|
<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">击败全国 <b>{beatPct}%</b> 剧本</div>
|
2026-06-02 18:58:13 +08:00
|
|
|
</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-02 21:36:44 +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>
|
2026-06-02 18:58:13 +08:00
|
|
|
</div>
|
2026-06-02 21:36:44 +08:00
|
|
|
</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>
|
2026-06-02 18:58:13 +08:00
|
|
|
</div>
|
2026-06-02 21:36:44 +08:00
|
|
|
<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) => {
|
2026-06-02 18:58:13 +08:00
|
|
|
const score = result.dimensionScores[dim.key] ?? 0;
|
2026-06-02 21:36:44 +08:00
|
|
|
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
|
|
|
|
|
const lossPct = 1 - pct;
|
2026-06-02 18:58:13 +08:00
|
|
|
const isPerfect = score === dim.maxScore;
|
|
|
|
|
return (
|
2026-06-02 21:36:44 +08:00
|
|
|
<button key={dim.key} type="button" className="script-eval-report__bar-col">
|
|
|
|
|
<div className="script-eval-report__bar-score">
|
|
|
|
|
<b>{score}</b><small>/{dim.maxScore}</small>{isPerfect ? <em>*</em> : null}
|
2026-06-02 18:58:13 +08:00
|
|
|
</div>
|
2026-06-02 21:36:44 +08:00
|
|
|
<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}%` }} />
|
2026-06-02 18:58:13 +08:00
|
|
|
</div>
|
2026-06-02 21:36:44 +08:00
|
|
|
<strong>{dim.label}</strong>
|
|
|
|
|
<span>{dim.hint}</span>
|
|
|
|
|
</button>
|
2026-06-02 18:58:13 +08:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-02 21:36:44 +08:00
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-06-02 18:58:13 +08:00
|
|
|
))}
|
|
|
|
|
</div>
|
2026-06-02 21:36:44 +08:00
|
|
|
</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>
|
2026-06-02 18:58:13 +08:00
|
|
|
))}
|
|
|
|
|
</div>
|
2026-06-02 21:36:44 +08:00
|
|
|
</section>
|
|
|
|
|
) : null}
|
2026-06-02 12:38:01 +08:00
|
|
|
</div>
|
2026-06-02 18:58:13 +08:00
|
|
|
|
2026-06-02 21:36:44 +08:00
|
|
|
{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>
|
2026-06-02 18:58:13 +08:00
|
|
|
<tbody>
|
2026-06-02 21:36:44 +08:00
|
|
|
{result.suggestions.map((item, index) => {
|
|
|
|
|
const high = index < 2;
|
2026-06-02 18:58:13 +08:00
|
|
|
return (
|
2026-06-02 21:36:44 +08:00
|
|
|
<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>
|
2026-06-02 18:58:13 +08:00
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
2026-06-02 21:36:44 +08:00
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-06-02 12:38:01 +08:00
|
|
|
)}
|
2026-06-02 18:58:13 +08:00
|
|
|
</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-02 18:58:13 +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;
|