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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user