Files
ttstd-infinite-canvas/web/src/pages/admin/components/admin-ui.tsx

320 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { StatusBadge } from "@/components/ui/base/badges";
import { EmptyState } from "@/components/ui/product/empty-state";
import { App, Button, Dropdown, 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 { 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 AdminStatTile({ label, value, detail, trend }: { label: string; value: string | number; detail?: string; trend?: { value: string; tone?: AdminStatusTone } }) {
return (
<div className="admin-stat-tile">
<div className="admin-stat-tile-label">{label}</div>
<div className="admin-stat-tile-value">{value}</div>
{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}
{detail ? <span>{detail}</span> : null}
</div>
) : null}
</div>
);
}
export function AdminDataTable<RecordType extends object>({
toolbar,
toolbarActive,
toolbarFilters,
toolbarFiltersAlwaysVisible = true,
toolbarActiveFilters,
onReset,
trailing,
batchActions,
footer,
table,
empty,
skeletonColumns = 6,
skeletonRows = 8,
className,
}: {
toolbar?: ReactNode;
toolbarActive?: boolean;
toolbarFilters?: ReactNode;
toolbarFiltersAlwaysVisible?: boolean;
toolbarActiveFilters?: ReactNode;
onReset?: () => void;
trailing?: ReactNode;
batchActions?: ReactNode;
footer?: ReactNode;
table: TableProps<RecordType>;
empty?: ReactNode;
skeletonColumns?: number;
skeletonRows?: number;
className?: string;
}) {
const dataSource = table.dataSource as readonly RecordType[] | undefined;
const showSkeleton = Boolean(table.loading) && !dataSource?.length;
return (
<div className="admin-data-table">
{toolbar ? (
<ListToolbar active={toolbarActive} filters={toolbarFilters} filtersAlwaysVisible={toolbarFiltersAlwaysVisible} activeFilters={toolbarActiveFilters} onReset={onReset} trailing={trailing}>
{toolbar}
</ListToolbar>
) : null}
{batchActions}
<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>
</div>
);
}
function isStatusConfig(value: ReactNode | { label: string; color?: string }): value is { label: string; color?: string } {
if (!value || typeof value !== "object") return false;
return typeof (value as { label?: unknown }).label === "string";
}
export function AdminExportButton({
exportFile,
fileName,
label = "导出",
successMessage,
errorMessage = "导出失败",
size,
...buttonProps
}: Omit<ButtonProps, "children" | "icon" | "loading" | "onClick"> & {
exportFile: () => Blob | Promise<Blob>;
fileName: string | (() => string);
label?: string;
successMessage?: string;
errorMessage?: string;
}) {
const { message } = App.useApp();
const [exporting, setExporting] = useState(false);
const runExport = async () => {
setExporting(true);
try {
const blob = await exportFile();
saveAs(blob, typeof fileName === "function" ? fileName() : fileName);
if (successMessage) message.success(successMessage);
} catch (error) {
message.error(error instanceof Error ? error.message : errorMessage);
} finally {
setExporting(false);
}
};
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 }) {
return <EmptyState size="compact" icon={SearchX} title={title || (filtered ? "没有符合筛选条件的数据" : "暂无数据")} description={description} action={action} className="min-h-40 w-full justify-center" />;
}
export function AdminFilterChip({ label, onRemove }: { label: ReactNode; onRemove: () => void }) {
return (
<button type="button" className="admin-filter-chip" onClick={onRemove}>
<span>{label}</span>
<X className="size-3" aria-hidden="true" />
<span className="sr-only">移除筛选</span>
</button>
);
}
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))` }}>
{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))` }}>
{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")} />
))}
</div>
))}
</div>
);
}
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="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>
);
}
export type AdminRowAction = {
key: string;
label: ReactNode;
icon?: ReactNode;
danger?: boolean;
disabled?: boolean;
onClick: () => void | Promise<void>;
confirm?: {
title: string;
description: string;
okText: string;
};
};
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));
const items: MenuProps["items"] = menuActions.map((action) => ({
key: action.key,
label: action.label,
icon: action.icon,
danger: action.danger,
disabled: action.disabled,
}));
const runAction = (action: AdminRowAction) => {
if (!action.confirm) {
void action.onClick();
return;
}
modal.confirm({
title: action.confirm.title,
content: action.confirm.description,
okText: action.confirm.okText,
cancelText: "取消",
okButtonProps: { danger: action.danger },
onOk: action.onClick,
});
};
const renderActionButton = (action: AdminRowAction) => (
<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>
);
return (
<div className="admin-row-actions">
{primary ? (
<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}
{visibleActions.map(renderActionButton)}
{menuActions.length ? (
<Dropdown
trigger={["click"]}
menu={{
items,
onClick: ({ key }) => {
const action = actions.find((item) => item.key === key);
if (action) runAction(action);
},
}}
>
<Button type="text" size="small" className="admin-row-action admin-row-action-more" aria-label="更多操作">
<span>更多</span>
<ChevronDown className="admin-row-action-chevron" aria-hidden="true" />
</Button>
</Dropdown>
) : null}
</div>
);
}
export function SettingsSectionCard({
icon,
title,
description,
status,
children,
footer,
className,
contentClassName,
layout = "split",
}: {
icon?: ReactNode;
title: string;
description?: string;
status?: { label: string; color?: string } | ReactNode;
children: ReactNode;
footer?: ReactNode;
className?: string;
contentClassName?: string;
layout?: "split" | "stacked";
}) {
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="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}
</div>
</div>
{status ? (
<div className="admin-settings-section-status shrink-0">
{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("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}
</div>
</section>
);
}