feat(platform): 模型管理与后台能力 - 新增逻辑模型路由和响应拦截
This commit is contained in:
@@ -1,14 +1,83 @@
|
||||
import { App, Button, Dropdown, Tag } from "antd";
|
||||
import type { ButtonProps, MenuProps } from "antd";
|
||||
import { App, Button, Dropdown, Table } from "antd";
|
||||
import type { ButtonProps, MenuProps, TableProps } from "antd";
|
||||
import { saveAs } from "file-saver";
|
||||
import { CheckSquare2, Download, MoreHorizontal, SearchX, X } from "lucide-react";
|
||||
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";
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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,
|
||||
toolbarActiveFilters,
|
||||
onReset,
|
||||
trailing,
|
||||
batchActions,
|
||||
footer,
|
||||
table,
|
||||
empty,
|
||||
skeletonColumns = 6,
|
||||
skeletonRows = 8,
|
||||
className,
|
||||
}: {
|
||||
toolbar?: ReactNode;
|
||||
toolbarActive?: boolean;
|
||||
toolbarFilters?: ReactNode;
|
||||
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} 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>
|
||||
</div>
|
||||
{footer ? <div className="admin-table-pagination">{footer}</div> : null}
|
||||
</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";
|
||||
@@ -60,19 +129,27 @@ export function AdminTableEmpty({
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-64 flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<span className="grid size-11 place-items-center rounded-lg border border-border bg-muted/35 text-foreground/45">
|
||||
<SearchX className="size-5" />
|
||||
<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">
|
||||
<SearchX className="size-4" />
|
||||
</span>
|
||||
<div className="mt-3 text-sm font-medium">{title || (filtered ? "没有符合筛选条件的数据" : "暂无数据")}</div>
|
||||
<p className="mt-1 max-w-sm text-xs leading-5 text-foreground/50">
|
||||
{description || (filtered ? "调整搜索词或筛选条件后再试。" : "数据产生后会显示在这里。")}
|
||||
</p>
|
||||
{description ? <p className="mt-1 max-w-sm text-xs leading-5 text-foreground/50">{description}</p> : null}
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -117,12 +194,18 @@ export type AdminRowAction = {
|
||||
export function AdminRowActions({
|
||||
primary,
|
||||
actions,
|
||||
visibleActionCount,
|
||||
}: {
|
||||
primary?: { label: ReactNode; icon?: ReactNode; onClick: () => void; disabled?: boolean };
|
||||
primary?: { label: ReactNode; icon?: ReactNode; onClick: () => void | Promise<void>; disabled?: boolean };
|
||||
actions: AdminRowAction[];
|
||||
visibleActionCount?: number;
|
||||
}) {
|
||||
const { modal } = App.useApp();
|
||||
const items: MenuProps["items"] = actions.map((action) => ({
|
||||
// 少量操作直接露出,只有真正过多时才收进菜单,避免“更多”成为默认操作列。
|
||||
const resolvedVisibleActionCount = visibleActionCount ?? (actions.length <= 3 ? actions.length : 2);
|
||||
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,
|
||||
@@ -145,14 +228,28 @@ 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)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="admin-row-actions">
|
||||
{primary ? (
|
||||
<Button size="small" icon={primary.icon} disabled={primary.disabled} onClick={primary.onClick}>
|
||||
<Button type="text" size="small" className="admin-row-action admin-row-action-primary" disabled={primary.disabled} onClick={primary.onClick}>
|
||||
{primary.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{actions.length ? (
|
||||
{visibleActions.map(renderActionButton)}
|
||||
{menuActions.length ? (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
@@ -163,7 +260,10 @@ export function AdminRowActions({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button size="small" type="text" icon={<MoreHorizontal className="size-4" />} aria-label="更多操作" />
|
||||
<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>
|
||||
@@ -178,30 +278,35 @@ export function SettingsSectionCard({
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
contentClassName,
|
||||
layout = "split",
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description: 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("border-y border-border bg-background lg:grid lg:grid-cols-4", className)}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-4 py-4 lg:col-span-1 lg:block lg:border-b-0 lg:border-r">
|
||||
<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")}>
|
||||
<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">
|
||||
<h2 className="text-sm font-semibold leading-5">{title}</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/55">{description}</p>
|
||||
{description ? <p className="mt-1 text-xs leading-5 text-foreground/55">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{status ? <div className="shrink-0 lg:mt-4">{isStatusConfig(status) ? <Tag variant="filled" color={status.color}>{status.label}</Tag> : 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="min-w-0 lg:col-span-3">
|
||||
<div className={cn("min-w-0", isStacked ? "" : "lg:col-span-3", contentClassName)}>
|
||||
{children}
|
||||
{footer ? <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">{footer}</div> : null}
|
||||
{footer ? <div className="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