928 lines
38 KiB
TypeScript
928 lines
38 KiB
TypeScript
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<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, 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<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, 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<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;
|