Files
NianAIGC/components/usage-manager.tsx

351 lines
14 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.

"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { BarChart3, Building2, CalendarDays, Loader2, RefreshCw, RotateCcw, Users } from "lucide-react";
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
import { usagePresetRange, type AdminUsageReport } from "@/lib/usage";
type UsageFilters = {
startDate: string;
endDate: string;
organizationId: string;
ownerId: string;
capability: string;
provider: string;
};
function initialFilters(): UsageFilters {
const range = usagePresetRange("month");
return {
startDate: range.startDate,
endDate: range.endDate,
organizationId: "",
ownerId: "",
capability: "",
provider: ""
};
}
export function UsageManager({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const [filters, setFilters] = useState<UsageFilters>(initialFilters);
const [report, setReport] = useState<AdminUsageReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const managerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const feedbackRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const controller = new AbortController();
void loadUsage(controller.signal);
return () => controller.abort();
}, [filters]);
useEffect(() => runScopedMotion(managerRef, (scope) => revealChildren(scope)), []);
useEffect(() => {
crossfadeIn(contentRef.current);
}, [report]);
useEffect(() => {
pulseFeedback(feedbackRef.current);
}, [error, report?.warnings]);
async function loadUsage(signal?: AbortSignal) {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value) params.set(key, value);
}
const response = await fetch(`/api/admin/usage?${params.toString()}`, {
cache: "no-store",
signal
});
const payload = await response.json() as AdminUsageReport & { error?: string };
if (!response.ok) throw new Error(payload.error || "读取用量数据失败");
setReport(payload);
} catch (nextError) {
if (nextError instanceof DOMException && nextError.name === "AbortError") return;
setError(nextError instanceof Error ? nextError.message : String(nextError));
} finally {
if (!signal?.aborted) setLoading(false);
}
}
function updateFilter(key: keyof UsageFilters, value: string) {
setFilters((current) => ({ ...current, [key]: value }));
}
function resetFilters() {
setFilters(initialFilters());
}
return (
<div className="usage-manager" ref={managerRef}>
<div className="workspace-head" data-animate>
<div>
<h1 className="workspace-title">用量管理</h1>
<p className="workspace-copy">按成功任务统计平台内账号与组织的真实服务用量。</p>
</div>
<div className="workspace-meta">
<span className="status">北京时间</span>
<span className="status running">{report?.range.label || "本月"}</span>
</div>
</div>
<section className="panel usage-filters" data-animate aria-label="用量筛选">
<label className="field">
<span>开始日期</span>
<input
type="date"
value={filters.startDate}
max={filters.endDate}
onChange={(event) => updateFilter("startDate", event.target.value)}
/>
</label>
<label className="field">
<span>结束日期</span>
<input
type="date"
value={filters.endDate}
min={filters.startDate}
onChange={(event) => updateFilter("endDate", event.target.value)}
/>
</label>
{isSuperAdmin ? (
<>
<label className="field">
<span>组织</span>
<select value={filters.organizationId} onChange={(event) => updateFilter("organizationId", event.target.value)}>
<option value="">全部组织</option>
{report?.options.organizations.map((option) => (
<option value={option.value} key={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="field">
<span>账号</span>
<select value={filters.ownerId} onChange={(event) => updateFilter("ownerId", event.target.value)}>
<option value="">全部账号</option>
{report?.options.accounts.map((option) => (
<option value={option.value} key={option.value}>{option.label}</option>
))}
</select>
</label>
</>
) : null}
<label className="field">
<span>功能类型</span>
<select value={filters.capability} onChange={(event) => updateFilter("capability", event.target.value)}>
<option value="">全部功能</option>
{report?.options.capabilities.map((option) => (
<option value={option.value} key={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="field">
<span>服务商</span>
<select value={filters.provider} onChange={(event) => updateFilter("provider", event.target.value)}>
<option value="">全部服务商</option>
{report?.options.providers.map((option) => (
<option value={option.value} key={option.value}>{option.label}</option>
))}
</select>
</label>
<div className="usage-filter-actions">
<button className="button" type="button" onClick={resetFilters} disabled={loading}>
<RotateCcw aria-hidden="true" />重置
</button>
<button className="button" type="button" onClick={() => void loadUsage()} disabled={loading}>
{loading ? <Loader2 className="spin" aria-hidden="true" /> : <RefreshCw aria-hidden="true" />}刷新
</button>
</div>
</section>
{error || report?.warnings?.length ? (
<div ref={feedbackRef} className="usage-feedback">
{error ? <div className="callout" role="alert">{error}</div> : null}
{!error && report?.warnings?.length ? (
<div className="callout usage-warning" role="status">{report.warnings.join(";")}</div>
) : null}
</div>
) : null}
{loading && !report ? (
<section className="panel settings-loading">
<Loader2 className="spin" aria-hidden="true" />正在汇总用量
</section>
) : report ? (
<div className={loading ? "usage-content refreshing" : "usage-content"} ref={contentRef}>
<section className="usage-metrics" data-animate aria-label="用量概览">
<UsageMetric icon={<BarChart3 />} label="成功任务" value={report.summary.total} suffix="次" />
<UsageMetric icon={<Users />} label="活跃账号" value={report.summary.activeAccounts} suffix="个" />
<UsageMetric icon={<Building2 />} label="活跃组织" value={report.summary.activeOrganizations} suffix="个" />
<UsageMetric icon={<CalendarDays />} label="日均用量" value={report.summary.averagePerDay} suffix="次" />
</section>
<div className="usage-analysis-grid" data-animate>
<section className="panel usage-trend-panel">
<div className="panel-head">
<div>
<h2>用量趋势</h2>
<p>{report.range.dayCount > 62 ? "按月汇总" : "按日汇总"}</p>
</div>
</div>
<UsageTrend report={report} />
</section>
<section className="panel usage-breakdown-panel">
<div className="panel-head">
<div>
<h2>功能分布</h2>
<p>每个成功任务计 1 次</p>
</div>
</div>
<UsageBreakdown items={report.byCapability} total={report.summary.total} />
<div className="usage-provider-divider" />
<div className="panel-head compact">
<div><h3>服务商分布</h3></div>
</div>
<UsageBreakdown items={report.byProvider} total={report.summary.total} compact />
</section>
</div>
<section className="panel usage-table-panel" data-animate>
<div className="panel-head">
<div>
<h2>组织汇总</h2>
<p>{report.organizations.length} 个统计分组</p>
</div>
</div>
<div className="usage-table usage-organization-table">
<div className="usage-table-row usage-table-head" aria-hidden="true">
<span>组织</span><span>活跃账号</span><span>用量</span><span>最近使用</span>
</div>
{report.organizations.length ? report.organizations.map((row) => (
<div className="usage-table-row" key={row.organizationId}>
<strong>{row.organizationName}</strong>
<span>{row.accountCount} 个</span>
<span><b>{row.count}</b> 次</span>
<time dateTime={row.lastUsedAt}>{formatAdminTime(row.lastUsedAt)}</time>
</div>
)) : <UsageEmpty label="当前筛选下暂无组织用量" />}
</div>
</section>
{isSuperAdmin ? <section className="panel usage-table-panel" data-animate>
<div className="panel-head">
<div>
<h2>账号汇总</h2>
<p>{report.accounts.length} 个活跃账号</p>
</div>
</div>
<div className="usage-table usage-account-table">
<div className="usage-table-row usage-table-head" aria-hidden="true">
<span>账号</span><span>组织</span><span>用量</span><span>最近使用</span>
</div>
{report.accounts.length ? report.accounts.map((row) => (
<div className="usage-table-row" key={row.ownerId}>
<div className="usage-account-name">
<strong>{row.accountName}</strong>
{row.accountUsername ? <small>{row.accountUsername}</small> : null}
</div>
<span>{row.organizationName}</span>
<span><b>{row.count}</b> 次</span>
<time dateTime={row.lastUsedAt}>{formatAdminTime(row.lastUsedAt)}</time>
</div>
)) : <UsageEmpty label="当前筛选下暂无账号用量" />}
</div>
</section> : null}
{isSuperAdmin ? <section className="panel usage-table-panel" data-animate>
<div className="panel-head">
<div>
<h2>用量明细</h2>
<p>仅显示计量元数据,不展示创作内容</p>
</div>
<span className="status">最近 {report.recent.length} 条</span>
</div>
<div className="usage-detail-list">
{report.recent.length ? report.recent.map((record) => (
<article className="usage-detail-row" key={record.id}>
<div className="usage-account-name">
<strong>{record.accountName}</strong>
<small>{record.organizationName}</small>
</div>
<div>
<strong>{record.capabilityLabel}</strong>
<small>{record.providerLabel}{record.reqKey ? ` · ${record.reqKey}` : ""}</small>
</div>
<time dateTime={record.createdAt}>{formatAdminTime(record.createdAt)}</time>
<span className="status">1 次</span>
</article>
)) : <UsageEmpty label="当前筛选下暂无用量明细" />}
</div>
</section> : null}
</div>
) : null}
</div>
);
}
function UsageMetric({ icon, label, value, suffix }: { icon: ReactNode; label: string; value: number; suffix: string }) {
return (
<article className="panel usage-metric">
<div className="usage-metric-icon" aria-hidden="true">{icon}</div>
<span>{label}</span>
<strong>{value}<small>{suffix}</small></strong>
</article>
);
}
function UsageTrend({ report }: { report: AdminUsageReport }) {
const max = Math.max(1, ...report.trend.map((point) => point.count));
if (!report.trend.length) return <UsageEmpty label="暂无趋势数据" />;
return (
<div className="usage-trend" role="img" aria-label={`${report.range.label}用量趋势`}>
{report.trend.map((point) => (
<div className="usage-trend-point" key={point.date} title={`${point.date}:${point.count} 次`}>
<div className="usage-trend-value">{point.count || ""}</div>
<div className="usage-trend-track">
<span style={{ height: `${Math.max(point.count ? 8 : 0, point.count / max * 100)}%` }} />
</div>
<small>{point.label}</small>
</div>
))}
</div>
);
}
function UsageBreakdown({ items, total, compact = false }: { items: AdminUsageReport["byCapability"]; total: number; compact?: boolean }) {
if (!items.length) return <UsageEmpty label="暂无分布数据" />;
return (
<div className={compact ? "usage-breakdown compact" : "usage-breakdown"}>
{items.map((item) => (
<div className="usage-breakdown-row" key={item.key}>
<div><span>{item.label}</span><strong>{item.count}</strong></div>
<div className="usage-progress"><span style={{ width: `${total ? item.count / total * 100 : 0}%` }} /></div>
</div>
))}
</div>
);
}
function UsageEmpty({ label }: { label: string }) {
return <div className="usage-empty">{label}</div>;
}
function formatAdminTime(value?: string): string {
if (!value) return "—";
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}