157 lines
5.7 KiB
TypeScript
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));
|
|
}
|