style(admin): 管理后台 - 统一工作区与模型编辑器布局
This commit is contained in:
@@ -13,7 +13,11 @@ export const configuredSecretText = "已配置 · 留空不改";
|
||||
export type AdminStatusTone = "neutral" | "success" | "warning" | "error" | "info";
|
||||
|
||||
export function AdminStatusBadge({ label, tone = "neutral", title }: { label: string; tone?: AdminStatusTone; title?: string }) {
|
||||
return <span className="admin-status-badge" data-tone={tone} title={title}>{label}</span>;
|
||||
return (
|
||||
<span className="admin-status-badge" data-tone={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,6 +40,7 @@ export function AdminDataTable<RecordType extends object>({
|
||||
toolbar,
|
||||
toolbarActive,
|
||||
toolbarFilters,
|
||||
toolbarFiltersAlwaysVisible = true,
|
||||
toolbarActiveFilters,
|
||||
onReset,
|
||||
trailing,
|
||||
@@ -50,6 +55,7 @@ export function AdminDataTable<RecordType extends object>({
|
||||
toolbar?: ReactNode;
|
||||
toolbarActive?: boolean;
|
||||
toolbarFilters?: ReactNode;
|
||||
toolbarFiltersAlwaysVisible?: boolean;
|
||||
toolbarActiveFilters?: ReactNode;
|
||||
onReset?: () => void;
|
||||
trailing?: ReactNode;
|
||||
@@ -66,14 +72,18 @@ export function AdminDataTable<RecordType extends object>({
|
||||
|
||||
return (
|
||||
<div className="admin-data-table">
|
||||
{toolbar ? <ListToolbar active={toolbarActive} filters={toolbarFilters} activeFilters={toolbarActiveFilters} onReset={onReset} trailing={trailing}>{toolbar}</ListToolbar> : null}
|
||||
{toolbar ? (
|
||||
<ListToolbar active={toolbarActive} filters={toolbarFilters} filtersAlwaysVisible={toolbarFiltersAlwaysVisible} activeFilters={toolbarActiveFilters} onReset={onReset} trailing={trailing}>
|
||||
{toolbar}
|
||||
</ListToolbar>
|
||||
) : null}
|
||||
{batchActions}
|
||||
<div className={cn("admin-table-surface", className)}>
|
||||
<div className="admin-table-scroll">
|
||||
{showSkeleton ? <AdminTableSkeleton rows={skeletonRows} columns={skeletonColumns} /> : <Table {...table} locale={{ ...table.locale, emptyText: empty ?? table.locale?.emptyText }} />}
|
||||
<div className={cn("admin-table-frame", footer && "has-pagination")}>
|
||||
<div className={cn("admin-table-surface", className)}>
|
||||
<div className="admin-table-scroll">{showSkeleton ? <AdminTableSkeleton rows={skeletonRows} columns={skeletonColumns} /> : <Table {...table} locale={{ ...table.locale, emptyText: empty ?? table.locale?.emptyText }} />}</div>
|
||||
</div>
|
||||
{footer ? <div className="admin-table-pagination">{footer}</div> : null}
|
||||
</div>
|
||||
{footer ? <div className="admin-table-pagination">{footer}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,20 +124,14 @@ export function AdminExportButton({
|
||||
}
|
||||
};
|
||||
|
||||
return <Button {...buttonProps} size={size} icon={<Download className={size === "small" ? "size-3.5" : "size-4"} />} loading={exporting} onClick={() => void runExport()}>{label}</Button>;
|
||||
return (
|
||||
<Button {...buttonProps} size={size} icon={<Download className={size === "small" ? "size-3.5" : "size-4"} />} loading={exporting} onClick={() => void runExport()}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminTableEmpty({
|
||||
filtered = false,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
filtered?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
export function AdminTableEmpty({ filtered = false, title, description, action }: { filtered?: boolean; title?: string; description?: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-40 flex-col items-center justify-center px-6 py-8 text-center">
|
||||
<span className="grid size-9 place-items-center rounded-md bg-muted/35 text-foreground/45">
|
||||
@@ -154,7 +158,9 @@ export function AdminTableSkeleton({ rows = 8, columns = 6 }: { rows?: number; c
|
||||
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))` }}>
|
||||
{Array.from({ length: columns }).map((_, index) => <span key={index} className="h-3 w-16 max-w-full rounded bg-foreground/10" />)}
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<span key={index} className="h-3 w-16 max-w-full rounded bg-foreground/10" />
|
||||
))}
|
||||
</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))` }}>
|
||||
@@ -171,8 +177,16 @@ export function AdminBatchBar({ count, onClear, children }: { count: number; onC
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<div className="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="flex items-center gap-2 text-sm font-medium"><CheckSquare2 className="size-4 text-foreground/60" />已选择 {count} 项</div>
|
||||
<div className="flex flex-wrap items-center gap-2">{children}<Button type="text" size="small" icon={<X className="size-3.5" />} onClick={onClear}>取消选择</Button></div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CheckSquare2 className="size-4 text-foreground/60" />
|
||||
已选择 {count} 项
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{children}
|
||||
<Button type="text" size="small" icon={<X className="size-3.5" />} onClick={onClear}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,18 +205,10 @@ export type AdminRowAction = {
|
||||
};
|
||||
};
|
||||
|
||||
export function AdminRowActions({
|
||||
primary,
|
||||
actions,
|
||||
visibleActionCount,
|
||||
}: {
|
||||
primary?: { label: ReactNode; icon?: ReactNode; onClick: () => void | Promise<void>; disabled?: boolean };
|
||||
actions: AdminRowAction[];
|
||||
visibleActionCount?: number;
|
||||
}) {
|
||||
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 <= 3 ? actions.length : 2);
|
||||
// 行内只保留一个最常用的次操作;低频或危险操作收进菜单,避免操作列堆成一排文字链接。
|
||||
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));
|
||||
const items: MenuProps["items"] = menuActions.map((action) => ({
|
||||
@@ -229,14 +235,7 @@ export function AdminRowActions({
|
||||
};
|
||||
|
||||
const renderActionButton = (action: AdminRowAction) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="text"
|
||||
size="small"
|
||||
className={cn("admin-row-action", action.danger && "admin-row-action-danger")}
|
||||
disabled={action.disabled}
|
||||
onClick={() => runAction(action)}
|
||||
>
|
||||
<Button key={action.key} type="text" size="small" className={cn("admin-row-action", action.danger && "admin-row-action-danger")} icon={action.icon} disabled={action.disabled} onClick={() => runAction(action)}>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
@@ -244,7 +243,7 @@ export function AdminRowActions({
|
||||
return (
|
||||
<div className="admin-row-actions">
|
||||
{primary ? (
|
||||
<Button type="text" size="small" className="admin-row-action admin-row-action-primary" disabled={primary.disabled} onClick={primary.onClick}>
|
||||
<Button type="text" size="small" className="admin-row-action admin-row-action-primary" icon={primary.icon} disabled={primary.disabled} onClick={primary.onClick}>
|
||||
{primary.label}
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -293,8 +292,8 @@ export function SettingsSectionCard({
|
||||
}) {
|
||||
const isStacked = layout === "stacked";
|
||||
return (
|
||||
<section className={cn("admin-settings-section", isStacked ? "" : "lg:grid lg:grid-cols-4", className)}>
|
||||
<div className={cn("flex flex-wrap items-start justify-between gap-3 px-4 py-4", isStacked ? "" : "lg:col-span-1 lg:block")}>
|
||||
<section className={cn("admin-settings-section", isStacked ? "is-stacked" : "lg:grid lg:grid-cols-4", className)}>
|
||||
<div className={cn("admin-settings-section-summary flex flex-wrap items-start justify-between gap-3 px-4 py-4", isStacked ? "" : "lg:col-span-1 lg:block")}>
|
||||
<div className="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="min-w-0">
|
||||
@@ -302,11 +301,19 @@ export function SettingsSectionCard({
|
||||
{description ? <p className="mt-1 text-xs leading-5 text-foreground/55">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{status ? <div className={cn("shrink-0", isStacked ? "" : "lg:mt-4")}>{isStatusConfig(status) ? <AdminStatusBadge label={status.label} tone={status.color === "success" ? "success" : status.color === "warning" ? "warning" : status.color === "error" ? "error" : status.color === "blue" ? "info" : "neutral"} /> : status}</div> : null}
|
||||
{status ? (
|
||||
<div className={cn("shrink-0", isStacked ? "" : "lg:mt-4")}>
|
||||
{isStatusConfig(status) ? (
|
||||
<AdminStatusBadge label={status.label} tone={status.color === "success" ? "success" : status.color === "warning" ? "warning" : status.color === "error" ? "error" : status.color === "blue" ? "info" : "neutral"} />
|
||||
) : (
|
||||
status
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn("min-w-0", isStacked ? "" : "lg:col-span-3", contentClassName)}>
|
||||
<div className={cn("admin-settings-section-content min-w-0", isStacked ? "" : "lg:col-span-3", contentClassName)}>
|
||||
{children}
|
||||
{footer ? <div className="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-4 py-3">{footer}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user