b5212dcdc2
Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
265 lines
8.2 KiB
TypeScript
265 lines
8.2 KiB
TypeScript
import { Clipboard, Filter, UserRound } from "lucide-react";
|
|
import { type ReactNode, useMemo } from "react";
|
|
import { toast } from "sonner";
|
|
import type { LedgerEntry, LedgerQuery, SortOrder } from "../../api/types";
|
|
import { DataTable, type DataColumn } from "../../components/data-table";
|
|
import { Badge, Button } from "../../components/primitives";
|
|
import { formatDateTime, formatNumber, formatSignedCredits } from "../../lib/format";
|
|
import {
|
|
ledgerActionLabel,
|
|
ledgerActionTone,
|
|
ledgerEntryTypeLabel,
|
|
ledgerUsageClass,
|
|
ledgerUsageLabel,
|
|
} from "./ledger-format";
|
|
|
|
export function LedgerTable({
|
|
entries,
|
|
caption,
|
|
emptyTitle,
|
|
showUser = false,
|
|
sort,
|
|
order,
|
|
onSortChange,
|
|
onReferenceFilter,
|
|
footer,
|
|
}: {
|
|
entries: LedgerEntry[];
|
|
caption: string;
|
|
emptyTitle: string;
|
|
showUser?: boolean;
|
|
sort: NonNullable<LedgerQuery["sort"]>;
|
|
order: SortOrder;
|
|
onSortChange: (sort: NonNullable<LedgerQuery["sort"]>, order: SortOrder) => void;
|
|
onReferenceFilter: (referenceId: string) => void;
|
|
footer?: ReactNode;
|
|
}) {
|
|
const columns = useMemo<DataColumn<LedgerEntry>[]>(() => {
|
|
const result: DataColumn<LedgerEntry>[] = [
|
|
{
|
|
accessorKey: "createdAt",
|
|
header: "时间",
|
|
cell: ({ getValue }) => (
|
|
<span className="whitespace-nowrap text-xs text-muted">
|
|
{formatDateTime(String(getValue()))}
|
|
</span>
|
|
),
|
|
},
|
|
];
|
|
if (showUser) {
|
|
result.push({
|
|
accessorKey: "userId",
|
|
header: "用户",
|
|
cell: ({ getValue }) => {
|
|
const userId = String(getValue());
|
|
return (
|
|
<a
|
|
className="group/user inline-flex max-w-52 items-center gap-2 text-left text-primary"
|
|
href={`#/users?userId=${encodeURIComponent(userId)}`}
|
|
title={userId}
|
|
>
|
|
<UserRound className="size-3.5 shrink-0" aria-hidden />
|
|
<span className="truncate font-mono text-xs underline-offset-4 group-hover/user:underline">
|
|
{userId}
|
|
</span>
|
|
</a>
|
|
);
|
|
},
|
|
});
|
|
}
|
|
result.push(
|
|
{
|
|
accessorKey: "type",
|
|
header: "账务动作",
|
|
cell: ({ getValue }) => (
|
|
<Badge tone={ledgerActionTone(String(getValue()))}>
|
|
{ledgerActionLabel(String(getValue()))}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "entryType",
|
|
header: "业务来源",
|
|
cell: ({ getValue }) => (
|
|
<span className="whitespace-nowrap text-xs font-semibold">
|
|
{ledgerEntryTypeLabel(String(getValue() ?? ""))}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "usageType",
|
|
header: "消费能力",
|
|
cell: ({ getValue }) => {
|
|
const usageType = getValue() ? String(getValue()) : undefined;
|
|
return usageType ? (
|
|
<Badge className={ledgerUsageClass(usageType)}>
|
|
{ledgerUsageLabel(usageType)}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-muted">—</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
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>
|
|
),
|
|
},
|
|
);
|
|
return result;
|
|
}, [showUser]);
|
|
|
|
return (
|
|
<DataTable
|
|
data={entries}
|
|
columns={columns}
|
|
caption={caption}
|
|
emptyTitle={emptyTitle}
|
|
sort={{ key: sort, order }}
|
|
sortableColumns={{ createdAt: "createdAt", amount: "amount" }}
|
|
onSortChange={(nextSort, nextOrder) =>
|
|
onSortChange(nextSort as NonNullable<LedgerQuery["sort"]>, nextOrder)
|
|
}
|
|
renderExpandedRow={(entry) => (
|
|
<LedgerDetails entry={entry} onReferenceFilter={onReferenceFilter} />
|
|
)}
|
|
getRowId={(entry) => entry.entryId}
|
|
footer={footer}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function LedgerDetails({
|
|
entry,
|
|
onReferenceFilter,
|
|
}: {
|
|
entry: LedgerEntry;
|
|
onReferenceFilter: (referenceId: string) => void;
|
|
}) {
|
|
const details = entry.details;
|
|
return (
|
|
<section className="space-y-4" aria-label="流水安全详情">
|
|
<dl className="grid gap-x-8 gap-y-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
<DetailItem label="流水 ID">
|
|
<Identifier value={entry.entryId} />
|
|
</DetailItem>
|
|
<DetailItem label="关联 ID">
|
|
{entry.referenceId ? (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Identifier value={entry.referenceId} />
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onReferenceFilter(entry.referenceId!)}
|
|
>
|
|
<Filter className="size-3.5" aria-hidden />
|
|
筛选同笔生命周期
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</DetailItem>
|
|
<DetailItem label="原因代码">
|
|
<span className="font-mono text-[11px] text-muted">{entry.reasonCode || "—"}</span>
|
|
</DetailItem>
|
|
{details?.kind === "manualGrant" ? (
|
|
<>
|
|
<DetailItem label="人工原因">{details.reason || "—"}</DetailItem>
|
|
<DetailItem label="操作员">{details.operatorName || "—"}</DetailItem>
|
|
</>
|
|
) : null}
|
|
{details?.kind === "storeKit" ? (
|
|
<>
|
|
<DetailItem label="商品">{details.productId || "—"}</DetailItem>
|
|
<DetailItem label="交易 ID">
|
|
<Identifier value={details.transactionId} />
|
|
</DetailItem>
|
|
<DetailItem label="原始交易 ID">
|
|
<Identifier value={details.originalTransactionId} />
|
|
</DetailItem>
|
|
<DetailItem label="环境">{details.environment || "—"}</DetailItem>
|
|
<DetailItem label="购买时间">{formatDateTime(details.purchasedAt)}</DetailItem>
|
|
</>
|
|
) : null}
|
|
{details?.kind === "referral" ? (
|
|
<>
|
|
<DetailItem label="裂变角色">{referralRoleLabel(details.role)}</DetailItem>
|
|
<DetailItem label="关联用户">
|
|
<Identifier value={details.relatedUserId} />
|
|
</DetailItem>
|
|
</>
|
|
) : null}
|
|
{details?.kind === "usage" ? (
|
|
<DetailItem label="预留 ID">
|
|
<Identifier value={details.reservationId} />
|
|
</DetailItem>
|
|
) : null}
|
|
</dl>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function DetailItem({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<div className="min-w-0">
|
|
<dt className="text-[11px] font-bold uppercase tracking-wide text-muted">{label}</dt>
|
|
<dd className="mt-1.5 break-words text-xs font-medium text-foreground">{children}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Identifier({ value }: { value?: string }) {
|
|
if (!value) return <span className="text-muted">—</span>;
|
|
return (
|
|
<span className="inline-flex max-w-full items-center gap-1.5">
|
|
<code className="break-all text-[11px]">{value}</code>
|
|
<button
|
|
className="grid size-7 shrink-0 place-items-center rounded-lg text-muted transition hover:bg-surface hover:text-primary focus-visible:ring-4 focus-visible:ring-primary/15"
|
|
type="button"
|
|
title="复制 ID"
|
|
aria-label={`复制 ID ${value}`}
|
|
onClick={() => void copyIdentifier(value)}
|
|
>
|
|
<Clipboard className="size-3.5" aria-hidden />
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
async function copyIdentifier(value: string) {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
toast.success("ID 已复制");
|
|
} catch {
|
|
toast.error("无法复制,请手动选择 ID");
|
|
}
|
|
}
|
|
|
|
function referralRoleLabel(role?: string): string {
|
|
if (!role) return "—";
|
|
const labels: Record<string, string> = {
|
|
inviter: "邀请人",
|
|
invitee: "受邀人",
|
|
INVITER: "邀请人",
|
|
INVITEE: "受邀人",
|
|
};
|
|
return labels[role] ?? role;
|
|
}
|