feat: add local auth billing and usage management
This commit is contained in:
1 parent
f642b5e71f
commit
196fdde83f
119 files changed
+15695
-2650
No files matched your search
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Clock3, Loader2, UserCircle } from "lucide-react";
|
||||
import type { AuthUser } from "@/lib/auth/session";
|
||||
import {
|
||||
USAGE_PRESET_OPTIONS,
|
||||
type PersonalUsageReport,
|
||||
type UsagePreset
|
||||
} from "@/lib/usage";
|
||||
|
||||
export function AccountUsageMenu({ user }: { user: AuthUser }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [preset, setPreset] = useState<UsagePreset>("month");
|
||||
const [report, setReport] = useState<PersonalUsageReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/usage?preset=${preset}`, { cache: "no-store", signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
const payload = await response.json() as PersonalUsageReport & { 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 (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [open, preset]);
|
||||
|
||||
return (
|
||||
<div className="account-usage-menu" ref={containerRef}>
|
||||
<button
|
||||
className="account-chip account-chip-button"
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls="account-usage-popover"
|
||||
title={`${user.username || user.subject} · 查看用量`}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<UserCircle aria-hidden="true" />
|
||||
<span>{user.displayName}</span>
|
||||
<ChevronDown className={open ? "account-chevron open" : "account-chevron"} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<section
|
||||
className="account-usage-popover"
|
||||
id="account-usage-popover"
|
||||
role="dialog"
|
||||
aria-label="我的用量"
|
||||
>
|
||||
<header className="account-usage-head">
|
||||
<div>
|
||||
<span>我的用量</span>
|
||||
<strong>{user.displayName}</strong>
|
||||
</div>
|
||||
<small>{user.username || user.subject}</small>
|
||||
</header>
|
||||
|
||||
<div className="segmented account-usage-tabs" aria-label="用量周期">
|
||||
{USAGE_PRESET_OPTIONS.map((option) => (
|
||||
<button
|
||||
className={preset === option.value ? "active" : ""}
|
||||
type="button"
|
||||
aria-pressed={preset === option.value}
|
||||
key={option.value}
|
||||
onClick={() => setPreset(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <div className="account-usage-error" role="alert">{error}</div> : null}
|
||||
{loading && !report ? (
|
||||
<div className="account-usage-loading"><Loader2 className="spin" />正在读取用量</div>
|
||||
) : report ? (
|
||||
<div className={loading ? "account-usage-content refreshing" : "account-usage-content"}>
|
||||
<div className="account-usage-total">
|
||||
<span>{report.range.label}</span>
|
||||
<strong>{report.total}</strong>
|
||||
<small>次成功任务</small>
|
||||
</div>
|
||||
|
||||
<div className="account-usage-breakdown" aria-label="按功能分类">
|
||||
{report.byCapability.map((item) => (
|
||||
<div key={item.key}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="account-usage-recent">
|
||||
<div className="account-usage-section-title">
|
||||
<Clock3 aria-hidden="true" />
|
||||
<strong>最近记录</strong>
|
||||
</div>
|
||||
{report.recent.length ? report.recent.map((record) => (
|
||||
<div className="account-usage-record" key={record.id}>
|
||||
<div>
|
||||
<strong>{record.capabilityLabel}</strong>
|
||||
<small>{record.providerLabel}{record.reqKey ? ` · ${record.reqKey}` : ""}</small>
|
||||
</div>
|
||||
<time dateTime={record.createdAt}>{formatUsageTime(record.createdAt)}</time>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="account-usage-empty">当前周期暂无真实任务用量</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUsageTime(value: string): string {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}).format(new Date(value));
|
||||
}
|
||||
Reference in new issue
Block a user