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
+40 -154
View File
@@ -1,13 +1,9 @@
import { ArrowDownUp, Coins, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { useCallback, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { adminApi } from "../../api/client";
import type { LedgerEntry, LedgerEntryType, LedgerQuery, SortOrder } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import type { LedgerQuery } from "../../api/types";
import {
Badge,
Button,
Card,
ErrorState,
@@ -15,45 +11,21 @@ import {
LoadingState,
PageHeader,
} from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page";
import {
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../../lib/format";
import { formatNumber } from "../../lib/format";
import { LedgerFilters } from "./ledger-filters";
import { LedgerTable } from "./ledger-table";
import { useLedgerFilters } from "./use-ledger-filters";
export function CreditsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialUserId = searchParams.get("userId") ?? "";
const [query, setQuery] = useState(initialUserId);
const [activeUserId, setActiveUserId] = useState(initialUserId);
const [from, setFrom] = useState(searchParams.get("from") ?? "");
const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [type, setType] = useState<"" | LedgerEntryType>(
(searchParams.get("type") as LedgerEntryType | null) ?? "",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("order") === "asc" ? "asc" : "desc",
);
const ledgerQuery = useMemo<LedgerQuery>(
() => ({
from: from || undefined,
until: until || undefined,
type: type || undefined,
sort: "createdAt",
order,
limit: 50,
}),
[from, order, type, until],
);
const filters = useLedgerFilters();
const fetchLedger = useCallback(
(value: LedgerQuery) =>
activeUserId
? adminApi.ledger(activeUserId, value)
: adminApi.latestLedger(value),
activeUserId ? adminApi.ledger(activeUserId, value) : adminApi.latestLedger(value),
[activeUserId],
);
const {
@@ -64,88 +36,27 @@ export function CreditsPage() {
error,
loadMore,
reload,
} = useCursorPage(ledgerQuery, fetchLedger);
useEffect(() => {
const params = new URLSearchParams();
if (activeUserId) params.set("userId", activeUserId);
if (from) params.set("from", from);
if (until) params.set("until", until);
if (type) params.set("type", type);
params.set("sort", "createdAt");
params.set("order", order);
setSearchParams(params, { replace: true });
}, [activeUserId, from, order, setSearchParams, type, until]);
} = useCursorPage(filters.query, fetchLedger);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const userId = query.trim() || undefined;
setActiveUserId(userId ?? "");
const userId = query.trim();
setActiveUserId(userId);
setSearchParams((current) => {
const params = new URLSearchParams(current);
if (userId) params.set("userId", userId);
else params.delete("userId");
return params;
}, { replace: true });
}
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>
),
},
],
[],
const hasBusinessFilter = Boolean(
filters.state.from ||
filters.state.until ||
filters.state.type ||
filters.state.entryType ||
filters.state.usageType ||
filters.state.referenceId,
);
return (
@@ -153,7 +64,7 @@ export function CreditsPage() {
<PageHeader
eyebrow="积分账本"
title="积分流水"
description="查看不可变积分账本,可按完整内部用户 ID 精确查询。"
description="从账务动作、业务来源和消费能力三个维度查看不可变积分账本。"
/>
<Card className="overflow-hidden">
@@ -181,38 +92,12 @@ export function CreditsPage() {
</Button>
</form>
</div>
<TableToolbar
active={Boolean(activeUserId || from || until || type || order !== "desc")}
onClear={() => {
setQuery("");
setActiveUserId("");
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={[
{ value: "", label: "全部类型" },
{ value: "grant", label: "赠送" },
{ value: "reserve", label: "预留" },
{ value: "settle", label: "结算" },
{ value: "refund", label: "退款" },
{ value: "adjustment", label: "调整" },
]}
onChange={setType}
/>
</TableToolbar>
<LedgerFilters
value={filters.state}
onChange={filters.update}
onClear={filters.clear}
/>
<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 />
@@ -233,18 +118,19 @@ export function CreditsPage() {
) : loading ? (
<LoadingState label="加载积分流水" />
) : (
<DataTable
data={items}
columns={columns}
<LedgerTable
entries={items}
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
emptyTitle={
activeUserId || from || until || type
? "没有符合当前筛选条件的流水"
activeUserId || hasBusinessFilter
? "没有符合当前用户和筛选条件的流水"
: "暂无积分流水"
}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
showUser={!activeUserId}
sort={filters.state.sort}
order={filters.state.order}
onSortChange={(sort, order) => filters.update({ sort, order })}
onReferenceFilter={(referenceId) => filters.update({ referenceId })}
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">