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:
@@ -0,0 +1,264 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user