Files
NianAIGC/components/account-usage-menu.tsx

157 lines
5.7 KiB
TypeScript

"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));
}