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

572 lines
26 KiB
TypeScript
Raw Normal View History

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";
import { useSessionStore } from "../../stores";
2026-06-02 12:38:01 +08:00
interface ScoreDimension {
key: string;
label: string;
maxScore: number;
hint: string;
detail: string;
2026-06-02 12:38:01 +08:00
}
interface EvalResult {
totalScore: number;
grade: string;
dimensionScores: Record<string, number>;
summary: string;
issues: string[];
highlights: string[];
suggestions: string[];
}
interface HistoryEntry {
name: string;
date: string;
timestamp: number;
score: number;
grade: string;
}
2026-06-02 12:38:01 +08:00
function getGrade(score: number): string {
if (score >= 97) return "S+";
if (score >= 93) return "S";
if (score >= 88) return "A+";
if (score >= 83) return "A";
if (score >= 78) return "B+";
if (score >= 70) return "B";
return "C";
2026-06-02 12:38:01 +08:00
}
const HISTORY_KEY = "omniai:script-eval-history";
2026-06-02 12:38:01 +08:00
function loadHistory(): HistoryEntry[] {
try {
const raw = localStorage.getItem(HISTORY_KEY);
return raw ? (JSON.parse(raw) as HistoryEntry[]) : [];
} catch { return []; }
2026-06-02 12:38:01 +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
}
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(`## 六维评分`);
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);
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);
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);
const scoreFrameRef = useRef<number | null>(null);
2026-06-02 12:38:01 +08:00
const session = useSessionStore((s) => s.session);
2026-06-02 12:38:01 +08:00
const hasContent = Boolean(script.trim());
// Score animation
useEffect(() => {
if (!result) return;
const start = performance.now();
const target = result.totalScore;
const dur = 1400;
function tick(now: number) {
const t = Math.min((now - start) / dur, 1);
const e = 1 - Math.pow(1 - t, 3);
setAnimatedScore(Math.round(e * target));
if (t < 1) scoreFrameRef.current = requestAnimationFrame(tick);
}
scoreFrameRef.current = requestAnimationFrame(tick);
return () => { if (scoreFrameRef.current) cancelAnimationFrame(scoreFrameRef.current); };
}, [result]);
2026-06-02 12:38:01 +08:00
const 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 {
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);
setAnimatedScore(0);
setActiveDim(null);
2026-06-02 12:38:01 +08:00
try {
const aiResult = await evaluateScript(script);
setResult(aiResult);
const g = getGrade(aiResult.totalScore);
const entry: HistoryEntry = {
name: uploadedFile?.name?.replace(/\.[^.]+$/, "") ?? `剧本 ${new Date().toLocaleDateString("zh-CN")}`,
date: new Date().toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }),
timestamp: Date.now(),
score: aiResult.totalScore,
grade: g,
};
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);
setEvalError(null);
2026-06-02 12:38:01 +08:00
setUploadedFile(null);
setCopied(false);
setAnimatedScore(0);
setActiveDim(null);
2026-06-02 12:38:01 +08:00
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleCopyReport = async () => {
if (!result) return;
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 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 (
<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>
) : (
<>
<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
<div className="script-eval-v5-lp-section">
<div className="script-eval-v5-lp-label">AI </div>
<div className="script-eval-v5-info-grid">
{!result ? (
<div className="script-eval-v5-info-empty"></div>
) : (
<>
<div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val"><span className="script-eval-v5-info-tag">{result.totalScore} · {grade}</span></span>
</div>
<div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{script.length} </span>
</div>
<div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{new Date().toLocaleDateString("zh-CN")}</span>
</div>
<div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{beatPct}%</span>
</div>
</>
)}
</div>
</div>
2026-06-02 12:38:01 +08:00
<div className="script-eval-v5-lp-section is-fill">
<div className="script-eval-v5-lp-label"></div>
<div className="script-eval-v5-history-list">
{!session ? (
<div className="script-eval-v5-history-empty"></div>
) : history.length === 0 ? (
<div className="script-eval-v5-history-empty"></div>
) : (
history.map((item, i) => (
<div key={i} className={`script-eval-v5-history-item${i === 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>
<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
<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 12:38:01 +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>
<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>
<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>
);
})()}
</div>
2026-06-02 12:38:01 +08:00
</div>
{(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>
)}
{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 12:38:01 +08:00
)}
</div>
2026-06-02 12:38:01 +08:00
<div className="script-eval-v5-statusbar">
<div className="script-eval-v5-status-dot" />
<span>{loading ? "评测中..." : result ? "评测完成" : hasContent ? "待评测" : "等待上传"}</span>
<span className="script-eval-v5-sb-right">{result ? `六维标准 · ${result.totalScore}` : "六维标准"}</span>
</div>
</div>
</div>
2026-06-02 12:38:01 +08:00
</section>
);
}
export default ScriptTokensPage;