Add StoreKit history and modernize admin console
Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
This commit is contained in:
@@ -0,0 +1,733 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
Gift,
|
||||
Search,
|
||||
Sparkles,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
LedgerEntry,
|
||||
UserDetail,
|
||||
UserSummary,
|
||||
UserUsageAggregate,
|
||||
} from "../../api/types";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
ErrorState,
|
||||
Input,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
Textarea,
|
||||
} from "../../components/primitives";
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
formatDateTime,
|
||||
formatNumber,
|
||||
formatSignedCredits,
|
||||
statusLabel,
|
||||
usageTypeLabel,
|
||||
} from "../../lib/format";
|
||||
import { useAuth } from "../auth/auth-context";
|
||||
|
||||
export function UsersPage() {
|
||||
const { auth } = useAuth();
|
||||
const role = auth.status === "authenticated" ? auth.role : "ANALYST";
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>();
|
||||
|
||||
return selectedUserId ? (
|
||||
<UserDetailView
|
||||
userId={selectedUserId}
|
||||
canGrant={role === "SUPER_ADMIN"}
|
||||
onBack={() => setSelectedUserId(undefined)}
|
||||
/>
|
||||
) : (
|
||||
<UserList onSelect={setSelectedUserId} />
|
||||
);
|
||||
}
|
||||
|
||||
function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeQuery, setActiveQuery] = useState("");
|
||||
const [items, setItems] = useState<UserSummary[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const load = useCallback(async (search = "") => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const page = await adminApi.users(search);
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const search = query.trim();
|
||||
setActiveQuery(search);
|
||||
void load(search);
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await adminApi.users(activeQuery, nextCursor);
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
toast.error(requestError instanceof ApiError ? requestError.message : "加载用户失败");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<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={() => void load(activeQuery)} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载用户列表" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
caption={activeQuery ? "用户查询结果" : "全部用户"}
|
||||
emptyTitle="未找到匹配用户"
|
||||
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 [ledger, setLedger] = useState<LedgerEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [grantOpen, setGrantOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const [detail, page] = await Promise.all([
|
||||
adminApi.user(userId),
|
||||
adminApi.ledger(userId),
|
||||
]);
|
||||
setUser(detail);
|
||||
setLedger(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function loadMoreLedger() {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await adminApi.ledger(userId, nextCursor);
|
||||
setLedger((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
toast.error(
|
||||
requestError instanceof ApiError ? requestError.message : "加载积分流水失败",
|
||||
);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
const ledgerColumns = useLedgerColumns();
|
||||
|
||||
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||
if (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="所有变动均来自不可变账本"
|
||||
/>
|
||||
<DataTable
|
||||
data={ledger}
|
||||
columns={ledgerColumns}
|
||||
caption={`${user.displayName || user.userId} 的积分流水`}
|
||||
emptyTitle="暂无积分流水"
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void loadMoreLedger()}
|
||||
loading={loadingMore}
|
||||
>
|
||||
加载更多流水
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{usage.length > 0 ? <UsagePanel usage={usage} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canGrant ? (
|
||||
<GrantDialog
|
||||
open={grantOpen}
|
||||
user={user}
|
||||
onOpenChange={setGrantOpen}
|
||||
onSuccess={load}
|
||||
/>
|
||||
) : 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 useLedgerColumns(): DataColumn<LedgerEntry>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "时间",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted">
|
||||
{formatDateTime(String(getValue()))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "类型",
|
||||
cell: ({ getValue }) => <Badge>{statusLabel(String(getValue()))}</Badge>,
|
||||
},
|
||||
{
|
||||
accessorKey: "usageType",
|
||||
header: "消费类型",
|
||||
cell: ({ getValue }) =>
|
||||
getValue() ? <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge> : "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: "变动",
|
||||
cell: ({ getValue }) => {
|
||||
const value = Number(getValue());
|
||||
return (
|
||||
<span
|
||||
className={`font-bold tabular-nums ${value >= 0 ? "text-success" : "text-danger"}`}
|
||||
>
|
||||
{formatSignedCredits(value)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "balanceAfter",
|
||||
header: "结余",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "reasonCode",
|
||||
header: "原因",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono text-[11px] text-muted">{String(getValue())}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user