feat: add local auth billing and usage management

This commit is contained in:
inman committed 2026-08-12 12:13:06 +08:00
1 parent f642b5e71f
commit 196fdde83f
119 files changed
+15695 -2650

No files matched your search

+350
View File
@@ -0,0 +1,350 @@
"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));
}