Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
import { CopyOutlined, DownOutlined, DownloadOutlined, FileTextOutlined, ReloadOutlined, TrophyOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { 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: 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 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);
|
||||
|
||||
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 () => {
|
||||
if (!hasContent) return;
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setEvalError(null);
|
||||
try {
|
||||
const aiResult = await evaluateScript(script);
|
||||
setResult(aiResult);
|
||||
} catch (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;
|
||||
@@ -0,0 +1,415 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
BarChartOutlined,
|
||||
CheckCircleOutlined,
|
||||
LeftOutlined,
|
||||
LineChartOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
WebEnterpriseUsageMember,
|
||||
WebEnterpriseUsageRecord,
|
||||
WebEnterpriseUsageSummary,
|
||||
WebImageWorkbenchTool,
|
||||
WebUsageSummary,
|
||||
WebUserSession,
|
||||
WebViewKey,
|
||||
} from "../../types";
|
||||
|
||||
interface TokenUsagePageProps {
|
||||
session: WebUserSession | null;
|
||||
usage: WebUsageSummary;
|
||||
loadEnterpriseUsage?: () => Promise<WebEnterpriseUsageSummary>;
|
||||
loadPersonalUsage?: () => Promise<WebEnterpriseUsageSummary>;
|
||||
onOpenMore?: () => void;
|
||||
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
|
||||
onSelectView?: (view: WebViewKey) => void;
|
||||
}
|
||||
|
||||
function formatCredits(cents: number): string {
|
||||
const value = Math.max(0, cents) / 100;
|
||||
return value >= 1000 ? `${Math.round(value).toLocaleString()} 积分` : `${value.toFixed(2).replace(/\.00$/, "")} 积分`;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function getInitials(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return "U";
|
||||
return normalized.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function getEnterpriseBalanceCents(
|
||||
session: WebUserSession | null,
|
||||
usage: WebUsageSummary,
|
||||
enterpriseUsage: WebEnterpriseUsageSummary | null,
|
||||
) {
|
||||
if (enterpriseUsage) return enterpriseUsage.balanceCents;
|
||||
if (typeof usage.enterpriseBalanceCents === "number") return usage.enterpriseBalanceCents;
|
||||
return session?.user.enterpriseBalanceCents ?? usage.balanceCents;
|
||||
}
|
||||
|
||||
function memberDisplayName(member: WebEnterpriseUsageMember): string {
|
||||
return member.displayName || member.username || String(member.userId);
|
||||
}
|
||||
|
||||
function recordDuration(record: WebEnterpriseUsageRecord): string {
|
||||
return record.durationSeconds ? `${record.durationSeconds}s` : record.resolution || "-";
|
||||
}
|
||||
|
||||
const LOW_BALANCE_THRESHOLD_CENTS = 2000;
|
||||
|
||||
function formatDayLabel(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
}
|
||||
|
||||
type TrendPoint = { date: string; usedCents: number; taskCount: number };
|
||||
|
||||
function UsageTrendChart({ data }: { data: TrendPoint[] }) {
|
||||
const W = 480;
|
||||
const H = 120;
|
||||
const padX = 28;
|
||||
const padY = 18;
|
||||
const maxCents = Math.max(1, ...data.map((d) => d.usedCents));
|
||||
const stepX = data.length > 1 ? (W - padX * 2) / (data.length - 1) : 0;
|
||||
const yOf = (cents: number) => H - padY - (cents / maxCents) * (H - padY * 2);
|
||||
const xOf = (i: number) => padX + i * stepX;
|
||||
const points = data.map((d, i) => ({ x: xOf(i), y: yOf(d.usedCents), ...d }));
|
||||
const linePath = points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
|
||||
const areaPath = points.length
|
||||
? `${linePath} L${points[points.length - 1].x.toFixed(1)},${H - padY} L${points[0].x.toFixed(1)},${H - padY} Z`
|
||||
: "";
|
||||
const totalCents = data.reduce((sum, d) => sum + d.usedCents, 0);
|
||||
const peak = points.reduce((best, p) => (p.usedCents > best.usedCents ? p : best), points[0] || { usedCents: 0, x: 0, y: 0 });
|
||||
|
||||
return (
|
||||
<div className="usage-trend">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="usage-trend__svg" role="img" aria-label="近 7 天积分消耗趋势">
|
||||
<defs>
|
||||
<linearGradient id="usageTrendFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--accent)" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="var(--accent)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{[0.25, 0.5, 0.75, 1].map((f) => (
|
||||
<line key={f} x1={padX} y1={yOf(maxCents * f)} x2={W - padX} y2={yOf(maxCents * f)} className="usage-trend__grid" />
|
||||
))}
|
||||
{areaPath ? <path d={areaPath} fill="url(#usageTrendFill)" /> : null}
|
||||
{linePath ? <path d={linePath} className="usage-trend__line" /> : null}
|
||||
{points.map((p) => (
|
||||
<circle key={p.date} cx={p.x} cy={p.y} r={3.5} className="usage-trend__dot">
|
||||
<title>{`${formatDayLabel(p.date)} · ${formatCredits(p.usedCents)} · ${p.taskCount} 次`}</title>
|
||||
</circle>
|
||||
))}
|
||||
{points.map((p, i) => (
|
||||
<text key={`lbl-${p.date}`} x={p.x} y={H - 6} className="usage-trend__xlabel" textAnchor={i === 0 ? "start" : i === points.length - 1 ? "end" : "middle"}>
|
||||
{formatDayLabel(p.date)}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
<div className="usage-trend__meta">
|
||||
<span>近 7 天共 <b>{formatCredits(totalCents)}</b></span>
|
||||
{peak.usedCents > 0 ? <span>峰值 {formatDayLabel(peak.date)} · {formatCredits(peak.usedCents)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenUsagePage({
|
||||
session,
|
||||
usage,
|
||||
loadEnterpriseUsage,
|
||||
loadPersonalUsage,
|
||||
onOpenMore,
|
||||
onOpenImageTool,
|
||||
onSelectView,
|
||||
}: TokenUsagePageProps) {
|
||||
const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null);
|
||||
const [enterpriseUsageLoading, setEnterpriseUsageLoading] = useState(false);
|
||||
const [enterpriseUsageError, setEnterpriseUsageError] = useState<string | null>(null);
|
||||
const isEnterpriseAdmin = session?.user.enterpriseRole === "admin";
|
||||
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
|
||||
|
||||
const refreshEnterpriseUsage = useCallback(async () => {
|
||||
const loader = isEnterpriseAdmin ? loadEnterpriseUsage : loadPersonalUsage;
|
||||
if (!loader) {
|
||||
setEnterpriseUsage(null);
|
||||
setEnterpriseUsageError(null);
|
||||
return;
|
||||
}
|
||||
setEnterpriseUsageLoading(true);
|
||||
setEnterpriseUsageError(null);
|
||||
try {
|
||||
setEnterpriseUsage(await loader());
|
||||
} catch (error) {
|
||||
setEnterpriseUsage(null);
|
||||
setEnterpriseUsageError(error instanceof Error ? error.message : "用量数据暂时不可用");
|
||||
} finally {
|
||||
setEnterpriseUsageLoading(false);
|
||||
}
|
||||
}, [isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshEnterpriseUsage();
|
||||
}, [refreshEnterpriseUsage]);
|
||||
|
||||
const enterpriseBalanceCents = getEnterpriseBalanceCents(session, usage, enterpriseUsage);
|
||||
const totalCalls = enterpriseUsage
|
||||
? enterpriseUsage.members.reduce((sum, member) => sum + member.taskCount, 0)
|
||||
: usage.imageUsed + usage.videoUsed + usage.textUsed;
|
||||
const totalUsedCents = enterpriseUsage?.totalUsedCents ?? 0;
|
||||
const modelBreakdown = enterpriseUsage?.modelBreakdown ?? [];
|
||||
const dailyTrend = enterpriseUsage?.dailyTrend ?? [];
|
||||
const availableBalanceCents = isEnterpriseAccount ? enterpriseBalanceCents : usage.balanceCents;
|
||||
const isLowBalance = !usage.betaUnlimited && availableBalanceCents < LOW_BALANCE_THRESHOLD_CENTS;
|
||||
const maxModelCents = Math.max(1, ...modelBreakdown.map((m) => m.usedCents));
|
||||
const records = enterpriseUsage?.records ?? [];
|
||||
const [recordPage, setRecordPage] = useState(0);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(records.length / pageSize));
|
||||
const pagedRecords = records.slice(recordPage * pageSize, (recordPage + 1) * pageSize);
|
||||
const members = useMemo<WebEnterpriseUsageMember[]>(() => {
|
||||
if (enterpriseUsage && enterpriseUsage.members.length) return enterpriseUsage.members;
|
||||
if (!session) return [];
|
||||
return [
|
||||
{
|
||||
userId: session.user.id,
|
||||
username: session.user.username,
|
||||
displayName: session.user.displayName,
|
||||
role: session.user.enterpriseRole || session.user.role || "personal",
|
||||
usedCents: 0,
|
||||
taskCount: totalCalls,
|
||||
lastUsedAt: null,
|
||||
},
|
||||
];
|
||||
}, [enterpriseUsage, session, totalCalls]);
|
||||
|
||||
const metricCards = [
|
||||
{
|
||||
key: "balance",
|
||||
label: "可用余额",
|
||||
value: usage.betaUnlimited ? "不限量" : formatCredits(availableBalanceCents),
|
||||
hint: isLowBalance ? "余额偏低,建议充值" : isEnterpriseAccount ? "企业共享额度" : "个人账户额度",
|
||||
tone: isLowBalance ? "warn" : "accent",
|
||||
},
|
||||
{
|
||||
key: "used",
|
||||
label: "已用积分",
|
||||
value: formatCredits(totalUsedCents),
|
||||
hint: enterpriseUsage ? `${enterpriseUsage.enterpriseName || "企业"}累计` : "累计消耗",
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
key: "calls",
|
||||
label: "调用次数",
|
||||
value: `${totalCalls}`,
|
||||
hint: `${modelBreakdown.length || 0} 个模型`,
|
||||
tone: "neutral",
|
||||
},
|
||||
];
|
||||
|
||||
const systemStatus = [
|
||||
{ label: "账户类型", value: isEnterpriseAccount ? "企业账户" : "个人账户", tone: "good" },
|
||||
{ label: "企业空间", value: enterpriseUsage?.enterpriseName || session?.user.enterpriseName || "-" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="script-token-page token-usage-page management-center-page" aria-label="管理中心">
|
||||
<main className="management-center-shell">
|
||||
<header className="management-center-toolbar" aria-label="管理中心操作">
|
||||
<div className="management-center-toolbar__title">
|
||||
<button type="button" className="management-center-toolbar__back" aria-label="返回工具盒" onClick={onOpenMore}>
|
||||
<ArrowLeftOutlined />
|
||||
</button>
|
||||
<strong>管理中心</strong>
|
||||
</div>
|
||||
<span className="management-center-status-pill">
|
||||
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
|
||||
</span>
|
||||
<button type="button" onClick={refreshEnterpriseUsage}>
|
||||
<ReloadOutlined />
|
||||
刷新数据
|
||||
</button>
|
||||
<button type="button">
|
||||
<UserOutlined />
|
||||
成员管理
|
||||
</button>
|
||||
<button type="button" className="is-primary" onClick={() => onSelectView?.("settings")}>
|
||||
<SettingOutlined />
|
||||
服务设置
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{isLowBalance ? (
|
||||
<div className="management-balance-alert" role="alert">
|
||||
<WarningOutlined />
|
||||
<span>当前余额 {formatCredits(availableBalanceCents)},可能不足以完成下一次生成,请及时充值。</span>
|
||||
<button type="button" onClick={() => onSelectView?.("settings")}>去充值</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="management-metric-cards" aria-label="关键指标">
|
||||
{metricCards.map((card) => (
|
||||
<article key={card.key} className={`management-metric-card is-${card.tone}`}>
|
||||
<span className="management-metric-card__label">{card.label}</span>
|
||||
<strong className="management-metric-card__value">{card.value}</strong>
|
||||
<span className="management-metric-card__hint">{card.hint}</span>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="management-center-overview" aria-label="系统概览">
|
||||
<article className="management-card management-card--chart">
|
||||
<div className="management-card__head">
|
||||
<h2>
|
||||
<BarChartOutlined />
|
||||
模型消耗分布
|
||||
</h2>
|
||||
<span>{modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
|
||||
</div>
|
||||
{modelBreakdown.length ? (
|
||||
<div className="management-model-list">
|
||||
{modelBreakdown.map((item) => (
|
||||
<div className="management-model-bar" key={item.model}>
|
||||
<div className="management-model-bar__top">
|
||||
<strong title={item.model}>{item.model}</strong>
|
||||
<em>{formatCredits(item.usedCents)}</em>
|
||||
</div>
|
||||
<div className="management-model-bar__track">
|
||||
<span style={{ width: `${Math.max(4, Math.round((item.usedCents / maxModelCents) * 100))}%` }} />
|
||||
</div>
|
||||
<span className="management-model-bar__sub">{item.taskCount} 次调用 · {Math.round((item.usedCents / Math.max(1, totalUsedCents)) * 100)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="management-empty-chart">
|
||||
<BarChartOutlined />
|
||||
<span>暂无模型用量数据</span>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="management-card management-status-card">
|
||||
<div className="management-card__head">
|
||||
<h2>系统状态</h2>
|
||||
</div>
|
||||
<dl>
|
||||
{systemStatus.map((item) => (
|
||||
<div key={item.label}>
|
||||
<dt>{item.label}</dt>
|
||||
<dd className={item.tone === "good" ? "is-good" : undefined}>
|
||||
{item.tone === "good" ? <i /> : null}
|
||||
{item.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="management-status-trend">
|
||||
<span className="management-status-trend__title">近 7 天趋势</span>
|
||||
{dailyTrend.some((d) => d.usedCents > 0) ? (
|
||||
<UsageTrendChart data={dailyTrend} />
|
||||
) : (
|
||||
<span className="management-status-trend__empty">暂无数据</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="management-card management-members">
|
||||
<div className="management-card__head">
|
||||
<h2>
|
||||
<TeamOutlined />
|
||||
团队成员 ({members.length})
|
||||
</h2>
|
||||
<button type="button">{isEnterpriseAdmin ? "企业管理员" : "当前账号"}</button>
|
||||
</div>
|
||||
<div className="management-member-list">
|
||||
{members.map((member) => (
|
||||
<article key={String(member.userId)} className="management-member-row">
|
||||
<span className="management-member-avatar">{getInitials(memberDisplayName(member))}</span>
|
||||
<div>
|
||||
<strong>{memberDisplayName(member)}</strong>
|
||||
<em>uid: {member.userId}</em>
|
||||
</div>
|
||||
<span className="management-member-role">{member.role === "admin" ? "管理员" : member.role === "employee" ? "员工" : member.role}</span>
|
||||
<span>{formatCredits(member.usedCents)}</span>
|
||||
<span className="management-member-meter">
|
||||
<b>{member.taskCount} 调用</b>
|
||||
<b>{formatDateTime(member.lastUsedAt)}</b>
|
||||
</span>
|
||||
<CheckCircleOutlined />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="management-card management-records">
|
||||
<div className="management-card__head">
|
||||
<h2>调用记录</h2>
|
||||
<span>{records.length} 条记录</span>
|
||||
</div>
|
||||
<div className="management-record-table" role="table" aria-label="调用记录">
|
||||
<div role="row" className="management-record-table__head">
|
||||
<span role="columnheader">时间</span>
|
||||
<span role="columnheader">用户</span>
|
||||
<span role="columnheader">模型</span>
|
||||
<span role="columnheader">提示词</span>
|
||||
<span role="columnheader">状态</span>
|
||||
<span role="columnheader">耗时</span>
|
||||
<span role="columnheader">积分</span>
|
||||
</div>
|
||||
{pagedRecords.length ? (
|
||||
pagedRecords.map((record) => (
|
||||
<div role="row" className="management-record-table__row" key={record.id}>
|
||||
<span>{formatDateTime(record.createdAt)}</span>
|
||||
<span>{record.username}</span>
|
||||
<span>{record.model}</span>
|
||||
<span title={record.prompt || ""}>{record.prompt || "-"}</span>
|
||||
<span className={record.status === "completed" ? "is-good" : record.status === "failed" ? "is-error" : undefined}>{record.status === "completed" ? "成功" : record.status === "failed" ? "失败" : record.status}</span>
|
||||
<span>{recordDuration(record)}</span>
|
||||
<span>{record.amountCents > 0 ? formatCredits(record.amountCents) : "-"}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="management-record-empty" role="row">
|
||||
暂无调用记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{records.length > pageSize && (
|
||||
<div className="management-record-pagination">
|
||||
<button type="button" disabled={recordPage === 0} onClick={() => setRecordPage((p) => p - 1)}>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
<span>{recordPage + 1} / {totalPages}</span>
|
||||
<button type="button" disabled={recordPage >= totalPages - 1} onClick={() => setRecordPage((p) => p + 1)}>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsagePage;
|
||||
Reference in New Issue
Block a user