Add StoreKit history and modernize admin console
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

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:
Rocky
2026-08-19 22:13:13 +08:00
parent 11ec34dacb
commit 231c5040a5
51 changed files with 6484 additions and 4236 deletions
+733
View File
@@ -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>
);
}