Enhance ledger operations and referral lifecycle
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
This commit is contained in:
Rocky
2026-08-20 22:14:22 +08:00
parent 74c3fcd45f
commit b5212dcdc2
38 changed files with 3080 additions and 620 deletions
+7 -2
View File
@@ -160,6 +160,11 @@ function cursorQuery<T extends CursorQuery>(value?: string | T): CursorQuery | T
type CursorQuery = { cursor?: string };
function ledgerQuery(value?: string | LedgerQuery): CursorQuery | LedgerQuery {
if (typeof value === "string") return { cursor: value };
return value ? { ...value, referenceId: value.referenceId?.trim() } : {};
}
export const adminApi = {
session: () => request<SessionResponse>("/auth/session"),
@@ -195,12 +200,12 @@ export const adminApi = {
latestLedger: (value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>(
`/credits/ledger${encodeQuery(cursorQuery(value))}`,
`/credits/ledger${encodeQuery(ledgerQuery(value))}`,
),
ledger: (userId: string, value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${encodeQuery(cursorQuery(value))}`,
`/users/${encodeURIComponent(userId)}/ledger${encodeQuery(ledgerQuery(value))}`,
),
grantCredits: (payload: CreditGrantRequest) =>
+48 -6
View File
@@ -27,8 +27,12 @@ export interface UsersQuery extends CursorPageQuery {
status?: UserSummary["status"];
}
export interface LedgerQuery extends CursorPageQuery {
type?: LedgerEntryType;
export interface LedgerQuery extends Omit<CursorPageQuery, "sort"> {
type?: LedgerAction;
entryType?: LedgerEntryType;
usageType?: UsageType;
referenceId?: string;
sort?: "createdAt" | "amount";
}
export interface AuditQuery extends CursorPageQuery {
@@ -219,23 +223,61 @@ export interface PageResult<T> {
nextCursor?: string;
}
export type LedgerEntryType =
export type LedgerAction =
| "grant"
| "reserve"
| "settle"
| "refund"
| "adjustment";
| "refund";
export type LedgerEntryType =
| "SIGNUP_TRIAL"
| "MANUAL_GRANT"
| "USAGE_RESERVE"
| "USAGE_SETTLE"
| "USAGE_RELEASE"
| "USAGE_REFUND"
| "REFERRAL_INVITER"
| "REFERRAL_INVITEE"
| "STOREKIT_PURCHASE"
| "SUBSCRIPTION_GRANT";
export type UsageType = "polish" | "asr" | "ai" | "agent" | "hotword";
export type LedgerEntryDetails =
| {
kind: "manualGrant";
reason?: string;
operatorName?: string;
}
| {
kind: "storeKit";
productId?: string;
transactionId?: string;
originalTransactionId?: string;
environment?: string;
purchasedAt?: string;
}
| {
kind: "referral";
role?: string;
relatedUserId?: string;
}
| {
kind: "usage";
reservationId?: string;
};
export interface LedgerEntry {
entryId: string;
userId: string;
type: LedgerEntryType;
type: LedgerAction;
entryType: LedgerEntryType;
amount: number;
balanceAfter: number;
reasonCode: string;
usageType?: UsageType;
referenceId?: string;
details?: LedgerEntryDetails;
createdAt: string;
}
+77 -17
View File
@@ -5,8 +5,14 @@ import {
type LegacyColumnDef,
useLegacyTable,
} from "@tanstack/react-table/legacy";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { type ReactNode } from "react";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { Fragment, type ReactNode, useState } from "react";
import type { SortOrder } from "../api/types";
import { cn } from "../lib/utils";
import { EmptyState } from "./primitives";
@@ -23,6 +29,9 @@ export function DataTable<T extends RowData>({
sort,
sortableColumns,
onSortChange,
renderExpandedRow,
getRowId,
expandLabel = "详情",
}: {
data: T[];
columns: DataColumn<T>[];
@@ -33,7 +42,11 @@ export function DataTable<T extends RowData>({
sort?: { key: string; order: SortOrder };
sortableColumns?: Record<string, string>;
onSortChange?: (key: string, order: SortOrder) => void;
renderExpandedRow?: (row: T) => ReactNode;
getRowId?: (row: T) => string;
expandLabel?: string;
}) {
const [expandedRows, setExpandedRows] = useState<Set<string>>(() => new Set());
const table = useLegacyTable({
data,
columns,
@@ -94,25 +107,72 @@ export function DataTable<T extends RowData>({
</th>
);
})}
{renderExpandedRow ? (
<th
className="sticky top-0 z-[1] border-b border-border bg-surface/95 px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted backdrop-blur"
scope="col"
>
{expandLabel}
</th>
) : null}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="group transition-colors hover:bg-surface-muted/70"
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className="border-b border-border/70 px-5 py-4 align-middle text-foreground last:text-right group-last:border-b-0"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
{table.getRowModel().rows.map((row) => {
const expansionKey = getRowId?.(row.original) ?? row.id;
const expanded = expandedRows.has(expansionKey);
const panelId = `expanded-row-${expansionKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
return (
<Fragment key={row.id}>
<tr className="group transition-colors hover:bg-surface-muted/70">
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className="border-b border-border/70 px-5 py-4 align-middle text-foreground last:text-right group-last:border-b-0"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{renderExpandedRow ? (
<td className="border-b border-border/70 px-5 py-4 text-right align-middle">
<button
className="inline-flex min-h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-semibold text-primary transition hover:bg-primary-soft focus-visible:ring-4 focus-visible:ring-primary/15"
type="button"
aria-expanded={expanded}
aria-controls={panelId}
onClick={() =>
setExpandedRows((current) => {
const next = new Set(current);
if (next.has(expansionKey)) next.delete(expansionKey);
else next.add(expansionKey);
return next;
})
}
>
{expanded ? (
<ChevronDown className="size-3.5" aria-hidden />
) : (
<ChevronRight className="size-3.5" aria-hidden />
)}
{expanded ? "收起" : "展开"}
</button>
</td>
) : null}
</tr>
{renderExpandedRow && expanded ? (
<tr id={panelId}>
<td
className="border-b border-border bg-surface-muted/45 px-5 py-4"
colSpan={row.getVisibleCells().length + 1}
>
{renderExpandedRow(row.original)}
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
+3 -1
View File
@@ -5,10 +5,12 @@ export function TableToolbar({
children,
active,
onClear,
clearLabel = "清除筛选",
}: {
children: ReactNode;
active: boolean;
onClear: () => void;
clearLabel?: string;
}) {
return (
<div
@@ -24,7 +26,7 @@ export function TableToolbar({
disabled={!active}
onClick={onClear}
>
{clearLabel}
</Button>
</div>
);
+40 -154
View File
@@ -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">
@@ -0,0 +1,170 @@
import { CalendarRange, Link2 } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import { Button, Input } from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import {
ledgerActionLabel,
ledgerActionOptions,
ledgerEntryTypeLabel,
ledgerEntryTypeOptions,
ledgerUsageLabel,
ledgerUsageOptions,
} from "./ledger-format";
import {
ledgerDatePreset,
type LedgerDatePreset,
type LedgerFilterState,
} from "./use-ledger-filters";
export const ledgerDatePresetOptions: ReadonlyArray<{
value: LedgerDatePreset;
label: string;
}> = [
{ value: "all", label: "全部时间" },
{ value: "today", label: "今天" },
{ value: "7d", label: "最近 7 天" },
{ value: "30d", label: "最近 30 天" },
{ value: "90d", label: "最近 90 天" },
{ value: "custom", label: "自定义" },
];
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function LedgerFilters({
value,
onChange,
onClear,
}: {
value: LedgerFilterState;
onChange: (patch: Partial<LedgerFilterState>) => void;
onClear: () => void;
}) {
const [referenceDraft, setReferenceDraft] = useState(value.referenceId);
const normalizedReference = referenceDraft.trim();
const referenceValid =
normalizedReference.length === 0 || UUID_PATTERN.test(normalizedReference);
useEffect(() => {
setReferenceDraft(value.referenceId);
}, [value.referenceId]);
function applyReference(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (referenceValid) onChange({ referenceId: normalizedReference });
}
const active =
value.range !== "all" ||
Boolean(value.type || value.entryType || value.usageType || value.referenceId) ||
value.sort !== "createdAt" ||
value.order !== "desc";
const summary = [
value.type ? `账务动作:${ledgerActionLabel(value.type)}` : "",
value.entryType ? `业务来源:${ledgerEntryTypeLabel(value.entryType)}` : "",
value.usageType ? `消费能力:${ledgerUsageLabel(value.usageType)}` : "",
value.referenceId ? `关联 ID${value.referenceId}` : "",
value.range !== "all"
? `时间:${ledgerDatePresetOptions.find((option) => option.value === value.range)?.label}`
: "",
].filter(Boolean);
return (
<>
<TableToolbar
active={active}
onClear={onClear}
clearLabel="清除全部"
>
<FilterControl
label="时间范围"
value={value.range}
options={ledgerDatePresetOptions}
onChange={(range) => {
if (range === "custom") onChange({ range });
else onChange(ledgerDatePreset(range));
}}
/>
{value.range === "custom" ? (
<DateRangeControl
value={{ from: value.from || undefined, until: value.until || undefined }}
onChange={(range) =>
onChange({
from: range.from ?? "",
until: range.until ?? "",
range: "custom",
})
}
/>
) : null}
<FilterControl
label="账务动作"
value={value.type}
options={ledgerActionOptions}
onChange={(type) => onChange({ type })}
/>
<FilterControl
label="业务来源"
value={value.entryType}
options={ledgerEntryTypeOptions}
onChange={(entryType) => onChange({ entryType })}
/>
<FilterControl
label="消费能力"
value={value.usageType}
options={ledgerUsageOptions}
onChange={(usageType) => onChange({ usageType })}
/>
<form className="grid min-w-64 flex-1 gap-1" onSubmit={applyReference}>
<label className="text-xs font-semibold text-muted" htmlFor="ledger-reference-id">
ID
</label>
<span className="flex items-center gap-2">
<span className="relative flex-1">
<Link2
className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted"
aria-hidden
/>
<Input
id="ledger-reference-id"
className="h-9 rounded-lg pl-9 font-mono text-xs"
value={referenceDraft}
onChange={(event) => setReferenceDraft(event.target.value)}
placeholder="输入 referenceId"
maxLength={36}
autoComplete="off"
aria-invalid={!referenceValid}
aria-describedby={!referenceValid ? "ledger-reference-error" : undefined}
/>
</span>
<Button
size="sm"
variant="secondary"
type="submit"
disabled={!referenceValid || normalizedReference === value.referenceId}
>
</Button>
</span>
{!referenceValid ? (
<span id="ledger-reference-error" className="text-xs font-medium text-danger">
UUID
</span>
) : null}
</form>
</TableToolbar>
<div
className="flex items-start gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6"
aria-live="polite"
>
<CalendarRange className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden />
<span className="font-semibold text-foreground"></span>
<span className="min-w-0 break-all">
{summary.length > 0 ? summary.join(" · ") : "全部流水"}
</span>
</div>
</>
);
}
@@ -0,0 +1,96 @@
import type {
LedgerAction,
LedgerEntryType,
UsageType,
} from "../../api/types";
export type LedgerTone = "neutral" | "success" | "danger" | "warning" | "info" | "violet";
export const ledgerActionOptions: ReadonlyArray<{
value: "" | LedgerAction;
label: string;
}> = [
{ value: "", label: "全部账务动作" },
{ value: "grant", label: "入账" },
{ value: "reserve", label: "预留" },
{ value: "settle", label: "结算" },
{ value: "refund", label: "退还" },
];
export const ledgerEntryTypeOptions: ReadonlyArray<{
value: "" | LedgerEntryType;
label: string;
}> = [
{ value: "", label: "全部业务来源" },
{ value: "SIGNUP_TRIAL", label: "注册试用赠送" },
{ value: "MANUAL_GRANT", label: "人工赠送" },
{ value: "USAGE_RESERVE", label: "消费预留" },
{ value: "USAGE_SETTLE", label: "消费结算" },
{ value: "USAGE_RELEASE", label: "消费释放" },
{ value: "USAGE_REFUND", label: "消费退款" },
{ value: "REFERRAL_INVITER", label: "邀请人奖励" },
{ value: "REFERRAL_INVITEE", label: "受邀人奖励" },
{ value: "STOREKIT_PURCHASE", label: "App Store 购买" },
{ value: "SUBSCRIPTION_GRANT", label: "订阅赠送" },
];
export const ledgerUsageOptions: ReadonlyArray<{
value: "" | UsageType;
label: string;
}> = [
{ value: "", label: "全部消费能力" },
{ value: "polish", label: "文字润色" },
{ value: "asr", label: "语音转写" },
{ value: "ai", label: "AI 助手" },
{ value: "agent", label: "智能代理" },
{ value: "hotword", label: "热词" },
];
const entryTypeLabels = Object.fromEntries(
ledgerEntryTypeOptions
.filter((option) => option.value)
.map((option) => [option.value, option.label]),
) as Record<LedgerEntryType, string>;
const usageLabels = Object.fromEntries(
ledgerUsageOptions
.filter((option) => option.value)
.map((option) => [option.value, option.label]),
) as Record<UsageType, string>;
const actionPresentation: Record<LedgerAction, { label: string; tone: LedgerTone }> = {
grant: { label: "入账", tone: "success" },
reserve: { label: "预留", tone: "warning" },
settle: { label: "结算", tone: "info" },
refund: { label: "退还", tone: "violet" },
};
const usageClasses: Record<UsageType, string> = {
polish: "bg-violet-soft text-violet",
asr: "bg-primary-soft text-primary",
ai: "bg-success-soft text-success",
agent: "bg-warning-soft text-warning",
hotword: "bg-pink-500/10 text-pink-700 dark:text-pink-300",
};
export function ledgerEntryTypeLabel(value?: string): string {
if (!value) return "未知类型(空值)";
return entryTypeLabels[value as LedgerEntryType] ?? `未知类型(${value}`;
}
export function ledgerActionLabel(value?: string): string {
return actionPresentation[value as LedgerAction]?.label ?? `未知动作(${value || "空值"}`;
}
export function ledgerActionTone(value?: string): LedgerTone {
return actionPresentation[value as LedgerAction]?.tone ?? "neutral";
}
export function ledgerUsageLabel(value?: string): string {
if (!value) return "—";
return usageLabels[value as UsageType] ?? `未知能力(${value}`;
}
export function ledgerUsageClass(value?: string): string {
return usageClasses[value as UsageType] ?? "bg-surface-muted text-muted";
}
@@ -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;
}
@@ -0,0 +1,156 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type {
LedgerAction,
LedgerEntryType,
LedgerQuery,
SortOrder,
UsageType,
} from "../../api/types";
import {
ledgerActionOptions,
ledgerEntryTypeOptions,
ledgerUsageOptions,
} from "./ledger-format";
export type LedgerDatePreset = "all" | "today" | "7d" | "30d" | "90d" | "custom";
export interface LedgerFilterState {
from: string;
until: string;
type: "" | LedgerAction;
entryType: "" | LedgerEntryType;
usageType: "" | UsageType;
referenceId: string;
range: LedgerDatePreset;
sort: NonNullable<LedgerQuery["sort"]>;
order: SortOrder;
}
const defaultState: LedgerFilterState = {
from: "",
until: "",
type: "",
entryType: "",
usageType: "",
referenceId: "",
range: "all",
sort: "createdAt",
order: "desc",
};
const ranges = new Set<LedgerDatePreset>(["all", "today", "7d", "30d", "90d", "custom"]);
const sorts = new Set<LedgerFilterState["sort"]>(["createdAt", "amount"]);
const orders = new Set<SortOrder>(["asc", "desc"]);
function parameterName(prefix: string, name: string): string {
if (!prefix) return name;
return `${prefix}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
}
function validOption<T extends string>(
value: string | null,
options: ReadonlyArray<{ value: "" | T }>,
): "" | T {
return options.some((option) => option.value === value) ? (value as T) : "";
}
export function readLedgerFilterState(
searchParams: URLSearchParams,
prefix = "",
): LedgerFilterState {
const read = (name: string) => searchParams.get(parameterName(prefix, name));
const from = read("from") ?? "";
const until = read("until") ?? "";
const storedRange = read("range") as LedgerDatePreset | null;
return {
from,
until,
type: validOption(read("type"), ledgerActionOptions),
entryType: validOption(read("entryType"), ledgerEntryTypeOptions),
usageType: validOption(read("usageType"), ledgerUsageOptions),
referenceId: read("referenceId") ?? "",
range: storedRange && ranges.has(storedRange) ? storedRange : from || until ? "custom" : "all",
sort: sorts.has(read("sort") as LedgerFilterState["sort"])
? (read("sort") as LedgerFilterState["sort"])
: "createdAt",
order: orders.has(read("order") as SortOrder) ? (read("order") as SortOrder) : "desc",
};
}
export function ledgerDatePreset(
preset: Exclude<LedgerDatePreset, "custom">,
now = new Date(),
): Pick<LedgerFilterState, "from" | "until" | "range"> {
if (preset === "all") return { from: "", until: "", range: "all" };
const today = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
const until = new Date(today);
until.setUTCDate(until.getUTCDate() + 1);
const from = new Date(today);
const days = preset === "today" ? 1 : Number.parseInt(preset, 10);
from.setUTCDate(from.getUTCDate() - (days - 1));
return { from: from.toISOString(), until: until.toISOString(), range: preset };
}
export function useLedgerFilters(prefix = "") {
const [searchParams, setSearchParams] = useSearchParams();
const [state, setState] = useState<LedgerFilterState>(() =>
readLedgerFilterState(searchParams, prefix),
);
useEffect(() => {
setSearchParams((current) => {
const params = new URLSearchParams(current);
const keys: Array<keyof LedgerFilterState> = [
"from",
"until",
"type",
"entryType",
"usageType",
"referenceId",
"range",
"sort",
"order",
];
keys.forEach((key) => params.delete(parameterName(prefix, key)));
if (state.from) params.set(parameterName(prefix, "from"), state.from);
if (state.until) params.set(parameterName(prefix, "until"), state.until);
if (state.type) params.set(parameterName(prefix, "type"), state.type);
if (state.entryType) params.set(parameterName(prefix, "entryType"), state.entryType);
if (state.usageType) params.set(parameterName(prefix, "usageType"), state.usageType);
if (state.referenceId) {
params.set(parameterName(prefix, "referenceId"), state.referenceId);
}
if (state.range !== "all") params.set(parameterName(prefix, "range"), state.range);
if (state.sort !== "createdAt") params.set(parameterName(prefix, "sort"), state.sort);
if (state.order !== "desc") params.set(parameterName(prefix, "order"), state.order);
return params;
}, { replace: true });
}, [prefix, setSearchParams, state]);
const query = useMemo<LedgerQuery>(
() => ({
from: state.from || undefined,
until: state.until || undefined,
type: state.type || undefined,
entryType: state.entryType || undefined,
usageType: state.usageType || undefined,
referenceId: state.referenceId.trim() || undefined,
sort: state.sort,
order: state.order,
limit: 50,
}),
[state],
);
return {
state,
query,
setState,
update: (patch: Partial<LedgerFilterState>) =>
setState((current) => ({ ...current, ...patch })),
clear: () => setState(defaultState),
};
}
+62 -172
View File
@@ -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>[]>(
() => [
-1
View File
@@ -43,7 +43,6 @@ export function statusLabel(status: string): string {
reserve: "预留",
settle: "结算",
refund: "退还",
adjustment: "调整",
};
return labels[status] ?? status;
}
+30
View File
@@ -141,6 +141,36 @@ describe("adminApi", () => {
);
});
it("流水查询编码三维筛选、关联 ID 与金额排序", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await adminApi.latestLedger({
cursor: "next+/=",
limit: 50,
from: "2026-08-01T00:00:00.000Z",
until: "2026-08-21T00:00:00.000Z",
type: "settle",
entryType: "USAGE_SETTLE",
usageType: "hotword",
referenceId: "11111111-1111-4111-8111-111111111111",
sort: "amount",
order: "asc",
});
const url = String(fetchMock.mock.calls[0]?.[0]);
expect(url).toContain("cursor=next%2B%2F%3D&limit=50");
expect(url).toContain("type=settle&entryType=USAGE_SETTLE&usageType=hotword");
expect(url).toContain(
"referenceId=11111111-1111-4111-8111-111111111111&sort=amount&order=asc",
);
});
it("缺少 CSRF 时在发送变更请求前失败", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
+52
View File
@@ -6,6 +6,11 @@ import { DataTable, type DataColumn } from "../components/data-table";
import { DateRangeControl } from "../components/date-range-control";
import type { SortOrder } from "../api/types";
import { stableSort } from "../lib/sort";
import { LedgerFilters } from "../features/credits/ledger-filters";
import {
ledgerDatePreset,
readLedgerFilterState,
} from "../features/credits/use-ledger-filters";
afterEach(cleanup);
@@ -60,4 +65,51 @@ describe("筛选与排序基础能力", () => {
until: "2026-08-21T00:00:00.000Z",
});
});
it("流水时间预设生成 UTC 半开区间", () => {
const now = new Date("2026-08-20T21:30:00+08:00");
expect(ledgerDatePreset("today", now)).toEqual({
from: "2026-08-20T00:00:00.000Z",
until: "2026-08-21T00:00:00.000Z",
range: "today",
});
expect(ledgerDatePreset("7d", now)).toEqual({
from: "2026-08-14T00:00:00.000Z",
until: "2026-08-21T00:00:00.000Z",
range: "7d",
});
expect(ledgerDatePreset("all", now)).toEqual({
from: "",
until: "",
range: "all",
});
});
it("关联 ID 完整且合法后才应用筛选", async () => {
const onChange = vi.fn();
render(
<LedgerFilters
value={readLedgerFilterState(new URLSearchParams())}
onChange={onChange}
onClear={vi.fn()}
/>,
);
const input = screen.getByLabelText("关联 ID");
await userEvent.type(input, "invalid");
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(screen.getByText("请输入完整 UUID")).toBeTruthy();
expect(screen.getByRole("button", { name: "应用" }).hasAttribute("disabled")).toBe(true);
expect(onChange).not.toHaveBeenCalled();
await userEvent.clear(input);
await userEvent.type(input, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
await userEvent.click(screen.getByRole("button", { name: "应用" }));
expect(onChange).toHaveBeenLastCalledWith({
referenceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
});
});
});
+39
View File
@@ -6,6 +6,10 @@ import {
statusLabel,
usageTypeLabel,
} from "../lib/format";
import {
ledgerEntryTypeLabel,
ledgerUsageClass,
} from "../features/credits/ledger-format";
describe("format helpers", () => {
it("转义服务端文本以阻止 HTML 注入", () => {
@@ -33,4 +37,39 @@ describe("format helpers", () => {
expect(usageTypeLabel("hotword")).toBe("热词");
expect(usageTypeLabel()).toBe("—");
});
it("十种流水业务来源使用稳定中文标签,未知值保留原值", () => {
expect([
"SIGNUP_TRIAL",
"MANUAL_GRANT",
"USAGE_RESERVE",
"USAGE_SETTLE",
"USAGE_RELEASE",
"USAGE_REFUND",
"REFERRAL_INVITER",
"REFERRAL_INVITEE",
"STOREKIT_PURCHASE",
"SUBSCRIPTION_GRANT",
].map(ledgerEntryTypeLabel)).toEqual([
"注册试用赠送",
"人工赠送",
"消费预留",
"消费结算",
"消费释放",
"消费退款",
"邀请人奖励",
"受邀人奖励",
"App Store 购买",
"订阅赠送",
]);
expect(ledgerEntryTypeLabel("FUTURE_TYPE")).toBe("未知类型(FUTURE_TYPE");
});
it("五种消费能力使用固定且可读的类别色", () => {
expect(ledgerUsageClass("polish")).toContain("text-violet");
expect(ledgerUsageClass("asr")).toContain("text-primary");
expect(ledgerUsageClass("ai")).toContain("text-success");
expect(ledgerUsageClass("agent")).toContain("text-warning");
expect(ledgerUsageClass("hotword")).toContain("text-pink");
});
});
+92 -2
View File
@@ -85,6 +85,7 @@ describe("React 管理页面", () => {
entryId: "ledger-1",
userId,
type: "settle",
entryType: "USAGE_SETTLE",
amount: -18,
balanceAfter: 102,
reasonCode: "USAGE_SETTLE",
@@ -97,11 +98,100 @@ describe("React 管理页面", () => {
render(<App />);
expect(await screen.findByRole("heading", { name: "积分流水" })).toBeTruthy();
expect(await screen.findByText("USAGE_SETTLE")).toBeTruthy();
expect(screen.getByText("热词")).toBeTruthy();
expect(await screen.findByText("消费结算")).toBeTruthy();
expect(screen.getAllByText("热词")).toHaveLength(2);
expect(screen.getByText("-18")).toBeTruthy();
});
it("积分流水支持金额排序、展开安全详情、关联筛选与用户跳转", async () => {
mockSession("SUPPORT");
const referenceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const entry: LedgerEntry = {
entryId: "ledger-manual-1",
userId,
type: "grant",
entryType: "MANUAL_GRANT",
amount: 50,
balanceAfter: 170,
reasonCode: "MANUAL_CREDIT_GRANT",
referenceId,
details: {
kind: "manualGrant",
reason: "客服补偿",
operatorName: "support",
},
createdAt: "2026-08-20T09:00:00Z",
};
vi.spyOn(adminApi, "latestLedger").mockResolvedValue({ items: [entry] });
window.location.hash = "#/credits";
render(<App />);
const userLink = await screen.findByRole("link", { name: userId });
expect(screen.getAllByText("人工赠送")).toHaveLength(2);
expect(screen.queryByText("MANUAL_CREDIT_GRANT")).toBeNull();
expect(userLink.getAttribute("href")).toBe(`#/users?userId=${userId}`);
await userEvent.click(screen.getByRole("button", { name: /变动/ }));
await waitFor(() => {
expect(adminApi.latestLedger).toHaveBeenLastCalledWith(
expect.objectContaining({ sort: "amount", order: "desc" }),
);
});
expect(window.location.hash).toContain("sort=amount");
await userEvent.click(screen.getByRole("button", { name: "展开" }));
expect(screen.getByText("客服补偿")).toBeTruthy();
expect(screen.getByText("support")).toBeTruthy();
expect(screen.getByText("MANUAL_CREDIT_GRANT")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "筛选同笔生命周期" }));
await waitFor(() => {
expect(adminApi.latestLedger).toHaveBeenLastCalledWith(
expect.objectContaining({ referenceId }),
);
});
expect(window.location.hash).toContain(`referenceId=${referenceId}`);
});
it("用户 URL 可直接打开详情,ledger 前缀不覆盖列表筛选", async () => {
mockSession("SUPPORT");
vi.spyOn(adminApi, "user").mockResolvedValue({
...user("链接用户", userId),
qualifiedUsage: false,
});
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
vi.spyOn(adminApi, "users").mockResolvedValue({ items: [] });
window.location.hash =
`#/users?q=11111111&status=active&order=asc&userId=${userId}` +
"&ledgerType=reserve&ledgerEntryType=USAGE_RESERVE&ledgerUsageType=ai";
render(<App />);
expect(await screen.findByRole("heading", { name: "链接用户" })).toBeTruthy();
await waitFor(() => {
expect(adminApi.ledger).toHaveBeenCalledWith(
userId,
expect.objectContaining({
type: "reserve",
entryType: "USAGE_RESERVE",
usageType: "ai",
}),
);
});
expect(window.location.hash).toContain("q=11111111");
expect(window.location.hash).toContain("ledgerType=reserve");
await userEvent.click(screen.getByRole("button", { name: "返回用户列表" }));
await waitFor(() => {
expect(window.location.hash).not.toContain("userId=");
expect(window.location.hash).toContain("q=11111111");
expect(window.location.hash).toContain("status=active");
expect(window.location.hash).toContain("order=asc");
});
});
it("安全中心保留管理员游标分页", async () => {
mockSession("SUPER_ADMIN");
const first = operator("operator-1", "owner");