feat: 首页增加工具箱功能区、剧本评测可视化展示;重构剧本评分页面UI
- 首页新增工具箱功能区(ToolboxSection),展示四大AI工具卡片 - 首页剧本功能区替换为六维柱状图可视化(ScriptReviewVisual) - 剧本评分页面(ScriptTokensPage)全面重构为新版UI布局 - 左侧面板:上传区、AI识别信息、历史评测(持久化)、操作按钮 - 右侧:剧本输入区、评测结果Hero、六维柱状图、亮点/扣分点、优化建议表格 - 历史评测支持localStorage持久化,按时间倒序排列
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
import { CopyOutlined, DownOutlined, DownloadOutlined, FileTextOutlined, ReloadOutlined, TrophyOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FileTextOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import { evaluateScript } from "../../api/scriptEvalClient";
|
||||
import { useSessionStore } from "../../stores";
|
||||
|
||||
interface ScoreDimension {
|
||||
key: string;
|
||||
label: string;
|
||||
maxScore: number;
|
||||
weight: number;
|
||||
description: string;
|
||||
hint: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface EvalResult {
|
||||
@@ -20,103 +27,46 @@ interface EvalResult {
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
const RADAR_CENTER = 100;
|
||||
const RADAR_RADIUS = 82;
|
||||
const RADAR_ANGLES = [-90, -30, 30, 90, 150, 210];
|
||||
interface HistoryEntry {
|
||||
name: string;
|
||||
date: string;
|
||||
timestamp: number;
|
||||
score: number;
|
||||
grade: string;
|
||||
}
|
||||
|
||||
const scoreDimensions: ScoreDimension[] = [
|
||||
{
|
||||
key: "hook",
|
||||
label: "钩子设计",
|
||||
maxScore: 20,
|
||||
weight: 0.2,
|
||||
description: "开篇吸引力、悬念设置、黄金三秒法则",
|
||||
},
|
||||
{
|
||||
key: "character",
|
||||
label: "角色塑造",
|
||||
maxScore: 15,
|
||||
weight: 0.15,
|
||||
description: "人物立体度、动机合理性、弧光设计",
|
||||
},
|
||||
{
|
||||
key: "plot",
|
||||
label: "剧情结构",
|
||||
maxScore: 20,
|
||||
weight: 0.2,
|
||||
description: "起承转合、节奏把控、冲突设计",
|
||||
},
|
||||
{
|
||||
key: "dialogue",
|
||||
label: "台词对白",
|
||||
maxScore: 15,
|
||||
weight: 0.15,
|
||||
description: "语言质感、角色差异化、潜台词",
|
||||
},
|
||||
{
|
||||
key: "visual",
|
||||
label: "画面表现",
|
||||
maxScore: 15,
|
||||
weight: 0.15,
|
||||
description: "镜头感、空间层次、视觉冲击力",
|
||||
},
|
||||
{
|
||||
key: "content",
|
||||
label: "内容深度",
|
||||
maxScore: 15,
|
||||
weight: 0.15,
|
||||
description: "主题表达、情感共鸣、思想内核",
|
||||
},
|
||||
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";
|
||||
|
||||
function loadHistory(): HistoryEntry[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY);
|
||||
return raw ? (JSON.parse(raw) as HistoryEntry[]) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function saveHistory(entries: HistoryEntry[]) {
|
||||
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, 20))); } catch { /* quota exceeded */ }
|
||||
}
|
||||
|
||||
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: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" },
|
||||
];
|
||||
|
||||
function radarPoint(angle: number, radius: number) {
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
return {
|
||||
x: RADAR_CENTER + radius * Math.cos(radians),
|
||||
y: RADAR_CENTER + radius * Math.sin(radians),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRadarPoints(scores: Record<string, number> | null) {
|
||||
if (!scores) return "100,100 100,100 100,100 100,100 100,100 100,100";
|
||||
|
||||
return scoreDimensions
|
||||
.map((dimension, index) => {
|
||||
const ratio = Math.max(0, Math.min(1, (scores[dimension.key] ?? 0) / dimension.maxScore));
|
||||
const point = radarPoint(RADAR_ANGLES[index] ?? 0, RADAR_RADIUS * ratio);
|
||||
return `${point.x.toFixed(1)},${point.y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function RadarPreview({ result }: { result: EvalResult | null }) {
|
||||
return (
|
||||
<div className={`script-eval-v4-radar-container${result ? " has-glow" : ""}`}>
|
||||
<svg className="script-eval-v4-radar-svg" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="scriptEvalV4RadarGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgba(0, 255, 136, 0.34)" />
|
||||
<stop offset="100%" stopColor="rgba(123, 231, 255, 0.1)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g className="script-eval-v4-radar-grid">
|
||||
<polygon points="100,15 173,55 173,145 100,185 27,145 27,55" />
|
||||
<polygon points="100,35 158,68 158,132 100,165 42,132 42,68" />
|
||||
<polygon points="100,55 143,81 143,119 100,145 57,119 57,81" />
|
||||
<polygon points="100,75 128,94 128,106 100,125 72,106 72,94" />
|
||||
<line x1="100" y1="15" x2="100" y2="185" />
|
||||
<line x1="27" y1="55" x2="173" y2="145" />
|
||||
<line x1="173" y1="55" x2="27" y2="145" />
|
||||
</g>
|
||||
<polygon
|
||||
className={`script-eval-v4-radar-outline${result ? " has-data" : ""}`}
|
||||
points={makeRadarPoints(result?.dimensionScores ?? null)}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# 剧本评测报告`);
|
||||
@@ -127,10 +77,10 @@ function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||
lines.push(result.summary);
|
||||
lines.push("");
|
||||
lines.push(`## 六维评分`);
|
||||
for (const dim of scoreDimensions) {
|
||||
for (const dim of SCORE_DIMENSIONS) {
|
||||
const score = result.dimensionScores[dim.key] ?? 0;
|
||||
const pct = Math.round((score / dim.maxScore) * 100);
|
||||
lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.description}`);
|
||||
lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`);
|
||||
}
|
||||
if (result.highlights.length > 0) {
|
||||
lines.push("");
|
||||
@@ -150,13 +100,6 @@ function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||
lines.push("");
|
||||
lines.push(`---`);
|
||||
lines.push(`*评测时间: ${new Date().toLocaleString("zh-CN")}*`);
|
||||
lines.push("");
|
||||
lines.push(`<details><summary>原始剧本 (${script.length} 字)</summary>`);
|
||||
lines.push("");
|
||||
lines.push("```");
|
||||
lines.push(script.slice(0, 2000) + (script.length > 2000 ? "\n...(已截断)" : ""));
|
||||
lines.push("```");
|
||||
lines.push("</details>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -165,39 +108,44 @@ function ScriptTokensPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<EvalResult | null>(null);
|
||||
const [evalError, setEvalError] = useState<string | null>(null);
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(true);
|
||||
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);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const scoreFrameRef = useRef<number | null>(null);
|
||||
|
||||
const session = useSessionStore((s) => s.session);
|
||||
const hasContent = Boolean(script.trim());
|
||||
const lineNumbers = useMemo(() => {
|
||||
const count = Math.min(160, Math.max(10, script.split(/\r\n|\r|\n/).length));
|
||||
return Array.from({ length: count }, (_, index) => index + 1);
|
||||
}, [script]);
|
||||
|
||||
const handleUploadKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
// 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 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 文件,或直接粘贴剧本文本后开始评测。`,
|
||||
);
|
||||
setScript(`[已上传文件:${file.name}]\n\n暂不支持解析 ${ext.toUpperCase()} 格式,请上传 TXT 或 MD 文件。`);
|
||||
}
|
||||
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -206,22 +154,36 @@ function ScriptTokensPage() {
|
||||
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,
|
||||
};
|
||||
const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)];
|
||||
saveHistory(updated);
|
||||
setHistory(updated);
|
||||
} catch (err) {
|
||||
setEvalError(err instanceof Error ? err.message : "评测服务暂时不可用,请稍后重试");
|
||||
}
|
||||
setDetailsExpanded(true);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setScript("");
|
||||
setResult(null);
|
||||
setDetailsExpanded(true);
|
||||
setEvalError(null);
|
||||
setUploadedFile(null);
|
||||
setCopied(false);
|
||||
setAnimatedScore(0);
|
||||
setActiveDim(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
@@ -260,226 +222,348 @@ function ScriptTokensPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const scoreStatus = loading ? "评测中" : result ? "评测完成" : "待生成评分";
|
||||
const scoreHint =
|
||||
result?.summary ??
|
||||
(hasContent ? "点击「开始评测」生成六维雷达评分和优化路径。" : "粘贴完整剧本后,点击「开始评测」生成六维雷达评分和优化路径。");
|
||||
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(/\.[^.]+$/, "") ?? "剧本评测";
|
||||
|
||||
return (
|
||||
<section className="script-token-page script-eval-v4 page-motion">
|
||||
<main className="script-token-page__scroll script-eval-v4-stage">
|
||||
<section className="script-eval-v4-app" aria-label="剧本评测工具">
|
||||
<div className="script-eval-v4-panel-left">
|
||||
<section className="script-eval-v4-glass script-eval-v4-input-card">
|
||||
<div
|
||||
className="script-eval-v4-upload-area"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onKeyDown={handleUploadKeyDown}
|
||||
>
|
||||
<UploadOutlined />
|
||||
<div className="upload-text">
|
||||
{uploadedFile ? uploadedFile.name : "粘贴文本或上传文档"}
|
||||
<div className="hint">
|
||||
{uploadedFile ? `${(uploadedFile.size / 1024).toFixed(1)}KB,已载入文件信息` : "建议包含场景、角色、动作和台词"}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleFileUpload} />
|
||||
<button
|
||||
type="button"
|
||||
className="script-eval-v4-upload-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
上传
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="script-eval-v4-text-shell">
|
||||
<div className="script-eval-v4-line-numbers" aria-hidden="true">
|
||||
{lineNumbers.map((line) => (
|
||||
<span key={line}>{line}</span>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="script-eval-v4-text-input"
|
||||
value={script}
|
||||
onChange={(event) => setScript(event.target.value)}
|
||||
placeholder={
|
||||
"在此粘贴你的剧本内容...\n\n【第一幕】夜晚,城市天台。霓虹灯映照着雨后的地面。\n小凯独自站在天台边缘,手中握着一张皱巴巴的纸条..."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="script-eval-v4-button-group">
|
||||
<button
|
||||
type="button"
|
||||
className="script-eval-v4-btn-primary"
|
||||
disabled={loading || !hasContent}
|
||||
onClick={() => void handleEvaluate()}
|
||||
>
|
||||
<span>{loading ? "评测中..." : "开始评测"}</span>
|
||||
</button>
|
||||
<button type="button" className="script-eval-v4-btn-secondary" onClick={handleReset}>
|
||||
<ReloadOutlined />
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<aside className="script-eval-v4-panel-right" aria-label="评分结果">
|
||||
{evalError ? (
|
||||
<div className="script-eval-v4-error" role="alert">
|
||||
<span className="script-eval-v4-error__icon">⚠</span>
|
||||
<span>{evalError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className={`script-eval-v4-glass script-eval-v4-score-card${loading ? " loading" : ""}${result ? " ready" : ""}`}>
|
||||
<div className="script-eval-v4-score-header">
|
||||
<div className="score-title">SCORE BOARD</div>
|
||||
<span className={`score-status${result ? " ready" : ""}`}>{scoreStatus}</span>
|
||||
</div>
|
||||
|
||||
<div className="script-eval-v4-score-main">
|
||||
<RadarPreview result={result} />
|
||||
|
||||
<div className="script-eval-v4-score-display">
|
||||
<div className={`score-number${result ? " has-data" : ""}`}>
|
||||
{result ? (
|
||||
<>
|
||||
{result.totalScore} <span>/ 100</span>
|
||||
</>
|
||||
) : (
|
||||
"— / 100"
|
||||
)}
|
||||
<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="score-label">综合评分 {result ? `· ${result.grade} 级` : ""}</div>
|
||||
<div className="score-hint">{scoreHint}</div>
|
||||
</div>
|
||||
</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-v4-dimensions-tags">
|
||||
{scoreDimensions.map((dimension) => (
|
||||
<span className="tag" key={dimension.key}>
|
||||
{dimension.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<section className="script-eval-v4-glass script-eval-v4-details-card">
|
||||
<button
|
||||
type="button"
|
||||
className="script-eval-v4-details-header"
|
||||
onClick={() => setDetailsExpanded((expanded) => !expanded)}
|
||||
aria-expanded={detailsExpanded}
|
||||
>
|
||||
<span className="details-title">
|
||||
<TrophyOutlined />
|
||||
DIMENSIONS 六维详情
|
||||
</span>
|
||||
<DownOutlined className={`expand-icon${detailsExpanded ? " expanded" : ""}`} />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<div className={`script-eval-v4-details-content${detailsExpanded ? " expanded" : ""}`}>
|
||||
<div className="script-eval-v4-details-list">
|
||||
{scoreDimensions.map((dimension) => {
|
||||
const score = result?.dimensionScores[dimension.key] ?? 0;
|
||||
const pct = result ? Math.round((score / dimension.maxScore) * 100) : 0;
|
||||
return (
|
||||
<article className="script-eval-v4-detail-row" key={dimension.key}>
|
||||
<div className="detail-row-main">
|
||||
<span className="dimension-name">{dimension.label}</span>
|
||||
<div className="dimension-bar" aria-hidden="true">
|
||||
<span className="dimension-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="dimension-score">{result ? `${score}/${dimension.maxScore}` : `${dimension.maxScore}分`}</span>
|
||||
{/* 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 className="dimension-desc">{dimension.description}</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="script-eval-v5-illust-label">
|
||||
<FileTextOutlined />
|
||||
<span>上传或粘贴剧本开始评测</span>
|
||||
</div>
|
||||
<div className="script-eval-v5-illust-hint">支持 TXT / MD 格式,点击此处或左侧上传区导入剧本文件</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{result && (result.highlights.length > 0 || result.issues.length > 0) && (
|
||||
<section className="script-eval-v4-glass script-eval-v4-insights-card">
|
||||
{result.highlights.length > 0 && (
|
||||
<div className="script-eval-v4-insight-group highlights">
|
||||
<div className="insight-group-title">
|
||||
<span className="insight-icon">✦</span>
|
||||
HIGHLIGHTS 亮点
|
||||
</div>
|
||||
<ul className="insight-list">
|
||||
{result.highlights.map((h, i) => (
|
||||
<li key={i} className="insight-item highlight-item">{h}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{result.issues.length > 0 && (
|
||||
<div className="script-eval-v4-insight-group issues">
|
||||
<div className="insight-group-title">
|
||||
<span className="insight-icon">△</span>
|
||||
ISSUES 扣分点
|
||||
</div>
|
||||
<ul className="insight-list">
|
||||
{result.issues.map((issue, i) => (
|
||||
<li key={i} className="insight-item issue-item">{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{result && result.suggestions.length > 0 && (
|
||||
<section className="script-eval-v4-glass script-eval-v4-suggestions-card">
|
||||
<div className="suggestions-header">
|
||||
<span className="suggestions-title">
|
||||
<span className="insight-icon">→</span>
|
||||
SUGGESTIONS 优化路径
|
||||
</span>
|
||||
</div>
|
||||
<ul className="suggestion-list">
|
||||
{result.suggestions.map((s, i) => (
|
||||
<li key={i} className="suggestion-item">
|
||||
<span className="suggestion-index">{i + 1}</span>
|
||||
<span className="suggestion-text">{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="script-eval-v4-report-actions">
|
||||
<button type="button" className="script-eval-v4-report-btn" onClick={() => void handleCopyReport()}>
|
||||
<CopyOutlined />
|
||||
<span>{copied ? "已复制" : "复制报告"}</span>
|
||||
</button>
|
||||
<button type="button" className="script-eval-v4-report-btn" onClick={handleExportMarkdown}>
|
||||
<DownloadOutlined />
|
||||
<span>导出 Markdown</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<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}%` }} />
|
||||
</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>
|
||||
|
||||
{!result && (
|
||||
<section className="script-eval-v4-note">
|
||||
<FileTextOutlined />
|
||||
<span>评分后会展示当前短板、改写方向和可执行优化路径。</span>
|
||||
</section>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user