b5212dcdc2
Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
752 lines
23 KiB
TypeScript
752 lines
23 KiB
TypeScript
import {
|
|
ArrowLeft,
|
|
CheckCircle2,
|
|
Coins,
|
|
Gift,
|
|
Search,
|
|
Sparkles,
|
|
Users,
|
|
} from "lucide-react";
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type FormEvent,
|
|
} from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
import { adminApi, ApiError } from "../../api/client";
|
|
import type {
|
|
LedgerQuery,
|
|
SortOrder,
|
|
UserDetail,
|
|
UsersQuery,
|
|
UserSummary,
|
|
UserUsageAggregate,
|
|
} from "../../api/types";
|
|
import { DataTable, type DataColumn } from "../../components/data-table";
|
|
import { DateRangeControl } from "../../components/date-range-control";
|
|
import { FilterControl } from "../../components/filter-control";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Dialog,
|
|
ErrorState,
|
|
Input,
|
|
LoadingState,
|
|
PageHeader,
|
|
Textarea,
|
|
} from "../../components/primitives";
|
|
import { TableToolbar } from "../../components/table-toolbar";
|
|
import { useCursorPage } from "../../hooks/use-cursor-page";
|
|
import {
|
|
createIdempotencyKey,
|
|
formatDateTime,
|
|
formatNumber,
|
|
statusLabel,
|
|
usageTypeLabel,
|
|
} from "../../lib/format";
|
|
import { useAuth } from "../auth/auth-context";
|
|
import { LedgerFilters } from "../credits/ledger-filters";
|
|
import { LedgerTable } from "../credits/ledger-table";
|
|
import { useLedgerFilters } from "../credits/use-ledger-filters";
|
|
|
|
export function UsersPage() {
|
|
const { auth } = useAuth();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const role = auth.status === "authenticated" ? auth.role : "ANALYST";
|
|
const selectedUserId = searchParams.get("userId") ?? undefined;
|
|
|
|
function selectUser(userId?: string) {
|
|
setSearchParams((current) => {
|
|
const params = new URLSearchParams(current);
|
|
if (userId) params.set("userId", userId);
|
|
else params.delete("userId");
|
|
return params;
|
|
});
|
|
}
|
|
|
|
return selectedUserId ? (
|
|
<UserDetailView
|
|
userId={selectedUserId}
|
|
canGrant={role === "SUPER_ADMIN"}
|
|
onBack={() => selectUser()}
|
|
/>
|
|
) : (
|
|
<UserList onSelect={selectUser} />
|
|
);
|
|
}
|
|
|
|
function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const initialQuery = searchParams.get("q") ?? "";
|
|
const [query, setQuery] = useState(initialQuery);
|
|
const [activeQuery, setActiveQuery] = useState(initialQuery);
|
|
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
|
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
|
const [status, setStatus] = useState<"" | UserSummary["status"]>(
|
|
(searchParams.get("status") as UserSummary["status"] | null) ?? "",
|
|
);
|
|
const [order, setOrder] = useState<SortOrder>(
|
|
searchParams.get("order") === "asc" ? "asc" : "desc",
|
|
);
|
|
const usersQuery = useMemo<UsersQuery>(
|
|
() => ({
|
|
q: activeQuery || undefined,
|
|
from: from || undefined,
|
|
until: until || undefined,
|
|
status: status || undefined,
|
|
sort: "createdAt",
|
|
order,
|
|
limit: 50,
|
|
}),
|
|
[activeQuery, from, order, status, until],
|
|
);
|
|
const fetchUsers = useCallback((value: UsersQuery) => adminApi.users(value), []);
|
|
const {
|
|
items,
|
|
nextCursor,
|
|
loading,
|
|
loadingMore,
|
|
error,
|
|
loadMore,
|
|
reload,
|
|
} = useCursorPage(usersQuery, fetchUsers);
|
|
|
|
useEffect(() => {
|
|
const params = new URLSearchParams();
|
|
if (activeQuery) params.set("q", activeQuery);
|
|
if (from) params.set("from", from);
|
|
if (until) params.set("until", until);
|
|
if (status) params.set("status", status);
|
|
params.set("sort", "createdAt");
|
|
params.set("order", order);
|
|
setSearchParams(params, { replace: true });
|
|
}, [activeQuery, from, order, setSearchParams, status, until]);
|
|
|
|
function submit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
const search = query.trim();
|
|
setActiveQuery(search);
|
|
}
|
|
|
|
const columns = useMemo<DataColumn<UserSummary>[]>(
|
|
() => [
|
|
{
|
|
id: "user",
|
|
header: "用户",
|
|
cell: ({ row }) => (
|
|
<button
|
|
className="group/user max-w-64 text-left"
|
|
type="button"
|
|
onClick={() => onSelect(row.original.userId)}
|
|
>
|
|
<span className="block font-semibold transition group-hover/user:text-primary">
|
|
{row.original.displayName || "未命名用户"}
|
|
</span>
|
|
<span className="mt-1 block truncate font-mono text-[11px] text-muted">
|
|
{row.original.userId}
|
|
</span>
|
|
</button>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "status",
|
|
header: "状态",
|
|
cell: ({ getValue }) => <UserStatus status={String(getValue())} />,
|
|
},
|
|
{
|
|
accessorKey: "consumedCredits",
|
|
header: "累计使用",
|
|
cell: ({ getValue }) => (
|
|
<span className="tabular-nums">{formatNumber(Number(getValue()))}</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "creditBalance",
|
|
header: "当前积分",
|
|
cell: ({ getValue }) => (
|
|
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "createdAt",
|
|
header: "注册时间",
|
|
cell: ({ getValue }) => (
|
|
<span className="whitespace-nowrap text-xs text-muted">
|
|
{formatDateTime(String(getValue()))}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "操作",
|
|
cell: ({ row }) => (
|
|
<Button size="sm" variant="secondary" onClick={() => onSelect(row.original.userId)}>
|
|
查看详情
|
|
</Button>
|
|
),
|
|
},
|
|
],
|
|
[onSelect],
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
eyebrow="账户管理"
|
|
title="用户列表"
|
|
description="按注册时间浏览账户,支持完整或后 8 位内部用户 ID 查询。"
|
|
/>
|
|
<Card className="overflow-hidden">
|
|
<div className="border-b border-border p-5 sm:p-6">
|
|
<form className="flex max-w-2xl flex-col gap-3 sm:flex-row" onSubmit={submit}>
|
|
<label className="relative flex-1">
|
|
<span className="sr-only">内部用户 ID</span>
|
|
<Search
|
|
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted"
|
|
aria-hidden
|
|
/>
|
|
<Input
|
|
className="pl-10"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
type="search"
|
|
placeholder="输入完整或后 8 位用户 ID"
|
|
maxLength={36}
|
|
autoComplete="off"
|
|
/>
|
|
</label>
|
|
<Button type="submit">
|
|
<Search className="size-4" aria-hidden />
|
|
搜索用户
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
<TableToolbar
|
|
active={Boolean(activeQuery || from || until || status || order !== "desc")}
|
|
onClear={() => {
|
|
setQuery("");
|
|
setActiveQuery("");
|
|
setFrom("");
|
|
setUntil("");
|
|
setStatus("");
|
|
setOrder("desc");
|
|
}}
|
|
>
|
|
<DateRangeControl
|
|
value={{ from: from || undefined, until: until || undefined }}
|
|
onChange={(value) => {
|
|
setFrom(value.from ?? "");
|
|
setUntil(value.until ?? "");
|
|
}}
|
|
/>
|
|
<FilterControl
|
|
label="账户状态"
|
|
value={status}
|
|
options={[
|
|
{ value: "", label: "全部状态" },
|
|
{ value: "active", label: "正常" },
|
|
{ value: "suspended", label: "已暂停" },
|
|
]}
|
|
onChange={setStatus}
|
|
/>
|
|
</TableToolbar>
|
|
<div className="flex items-center gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
|
|
<Users className="size-4 text-primary" aria-hidden />
|
|
{activeQuery ? "查询结果" : "全部用户"}
|
|
<span aria-hidden>·</span>
|
|
<strong className="text-foreground">{formatNumber(items.length)} 位</strong>
|
|
</div>
|
|
{error ? (
|
|
<div className="p-6">
|
|
<ErrorState error={error} retry={reload} />
|
|
</div>
|
|
) : loading ? (
|
|
<LoadingState label="加载用户列表" />
|
|
) : (
|
|
<DataTable
|
|
data={items}
|
|
columns={columns}
|
|
caption={activeQuery ? "用户查询结果" : "全部用户"}
|
|
emptyTitle={
|
|
activeQuery || from || until || status
|
|
? "没有符合当前筛选条件的用户"
|
|
: "暂无用户"
|
|
}
|
|
sort={{ key: "createdAt", order }}
|
|
sortableColumns={{ createdAt: "createdAt" }}
|
|
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
|
footer={
|
|
nextCursor ? (
|
|
<div className="flex justify-center border-t border-border p-5">
|
|
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
|
|
加载更多用户
|
|
</Button>
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UserDetailView({
|
|
userId,
|
|
canGrant,
|
|
onBack,
|
|
}: {
|
|
userId: string;
|
|
canGrant: boolean;
|
|
onBack: () => void;
|
|
}) {
|
|
const [user, setUser] = useState<UserDetail>();
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<unknown>();
|
|
const [grantOpen, setGrantOpen] = useState(false);
|
|
const filters = useLedgerFilters("ledger");
|
|
const requestVersion = useRef(0);
|
|
const fetchLedger = useCallback(
|
|
(value: LedgerQuery) => adminApi.ledger(userId, value),
|
|
[userId],
|
|
);
|
|
const ledgerPage = useCursorPage(filters.query, fetchLedger);
|
|
|
|
const load = useCallback(async () => {
|
|
const version = ++requestVersion.current;
|
|
setLoading(true);
|
|
setError(undefined);
|
|
try {
|
|
const detail = await adminApi.user(userId);
|
|
if (requestVersion.current !== version) return;
|
|
setUser(detail);
|
|
} catch (requestError) {
|
|
if (requestVersion.current === version) setError(requestError);
|
|
} finally {
|
|
if (requestVersion.current === version) setLoading(false);
|
|
}
|
|
}, [userId]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
if (error || ledgerPage.error) {
|
|
return (
|
|
<ErrorState
|
|
error={error ?? ledgerPage.error}
|
|
retry={() => {
|
|
void load();
|
|
ledgerPage.reload();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
if (loading || ledgerPage.loading || !user) return <LoadingState label="加载用户详情" />;
|
|
|
|
const usage = user.usage ?? [];
|
|
const requests = usage.reduce((total, item) => total + item.requests, 0);
|
|
const chargedCredits = usage.reduce((total, item) => total + item.chargedCredits, 0);
|
|
const inviterUserId = user.referral?.inviterUserId ?? user.referredByUserId;
|
|
const qualifiedUsage = user.qualifiedUsage || requests > 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<Button className="-ml-3 mb-4" variant="ghost" onClick={onBack}>
|
|
<ArrowLeft className="size-4" aria-hidden />
|
|
返回用户列表
|
|
</Button>
|
|
<PageHeader
|
|
eyebrow="用户详情"
|
|
title={user.displayName || "未命名用户"}
|
|
description={user.userId}
|
|
actions={
|
|
canGrant ? (
|
|
<Button onClick={() => setGrantOpen(true)}>
|
|
<Gift className="size-4" aria-hidden />
|
|
人工赠送积分
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<section className="grid gap-6 xl:grid-cols-[340px_minmax(0,1fr)]">
|
|
<Card className="h-fit overflow-hidden">
|
|
<div className="bg-gradient-to-br from-primary-soft to-violet-soft/40 p-6">
|
|
<div className="flex items-center gap-4">
|
|
<span className="grid size-14 place-items-center rounded-2xl bg-surface text-xl font-bold text-primary shadow-sm">
|
|
{(user.displayName || "用").slice(0, 1)}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<strong className="block truncate">{user.displayName || "未命名用户"}</strong>
|
|
<div className="mt-2">
|
|
<UserStatus status={user.status} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="p-6">
|
|
<p className="text-xs font-medium text-muted">当前积分</p>
|
|
<strong className="mt-2 block text-4xl font-bold tracking-[-0.06em] tabular-nums">
|
|
{formatNumber(user.creditBalance)}
|
|
</strong>
|
|
<dl className="mt-6 divide-y divide-border">
|
|
<DetailRow label="注册时间" value={formatDateTime(user.createdAt)} />
|
|
<DetailRow label="最近活跃" value={formatDateTime(user.lastActiveAt)} />
|
|
<DetailRow
|
|
label="有效使用"
|
|
value={qualifiedUsage ? `已达成 · ${formatNumber(requests)} 次` : "未达成"}
|
|
/>
|
|
<DetailRow label="累计消耗" value={`${formatNumber(chargedCredits)} 积分`} />
|
|
<DetailRow label="邀请码" value={user.referralCode || "—"} mono />
|
|
<DetailRow label="邀请来源" value={inviterUserId || "—"} mono />
|
|
{user.referral ? (
|
|
<DetailRow
|
|
label="邀请成效"
|
|
value={`${formatNumber(user.referral.rewardedInvites)} / ${formatNumber(user.referral.invitedUsers)} 已奖励`}
|
|
/>
|
|
) : null}
|
|
</dl>
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="space-y-6">
|
|
<Card className="overflow-hidden">
|
|
<SectionHeading
|
|
icon={Coins}
|
|
title="积分流水"
|
|
description="所有变动均来自不可变账本"
|
|
/>
|
|
<LedgerFilters
|
|
value={filters.state}
|
|
onChange={filters.update}
|
|
onClear={filters.clear}
|
|
/>
|
|
<LedgerTable
|
|
entries={ledgerPage.items}
|
|
caption={`${user.displayName || user.userId} 的积分流水`}
|
|
emptyTitle={
|
|
filters.state.from ||
|
|
filters.state.until ||
|
|
filters.state.type ||
|
|
filters.state.entryType ||
|
|
filters.state.usageType ||
|
|
filters.state.referenceId
|
|
? "没有符合当前筛选条件的流水"
|
|
: "暂无积分流水"
|
|
}
|
|
sort={filters.state.sort}
|
|
order={filters.state.order}
|
|
onSortChange={(sort, order) => filters.update({ sort, order })}
|
|
onReferenceFilter={(referenceId) => filters.update({ referenceId })}
|
|
footer={
|
|
ledgerPage.nextCursor ? (
|
|
<div className="flex justify-center border-t border-border p-5">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => void ledgerPage.loadMore()}
|
|
loading={ledgerPage.loadingMore}
|
|
>
|
|
加载更多流水
|
|
</Button>
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
</Card>
|
|
|
|
{usage.length > 0 ? <UsagePanel usage={usage} /> : null}
|
|
</div>
|
|
</section>
|
|
|
|
{canGrant ? (
|
|
<GrantDialog
|
|
open={grantOpen}
|
|
user={user}
|
|
onOpenChange={setGrantOpen}
|
|
onSuccess={async () => {
|
|
await load();
|
|
ledgerPage.reload();
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function GrantDialog({
|
|
open,
|
|
user,
|
|
onOpenChange,
|
|
onSuccess,
|
|
}: {
|
|
open: boolean;
|
|
user: UserDetail;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSuccess: () => Promise<void>;
|
|
}) {
|
|
const [step, setStep] = useState<"input" | "confirm">("input");
|
|
const [amount, setAmount] = useState("");
|
|
const [reason, setReason] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [outcomeUnknown, setOutcomeUnknown] = useState(false);
|
|
const [idempotencyKey, setIdempotencyKey] = useState(createIdempotencyKey);
|
|
|
|
function reset() {
|
|
setStep("input");
|
|
setAmount("");
|
|
setReason("");
|
|
setError("");
|
|
setSubmitting(false);
|
|
setOutcomeUnknown(false);
|
|
setIdempotencyKey(createIdempotencyKey());
|
|
}
|
|
|
|
function handleOpenChange(nextOpen: boolean) {
|
|
if (!nextOpen && outcomeUnknown) {
|
|
setError("赠送结果尚未确认,请在当前窗口使用同一请求安全重试");
|
|
return;
|
|
}
|
|
onOpenChange(nextOpen);
|
|
if (!nextOpen) reset();
|
|
}
|
|
|
|
function review() {
|
|
const numericAmount = Number(amount);
|
|
if (!Number.isSafeInteger(numericAmount) || numericAmount < 1 || numericAmount > 100_000) {
|
|
setError("积分必须是 1 至 100,000 的整数");
|
|
return;
|
|
}
|
|
if (reason.trim().length < 4) {
|
|
setError("请填写至少 4 个字符的赠送原因");
|
|
return;
|
|
}
|
|
setError("");
|
|
setStep("confirm");
|
|
}
|
|
|
|
async function submit() {
|
|
setSubmitting(true);
|
|
setError("");
|
|
try {
|
|
await adminApi.grantCredits({
|
|
userId: user.userId,
|
|
amount: Number(amount),
|
|
reason: reason.trim(),
|
|
idempotencyKey,
|
|
});
|
|
onOpenChange(false);
|
|
reset();
|
|
toast.success("积分赠送成功,账本已更新");
|
|
await onSuccess();
|
|
} catch (requestError) {
|
|
const unknown =
|
|
!(requestError instanceof ApiError) ||
|
|
requestError.status === 0 ||
|
|
requestError.status >= 500;
|
|
setOutcomeUnknown(unknown);
|
|
setError(
|
|
unknown
|
|
? "赠送结果尚未确认;重试会复用同一请求,不会重复到账"
|
|
: requestError instanceof ApiError
|
|
? requestError.message
|
|
: "赠送失败,请重试",
|
|
);
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={handleOpenChange}
|
|
title={step === "input" ? "人工赠送积分" : "确认赠送"}
|
|
description="赠送将写入不可变账本,并记录完整管理员审计日志。"
|
|
preventClose={outcomeUnknown}
|
|
>
|
|
{step === "input" ? (
|
|
<div className="space-y-5">
|
|
<Field label="用户 ID">
|
|
<Input value={user.userId} disabled />
|
|
</Field>
|
|
<Field label="赠送积分">
|
|
<Input
|
|
value={amount}
|
|
onChange={(event) => setAmount(event.target.value)}
|
|
type="number"
|
|
min={1}
|
|
max={100000}
|
|
step={1}
|
|
inputMode="numeric"
|
|
/>
|
|
</Field>
|
|
<Field label="赠送原因">
|
|
<Textarea
|
|
value={reason}
|
|
onChange={(event) => setReason(event.target.value)}
|
|
minLength={4}
|
|
maxLength={200}
|
|
placeholder="填写可审计的业务原因"
|
|
/>
|
|
</Field>
|
|
<p className="min-h-5 text-sm text-danger" role="alert">
|
|
{error}
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
|
取消
|
|
</Button>
|
|
<Button onClick={review}>下一步</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="rounded-2xl border border-danger/15 bg-danger-soft/60 p-5">
|
|
<p className="text-sm text-muted">即将向以下用户赠送积分</p>
|
|
<strong className="mt-2 block text-2xl font-bold tabular-nums">
|
|
{formatNumber(Number(amount))} 积分
|
|
</strong>
|
|
<p className="mt-3 text-sm">{user.displayName || user.userId}</p>
|
|
<p className="mt-2 rounded-xl bg-surface/70 p-3 text-xs leading-5 text-muted">
|
|
原因:{reason.trim()}
|
|
</p>
|
|
</div>
|
|
<p className="mt-4 min-h-5 text-sm text-danger" role="alert">
|
|
{error}
|
|
</p>
|
|
<div className="mt-5 flex justify-end gap-3">
|
|
<Button
|
|
variant="secondary"
|
|
disabled={outcomeUnknown || submitting}
|
|
onClick={() => setStep("input")}
|
|
>
|
|
返回修改
|
|
</Button>
|
|
<Button variant="danger" loading={submitting} onClick={() => void submit()}>
|
|
确认赠送
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function UsagePanel({ usage }: { usage: UserUsageAggregate[] }) {
|
|
const columns = useMemo<DataColumn<UserUsageAggregate>[]>(
|
|
() => [
|
|
{
|
|
accessorKey: "kind",
|
|
header: "类型",
|
|
cell: ({ getValue }) => <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge>,
|
|
},
|
|
{
|
|
accessorKey: "requests",
|
|
header: "请求",
|
|
cell: ({ getValue }) => formatNumber(Number(getValue())),
|
|
},
|
|
{
|
|
accessorKey: "chargedCredits",
|
|
header: "消耗积分",
|
|
cell: ({ getValue }) => formatNumber(Number(getValue())),
|
|
},
|
|
{
|
|
accessorKey: "asrMillis",
|
|
header: "语音毫秒",
|
|
cell: ({ getValue }) => formatNumber(Number(getValue())),
|
|
},
|
|
{
|
|
accessorKey: "inputTokens",
|
|
header: "输入 Token",
|
|
cell: ({ getValue }) => formatNumber(Number(getValue())),
|
|
},
|
|
{
|
|
accessorKey: "outputTokens",
|
|
header: "输出 Token",
|
|
cell: ({ getValue }) => formatNumber(Number(getValue())),
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
return (
|
|
<Card className="overflow-hidden">
|
|
<SectionHeading
|
|
icon={Sparkles}
|
|
title="使用统计"
|
|
description="按使用类型汇总,不包含任何用户内容"
|
|
/>
|
|
<DataTable data={usage} columns={columns} caption="用户使用统计" />
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function SectionHeading({
|
|
icon: Icon,
|
|
title,
|
|
description,
|
|
}: {
|
|
icon: typeof Coins;
|
|
title: string;
|
|
description: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
|
|
<div>
|
|
<h2 className="text-base font-bold">{title}</h2>
|
|
<p className="mt-1 text-xs text-muted">{description}</p>
|
|
</div>
|
|
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
|
|
<Icon className="size-4" aria-hidden />
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DetailRow({
|
|
label,
|
|
value,
|
|
mono,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
mono?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="flex items-start justify-between gap-4 py-3 text-xs">
|
|
<dt className="shrink-0 text-muted">{label}</dt>
|
|
<dd
|
|
className={`m-0 max-w-[190px] break-all text-right font-medium ${mono ? "font-mono text-[11px]" : ""}`}
|
|
>
|
|
{value}
|
|
</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UserStatus({ status }: { status: string }) {
|
|
const tone = status === "active" ? "success" : status === "suspended" ? "danger" : "neutral";
|
|
return (
|
|
<Badge tone={tone}>
|
|
{status === "active" ? <CheckCircle2 className="size-3" aria-hidden /> : null}
|
|
{statusLabel(status)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<label className="block">
|
|
<span className="mb-2 block text-sm font-semibold">{label}</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|