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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user