feat: 剧本评测页面支持更多文本文件格式上传,优化历史记录排序和UI交互

This commit is contained in:
OmniAI Developer
2026-06-02 21:36:44 +08:00
parent dd69b295c2
commit d4d8cc528d
2 changed files with 1450 additions and 164 deletions
+233 -154
View File
@@ -46,16 +46,126 @@ function getGrade(score: number): string {
}
const HISTORY_KEY = "omniai:script-eval-history";
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 / 字幕等";
function loadHistory(): HistoryEntry[] {
try {
const raw = localStorage.getItem(HISTORY_KEY);
return raw ? (JSON.parse(raw) as HistoryEntry[]) : [];
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.slice(0, 20))); } catch { /* quota exceeded */ }
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;
}
const SCORE_DIMENSIONS: ScoreDimension[] = [
@@ -138,13 +248,14 @@ function ScriptTokensPage() {
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";
const ext = getFileExtension(file.name);
const readable = isReadableTextFile(file, ext);
setUploadedFile({ name: file.name, size: file.size });
if (readable) {
setScript(await file.text());
const text = normalizeUploadedText(await decodeTextFile(file), ext);
setScript(text);
} else {
setScript(`[已上传文件:${file.name}]\n\n暂不支持解析 ${ext.toUpperCase()} 格式,请上传 TXT 或 MD 文件。`);
setScript(`[已上传文件:${file.name}]\n\n暂不支持解析 ${ext ? ext.toUpperCase() : "未知"} 格式,请上传常见文本类文件。`);
}
event.target.value = "";
};
@@ -167,7 +278,9 @@ function ScriptTokensPage() {
score: aiResult.totalScore,
grade: g,
};
const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)];
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) {
@@ -231,6 +344,8 @@ function ScriptTokensPage() {
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" });
return (
<section className="script-eval-v5 page-motion">
@@ -261,11 +376,11 @@ function ScriptTokensPage() {
<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 className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
</>
)}
</div>
<input ref={fileInputRef} type="file" accept=".txt,.md" style={{ display: "none" }} onChange={handleFileUpload} />
<input ref={fileInputRef} type="file" accept={TEXT_FILE_ACCEPT} style={{ display: "none" }} onChange={handleFileUpload} />
</div>
<div className="script-eval-v5-lp-section">
@@ -359,45 +474,31 @@ function ScriptTokensPage() {
</div>
</div>
<div className="script-eval-v5-right-content">
<div className={`script-eval-v5-right-content${result ? " is-report" : ""}`}>
{!result && (
<div className="script-eval-v5-input-section">
{/* Script-themed upload illustration */}
<div className="script-eval-v5-illustration" aria-label="上传剧本并开始评测">
<div
className="script-eval-v5-illustration"
className="script-eval-v5-illustration-hit"
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">
<div className="script-eval-v5-upload-card-icon">
<FileTextOutlined />
<span></span>
</div>
<div className="script-eval-v5-illust-hint"> TXT / MD </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>
<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>
@@ -407,153 +508,131 @@ function ScriptTokensPage() {
)}
{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>
<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-v5-hero-bar">
<div className="script-eval-v5-hero-bar-fill" style={{ width: `${animatedScore}%` }} />
</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>
<div className="script-eval-report__beat"> <b>{beatPct}%</b> </div>
</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 className="script-eval-report__summary">
<div className="script-eval-report__title-line">
<div>
<h1>{compactTitle}</h1>
<p>{`剧本评测 · ${scriptMinutes} min · ${reportDate}`}</p>
</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) => {
<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) => {
const score = result.dimensionScores[dim.key] ?? 0;
const pct = score / dim.maxScore;
const lossPct = (dim.maxScore - score) / dim.maxScore;
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
const lossPct = 1 - pct;
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>}
<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}
</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 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>
</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>
))}
</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>
</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>
</div>
</div>
);
})()}
</div>
</section>
) : null}
</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>
{result.suggestions.length > 0 ? (
<section className="script-eval-report__path-card">
<div className="script-eval-report__card-head">
<span><i /></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>
)}
</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>
<table className="script-eval-report__path-table">
<thead>
<tr>
<th>#</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{result.suggestions.map((s, i) => {
const isHigh = i < 2;
{result.suggestions.map((item, index) => {
const high = index < 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 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>
</div>
)}
</>
</section>
)}
</div>
File diff suppressed because it is too large Load Diff