Add admin dashboard filtering and sorting
Provide stable server-side list queries and focused chart controls so operators can inspect large datasets without misleading partial-page ordering.
This commit is contained in:
+41
-19
@@ -4,16 +4,21 @@ import type {
|
||||
AdminOperatorProvisioning,
|
||||
AdminSecuritySummary,
|
||||
AdminLoginResponse,
|
||||
AuditQuery,
|
||||
AuditLogEntry,
|
||||
CreditGrantRequest,
|
||||
CreditGrantResponse,
|
||||
LedgerQuery,
|
||||
LedgerEntry,
|
||||
OperatorsQuery,
|
||||
Overview,
|
||||
PageResult,
|
||||
ProductAnalyticsOverview,
|
||||
ReferralsQuery,
|
||||
ReferralOverview,
|
||||
SessionResponse,
|
||||
UserDetail,
|
||||
UsersQuery,
|
||||
UserSummary,
|
||||
} from "./types";
|
||||
|
||||
@@ -140,15 +145,21 @@ async function request<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function query(params: Record<string, string | undefined>): string {
|
||||
export function encodeQuery<T extends object>(params: T): string {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) search.set(key, value);
|
||||
if (value !== undefined && value !== "") search.set(key, String(value));
|
||||
});
|
||||
const result = search.toString();
|
||||
return result ? `?${result}` : "";
|
||||
}
|
||||
|
||||
function cursorQuery<T extends CursorQuery>(value?: string | T): CursorQuery | T {
|
||||
return typeof value === "string" ? { cursor: value } : (value ?? {});
|
||||
}
|
||||
|
||||
type CursorQuery = { cursor?: string };
|
||||
|
||||
export const adminApi = {
|
||||
session: () => request<SessionResponse>("/auth/session"),
|
||||
|
||||
@@ -161,28 +172,35 @@ export const adminApi = {
|
||||
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
||||
|
||||
overview: (range: string) =>
|
||||
request<Overview>(`/overview${query({ range })}`),
|
||||
request<Overview>(`/overview${encodeQuery({ range })}`),
|
||||
|
||||
referrals: (range: string) =>
|
||||
request<ReferralOverview>(`/referrals${query({ range })}`),
|
||||
referrals: (value: string | ReferralsQuery) => {
|
||||
const params = typeof value === "string" ? { range: value } : value;
|
||||
return request<ReferralOverview>(`/referrals${encodeQuery(params)}`);
|
||||
},
|
||||
|
||||
productAnalytics: (range: string) =>
|
||||
request<ProductAnalyticsOverview>(`/analytics${query({ range })}`),
|
||||
request<ProductAnalyticsOverview>(`/analytics${encodeQuery({ range })}`),
|
||||
|
||||
users: (search = "", cursor?: string) =>
|
||||
request<PageResult<UserSummary>>(
|
||||
`/users${query({ q: search.trim(), cursor })}`,
|
||||
),
|
||||
users: (value: string | UsersQuery = "", legacyCursor?: string) => {
|
||||
const params =
|
||||
typeof value === "string"
|
||||
? { q: value.trim(), cursor: legacyCursor }
|
||||
: { ...value, q: value.q?.trim() };
|
||||
return request<PageResult<UserSummary>>(`/users${encodeQuery(params)}`);
|
||||
},
|
||||
|
||||
user: (userId: string) =>
|
||||
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
|
||||
|
||||
latestLedger: (cursor?: string) =>
|
||||
request<PageResult<LedgerEntry>>(`/credits/ledger${query({ cursor })}`),
|
||||
|
||||
ledger: (userId: string, cursor?: string) =>
|
||||
latestLedger: (value?: string | LedgerQuery) =>
|
||||
request<PageResult<LedgerEntry>>(
|
||||
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
|
||||
`/credits/ledger${encodeQuery(cursorQuery(value))}`,
|
||||
),
|
||||
|
||||
ledger: (userId: string, value?: string | LedgerQuery) =>
|
||||
request<PageResult<LedgerEntry>>(
|
||||
`/users/${encodeURIComponent(userId)}/ledger${encodeQuery(cursorQuery(value))}`,
|
||||
),
|
||||
|
||||
grantCredits: (payload: CreditGrantRequest) =>
|
||||
@@ -196,11 +214,15 @@ export const adminApi = {
|
||||
headers: { "Idempotency-Key": payload.idempotencyKey },
|
||||
}),
|
||||
|
||||
auditLogs: (cursor?: string) =>
|
||||
request<PageResult<AuditLogEntry>>(`/audit${query({ cursor })}`),
|
||||
auditLogs: (value?: string | AuditQuery) =>
|
||||
request<PageResult<AuditLogEntry>>(
|
||||
`/audit${encodeQuery(cursorQuery(value))}`,
|
||||
),
|
||||
|
||||
operators: (cursor?: string) =>
|
||||
request<PageResult<AdminOperator>>(`/operators${query({ cursor })}`),
|
||||
operators: (value?: string | OperatorsQuery) =>
|
||||
request<PageResult<AdminOperator>>(
|
||||
`/operators${encodeQuery(cursorQuery(value))}`,
|
||||
),
|
||||
|
||||
operatorSummary: () =>
|
||||
request<AdminSecuritySummary>("/operators/summary"),
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
export type AdminRole = "SUPER_ADMIN" | "SUPPORT" | "ANALYST";
|
||||
|
||||
export type SortOrder = "asc" | "desc";
|
||||
export type AdminAuditAction =
|
||||
| "LOGIN_SUCCEEDED"
|
||||
| "LOGIN_FAILED"
|
||||
| "SESSION_REVOKED"
|
||||
| "OPERATOR_CREATED"
|
||||
| "OPERATOR_ENABLED"
|
||||
| "OPERATOR_DISABLED"
|
||||
| "OPERATOR_UNLOCKED"
|
||||
| "OPERATOR_CREDENTIALS_RESET"
|
||||
| "OPERATOR_SESSIONS_REVOKED"
|
||||
| "MANUAL_CREDIT_GRANTED";
|
||||
|
||||
export interface CursorPageQuery {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
from?: string;
|
||||
until?: string;
|
||||
sort?: "createdAt";
|
||||
order?: SortOrder;
|
||||
}
|
||||
|
||||
export interface UsersQuery extends CursorPageQuery {
|
||||
q?: string;
|
||||
status?: UserSummary["status"];
|
||||
}
|
||||
|
||||
export interface LedgerQuery extends CursorPageQuery {
|
||||
type?: LedgerEntryType;
|
||||
}
|
||||
|
||||
export interface AuditQuery extends CursorPageQuery {
|
||||
action?: AdminAuditAction;
|
||||
result?: AuditLogEntry["result"];
|
||||
}
|
||||
|
||||
export interface OperatorsQuery extends Omit<CursorPageQuery, "sort"> {
|
||||
role?: AdminRole;
|
||||
enabled?: boolean;
|
||||
locked?: boolean;
|
||||
sort?: "createdAt" | "username" | "lastLoginAt";
|
||||
}
|
||||
|
||||
export interface ReferralsQuery {
|
||||
range: string;
|
||||
sort?: "invited" | "qualified" | "creditsEarned";
|
||||
order?: SortOrder;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type AuthState =
|
||||
| { status: "anonymous" }
|
||||
| { status: "authenticated"; operatorName: string; role: AdminRole };
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ChartToolbar({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-end gap-3 border-b border-border bg-surface-muted/25 px-5 py-4"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import type { TrendPoint } from "../../api/types";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
|
||||
export function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||
export function TrendChart({
|
||||
points,
|
||||
showRegistrations = true,
|
||||
showCredits = true,
|
||||
}: {
|
||||
points: TrendPoint[];
|
||||
showRegistrations?: boolean;
|
||||
showCredits?: boolean;
|
||||
}) {
|
||||
if (points.length === 0) {
|
||||
return <div className="grid h-full place-items-center text-sm text-muted">暂无趋势数据</div>;
|
||||
}
|
||||
if (!showRegistrations && !showCredits) {
|
||||
return <div className="grid h-full place-items-center text-sm text-muted">请选择至少一个趋势系列</div>;
|
||||
}
|
||||
|
||||
const width = 760;
|
||||
const height = 300;
|
||||
@@ -53,26 +64,38 @@ export function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||
className="chart-grid-line"
|
||||
/>
|
||||
))}
|
||||
<text x={paddingX} y={paddingY - 9} className="chart-label">
|
||||
{formatNumber(registrationMax)} 用户
|
||||
</text>
|
||||
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
|
||||
{formatNumber(creditMax)} 积分
|
||||
</text>
|
||||
<polyline points={registrationLine} className="chart-line chart-line--primary" />
|
||||
<polyline points={creditLine} className="chart-line chart-line--violet" />
|
||||
{showRegistrations ? (
|
||||
<text x={paddingX} y={paddingY - 9} className="chart-label">
|
||||
{formatNumber(registrationMax)} 用户
|
||||
</text>
|
||||
) : null}
|
||||
{showCredits ? (
|
||||
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
|
||||
{formatNumber(creditMax)} 积分
|
||||
</text>
|
||||
) : null}
|
||||
{showRegistrations ? (
|
||||
<polyline points={registrationLine} className="chart-line chart-line--primary" />
|
||||
) : null}
|
||||
{showCredits ? (
|
||||
<polyline points={creditLine} className="chart-line chart-line--violet" />
|
||||
) : null}
|
||||
{coordinates.map(({ point, x, registrationY, creditY }, index) => (
|
||||
<g key={point.date}>
|
||||
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
|
||||
<title>
|
||||
{point.date}:新增 {formatNumber(point.registrations)} 位用户
|
||||
</title>
|
||||
</circle>
|
||||
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
|
||||
<title>
|
||||
{point.date}:消耗 {formatNumber(point.creditsUsed)} 积分
|
||||
</title>
|
||||
</circle>
|
||||
{showRegistrations ? (
|
||||
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
|
||||
<title>
|
||||
{point.date}:新增 {formatNumber(point.registrations)} 位用户
|
||||
</title>
|
||||
</circle>
|
||||
) : null}
|
||||
{showCredits ? (
|
||||
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
|
||||
<title>
|
||||
{point.date}:消耗 {formatNumber(point.creditsUsed)} 积分
|
||||
</title>
|
||||
</circle>
|
||||
) : null}
|
||||
{index % labelEvery === 0 || index === points.length - 1 ? (
|
||||
<text
|
||||
x={x}
|
||||
@@ -92,16 +115,16 @@ export function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">UTC 日期</th>
|
||||
<th scope="col">新增用户</th>
|
||||
<th scope="col">消耗积分</th>
|
||||
{showRegistrations ? <th scope="col">新增用户</th> : null}
|
||||
{showCredits ? <th scope="col">消耗积分</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{points.map((point) => (
|
||||
<tr key={point.date}>
|
||||
<td>{point.date}</td>
|
||||
<td>{formatNumber(point.registrations)}</td>
|
||||
<td>{formatNumber(point.creditsUsed)}</td>
|
||||
{showRegistrations ? <td>{formatNumber(point.registrations)}</td> : null}
|
||||
{showCredits ? <td>{formatNumber(point.creditsUsed)}</td> : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
type LegacyColumnDef,
|
||||
useLegacyTable,
|
||||
} from "@tanstack/react-table/legacy";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
import type { SortOrder } from "../api/types";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EmptyState } from "./primitives";
|
||||
|
||||
@@ -18,6 +20,9 @@ export function DataTable<T extends RowData>({
|
||||
emptyTitle = "暂无数据",
|
||||
footer,
|
||||
className,
|
||||
sort,
|
||||
sortableColumns,
|
||||
onSortChange,
|
||||
}: {
|
||||
data: T[];
|
||||
columns: DataColumn<T>[];
|
||||
@@ -25,6 +30,9 @@ export function DataTable<T extends RowData>({
|
||||
emptyTitle?: string;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
sort?: { key: string; order: SortOrder };
|
||||
sortableColumns?: Record<string, string>;
|
||||
onSortChange?: (key: string, order: SortOrder) => void;
|
||||
}) {
|
||||
const table = useLegacyTable({
|
||||
data,
|
||||
@@ -42,16 +50,50 @@ export function DataTable<T extends RowData>({
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
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"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
{headerGroup.headers.map((header) => {
|
||||
const sortKey = sortableColumns?.[header.column.id];
|
||||
const active = sortKey != null && sort?.key === sortKey;
|
||||
const ariaSort = active
|
||||
? sort.order === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: sortKey
|
||||
? "none"
|
||||
: undefined;
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
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"
|
||||
aria-sort={ariaSort}
|
||||
>
|
||||
{header.isPlaceholder ? null : sortKey && onSortChange ? (
|
||||
<button
|
||||
className="inline-flex min-h-8 items-center gap-1.5 rounded-lg px-1 text-left transition hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSortChange(
|
||||
sortKey,
|
||||
active && sort.order === "desc" ? "asc" : "desc",
|
||||
)
|
||||
}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{active ? (
|
||||
sort.order === "asc" ? (
|
||||
<ArrowUp className="size-3.5" aria-hidden />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" aria-hidden />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 opacity-60" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface DateRangeValue {
|
||||
from?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
function datePart(value?: string, exclusiveEnd = false): string {
|
||||
if (!value) return "";
|
||||
if (!exclusiveEnd) return value.slice(0, 10);
|
||||
const date = new Date(value);
|
||||
date.setUTCDate(date.getUTCDate() - 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function utcDate(value: string, exclusiveEnd: boolean): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
if (exclusiveEnd) date.setUTCDate(date.getUTCDate() + 1);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function DateRangeControl({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: DateRangeValue;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="flex min-w-0 flex-wrap items-end gap-2">
|
||||
<legend className="sr-only">日期范围</legend>
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
type="date"
|
||||
value={datePart(value.from)}
|
||||
max={datePart(value.until, true) || undefined}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, from: utcDate(event.target.value, false) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
type="date"
|
||||
value={datePart(value.until, true)}
|
||||
min={datePart(value.from) || undefined}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, until: utcDate(event.target.value, true) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface FilterOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function FilterControl<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly FilterOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value as T)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToggleFilter({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-h-9 cursor-pointer items-center gap-2 rounded-lg border border-border bg-input px-3 text-xs font-semibold text-foreground">
|
||||
<input
|
||||
className="size-4 accent-primary"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { SortOrder } from "../api/types";
|
||||
|
||||
export interface SortOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SortControl<T extends string>({
|
||||
value,
|
||||
order,
|
||||
options,
|
||||
onChange,
|
||||
label = "排序",
|
||||
}: {
|
||||
value: T;
|
||||
order: SortOrder;
|
||||
options: readonly SortOption<T>[];
|
||||
onChange: (value: T, order: SortOrder) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="flex min-w-0 flex-wrap items-end gap-2">
|
||||
<legend className="sr-only">{label}</legend>
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value as T, order)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
||||
{(["desc", "asc"] as const).map((direction) => (
|
||||
<button
|
||||
key={direction}
|
||||
className={`min-h-7 rounded-lg px-3 text-xs font-semibold transition ${
|
||||
order === direction
|
||||
? "bg-surface text-foreground shadow-sm"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
type="button"
|
||||
aria-pressed={order === direction}
|
||||
onClick={() => onChange(value, direction)}
|
||||
>
|
||||
{direction === "desc" ? "降序" : "升序"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
export function TableToolbar({
|
||||
children,
|
||||
active,
|
||||
onClear,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
active: boolean;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-end gap-3 border-b border-border bg-surface-muted/25 p-4 sm:p-5"
|
||||
aria-label="表格筛选与排序"
|
||||
>
|
||||
{children}
|
||||
<Button
|
||||
className="sm:ml-auto"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
disabled={!active}
|
||||
onClick={onClear}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,27 +9,47 @@ import {
|
||||
Target,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { AnalyticsRate, ProductAnalyticsOverview } from "../../api/types";
|
||||
import type {
|
||||
AnalyticsRate,
|
||||
ProductAnalyticsOverview,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||
import { CohortHeatmap } from "../../components/charts/cohort-heatmap";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||
import { FilterControl, ToggleFilter } from "../../components/filter-control";
|
||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { SortControl } from "../../components/sort-control";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
import { stableSort } from "../../lib/sort";
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [data, setData] = useState<ProductAnalyticsOverview>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [executionMode, setExecutionMode] = useState("");
|
||||
const [aiSort, setAiSort] = useState<"successes" | "users">("successes");
|
||||
const [aiOrder, setAiOrder] = useState<SortOrder>("desc");
|
||||
const [channelSort, setChannelSort] = useState<"installs" | "activated" | "rate">("installs");
|
||||
const [channelOrder, setChannelOrder] = useState<SortOrder>("desc");
|
||||
const [hideUnknown, setHideUnknown] = useState(false);
|
||||
const [cohortOrder, setCohortOrder] = useState<SortOrder>("desc");
|
||||
const [minimumCohortSize, setMinimumCohortSize] = useState(0);
|
||||
const [matureOnly, setMatureOnly] = useState(false);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setError(undefined);
|
||||
try {
|
||||
setData(await adminApi.productAnalytics(range));
|
||||
const response = await adminApi.productAnalytics(range);
|
||||
if (requestVersion.current === version) setData(response);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
@@ -37,6 +57,47 @@ export function AnalyticsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const visibleAiFeatures = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.aiFeatures ?? []).filter(
|
||||
(item) => !executionMode || item.executionMode === executionMode,
|
||||
),
|
||||
(item) => (aiSort === "successes" ? item.successes : item.users),
|
||||
aiOrder,
|
||||
),
|
||||
[aiOrder, aiSort, data?.aiFeatures, executionMode],
|
||||
);
|
||||
const visibleChannels = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.growth.channels ?? []).filter(
|
||||
(channel) => !hideUnknown || channel.channel !== "UNKNOWN",
|
||||
),
|
||||
(channel) =>
|
||||
channelSort === "installs"
|
||||
? channel.installations
|
||||
: channelSort === "activated"
|
||||
? channel.activated
|
||||
: channel.activationRate.percent ?? -1,
|
||||
channelOrder,
|
||||
),
|
||||
[channelOrder, channelSort, data?.growth.channels, hideUnknown],
|
||||
);
|
||||
const visibleCohorts = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.retention ?? []).filter(
|
||||
(cohort) =>
|
||||
cohort.size >= minimumCohortSize &&
|
||||
(!matureOnly || cohort.d30?.percent != null),
|
||||
),
|
||||
(cohort) => cohort.cohortDate,
|
||||
cohortOrder,
|
||||
),
|
||||
[cohortOrder, data?.retention, matureOnly, minimumCohortSize],
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||
if (!data) return <LoadingState label="加载产品数据" />;
|
||||
|
||||
@@ -100,7 +161,28 @@ export function AnalyticsPage() {
|
||||
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
||||
icon={ChartNoAxesCombined}
|
||||
/>
|
||||
<CohortHeatmap cohorts={data.retention} />
|
||||
<ChartToolbar label="留存 cohort 筛选与排序">
|
||||
<SortControl
|
||||
label="激活日期"
|
||||
value="date"
|
||||
order={cohortOrder}
|
||||
options={[{ value: "date", label: "激活日期" }]}
|
||||
onChange={(_, order) => setCohortOrder(order)}
|
||||
/>
|
||||
<FilterControl
|
||||
label="最小样本量"
|
||||
value={String(minimumCohortSize)}
|
||||
options={[
|
||||
{ value: "0", label: "不限" },
|
||||
{ value: "10", label: "至少 10 人" },
|
||||
{ value: "50", label: "至少 50 人" },
|
||||
{ value: "100", label: "至少 100 人" },
|
||||
]}
|
||||
onChange={(value) => setMinimumCohortSize(Number(value))}
|
||||
/>
|
||||
<ToggleFilter label="仅显示 D30 已成熟" checked={matureOnly} onChange={setMatureOnly} />
|
||||
</ChartToolbar>
|
||||
<CohortHeatmap cohorts={visibleCohorts} />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -111,13 +193,36 @@ export function AnalyticsPage() {
|
||||
description="客户端功能与执行模式,仅包含白名单元数据"
|
||||
icon={BrainCircuit}
|
||||
/>
|
||||
{data.aiFeatures.length === 0 ? (
|
||||
<p className="p-8 text-center text-sm text-muted">等待客户端接入事件后显示</p>
|
||||
<ChartToolbar label="AI 使用结构筛选与排序">
|
||||
<FilterControl
|
||||
label="执行模式"
|
||||
value={executionMode}
|
||||
options={[
|
||||
{ value: "", label: "全部模式" },
|
||||
{ value: "MANAGED", label: "托管" },
|
||||
{ value: "LOCAL", label: "本地" },
|
||||
{ value: "BYOK", label: "BYOK" },
|
||||
]}
|
||||
onChange={setExecutionMode}
|
||||
/>
|
||||
<SortControl
|
||||
value={aiSort}
|
||||
order={aiOrder}
|
||||
options={[
|
||||
{ value: "successes", label: "成功次数" },
|
||||
{ value: "users", label: "成功用户" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setAiSort(value);
|
||||
setAiOrder(order);
|
||||
}}
|
||||
/>
|
||||
</ChartToolbar>
|
||||
{visibleAiFeatures.length === 0 ? (
|
||||
<p className="p-8 text-center text-sm text-muted">当前筛选条件下暂无 AI 使用数据</p>
|
||||
) : (
|
||||
<ComparisonBarChart
|
||||
items={[...data.aiFeatures]
|
||||
.sort((left, right) => right.successes - left.successes)
|
||||
.map((item) => ({
|
||||
items={visibleAiFeatures.map((item) => ({
|
||||
id: `${item.feature}-${item.executionMode}`,
|
||||
label: featureLabel(item.feature),
|
||||
value: item.successes,
|
||||
@@ -227,9 +332,25 @@ export function AnalyticsPage() {
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
|
||||
<ChartToolbar label="渠道质量筛选与排序">
|
||||
<SortControl
|
||||
value={channelSort}
|
||||
order={channelOrder}
|
||||
options={[
|
||||
{ value: "installs", label: "新增安装" },
|
||||
{ value: "activated", label: "激活人数" },
|
||||
{ value: "rate", label: "激活率" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setChannelSort(value);
|
||||
setChannelOrder(order);
|
||||
}}
|
||||
/>
|
||||
<ToggleFilter label="隐藏未知来源" checked={hideUnknown} onChange={setHideUnknown} />
|
||||
</ChartToolbar>
|
||||
<div className="grid gap-0 xl:grid-cols-[1fr_260px]">
|
||||
<ComparisonBarChart
|
||||
items={data.growth.channels.map((channel) => ({
|
||||
items={visibleChannels.map((channel) => ({
|
||||
label: channelLabel(channel.channel),
|
||||
value: channel.installations,
|
||||
secondaryValue: channel.activated,
|
||||
@@ -237,7 +358,7 @@ export function AnalyticsPage() {
|
||||
}))}
|
||||
primaryLabel="新增安装"
|
||||
secondaryLabel="24 小时激活"
|
||||
emptyText="暂无渠道归因数据"
|
||||
emptyText="当前筛选条件下暂无渠道归因数据"
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { FileCheck2, ShieldCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type { AuditLogEntry } from "../../api/types";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type {
|
||||
AdminAuditAction,
|
||||
AuditLogEntry,
|
||||
AuditQuery,
|
||||
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 {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -12,48 +19,56 @@ import {
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import { formatDateTime, statusLabel } from "../../lib/format";
|
||||
|
||||
export function AuditPage() {
|
||||
const [items, setItems] = useState<AuditLogEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const page = await adminApi.auditLogs();
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||
const [action, setAction] = useState<"" | AdminAuditAction>(
|
||||
(searchParams.get("action") as AdminAuditAction | null) ?? "",
|
||||
);
|
||||
const [result, setResult] = useState<"" | AuditLogEntry["result"]>(
|
||||
(searchParams.get("result") as AuditLogEntry["result"] | null) ?? "",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const auditQuery = useMemo<AuditQuery>(
|
||||
() => ({
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
action: action || undefined,
|
||||
result: result || undefined,
|
||||
sort: "createdAt",
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[action, from, order, result, until],
|
||||
);
|
||||
const fetchAudit = useCallback((value: AuditQuery) => adminApi.auditLogs(value), []);
|
||||
const {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(auditQuery, fetchAudit);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await adminApi.auditLogs(nextCursor);
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
toast.error(
|
||||
requestError instanceof ApiError ? requestError.message : "加载审计记录失败",
|
||||
);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (until) params.set("until", until);
|
||||
if (action) params.set("action", action);
|
||||
if (result) params.set("result", result);
|
||||
params.set("sort", "createdAt");
|
||||
params.set("order", order);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [action, from, order, result, setSearchParams, until]);
|
||||
|
||||
const columns = useMemo<DataColumn<AuditLogEntry>[]>(
|
||||
() => [
|
||||
@@ -140,9 +155,55 @@ export function AuditPage() {
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<TableToolbar
|
||||
active={Boolean(from || until || action || result || order !== "desc")}
|
||||
onClear={() => {
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setAction("");
|
||||
setResult("");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="操作类型"
|
||||
value={action}
|
||||
options={[
|
||||
{ value: "", label: "全部操作" },
|
||||
{ value: "LOGIN_SUCCEEDED", label: "登录成功" },
|
||||
{ value: "LOGIN_FAILED", label: "登录失败" },
|
||||
{ value: "SESSION_REVOKED", label: "会话撤销" },
|
||||
{ value: "OPERATOR_CREATED", label: "创建管理员" },
|
||||
{ value: "OPERATOR_ENABLED", label: "启用管理员" },
|
||||
{ value: "OPERATOR_DISABLED", label: "停用管理员" },
|
||||
{ value: "OPERATOR_UNLOCKED", label: "解锁管理员" },
|
||||
{ value: "OPERATOR_CREDENTIALS_RESET", label: "重置管理员凭据" },
|
||||
{ value: "OPERATOR_SESSIONS_REVOKED", label: "撤销管理员会话" },
|
||||
{ value: "MANUAL_CREDIT_GRANTED", label: "人工赠送积分" },
|
||||
]}
|
||||
onChange={setAction}
|
||||
/>
|
||||
<FilterControl
|
||||
label="执行结果"
|
||||
value={result}
|
||||
options={[
|
||||
{ value: "", label: "全部结果" },
|
||||
{ value: "success", label: "成功" },
|
||||
{ value: "rejected", label: "已拒绝" },
|
||||
]}
|
||||
onChange={setResult}
|
||||
/>
|
||||
</TableToolbar>
|
||||
{error ? (
|
||||
<div className="p-6">
|
||||
<ErrorState error={error} retry={() => void load()} />
|
||||
<ErrorState error={error} retry={reload} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载审计日志" />
|
||||
@@ -151,7 +212,14 @@ export function AuditPage() {
|
||||
data={items}
|
||||
columns={columns}
|
||||
caption="管理员审计事件"
|
||||
emptyTitle="暂无审计记录"
|
||||
emptyTitle={
|
||||
from || until || action || result
|
||||
? "没有符合当前筛选条件的审计记录"
|
||||
: "暂无审计记录"
|
||||
}
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ArrowDownUp, Coins, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type { LedgerEntry } from "../../api/types";
|
||||
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 {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -12,6 +15,8 @@ import {
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import {
|
||||
formatDateTime,
|
||||
formatNumber,
|
||||
@@ -19,58 +24,63 @@ import {
|
||||
statusLabel,
|
||||
usageTypeLabel,
|
||||
} from "../../lib/format";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function CreditsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeUserId, setActiveUserId] = useState<string>();
|
||||
const [items, setItems] = useState<LedgerEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const load = useCallback(async (userId?: string) => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const page = userId
|
||||
? await adminApi.ledger(userId)
|
||||
: await adminApi.latestLedger();
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
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 fetchLedger = useCallback(
|
||||
(value: LedgerQuery) =>
|
||||
activeUserId
|
||||
? adminApi.ledger(activeUserId, value)
|
||||
: adminApi.latestLedger(value),
|
||||
[activeUserId],
|
||||
);
|
||||
const {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(ledgerQuery, fetchLedger);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = activeUserId
|
||||
? await adminApi.ledger(activeUserId, nextCursor)
|
||||
: await adminApi.latestLedger(nextCursor);
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
toast.error(requestError instanceof ApiError ? requestError.message : "加载流水失败");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
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]);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const userId = query.trim() || undefined;
|
||||
setActiveUserId(userId);
|
||||
void load(userId);
|
||||
setActiveUserId(userId ?? "");
|
||||
}
|
||||
|
||||
const columns = useMemo<DataColumn<LedgerEntry>[]>(
|
||||
@@ -171,6 +181,38 @@ 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>
|
||||
|
||||
<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 />
|
||||
@@ -186,7 +228,7 @@ export function CreditsPage() {
|
||||
|
||||
{error ? (
|
||||
<div className="p-6">
|
||||
<ErrorState error={error} retry={() => void load(activeUserId)} />
|
||||
<ErrorState error={error} retry={reload} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载积分流水" />
|
||||
@@ -195,7 +237,14 @@ export function CreditsPage() {
|
||||
data={items}
|
||||
columns={columns}
|
||||
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
|
||||
emptyTitle={activeUserId ? "该用户暂无积分流水" : "暂无积分流水"}
|
||||
emptyTitle={
|
||||
activeUserId || from || until || type
|
||||
? "没有符合当前筛选条件的流水"
|
||||
: "暂无积分流水"
|
||||
}
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
|
||||
@@ -4,28 +4,39 @@ import {
|
||||
CreditCard,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { Overview } from "../../api/types";
|
||||
import type { Overview, SortOrder } from "../../api/types";
|
||||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||
import { ChartLegend } from "../../components/charts/chart-legend";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { RadialMetric } from "../../components/charts/radial-metric";
|
||||
import { TrendChart } from "../../components/charts/trend-chart";
|
||||
import { ToggleFilter } from "../../components/filter-control";
|
||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { SortControl } from "../../components/sort-control";
|
||||
import { formatNumber, usageTypeLabel } from "../../lib/format";
|
||||
import { stableSort } from "../../lib/sort";
|
||||
|
||||
export function OverviewPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [data, setData] = useState<Overview>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [showRegistrations, setShowRegistrations] = useState(true);
|
||||
const [showCredits, setShowCredits] = useState(true);
|
||||
const [usageSort, setUsageSort] = useState<"credits" | "requests">("credits");
|
||||
const [usageOrder, setUsageOrder] = useState<SortOrder>("desc");
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setError(undefined);
|
||||
try {
|
||||
setData(await adminApi.overview(range));
|
||||
const response = await adminApi.overview(range);
|
||||
if (requestVersion.current === version) setData(response);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
@@ -33,6 +44,16 @@ export function OverviewPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const sortedUsage = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
data?.usage ?? [],
|
||||
(item) => (usageSort === "credits" ? item.chargedCredits : item.requests),
|
||||
usageOrder,
|
||||
),
|
||||
[data?.usage, usageOrder, usageSort],
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||
if (!data) return <LoadingState label="加载运营总览" />;
|
||||
|
||||
@@ -88,13 +109,29 @@ export function OverviewPage() {
|
||||
</div>
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ label: "新增用户", tone: "primary" },
|
||||
{ label: "积分消耗", tone: "violet" },
|
||||
...(showRegistrations ? [{ label: "新增用户", tone: "primary" as const }] : []),
|
||||
...(showCredits ? [{ label: "积分消耗", tone: "violet" as const }] : []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-wrap gap-2" aria-label="趋势系列显隐">
|
||||
<ToggleFilter
|
||||
label="新增用户"
|
||||
checked={showRegistrations}
|
||||
onChange={setShowRegistrations}
|
||||
/>
|
||||
<ToggleFilter
|
||||
label="积分消耗"
|
||||
checked={showCredits}
|
||||
onChange={setShowCredits}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[320px] w-full">
|
||||
<TrendChart points={data.trend} />
|
||||
<TrendChart
|
||||
points={data.trend}
|
||||
showRegistrations={showRegistrations}
|
||||
showCredits={showCredits}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -129,13 +166,25 @@ export function OverviewPage() {
|
||||
<p className="mt-1 text-xs text-muted">仅聚合计量数据,不包含任何用户内容</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChartToolbar label="使用构成排序">
|
||||
<SortControl
|
||||
value={usageSort}
|
||||
order={usageOrder}
|
||||
options={[
|
||||
{ value: "credits", label: "消耗积分" },
|
||||
{ value: "requests", label: "请求次数" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setUsageSort(value);
|
||||
setUsageOrder(order);
|
||||
}}
|
||||
/>
|
||||
</ChartToolbar>
|
||||
{data.usage.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-muted">当前周期暂无使用记录</div>
|
||||
) : (
|
||||
<ComparisonBarChart
|
||||
items={[...data.usage]
|
||||
.sort((left, right) => right.chargedCredits - left.chargedCredits)
|
||||
.map((item) => ({
|
||||
items={sortedUsage.map((item) => ({
|
||||
label: usageTypeLabel(item.kind),
|
||||
value: item.chargedCredits,
|
||||
hint: `${formatNumber(item.requests)} 次请求`,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { ReferralOverview, ReferralRankingItem } from "../../api/types";
|
||||
import type {
|
||||
ReferralOverview,
|
||||
ReferralRankingItem,
|
||||
ReferralsQuery,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||
import { RadialMetric } from "../../components/charts/radial-metric";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import {
|
||||
Card,
|
||||
ErrorState,
|
||||
@@ -14,26 +22,51 @@ import {
|
||||
StatCard,
|
||||
} from "../../components/primitives";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { SortControl } from "../../components/sort-control";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
|
||||
export function ReferralsPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [range, setRange] = useState(searchParams.get("range") ?? "30d");
|
||||
const [sort, setSort] = useState<NonNullable<ReferralsQuery["sort"]>>(
|
||||
(searchParams.get("sort") as ReferralsQuery["sort"] | null) ?? "qualified",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const [limit, setLimit] = useState(() => {
|
||||
const value = Number(searchParams.get("limit"));
|
||||
return [20, 50, 100].includes(value) ? value : 20;
|
||||
});
|
||||
const [data, setData] = useState<ReferralOverview>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const requestVersion = useRef(0);
|
||||
const query = useMemo<ReferralsQuery>(
|
||||
() => ({ range, sort, order, limit }),
|
||||
[limit, order, range, sort],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setError(undefined);
|
||||
setData(undefined);
|
||||
try {
|
||||
setData(await adminApi.referrals(range));
|
||||
const response = await adminApi.referrals(query);
|
||||
if (requestVersion.current === version) setData(response);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
}
|
||||
}, [range]);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({ range, sort, order, limit: String(limit) });
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [limit, order, range, setSearchParams, sort]);
|
||||
|
||||
const columns = useMemo<DataColumn<ReferralRankingItem>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -117,6 +150,35 @@ export function ReferralsPage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<ChartToolbar label="邀请排行设置">
|
||||
<SortControl
|
||||
label="排行指标"
|
||||
value={sort}
|
||||
order={order}
|
||||
options={[
|
||||
{ value: "invited", label: "邀请人数" },
|
||||
{ value: "qualified", label: "有效邀请" },
|
||||
{ value: "creditsEarned", label: "奖励积分" },
|
||||
]}
|
||||
onChange={(value, nextOrder) => {
|
||||
setSort(value);
|
||||
setOrder(nextOrder);
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="排行数量"
|
||||
value={String(limit)}
|
||||
options={[
|
||||
{ value: "20", label: "前 20 名" },
|
||||
{ value: "50", label: "前 50 名" },
|
||||
{ value: "100", label: "前 100 名" },
|
||||
]}
|
||||
onChange={(value) => setLimit(Number(value))}
|
||||
/>
|
||||
</ChartToolbar>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border p-5 sm:p-6">
|
||||
@@ -180,6 +242,16 @@ export function ReferralsPage() {
|
||||
columns={columns}
|
||||
caption="有效邀请用户排行"
|
||||
emptyTitle="当前周期暂无排行数据"
|
||||
sort={{ key: sort, order }}
|
||||
sortableColumns={{
|
||||
invited: "invited",
|
||||
qualified: "qualified",
|
||||
creditsEarned: "creditsEarned",
|
||||
}}
|
||||
onSortChange={(key, nextOrder) => {
|
||||
setSort(key as NonNullable<ReferralsQuery["sort"]>);
|
||||
setOrder(nextOrder);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
@@ -25,8 +26,12 @@ import type {
|
||||
AdminOperatorProvisioning,
|
||||
AdminRole,
|
||||
AdminSecuritySummary,
|
||||
OperatorsQuery,
|
||||
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 {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -38,6 +43,8 @@ import {
|
||||
PageHeader,
|
||||
StatCard,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import { formatDateTime, formatNumber } from "../../lib/format";
|
||||
import { useAuth } from "../auth/auth-context";
|
||||
|
||||
@@ -51,12 +58,22 @@ interface ConfirmationState {
|
||||
export function SecurityPage() {
|
||||
const { auth } = useAuth();
|
||||
const currentUsername = auth.status === "authenticated" ? auth.operatorName : "";
|
||||
const [operators, setOperators] = useState<AdminOperator[]>([]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [summary, setSummary] = useState<AdminSecuritySummary>();
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [summaryError, setSummaryError] = useState<unknown>();
|
||||
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||
const [role, setRole] = useState<"" | AdminRole>(
|
||||
(searchParams.get("role") as AdminRole | null) ?? "",
|
||||
);
|
||||
const [enabled, setEnabled] = useState(searchParams.get("enabled") ?? "");
|
||||
const [locked, setLocked] = useState(searchParams.get("locked") ?? "");
|
||||
const [sort, setSort] = useState<NonNullable<OperatorsQuery["sort"]>>(
|
||||
(searchParams.get("sort") as OperatorsQuery["sort"] | null) ?? "createdAt",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [resetOperator, setResetOperator] = useState<AdminOperator>();
|
||||
const [confirmation, setConfirmation] = useState<ConfirmationState>();
|
||||
@@ -64,44 +81,60 @@ export function SecurityPage() {
|
||||
data: AdminOperatorProvisioning;
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
const operatorsQuery = useMemo<OperatorsQuery>(
|
||||
() => ({
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
role: role || undefined,
|
||||
enabled: enabled === "" ? undefined : enabled === "true",
|
||||
locked: locked === "" ? undefined : locked === "true",
|
||||
sort,
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[enabled, from, locked, order, role, sort, until],
|
||||
);
|
||||
const fetchOperators = useCallback(
|
||||
(value: OperatorsQuery) => adminApi.operators(value),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
items: operators,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(operatorsQuery, fetchOperators);
|
||||
const loadSummary = useCallback(async () => {
|
||||
setSummaryError(undefined);
|
||||
try {
|
||||
const [page, securitySummary] = await Promise.all([
|
||||
adminApi.operators(),
|
||||
adminApi.operatorSummary(),
|
||||
]);
|
||||
setOperators(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
setSummary(securitySummary);
|
||||
setSummary(await adminApi.operatorSummary());
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSummaryError(requestError);
|
||||
}
|
||||
}, []);
|
||||
const load = useCallback(async () => {
|
||||
reload();
|
||||
await loadSummary();
|
||||
}, [loadSummary, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
void loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await adminApi.operators(nextCursor);
|
||||
setOperators((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
toast.error(
|
||||
requestError instanceof ApiError ? requestError.message : "加载管理员失败",
|
||||
);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (until) params.set("until", until);
|
||||
if (role) params.set("role", role);
|
||||
if (enabled) params.set("enabled", enabled);
|
||||
if (locked) params.set("locked", locked);
|
||||
params.set("sort", sort);
|
||||
params.set("order", order);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [enabled, from, locked, order, role, setSearchParams, sort, until]);
|
||||
|
||||
async function handleConfirmedAction() {
|
||||
if (!confirmation) return;
|
||||
@@ -129,7 +162,7 @@ export function SecurityPage() {
|
||||
const columns = useMemo<DataColumn<AdminOperator>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "operator",
|
||||
id: "username",
|
||||
header: "管理员",
|
||||
cell: ({ row }) => {
|
||||
const current = row.original.username === currentUsername;
|
||||
@@ -177,6 +210,15 @@ export function SecurityPage() {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "创建时间",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted">
|
||||
{formatDateTime(String(getValue()))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "操作",
|
||||
@@ -193,7 +235,9 @@ export function SecurityPage() {
|
||||
[currentUsername],
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||
if (error || summaryError) {
|
||||
return <ErrorState error={error ?? summaryError} retry={() => void load()} />;
|
||||
}
|
||||
if (loading || !summary) return <LoadingState label="加载安全中心" />;
|
||||
|
||||
return (
|
||||
@@ -258,11 +302,84 @@ export function SecurityPage() {
|
||||
</div>
|
||||
<KeyRound className="size-5 text-muted" aria-hidden />
|
||||
</div>
|
||||
<TableToolbar
|
||||
active={Boolean(
|
||||
from ||
|
||||
until ||
|
||||
role ||
|
||||
enabled ||
|
||||
locked ||
|
||||
sort !== "createdAt" ||
|
||||
order !== "desc",
|
||||
)}
|
||||
onClear={() => {
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setRole("");
|
||||
setEnabled("");
|
||||
setLocked("");
|
||||
setSort("createdAt");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="角色"
|
||||
value={role}
|
||||
options={[
|
||||
{ value: "", label: "全部角色" },
|
||||
{ value: "SUPER_ADMIN", label: "超级管理员" },
|
||||
{ value: "SUPPORT", label: "支持人员" },
|
||||
{ value: "ANALYST", label: "分析员" },
|
||||
]}
|
||||
onChange={setRole}
|
||||
/>
|
||||
<FilterControl
|
||||
label="启用状态"
|
||||
value={enabled}
|
||||
options={[
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "true", label: "已启用" },
|
||||
{ value: "false", label: "已停用" },
|
||||
]}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
<FilterControl
|
||||
label="锁定状态"
|
||||
value={locked}
|
||||
options={[
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "true", label: "已锁定" },
|
||||
{ value: "false", label: "未锁定" },
|
||||
]}
|
||||
onChange={setLocked}
|
||||
/>
|
||||
</TableToolbar>
|
||||
<DataTable
|
||||
data={operators}
|
||||
columns={columns}
|
||||
caption="管理员账户与安全状态"
|
||||
emptyTitle="暂无管理员账户"
|
||||
emptyTitle={
|
||||
from || until || role || enabled || locked
|
||||
? "没有符合当前筛选条件的管理员"
|
||||
: "暂无管理员账户"
|
||||
}
|
||||
sort={{ key: sort, order }}
|
||||
sortableColumns={{
|
||||
username: "username",
|
||||
lastLoginAt: "lastLoginAt",
|
||||
createdAt: "createdAt",
|
||||
}}
|
||||
onSortChange={(key, nextOrder) => {
|
||||
setSort(key as NonNullable<OperatorsQuery["sort"]>);
|
||||
setOrder(nextOrder);
|
||||
}}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
|
||||
@@ -11,18 +11,26 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
LedgerEntryType,
|
||||
LedgerQuery,
|
||||
LedgerEntry,
|
||||
SortOrder,
|
||||
UserDetail,
|
||||
UsersQuery,
|
||||
UserSummary,
|
||||
UserUsageAggregate,
|
||||
} 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 {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -34,6 +42,8 @@ import {
|
||||
PageHeader,
|
||||
Textarea,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
formatDateTime,
|
||||
@@ -44,6 +54,15 @@ import {
|
||||
} 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: "调整" },
|
||||
];
|
||||
|
||||
export function UsersPage() {
|
||||
const { auth } = useAuth();
|
||||
const role = auth.status === "authenticated" ? auth.role : "ANALYST";
|
||||
@@ -61,51 +80,56 @@ export function UsersPage() {
|
||||
}
|
||||
|
||||
function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeQuery, setActiveQuery] = useState("");
|
||||
const [items, setItems] = useState<UserSummary[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const load = useCallback(async (search = "") => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const page = await adminApi.users(search);
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialQuery = searchParams.get("q") ?? "";
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [activeQuery, setActiveQuery] = useState(initialQuery);
|
||||
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||
const [status, setStatus] = useState<"" | UserSummary["status"]>(
|
||||
(searchParams.get("status") as UserSummary["status"] | null) ?? "",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const usersQuery = useMemo<UsersQuery>(
|
||||
() => ({
|
||||
q: activeQuery || undefined,
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
status: status || undefined,
|
||||
sort: "createdAt",
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[activeQuery, from, order, status, until],
|
||||
);
|
||||
const fetchUsers = useCallback((value: UsersQuery) => adminApi.users(value), []);
|
||||
const {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(usersQuery, fetchUsers);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
const params = new URLSearchParams();
|
||||
if (activeQuery) params.set("q", activeQuery);
|
||||
if (from) params.set("from", from);
|
||||
if (until) params.set("until", until);
|
||||
if (status) params.set("status", status);
|
||||
params.set("sort", "createdAt");
|
||||
params.set("order", order);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [activeQuery, from, order, setSearchParams, status, until]);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const search = query.trim();
|
||||
setActiveQuery(search);
|
||||
void load(search);
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await adminApi.users(activeQuery, nextCursor);
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
toast.error(requestError instanceof ApiError ? requestError.message : "加载用户失败");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataColumn<UserSummary>[]>(
|
||||
@@ -201,6 +225,35 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<TableToolbar
|
||||
active={Boolean(activeQuery || from || until || status || order !== "desc")}
|
||||
onClear={() => {
|
||||
setQuery("");
|
||||
setActiveQuery("");
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setStatus("");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="账户状态"
|
||||
value={status}
|
||||
options={[
|
||||
{ value: "", label: "全部状态" },
|
||||
{ value: "active", label: "正常" },
|
||||
{ value: "suspended", label: "已暂停" },
|
||||
]}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
</TableToolbar>
|
||||
<div className="flex items-center gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
|
||||
<Users className="size-4 text-primary" aria-hidden />
|
||||
{activeQuery ? "查询结果" : "全部用户"}
|
||||
@@ -209,7 +262,7 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="p-6">
|
||||
<ErrorState error={error} retry={() => void load(activeQuery)} />
|
||||
<ErrorState error={error} retry={reload} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载用户列表" />
|
||||
@@ -218,7 +271,14 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||
data={items}
|
||||
columns={columns}
|
||||
caption={activeQuery ? "用户查询结果" : "全部用户"}
|
||||
emptyTitle="未找到匹配用户"
|
||||
emptyTitle={
|
||||
activeQuery || from || until || status
|
||||
? "没有符合当前筛选条件的用户"
|
||||
: "暂无用户"
|
||||
}
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
@@ -244,6 +304,7 @@ 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>();
|
||||
@@ -251,34 +312,75 @@ function UserDetailView({
|
||||
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 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 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),
|
||||
adminApi.ledger(userId, ledgerQuery),
|
||||
]);
|
||||
if (requestVersion.current !== version) return;
|
||||
setUser(detail);
|
||||
setLedger(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (requestVersion.current === version) setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
}, [ledgerQuery, 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, nextCursor);
|
||||
const page = await adminApi.ledger(userId, { ...ledgerQuery, cursor: nextCursor });
|
||||
if (requestVersion.current !== version) return;
|
||||
setLedger((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
@@ -286,7 +388,7 @@ function UserDetailView({
|
||||
requestError instanceof ApiError ? requestError.message : "加载积分流水失败",
|
||||
);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
if (requestVersion.current === version) setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,11 +472,37 @@ 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}
|
||||
caption={`${user.displayName || user.userId} 的积分流水`}
|
||||
emptyTitle="暂无积分流水"
|
||||
emptyTitle={from || until || type ? "没有符合当前筛选条件的流水" : "暂无积分流水"}
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { PageResult } from "../api/types";
|
||||
|
||||
export function useCursorPage<T, Q extends { cursor?: string }>(
|
||||
query: Q,
|
||||
fetchPage: (query: Q) => Promise<PageResult<T>>,
|
||||
) {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const version = ++requestVersion.current;
|
||||
setItems([]);
|
||||
setNextCursor(undefined);
|
||||
setLoading(true);
|
||||
setLoadingMore(false);
|
||||
setError(undefined);
|
||||
void fetchPage(query)
|
||||
.then((page) => {
|
||||
if (requestVersion.current !== version) return;
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
})
|
||||
.catch((requestError) => {
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestVersion.current === version) setLoading(false);
|
||||
});
|
||||
}, [fetchPage, query, refreshKey]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor || loadingMore) return;
|
||||
const version = requestVersion.current;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await fetchPage({ ...query, cursor: nextCursor });
|
||||
if (requestVersion.current !== version) return;
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
} finally {
|
||||
if (requestVersion.current === version) setLoadingMore(false);
|
||||
}
|
||||
}, [fetchPage, loadingMore, nextCursor, query]);
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload: useCallback(() => setRefreshKey((current) => current + 1), []),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SortOrder } from "../api/types";
|
||||
|
||||
export function stableSort<T>(
|
||||
items: readonly T[],
|
||||
value: (item: T) => number | string,
|
||||
order: SortOrder,
|
||||
): T[] {
|
||||
const direction = order === "asc" ? 1 : -1;
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((left, right) => {
|
||||
const leftValue = value(left.item);
|
||||
const rightValue = value(right.item);
|
||||
const compared =
|
||||
typeof leftValue === "string" && typeof rightValue === "string"
|
||||
? leftValue.localeCompare(rightValue)
|
||||
: Number(leftValue) - Number(rightValue);
|
||||
return compared === 0 ? left.index - right.index : compared * direction;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
@@ -105,6 +105,42 @@ describe("adminApi", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("typed query 编码日期、筛选、排序与布尔值", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(async () =>
|
||||
new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await adminApi.users({
|
||||
q: "user + value",
|
||||
from: "2026-08-01T00:00:00.000Z",
|
||||
until: "2026-08-20T23:59:59.999Z",
|
||||
status: "suspended",
|
||||
sort: "createdAt",
|
||||
order: "asc",
|
||||
limit: 25,
|
||||
});
|
||||
await adminApi.operators({
|
||||
enabled: false,
|
||||
locked: true,
|
||||
sort: "username",
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toContain(
|
||||
"q=user+%2B+value&from=2026-08-01T00%3A00%3A00.000Z",
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toContain(
|
||||
"status=suspended&sort=createdAt&order=asc&limit=25",
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
"/v1/admin/operators?enabled=false&locked=true&sort=username&order=desc",
|
||||
);
|
||||
});
|
||||
|
||||
it("缺少 CSRF 时在发送变更请求前失败", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
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";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("筛选与排序基础能力", () => {
|
||||
it("DataTable 暴露受控 aria-sort 并通过表头切换方向", async () => {
|
||||
function TableHarness() {
|
||||
const [order, setOrder] = useState<SortOrder>("desc");
|
||||
const columns: DataColumn<{ createdAt: string }>[] = [
|
||||
{ accessorKey: "createdAt", header: "时间" },
|
||||
];
|
||||
return (
|
||||
<DataTable
|
||||
data={[{ createdAt: "2026-08-20T00:00:00Z" }]}
|
||||
columns={columns}
|
||||
caption="排序测试"
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TableHarness />);
|
||||
const header = screen.getByRole("columnheader", { name: /时间/ });
|
||||
expect(header.getAttribute("aria-sort")).toBe("descending");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /时间/ }));
|
||||
|
||||
expect(header.getAttribute("aria-sort")).toBe("ascending");
|
||||
});
|
||||
|
||||
it("稳定排序不修改输入并保留同值项目顺序", () => {
|
||||
const source = [
|
||||
{ id: "first", value: 2 },
|
||||
{ id: "second", value: 2 },
|
||||
{ id: "third", value: 1 },
|
||||
];
|
||||
const result = stableSort(source, (item) => item.value, "desc");
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual(["first", "second", "third"]);
|
||||
expect(source.map((item) => item.id)).toEqual(["first", "second", "third"]);
|
||||
expect(result).not.toBe(source);
|
||||
});
|
||||
|
||||
it("日期范围将结束日期转换为次日 UTC 半开边界", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DateRangeControl value={{}} onChange={onChange} />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("结束日期"), "2026-08-20");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
until: "2026-08-21T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,12 +36,49 @@ describe("React 管理页面", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next");
|
||||
expect(adminApi.users).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ cursor: "user-next", sort: "createdAt", order: "desc" }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("第二位用户")).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: /安全中心/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("用户排序变更会清空旧游标并重新请求", async () => {
|
||||
mockSession("SUPPORT");
|
||||
vi.spyOn(adminApi, "users")
|
||||
.mockResolvedValueOnce({
|
||||
items: [user("第一页", userId)],
|
||||
nextCursor: "stale-cursor",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [user("旧游标结果", "22222222-2222-4222-8222-222222222222")],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [user("升序结果", "33333333-3333-4333-8333-333333333333")],
|
||||
});
|
||||
window.location.hash = "#/users";
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByText("第一页")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
|
||||
expect(await screen.findByText("旧游标结果")).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /注册时间/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(adminApi.users).toHaveBeenCalledTimes(3);
|
||||
const refreshedQuery = vi.mocked(adminApi.users).mock.calls[2]?.[0];
|
||||
expect(refreshedQuery).toMatchObject({ order: "asc" });
|
||||
expect(refreshedQuery).not.toHaveProperty("cursor");
|
||||
});
|
||||
expect(await screen.findByText("升序结果")).toBeTruthy();
|
||||
expect(screen.queryByText("旧游标结果")).toBeNull();
|
||||
expect(window.location.hash).toContain("order=asc");
|
||||
expect(window.location.hash).not.toContain("cursor");
|
||||
});
|
||||
|
||||
it("积分页自动显示最新不可变流水", async () => {
|
||||
mockSession("SUPPORT");
|
||||
const entry: LedgerEntry = {
|
||||
@@ -81,7 +118,10 @@ describe("React 管理页面", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "加载更多管理员" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next");
|
||||
expect(adminApi.operators).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ cursor: "operator-next", sort: "createdAt", order: "desc" }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("support")).toBeTruthy();
|
||||
expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
|
||||
@@ -101,6 +141,32 @@ describe("React 管理页面", () => {
|
||||
expect(screen.getByText("文字润色")).toBeTruthy();
|
||||
expect(screen.getByText("7 天免费转付费")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("产品图表可按执行模式筛选并按用户数稳定排序", async () => {
|
||||
mockSession("SUPPORT");
|
||||
const overview = analyticsOverview();
|
||||
overview.aiFeatures = [
|
||||
{ feature: "POLISH", executionMode: "MANAGED", users: 10, successes: 100 },
|
||||
{ feature: "AI_ASSISTANT", executionMode: "MANAGED", users: 40, successes: 50 },
|
||||
{ feature: "HOTWORD", executionMode: "LOCAL", users: 80, successes: 200 },
|
||||
];
|
||||
vi.spyOn(adminApi, "productAnalytics").mockResolvedValue(overview);
|
||||
window.location.hash = "#/analytics";
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByText("快捷指令")).toBeTruthy();
|
||||
await userEvent.selectOptions(screen.getByLabelText("执行模式"), "MANAGED");
|
||||
expect(screen.queryByText("快捷指令")).toBeNull();
|
||||
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("成功次数"), "users");
|
||||
|
||||
const labels = screen
|
||||
.getAllByRole("progressbar")
|
||||
.map((element) => element.getAttribute("aria-label"));
|
||||
expect(labels.indexOf("AI 助手 成功次数:50")).toBeLessThan(
|
||||
labels.indexOf("文字润色 成功次数:100"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
|
||||
|
||||
Reference in New Issue
Block a user