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
+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">