Files
omniai-web/src/features/script-tokens/TokenUsagePage.tsx
T
stringadmin 468d1d27dd fix: 全站页面保活机制、登录拦截优化、UI修复与功能完善
- 移除未登录全页面拦截,改为浏览自由 + 功能使用时弹窗
- 修复PageTransition退出动画卡死导致黑屏的bug
- CanvasPage添加加载中状态避免首次访问黑屏假死
- 全站7个工具页添加页面保活机制,切页后台任务不中断
- 修复未登录时401误触发"用户已在别处登录"弹窗
- 删除MorePage模板板块、微信登录、EcommerceTemplates/SizeTemplate路由
- 剧本评分接入DashScope qwen3.7-max直连API
- 电商视频生成重构为3阶段可视管线(策划→生成图片→生成视频)
- 电商视频保活增强:异步函数直接写localStorage避免卸载丢失
- Workbench侧边栏移除mode过滤,三模式共用同一对话列表
- 首页更新轮播图/背景视频、按钮跳转修正、文案优化
- AppShell顶栏新增网站备案信息按钮
- 多个页面的terminate/cancel按钮覆盖、单镜头重试、批量保存下载

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-06-03 01:39:06 +08:00

416 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = 680;
const H = 200;
const padX = 32;
const padY = 24;
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;