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:
@@ -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>[]>(
|
||||
() => [
|
||||
|
||||
Reference in New Issue
Block a user