Files
omniai-web/src/features/script-tokens/TokenUsagePage.tsx
T

418 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 { useCallback, useEffect, useMemo, useState } from "react";
import { ShellIcon } from "../../components/ShellIcon";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/script-tokens-v5.css";
import "../../styles/pages/script-tokens.css";
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 () => {
if (!session) return;
const loader = isEnterpriseAdmin ? loadEnterpriseUsage : loadPersonalUsage;
if (!loader) {
setEnterpriseUsage(null);
return;
}
setEnterpriseUsageLoading(true);
setEnterpriseUsageError(null);
try {
setEnterpriseUsage(await loader());
} catch (error) {
setEnterpriseUsage(null);
setEnterpriseUsageError(error instanceof Error ? error.message : "加载失败");
} finally {
setEnterpriseUsageLoading(false);
}
}, [session, 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 || "-" },
];
const pageStatusClass = enterpriseUsageLoading
? "is-syncing"
: enterpriseUsageError
? "has-sync-error"
: isLowBalance
? "has-low-balance"
: "is-healthy";
return (
<section className={`script-token-page token-usage-page management-center-page ${pageStatusClass}`} 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}>
<ShellIcon name="arrow-left" />
</button>
<span>
<strong></strong>
<small></small>
</span>
</div>
<span className={`management-center-status-pill ${enterpriseUsageError ? "is-error" : enterpriseUsageLoading ? "is-loading" : "is-online"}`}>
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
</span>
<button type="button" onClick={refreshEnterpriseUsage} disabled={enterpriseUsageLoading}>
<ShellIcon name="reload" />
</button>
<button type="button" className="is-muted-action">
<ShellIcon name="user" />
</button>
</header>
{isLowBalance ? (
<div className="management-balance-alert" role="alert">
<ShellIcon name="warning" />
<span> {formatCredits(availableBalanceCents)}</span>
</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>
<ShellIcon name="bar-chart" />
</h2>
<span>{enterpriseUsageLoading ? "SYNC" : 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">
<ShellIcon name="bar-chart" />
<span></span>
</div>
)}
</article>
<article className="management-card management-status-card">
<div className="management-card__head">
<h2>
<ShellIcon name="line-chart" />
</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>
<ShellIcon name="team" />
({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>
<ShellIcon name="check-circle" />
</article>
))}
</div>
</section>
<section className="management-card management-records">
<div className="management-card__head">
<h2>
<ShellIcon name="bar-chart" />
</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)}>
<ShellIcon name="chevron-left" />
</button>
<span>{recordPage + 1} / {totalPages}</span>
<button type="button" disabled={recordPage >= totalPages - 1} onClick={() => setRecordPage((p) => p + 1)}>
<ShellIcon name="chevron-right" />
</button>
</div>
)}
</section>
</main>
</section>
);
}
export default TokenUsagePage;