351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
"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));
|
||
}
|