refactor(admin): 管理后台 UI - 独立设计系统并与用户端隔离
把后台 token、Ant 主题、壳层控件和抽屉弹窗从用户工作台拆开,去掉树状侧栏装饰线,色板仍用原来的中性黑白台阶。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
6892732b06
commit
9ce158aaa0
43 files changed
+1912
-1410
No files matched your search
@@ -1,31 +1,21 @@
|
||||
import { StatusBadge } from "@/components/ui/base/badges";
|
||||
import { EmptyState } from "@/components/ui/product/empty-state";
|
||||
import { App, Button, Dropdown, Table } from "antd";
|
||||
import { App, Button, Dropdown, Select, Table } from "antd";
|
||||
import type { ButtonProps, MenuProps, TableProps } from "antd";
|
||||
import { saveAs } from "file-saver";
|
||||
import { CheckSquare2, ChevronDown, Download, SearchX, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { CheckSquare2, ChevronDown, ChevronLeft, ChevronRight, Download, ListFilter, RotateCcw, SearchX, X } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { ListToolbar } from "@/components/layout/workspace-page";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const configuredSecretText = "已配置 · 留空不改";
|
||||
|
||||
export type AdminStatusTone = "neutral" | "success" | "warning" | "error" | "info";
|
||||
|
||||
/** admin 状态徽章:归并到自研 StatusBadge(outline 圆点胶囊),admin 全部页面零改动统一视觉。
|
||||
* tone 归一:info 在 admin 中本就是前景中性(--admin-status-info: var(--foreground))→ neutral。 */
|
||||
const adminToneToBadgeTone: Record<AdminStatusTone, "neutral" | "success" | "warning" | "error"> = {
|
||||
neutral: "neutral",
|
||||
success: "success",
|
||||
warning: "warning",
|
||||
error: "error",
|
||||
info: "neutral",
|
||||
};
|
||||
|
||||
export function AdminStatusBadge({ label, tone = "neutral", title }: { label: string; tone?: AdminStatusTone; title?: string }) {
|
||||
return <StatusBadge tone={adminToneToBadgeTone[tone]} title={title} label={label} />;
|
||||
export function AdminStatusBadge({ label, tone = "neutral", title }: { label: string; tone?: AdminStatusTone; title?: string; variant?: string }) {
|
||||
return (
|
||||
<span className={cn("admin-status-badge", tone !== "neutral" && `is-${tone}`)} title={title}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminStatTile({ label, value, detail, trend }: { label: string; value: string | number; detail?: string; trend?: { value: string; tone?: AdminStatusTone } }) {
|
||||
@@ -36,7 +26,7 @@ export function AdminStatTile({ label, value, detail, trend }: { label: string;
|
||||
{trend || detail ? (
|
||||
<div className="admin-stat-tile-detail">
|
||||
{trend ? <AdminStatusBadge label={trend.value} tone={trend.tone || "neutral"} /> : null}
|
||||
{trend && detail ? <span className="mx-1.5 text-foreground/25">·</span> : null}
|
||||
{trend && detail ? <span className="mx-1.5">·</span> : null}
|
||||
{detail ? <span>{detail}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -44,6 +34,115 @@ export function AdminStatTile({ label, value, detail, trend }: { label: string;
|
||||
);
|
||||
}
|
||||
|
||||
export function ListToolbar({
|
||||
children,
|
||||
filters,
|
||||
filtersAlwaysVisible = false,
|
||||
activeFilters,
|
||||
trailing,
|
||||
active,
|
||||
onReset,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
filters?: ReactNode;
|
||||
filtersAlwaysVisible?: boolean;
|
||||
activeFilters?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
active?: boolean;
|
||||
onReset?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) setFiltersOpen(true);
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div className={cn("admin-list-toolbar", className)}>
|
||||
<div className="admin-list-toolbar-main">
|
||||
{children}
|
||||
{filters ? (
|
||||
<>
|
||||
{!filtersAlwaysVisible ? (
|
||||
<Button type="default" className="admin-filter-toggle" aria-expanded={filtersOpen} icon={<ListFilter className="size-3.5" />} onClick={() => setFiltersOpen((open) => !open)}>
|
||||
筛选{active ? <span className="admin-filter-active-dot" aria-label="有已应用筛选" /> : null}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className={cn("admin-list-toolbar-filters", (filtersAlwaysVisible || filtersOpen) && "is-open")}>{filters}</div>
|
||||
</>
|
||||
) : null}
|
||||
{activeFilters ? <div className="admin-list-toolbar-chips">{activeFilters}</div> : null}
|
||||
</div>
|
||||
<div className="admin-list-toolbar-actions">
|
||||
{active && onReset ? (
|
||||
<Button type="text" icon={<RotateCcw className="size-3.5" />} onClick={onReset}>
|
||||
重置
|
||||
</Button>
|
||||
) : null}
|
||||
{trailing}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pageItems(current: number, pages: number): Array<number | "…"> {
|
||||
if (pages <= 7) return Array.from({ length: pages }, (_, index) => index + 1);
|
||||
if (current <= 4) return [1, 2, 3, 4, 5, "…", pages];
|
||||
if (current >= pages - 3) return [1, "…", pages - 4, pages - 3, pages - 2, pages - 1, pages];
|
||||
return [1, "…", current - 1, current, current + 1, "…", pages];
|
||||
}
|
||||
|
||||
export function PaginationBar({
|
||||
current,
|
||||
pageSize,
|
||||
total,
|
||||
onChange,
|
||||
pageSizeOptions = [20, 50, 100],
|
||||
alwaysShow = false,
|
||||
itemLabel = "条",
|
||||
}: {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onChange: (page: number, pageSize: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
alwaysShow?: boolean;
|
||||
itemLabel?: string;
|
||||
}) {
|
||||
if (!alwaysShow && total <= pageSize && current === 1) return null;
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const start = total === 0 ? 0 : (current - 1) * pageSize + 1;
|
||||
const end = total === 0 ? 0 : Math.min(total, current * pageSize);
|
||||
const items = pageItems(current, pages);
|
||||
return (
|
||||
<div className="admin-pagination-bar">
|
||||
<span className="admin-pagination-total">{total === 0 ? `共 0 ${itemLabel}` : `${start}-${end} / 共 ${total} ${itemLabel}`}</span>
|
||||
<Select size="small" value={pageSize} className="admin-pagination-size" options={pageSizeOptions.map((size) => ({ value: size, label: `${size} ${itemLabel}/页` }))} onChange={(value) => onChange(1, Number(value))} aria-label="每页条数" />
|
||||
<div className="admin-pagination-pages" role="navigation" aria-label="分页">
|
||||
<button type="button" className="admin-pagination-btn" disabled={current <= 1} aria-label="上一页" onClick={() => onChange(current - 1, pageSize)}>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
{items.map((item, index) =>
|
||||
item === "…" ? (
|
||||
<span key={`ellipsis-${index}`} className="admin-pagination-ellipsis">
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button key={item} type="button" className={cn("admin-pagination-btn", item === current && "is-active")} aria-current={item === current ? "page" : undefined} onClick={() => onChange(item, pageSize)}>
|
||||
{item}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button type="button" className="admin-pagination-btn" disabled={current >= pages} aria-label="下一页" onClick={() => onChange(current + 1, pageSize)}>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminDataTable<RecordType extends object>({
|
||||
toolbar,
|
||||
toolbarActive,
|
||||
@@ -140,7 +239,18 @@ export function AdminExportButton({
|
||||
}
|
||||
|
||||
export function AdminTableEmpty({ filtered = false, title, description, action }: { filtered?: boolean; title?: string; description?: string; action?: ReactNode }) {
|
||||
return <EmptyState size="compact" icon={SearchX} title={title || (filtered ? "没有符合筛选条件的数据" : "暂无数据")} description={description} action={action} className="min-h-40 w-full justify-center" />;
|
||||
return (
|
||||
<div className="admin-empty">
|
||||
<SearchX className="size-5" aria-hidden="true" />
|
||||
<h3>{title || (filtered ? "没有符合筛选条件的数据" : "暂无数据")}</h3>
|
||||
{description ? <p>{description}</p> : null}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminEmpty({ title, description, action }: { title?: string; description?: string; action?: ReactNode; size?: string; icon?: unknown }) {
|
||||
return <AdminTableEmpty title={title} description={description} action={action} />;
|
||||
}
|
||||
|
||||
export function AdminFilterChip({ label, onRemove }: { label: ReactNode; onRemove: () => void }) {
|
||||
@@ -155,16 +265,16 @@ export function AdminFilterChip({ label, onRemove }: { label: ReactNode; onRemov
|
||||
|
||||
export function AdminTableSkeleton({ rows = 8, columns = 6 }: { rows?: number; columns?: number }) {
|
||||
return (
|
||||
<div className="animate-pulse motion-reduce:animate-none" aria-label="正在加载表格" role="status">
|
||||
<div className="grid h-11 items-center gap-4 border-b border-border bg-muted/30 px-4" style={{ gridTemplateColumns: `repeat(${columns}, minmax(72px, 1fr))` }}>
|
||||
<div aria-label="正在加载表格" role="status">
|
||||
<div className="grid h-9 items-center gap-4 border-b px-3" style={{ borderColor: "var(--admin-border)", gridTemplateColumns: `repeat(${columns}, minmax(72px, 1fr))` }}>
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<span key={index} className="h-3 w-16 max-w-full rounded bg-foreground/10" />
|
||||
<span key={index} className="h-2.5 w-16 max-w-full rounded" style={{ background: "color-mix(in srgb, var(--admin-text) 10%, transparent)" }} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: Math.max(8, rows) }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} className="grid min-h-14 items-center gap-4 border-b border-border/70 px-4 last:border-b-0" style={{ gridTemplateColumns: `repeat(${columns}, minmax(72px, 1fr))` }}>
|
||||
<div key={rowIndex} className="grid h-10 items-center gap-4 border-b px-3 last:border-b-0" style={{ borderColor: "var(--admin-row-border)", gridTemplateColumns: `repeat(${columns}, minmax(72px, 1fr))` }}>
|
||||
{Array.from({ length: columns }).map((_, columnIndex) => (
|
||||
<span key={columnIndex} className={cn("h-3 rounded bg-foreground/[0.07]", columnIndex === 0 ? "w-4/5" : columnIndex === columns - 1 ? "w-10" : "w-2/3")} />
|
||||
<span key={columnIndex} className="h-2.5 rounded" style={{ width: columnIndex === 0 ? "80%" : columnIndex === columns - 1 ? "40px" : "66%", background: "color-mix(in srgb, var(--admin-text) 7%, transparent)" }} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -175,9 +285,9 @@ export function AdminTableSkeleton({ rows = 8, columns = 6 }: { rows?: number; c
|
||||
export function AdminBatchBar({ count, onClear, children }: { count: number; onClear: () => void; children: ReactNode }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<div className="admin-batch-bar sticky top-0 z-20 mt-3 flex min-h-11 flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-background/95 px-3 py-2 shadow-sm backdrop-blur">
|
||||
<div className="admin-batch-bar sticky top-0 z-20 flex min-h-11 flex-wrap items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CheckSquare2 className="size-4 text-foreground/60" />
|
||||
<CheckSquare2 className="size-4" />
|
||||
已选择 {count} 项
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -206,7 +316,6 @@ export type AdminRowAction = {
|
||||
|
||||
export function AdminRowActions({ primary, actions, visibleActionCount }: { primary?: { label: ReactNode; icon?: ReactNode; onClick: () => void | Promise<void>; disabled?: boolean }; actions: AdminRowAction[]; visibleActionCount?: number }) {
|
||||
const { modal } = App.useApp();
|
||||
// 行内只保留一个最常用的次操作;低频或危险操作收进菜单,避免操作列堆成一排文字链接。
|
||||
const resolvedVisibleActionCount = visibleActionCount ?? (actions.length <= 1 ? actions.length : 1);
|
||||
const visibleActions = actions.slice(0, Math.max(0, resolvedVisibleActionCount));
|
||||
const menuActions = actions.slice(Math.max(0, resolvedVisibleActionCount));
|
||||
@@ -292,12 +401,12 @@ export function SettingsSectionCard({
|
||||
const isStacked = layout === "stacked";
|
||||
return (
|
||||
<section className={cn("admin-settings-section", isStacked && "is-stacked", className)}>
|
||||
<div className={cn("admin-settings-section-summary flex flex-wrap items-start justify-between gap-3 px-4 py-4", icon && "has-icon")}>
|
||||
<div className="admin-settings-section-summary-main flex min-w-0 items-start gap-3">
|
||||
{icon ? <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/40">{icon}</span> : null}
|
||||
<div className={cn("admin-settings-section-summary", icon && "has-icon")}>
|
||||
<div className="admin-settings-section-summary-main flex min-w-0 items-center gap-3">
|
||||
{icon ? <span className="admin-feature-domain-icon">{icon}</span> : null}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold leading-5">{title}</h2>
|
||||
{description ? <p className="mt-1 text-xs leading-5 text-foreground/55">{description}</p> : null}
|
||||
<h2>{title}</h2>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{status ? (
|
||||
@@ -312,7 +421,7 @@ export function SettingsSectionCard({
|
||||
</div>
|
||||
<div className={cn("admin-settings-section-content min-w-0", contentClassName)}>
|
||||
{children}
|
||||
{footer ? <div className="admin-settings-section-footer flex flex-wrap items-center justify-between gap-3 px-4 py-3">{footer}</div> : null}
|
||||
{footer ? <div className="admin-settings-section-footer flex flex-wrap items-center justify-between gap-3 px-3 py-3">{footer}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user