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:
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>[]>(
|
||||
() => [
|
||||
|
||||
@@ -43,7 +43,6 @@ export function statusLabel(status: string): string {
|
||||
reserve: "预留",
|
||||
settle: "结算",
|
||||
refund: "退还",
|
||||
adjustment: "调整",
|
||||
};
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user