Enhance ledger operations and referral lifecycle
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
This commit is contained in:
Rocky
2026-08-20 22:14:22 +08:00
parent 74c3fcd45f
commit b5212dcdc2
38 changed files with 3080 additions and 620 deletions
+62 -172
View File
@@ -19,9 +19,7 @@ import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
LedgerEntryType,
LedgerQuery,
LedgerEntry,
SortOrder,
UserDetail,
UsersQuery,
@@ -48,34 +46,37 @@ import {
createIdempotencyKey,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../../lib/format";
import { useAuth } from "../auth/auth-context";
const ledgerTypeOptions: Array<{ value: "" | LedgerEntryType; label: string }> = [
{ value: "", label: "全部类型" },
{ value: "grant", label: "赠送" },
{ value: "reserve", label: "预留" },
{ value: "settle", label: "结算" },
{ value: "refund", label: "退款" },
{ value: "adjustment", label: "调整" },
];
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, setSelectedUserId] = useState<string>();
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={() => setSelectedUserId(undefined)}
onBack={() => selectUser()}
/>
) : (
<UserList onSelect={setSelectedUserId} />
<UserList onSelect={selectUser} />
);
}
@@ -304,98 +305,49 @@ function UserDetailView({
canGrant: boolean;
onBack: () => void;
}) {
const [searchParams, setSearchParams] = useSearchParams();
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 [from, setFrom] = useState(searchParams.get("ledgerFrom") ?? "");
const [until, setUntil] = useState(searchParams.get("ledgerUntil") ?? "");
const [type, setType] = useState<"" | LedgerEntryType>(
(searchParams.get("ledgerType") as LedgerEntryType | null) ?? "",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("ledgerOrder") === "asc" ? "asc" : "desc",
);
const filters = useLedgerFilters("ledger");
const requestVersion = useRef(0);
const ledgerQuery = useMemo<LedgerQuery>(
() => ({
from: from || undefined,
until: until || undefined,
type: type || undefined,
sort: "createdAt",
order,
limit: 50,
}),
[from, order, type, until],
const fetchLedger = useCallback(
(value: LedgerQuery) => adminApi.ledger(userId, value),
[userId],
);
const ledgerPage = useCursorPage(filters.query, fetchLedger);
const load = useCallback(async () => {
const version = ++requestVersion.current;
setLedger([]);
setNextCursor(undefined);
setLoading(true);
setLoadingMore(false);
setError(undefined);
try {
const [detail, page] = await Promise.all([
adminApi.user(userId),
adminApi.ledger(userId, ledgerQuery),
]);
const detail = await adminApi.user(userId);
if (requestVersion.current !== version) return;
setUser(detail);
setLedger(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
if (requestVersion.current === version) setError(requestError);
} finally {
if (requestVersion.current === version) setLoading(false);
}
}, [ledgerQuery, userId]);
}, [userId]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
setSearchParams((current) => {
const params = new URLSearchParams(current);
["ledgerFrom", "ledgerUntil", "ledgerType", "ledgerOrder"].forEach((key) =>
params.delete(key),
);
if (from) params.set("ledgerFrom", from);
if (until) params.set("ledgerUntil", until);
if (type) params.set("ledgerType", type);
params.set("ledgerOrder", order);
return params;
}, { replace: true });
}, [from, order, setSearchParams, type, until]);
async function loadMoreLedger() {
if (!nextCursor) return;
const version = requestVersion.current;
setLoadingMore(true);
try {
const page = await adminApi.ledger(userId, { ...ledgerQuery, cursor: nextCursor });
if (requestVersion.current !== version) return;
setLedger((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载积分流水失败",
);
} finally {
if (requestVersion.current === version) setLoadingMore(false);
}
if (error || ledgerPage.error) {
return (
<ErrorState
error={error ?? ledgerPage.error}
retry={() => {
void load();
ledgerPage.reload();
}}
/>
);
}
const ledgerColumns = useLedgerColumns();
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (loading || !user) return <LoadingState label="加载用户详情" />;
if (loading || ledgerPage.loading || !user) return <LoadingState label="加载用户详情" />;
const usage = user.usage ?? [];
const requests = usage.reduce((total, item) => total + item.requests, 0);
@@ -472,44 +424,35 @@ function UserDetailView({
title="积分流水"
description="所有变动均来自不可变账本"
/>
<TableToolbar
active={Boolean(from || until || type || order !== "desc")}
onClear={() => {
setFrom("");
setUntil("");
setType("");
setOrder("desc");
}}
>
<DateRangeControl
value={{ from: from || undefined, until: until || undefined }}
onChange={(value) => {
setFrom(value.from ?? "");
setUntil(value.until ?? "");
}}
/>
<FilterControl
label="流水类型"
value={type}
options={ledgerTypeOptions}
onChange={setType}
/>
</TableToolbar>
<DataTable
data={ledger}
columns={ledgerColumns}
<LedgerFilters
value={filters.state}
onChange={filters.update}
onClear={filters.clear}
/>
<LedgerTable
entries={ledgerPage.items}
caption={`${user.displayName || user.userId} 的积分流水`}
emptyTitle={from || until || type ? "没有符合当前筛选条件的流水" : "暂无积分流水"}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
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={
nextCursor ? (
ledgerPage.nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button
variant="secondary"
onClick={() => void loadMoreLedger()}
loading={loadingMore}
onClick={() => void ledgerPage.loadMore()}
loading={ledgerPage.loadingMore}
>
</Button>
@@ -528,7 +471,10 @@ function UserDetailView({
open={grantOpen}
user={user}
onOpenChange={setGrantOpen}
onSuccess={load}
onSuccess={async () => {
await load();
ledgerPage.reload();
}}
/>
) : null}
</div>
@@ -694,62 +640,6 @@ export function GrantDialog({
);
}
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>[]>(
() => [