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 12:38:01 +08:00
|
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
|
function loadHistory(): HistoryEntry[] {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = localStorage.getItem(HISTORY_KEY);
|
|
|
|
|
|
return raw ? (JSON.parse(raw) as HistoryEntry[]) : [];
|
|
|
|
|
|
} catch { return []; }
|
2026-06-02 12:38:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
|
function saveHistory(entries: HistoryEntry[]) {
|
|
|
|
|
|
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, 20))); } catch { /* quota exceeded */ }
|
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;
|
|
|
|
|
|
const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
|
|
|
|
|
|
const readable = [".txt", ".md"].includes(ext) || file.type === "text/plain" || file.type === "text/markdown";
|
|
|
|
|
|
setUploadedFile({ name: file.name, size: file.size });
|
|
|
|
|
|
if (readable) {
|
|
|
|
|
|
setScript(await file.text());
|
|
|
|
|
|
} else {
|
2026-06-02 18:58:13 +08:00
|
|
|
|
setScript(`[已上传文件:${file.name}]\n\n暂不支持解析 ${ext.toUpperCase()} 格式,请上传 TXT 或 MD 文件。`);
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)];
|
|
|
|
|
|
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 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>
|
|
|
|
|
|
<div className="script-eval-v5-upload-hint">支持 .txt .md</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<input ref={fileInputRef} type="file" accept=".txt,.md" style={{ display: "none" }} onChange={handleFileUpload} />
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="script-eval-v5-right-content">
|
|
|
|
|
|
{!result && (
|
|
|
|
|
|
<div className="script-eval-v5-input-section">
|
|
|
|
|
|
{/* Script-themed upload illustration */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="script-eval-v5-illustration"
|
|
|
|
|
|
role="button"
|
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
|
onKeyDown={uploadKeyDown}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="script-eval-v5-illust-grid">
|
|
|
|
|
|
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
|
|
|
|
|
<div key={idx} className={`script-eval-v5-illust-page${idx === 1 ? " is-active" : ""}`}>
|
|
|
|
|
|
<div className="script-eval-v5-illust-page-lines">
|
|
|
|
|
|
<div className="script-eval-v5-illust-line" style={{ width: `${60 + Math.sin(idx * 1.2) * 20}%` }} />
|
|
|
|
|
|
<div className="script-eval-v5-illust-line" style={{ width: `${75 + Math.cos(idx * 1.7) * 15}%` }} />
|
|
|
|
|
|
<div className="script-eval-v5-illust-line" style={{ width: `${45 + Math.sin(idx * 2.1) * 25}%` }} />
|
|
|
|
|
|
<div className="script-eval-v5-illust-line" style={{ width: `${65 + Math.cos(idx * 1.3) * 20}%` }} />
|
|
|
|
|
|
<div className="script-eval-v5-illust-line is-short" style={{ width: `${35 + Math.sin(idx * 0.8) * 15}%` }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-illust-label">
|
|
|
|
|
|
<FileTextOutlined />
|
|
|
|
|
|
<span>上传或粘贴剧本开始评测</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-illust-hint">支持 TXT / MD 格式,点击此处或左侧上传区导入剧本文件</div>
|
|
|
|
|
|
</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
|
<div className="script-eval-v5-textarea-shell">
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="script-eval-v5-textarea"
|
|
|
|
|
|
value={script}
|
|
|
|
|
|
onChange={(e) => setScript(e.target.value)}
|
|
|
|
|
|
placeholder={"或直接在此粘贴剧本内容...\n\n【第一幕】夜晚,城市天台。霓虹灯映照着雨后的地面。\n小凯独自站在天台边缘,手中握着一张皱巴巴的纸条..."}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{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 && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="script-eval-v5-hero">
|
|
|
|
|
|
<div className="script-eval-v5-hero-top">
|
|
|
|
|
|
<span className="script-eval-v5-hero-num">{animatedScore}</span>
|
|
|
|
|
|
<span className="script-eval-v5-hero-total">/ 100</span>
|
|
|
|
|
|
<div className="script-eval-v5-hero-grade">
|
|
|
|
|
|
<span className="script-eval-v5-hero-grade-dot" />
|
|
|
|
|
|
<span>{grade}级</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-hero-bar">
|
|
|
|
|
|
<div className="script-eval-v5-hero-bar-fill" style={{ width: `${animatedScore}%` }} />
|
2026-06-02 12:38:01 +08:00
|
|
|
|
</div>
|
2026-06-02 18:58:13 +08:00
|
|
|
|
<div className="script-eval-v5-hero-beat">击败全国 <b>{beatPct}%</b> 剧本</div>
|
|
|
|
|
|
<div className="script-eval-v5-hero-title">{compactTitle}</div>
|
|
|
|
|
|
<div className="script-eval-v5-hero-desc">{result.summary}</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-02 18:58:13 +08:00
|
|
|
|
<div className="script-eval-v5-card">
|
|
|
|
|
|
<div className="script-eval-v5-card-head">
|
|
|
|
|
|
<div className="script-eval-v5-card-head-left">
|
|
|
|
|
|
<div className="script-eval-v5-ch-dot" />
|
|
|
|
|
|
<div className="script-eval-v5-ch-title">六维评分</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-ch-legend">
|
|
|
|
|
|
<div className="script-eval-v5-leg"><div className="script-eval-v5-ldot is-score" />得分</div>
|
|
|
|
|
|
<div className="script-eval-v5-leg"><div className="script-eval-v5-ldot is-loss" />扣分</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-card-body">
|
|
|
|
|
|
<div className="script-eval-v5-chart-container">
|
|
|
|
|
|
<div className="script-eval-v5-chart-bars">
|
|
|
|
|
|
{SCORE_DIMENSIONS.map((dim, i) => {
|
|
|
|
|
|
const score = result.dimensionScores[dim.key] ?? 0;
|
|
|
|
|
|
const pct = score / dim.maxScore;
|
|
|
|
|
|
const lossPct = (dim.maxScore - score) / dim.maxScore;
|
|
|
|
|
|
const isPerfect = score === dim.maxScore;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={dim.key}
|
|
|
|
|
|
className={`script-eval-v5-bcol${activeDim === i ? " is-active" : ""}${activeDim !== null && activeDim !== i ? " is-dimmed" : ""}`}
|
|
|
|
|
|
onClick={() => setActiveDim(activeDim === i ? null : i)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="script-eval-v5-bbar-area">
|
|
|
|
|
|
{lossPct > 0 && (
|
|
|
|
|
|
<div className="script-eval-v5-bseg is-loss" style={{ height: `${lossPct * 80}%`, transitionDelay: `${i * 80}ms` }} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className={`script-eval-v5-bseg is-score${isPerfect ? " is-perfect" : ""}`} style={{ height: `${pct * 80}%`, transitionDelay: `${i * 80}ms` }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-bscore-label">
|
|
|
|
|
|
{score}<span className="script-eval-v5-bmax">/{dim.maxScore}</span>
|
|
|
|
|
|
{isPerfect && <span className="script-eval-v5-bstar"> ★</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-chart-bottom">
|
|
|
|
|
|
<div className="script-eval-v5-chart-dims">
|
|
|
|
|
|
{SCORE_DIMENSIONS.map((dim, i) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={dim.key}
|
|
|
|
|
|
className={`script-eval-v5-chart-dim${activeDim === i ? " is-active" : ""}${activeDim !== null && activeDim !== i ? " is-dimmed" : ""}`}
|
|
|
|
|
|
onClick={() => setActiveDim(activeDim === i ? null : i)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="script-eval-v5-chart-dim-name">{dim.label}</div>
|
|
|
|
|
|
<div className="script-eval-v5-chart-dim-hint">{dim.hint}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{activeDim !== null && (() => {
|
|
|
|
|
|
const d = SCORE_DIMENSIONS[activeDim]!;
|
|
|
|
|
|
const s = result.dimensionScores[d.key] ?? 0;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="script-eval-v5-dim-overlay is-open">
|
|
|
|
|
|
<button className="script-eval-v5-dim-overlay-close" onClick={() => setActiveDim(null)}>✕</button>
|
|
|
|
|
|
<div className="script-eval-v5-do-inner">
|
|
|
|
|
|
<div className="script-eval-v5-do-left">
|
|
|
|
|
|
<div className="script-eval-v5-do-name">{d.label}</div>
|
|
|
|
|
|
<div className="script-eval-v5-do-score">{s}<span className="script-eval-v5-do-max">/{d.maxScore}</span></div>
|
|
|
|
|
|
<div className="script-eval-v5-do-bar"><div className="script-eval-v5-do-bar-fill" style={{ width: `${Math.round(s / d.maxScore * 100)}%` }} /></div>
|
|
|
|
|
|
<div className="script-eval-v5-do-hint">{d.hint}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-do-right"><div className="script-eval-v5-do-detail">{d.detail}</div></div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-02 18:58:13 +08:00
|
|
|
|
);
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
</div>
|
2026-06-02 18:58:13 +08:00
|
|
|
|
|
|
|
|
|
|
{(result.highlights.length > 0 || result.issues.length > 0) && (
|
|
|
|
|
|
<div className="script-eval-v5-findings">
|
|
|
|
|
|
{result.highlights.length > 0 && (
|
|
|
|
|
|
<div className="script-eval-v5-find-group">
|
|
|
|
|
|
<div className="script-eval-v5-find-group-label is-green">
|
|
|
|
|
|
✦ 亮点 <span className="script-eval-v5-fg-count">{result.highlights.length}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-fi-list">
|
|
|
|
|
|
{result.highlights.map((h, i) => (
|
|
|
|
|
|
<div key={i} className="script-eval-v5-fi-item is-highlight"><div className="script-eval-v5-fi-marker" /><div>{h}</div></div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{result.issues.length > 0 && (
|
|
|
|
|
|
<div className="script-eval-v5-find-group">
|
|
|
|
|
|
<div className="script-eval-v5-find-group-label is-orange">
|
|
|
|
|
|
⚠ 扣分点 <span className="script-eval-v5-fg-count">{result.issues.length}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-fi-list">
|
|
|
|
|
|
{result.issues.map((issue, i) => (
|
|
|
|
|
|
<div key={i} className="script-eval-v5-fi-item is-issue"><div className="script-eval-v5-fi-marker" /><div>{issue}</div></div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-06-02 18:58:13 +08:00
|
|
|
|
|
|
|
|
|
|
{result.suggestions.length > 0 && (
|
|
|
|
|
|
<div className="script-eval-v5-card">
|
|
|
|
|
|
<div className="script-eval-v5-card-head">
|
|
|
|
|
|
<div className="script-eval-v5-card-head-left"><div className="script-eval-v5-ch-dot" /><div className="script-eval-v5-ch-title">优化路径</div></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="script-eval-v5-card-body">
|
|
|
|
|
|
<table className="script-eval-v5-sug-table">
|
|
|
|
|
|
<thead><tr><th style={{ width: 60 }}>优先级</th><th style={{ width: 68 }}>类型</th><th>优化建议</th></tr></thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{result.suggestions.map((s, i) => {
|
|
|
|
|
|
const isHigh = i < 2;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr key={i} className={isHigh ? "is-high" : "is-mid"}>
|
|
|
|
|
|
<td><span className={`script-eval-v5-sug-priority${isHigh ? " is-high" : " is-mid"}`}>{isHigh ? "HIGH" : "MID"}</span></td>
|
|
|
|
|
|
<td><div className="script-eval-v5-sug-type">{isHigh ? "核心" : "增强"}</div></td>
|
|
|
|
|
|
<td>{s}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</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
|
|
|
|
</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;
|