Files
OSGAccountServer/admin-web/src/features/credits/credits-page.tsx
T
Rocky b5212dcdc2
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
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.
2026-08-20 22:14:22 +08:00

149 lines
5.0 KiB
TypeScript

import { ArrowDownUp, Coins, Search } from "lucide-react";
import { useCallback, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { adminApi } from "../../api/client";
import type { LedgerQuery } from "../../api/types";
import {
Button,
Card,
ErrorState,
Input,
LoadingState,
PageHeader,
} from "../../components/primitives";
import { useCursorPage } from "../../hooks/use-cursor-page";
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 filters = useLedgerFilters();
const fetchLedger = useCallback(
(value: LedgerQuery) =>
activeUserId ? adminApi.ledger(activeUserId, value) : adminApi.latestLedger(value),
[activeUserId],
);
const {
items,
nextCursor,
loading,
loadingMore,
error,
loadMore,
reload,
} = useCursorPage(filters.query, fetchLedger);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
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 hasBusinessFilter = Boolean(
filters.state.from ||
filters.state.until ||
filters.state.type ||
filters.state.entryType ||
filters.state.usageType ||
filters.state.referenceId,
);
return (
<div className="space-y-6">
<PageHeader
eyebrow="积分账本"
title="积分流水"
description="从账务动作、业务来源和消费能力三个维度查看不可变积分账本。"
/>
<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>
<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 />
<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={reload} />
</div>
) : loading ? (
<LoadingState label="加载积分流水" />
) : (
<LedgerTable
entries={items}
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
emptyTitle={
activeUserId || hasBusinessFilter
? "没有符合当前用户和筛选条件的流水"
: "暂无积分流水"
}
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">
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
加载更多流水
</Button>
</div>
) : null
}
/>
)}
</Card>
</div>
);
}