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
@@ -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>
);
}