Enhance ledger operations and referral lifecycle
Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user