d1b5d64bc8
变更内容: - scriptEvalClient: 替换系统提示词为 DeepSeek V4 完整版(六维评分细则、 评分类别、评分铁律、等级标准),维度满分调整为 hook 20/plot 20/ character 18/dialogue 15/visual 15/content 12,总分算法改为直接累加, 模型使用 qwen3.7-max,增加请求链路 console 日志 - ScriptTokensPage: 同步维度满分和描述(角色塑造 18、内容深度 12), 增加页面挂载和评测流程的 console 日志 - vite.config: esbuild.drop 移除 "console",保留 "debugger", 确保开发环境 console 日志正常输出
504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
import { CopyOutlined, DownOutlined, DownloadOutlined, FileTextOutlined, ReloadOutlined, TrophyOutlined, UploadOutlined } from "@ant-design/icons";
|
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
|
import { evaluateScript } from "../../api/scriptEvalClient";
|
|
|
|
interface ScoreDimension {
|
|
key: string;
|
|
label: string;
|
|
maxScore: number;
|
|
weight: number;
|
|
description: string;
|
|
}
|
|
|
|
interface EvalResult {
|
|
totalScore: number;
|
|
grade: string;
|
|
dimensionScores: Record<string, number>;
|
|
summary: string;
|
|
issues: string[];
|
|
highlights: string[];
|
|
suggestions: string[];
|
|
}
|
|
|
|
const RADAR_CENTER = 100;
|
|
const RADAR_RADIUS = 82;
|
|
const RADAR_ANGLES = [-90, -30, 30, 90, 150, 210];
|
|
|
|
const scoreDimensions: ScoreDimension[] = [
|
|
{
|
|
key: "hook",
|
|
label: "钩子设计",
|
|
maxScore: 20,
|
|
weight: 0.2,
|
|
description: "开篇吸引力、悬念设置、黄金三秒法则",
|
|
},
|
|
{
|
|
key: "character",
|
|
label: "角色塑造",
|
|
maxScore: 18,
|
|
weight: 0.18,
|
|
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: 12,
|
|
weight: 0.12,
|
|
description: "主题表达、情感共鸣、社会/人性洞察",
|
|
},
|
|
];
|
|
|
|
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(`# 剧本评测报告`);
|
|
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 scoreDimensions) {
|
|
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}`);
|
|
}
|
|
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")}*`);
|
|
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");
|
|
}
|
|
|
|
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 [detailsExpanded, setDetailsExpanded] = useState(true);
|
|
const [uploadedFile, setUploadedFile] = useState<{ name: string; size: number } | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
console.log("[剧本评分] 页面已加载,ScriptTokensPage mounted");
|
|
}, []);
|
|
|
|
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();
|
|
};
|
|
|
|
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 文件,或直接粘贴剧本文本后开始评测。`,
|
|
);
|
|
}
|
|
|
|
event.target.value = "";
|
|
};
|
|
|
|
const handleEvaluate = async () => {
|
|
console.log("[剧本评测] 点击开始评测,hasContent:", hasContent, "script长度:", script.length);
|
|
if (!hasContent) return;
|
|
setLoading(true);
|
|
setResult(null);
|
|
setEvalError(null);
|
|
try {
|
|
console.log("[剧本评测] 开始评测,剧本长度:", script.length, "字符");
|
|
const aiResult = await evaluateScript(script);
|
|
console.log("[剧本评测] 评测完成,结果:", {
|
|
总分: aiResult.totalScore,
|
|
等级: aiResult.grade,
|
|
维度得分: aiResult.dimensionScores,
|
|
摘要: aiResult.summary,
|
|
亮点: aiResult.highlights,
|
|
问题: aiResult.issues,
|
|
建议: aiResult.suggestions,
|
|
});
|
|
setResult(aiResult);
|
|
} catch (err) {
|
|
console.error("[剧本评测] 评测失败:", err);
|
|
setEvalError(err instanceof Error ? err.message : "评测服务暂时不可用,请稍后重试");
|
|
}
|
|
setDetailsExpanded(true);
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setScript("");
|
|
setResult(null);
|
|
setDetailsExpanded(true);
|
|
setUploadedFile(null);
|
|
setCopied(false);
|
|
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 scoreStatus = loading ? "评测中" : result ? "评测完成" : "待生成评分";
|
|
const scoreHint =
|
|
result?.summary ??
|
|
(hasContent ? "点击「开始评测」生成六维雷达评分和优化路径。" : "粘贴完整剧本后,点击「开始评测」生成六维雷达评分和优化路径。");
|
|
|
|
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>
|
|
</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>
|
|
|
|
<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>
|
|
<div className="score-label">综合评分 {result ? `· ${result.grade} 级` : ""}</div>
|
|
<div className="score-hint">{scoreHint}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="script-eval-v4-dimensions-tags">
|
|
{scoreDimensions.map((dimension) => (
|
|
<span className="tag" key={dimension.key}>
|
|
{dimension.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<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-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>
|
|
</div>
|
|
<div className="dimension-desc">{dimension.description}</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</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>
|
|
)}
|
|
|
|
{!result && (
|
|
<section className="script-eval-v4-note">
|
|
<FileTextOutlined />
|
|
<span>评分后会展示当前短板、改写方向和可执行优化路径。</span>
|
|
</section>
|
|
)}
|
|
</aside>
|
|
</section>
|
|
</main>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export default ScriptTokensPage;
|