Add admin dashboard filtering and sorting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

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:
Rocky
2026-08-20 18:05:49 +08:00
parent 034a3e8745
commit 74c3fcd45f
39 changed files with 2990 additions and 431 deletions
+41 -19
View File
@@ -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"),
+50
View File
@@ -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>
);
}
+46 -23
View File
@@ -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>
+52 -10
View File
@@ -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>
);
}
+57
View File
@@ -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={[
+110 -42
View File
@@ -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">
+97 -48
View File
@@ -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>
+156 -39
View File
@@ -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">
+175 -47
View File
@@ -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">
+62
View File
@@ -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), []),
};
}
+21
View File
@@ -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);
}
+36
View File
@@ -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);
+63
View File
@@ -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",
});
});
});
+68 -2
View File
@@ -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") {