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
@@ -0,0 +1,213 @@
import { ArrowDownUp, Coins, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { adminApi, ApiError } from "../../api/client";
import type { LedgerEntry } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Badge,
Button,
Card,
ErrorState,
Input,
LoadingState,
PageHeader,
} from "../../components/primitives";
import {
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../../lib/format";
import { toast } from "sonner";
export function CreditsPage() {
const [query, setQuery] = useState("");
const [activeUserId, setActiveUserId] = useState<string>();
const [items, setItems] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async (userId?: string) => {
setLoading(true);
setError(undefined);
try {
const page = userId
? await adminApi.ledger(userId)
: await adminApi.latestLedger();
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = activeUserId
? await adminApi.ledger(activeUserId, nextCursor)
: await adminApi.latestLedger(nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(requestError instanceof ApiError ? requestError.message : "加载流水失败");
} finally {
setLoadingMore(false);
}
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const userId = query.trim() || undefined;
setActiveUserId(userId);
void load(userId);
}
const columns = useMemo<DataColumn<LedgerEntry>[]>(
() => [
{
accessorKey: "createdAt",
header: "时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
accessorKey: "userId",
header: "用户 ID",
cell: ({ getValue }) => (
<span className="block max-w-48 truncate font-mono text-xs" title={String(getValue())}>
{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="rounded-lg bg-surface-muted px-2 py-1 font-mono text-[11px] text-muted">
{String(getValue())}
</span>
),
},
],
[],
);
return (
<div className="space-y-6">
<PageHeader
eyebrow="积分账本"
title="积分流水"
description="查看不可变积分账本,可按完整内部用户 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="输入完整用户 ID,留空查看全部"
maxLength={36}
autoComplete="off"
/>
</label>
<Button type="submit">
<ArrowDownUp className="size-4" aria-hidden />
</Button>
</form>
</div>
<div className="flex items-center gap-3 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
<Coins className="size-4 text-primary" aria-hidden />
<span>{activeUserId ? "指定用户流水" : "最新积分流水"}</span>
<span aria-hidden>·</span>
<strong className="font-semibold text-foreground">{formatNumber(items.length)} </strong>
{activeUserId ? (
<span className="ml-auto max-w-64 truncate font-mono" title={activeUserId}>
{activeUserId}
</span>
) : null}
</div>
{error ? (
<div className="p-6">
<ErrorState error={error} retry={() => void load(activeUserId)} />
</div>
) : loading ? (
<LoadingState label="加载积分流水" />
) : (
<DataTable
data={items}
columns={columns}
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
emptyTitle={activeUserId ? "该用户暂无积分流水" : "暂无积分流水"}
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>
);
}