Files
NianAIGC/components/usage-manager.tsx

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