[feat]:[FL-91][参考用户管理的布局样式修改ATP模型管理页面]
主要改动: 1. 更改Card组件:从自定义Card改为Ant Design原生Card(通过AntCard别名) 2. 调整容器布局:从Space改为div with space-y-6 class 3. 优化搜索区域:从Space wrap改为Form inline布局,增加搜索和重置筛选按钮 4. 增加动态表格滚动:添加tableScrollY状态和相关的ResizeObserver逻辑 5. 优化extra区域:增加Spin加载指示器显示数据加载状态 6. 统一样式:使Card title extra按钮布局与用户管理页面一致 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
@@ -14,21 +15,24 @@ import {
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
type CardProps,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react";
|
||||
|
||||
import { AdminPageLoading } from "@/components/admin-page-loading";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { CreatableSingleSelect } from "@/components/creatable-single-select";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import { getAtpAssetStatusDisplay } from "@/lib/atp-asset-display";
|
||||
import type { AtpAssetListResponse, AtpAssetSummary } from "@/types/auth";
|
||||
|
||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
||||
|
||||
type AssetFormValues = {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -155,6 +159,10 @@ function buildDimensionOptions(items: AtpAssetSummary[], picker: (item: AtpAsset
|
||||
.map((value) => ({ label: optionsMap.get(value) || value, value }));
|
||||
}
|
||||
|
||||
const ATP_TABLE_MIN_SCROLL_Y = 180;
|
||||
const ATP_TABLE_VIEWPORT_GAP = 40;
|
||||
const ATP_TABLE_FALLBACK_RESERVE = 220;
|
||||
|
||||
export default function AtpModelsPage() {
|
||||
const { message } = App.useApp();
|
||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||
@@ -166,6 +174,8 @@ export default function AtpModelsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
|
||||
const [editingAsset, setEditingAsset] = useState<AtpAssetSummary | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [tableScrollY, setTableScrollY] = useState(ATP_TABLE_MIN_SCROLL_Y);
|
||||
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const canRead = hasPermission("atp.read") || hasPermission("atp.run") || hasPermission("atp.manage");
|
||||
const canManage = hasPermission("atp.manage");
|
||||
@@ -243,6 +253,74 @@ export default function AtpModelsPage() {
|
||||
const sceneTypeOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.scene_type, DEFAULT_SCENE_TYPES), [assetItems]);
|
||||
const arresterConfigOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.arrester_config, DEFAULT_ARRESTER_CONFIGS), [assetItems]);
|
||||
|
||||
const updateTableScrollY = useCallback(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const anchor = tableScrollAnchorRef.current;
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorTop = anchor.getBoundingClientRect().top;
|
||||
const tableWrapper = anchor.querySelector<HTMLElement>(".ant-table-wrapper");
|
||||
const tableBody = anchor.querySelector<HTMLElement>(".ant-table-body");
|
||||
|
||||
let nextHeight = Math.floor(window.innerHeight - anchorTop - ATP_TABLE_FALLBACK_RESERVE);
|
||||
if (tableWrapper) {
|
||||
const wrapperRect = tableWrapper.getBoundingClientRect();
|
||||
const bodyHeight = tableBody?.getBoundingClientRect().height ?? ATP_TABLE_MIN_SCROLL_Y;
|
||||
const nonBodyHeight = Math.max(0, wrapperRect.height - bodyHeight);
|
||||
const topGap = Math.max(0, wrapperRect.top - anchorTop);
|
||||
nextHeight = Math.floor(window.innerHeight - anchorTop - topGap - nonBodyHeight - ATP_TABLE_VIEWPORT_GAP);
|
||||
}
|
||||
|
||||
const clampedHeight = Math.max(ATP_TABLE_MIN_SCROLL_Y, nextHeight);
|
||||
setTableScrollY((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(updateTableScrollY);
|
||||
}, [assetsQuery.error, keyword, statusFilter, assetItems.length, assetsQuery.isFetching, updateTableScrollY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const onViewportChange = () => {
|
||||
window.requestAnimationFrame(updateTableScrollY);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onViewportChange);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onViewportChange);
|
||||
};
|
||||
}, [updateTableScrollY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = tableScrollAnchorRef.current;
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
window.requestAnimationFrame(updateTableScrollY);
|
||||
});
|
||||
resizeObserver.observe(anchor);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [updateTableScrollY]);
|
||||
|
||||
const columns = useMemo<ColumnsType<AtpAssetSummary>>(
|
||||
() => [
|
||||
{
|
||||
@@ -339,47 +417,61 @@ export default function AtpModelsPage() {
|
||||
|
||||
if (!user || !canRead) {
|
||||
return (
|
||||
<Card title="ATP 模型管理">
|
||||
<AntCard title="ATP 模型管理">
|
||||
<Typography.Text type="secondary">
|
||||
{!user ? "请先登录后再查看 ATP 模型管理。" : "当前账号无 ATP 模块权限(需要 atp.read/atp.run/atp.manage)。"}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</AntCard>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
setKeyword(keywordInput);
|
||||
};
|
||||
|
||||
const handleResetSearch = () => {
|
||||
setKeywordInput("");
|
||||
setKeyword("");
|
||||
setStatusFilter(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Card
|
||||
<div className="space-y-6">
|
||||
<AntCard
|
||||
title="ATP 模型管理"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setEditingAsset(null);
|
||||
form.setFieldsValue(EMPTY_FORM);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建模型
|
||||
</Button>
|
||||
}
|
||||
extra={(
|
||||
<Space>
|
||||
{assetsQuery.isFetching && <Spin size="small" />}
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setEditingAsset(null);
|
||||
form.setFieldsValue(EMPTY_FORM);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建模型
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
<Form layout="inline" style={{ rowGap: 12 }}>
|
||||
<Form.Item label="关键词" className="min-w-[240px]">
|
||||
<Input
|
||||
allowClear
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onSearch={(value) => setKeyword(value)}
|
||||
onPressEnter={handleSearch}
|
||||
placeholder="按编码 / 名称 / 描述搜索"
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="状态" className="min-w-[170px]">
|
||||
<Select
|
||||
allowClear
|
||||
value={statusFilter}
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 180 }}
|
||||
onChange={(value) => setStatusFilter(value)}
|
||||
options={[
|
||||
{ value: "draft", label: "草稿" },
|
||||
@@ -388,12 +480,28 @@ export default function AtpModelsPage() {
|
||||
{ value: "archived", label: "归档" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
{assetsQuery.error instanceof Error ? (
|
||||
<Alert type="error" showIcon message="ATP 模型加载失败" description={assetsQuery.error.message} />
|
||||
) : null}
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button onClick={handleResetSearch}>重置筛选</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{assetsQuery.error instanceof Error ? (
|
||||
<Alert type="error" showIcon message="ATP 模型加载失败" description={assetsQuery.error.message} className="mt-4" />
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={tableScrollAnchorRef}
|
||||
className="admin-atp-models-table-anchor mt-4"
|
||||
style={{ "--admin-atp-models-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
|
||||
>
|
||||
<Table<AtpAssetSummary>
|
||||
rowKey="id"
|
||||
loading={assetsQuery.isLoading}
|
||||
@@ -401,10 +509,10 @@ export default function AtpModelsPage() {
|
||||
dataSource={assetItems}
|
||||
locale={{ emptyText: "暂无 ATP 模型" }}
|
||||
pagination={false}
|
||||
scroll={{ x: 1080 }}
|
||||
scroll={{ x: 1080, y: tableScrollY }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</AntCard>
|
||||
|
||||
<Modal
|
||||
title={editingAsset ? "编辑 ATP 模型" : "新建 ATP 模型"}
|
||||
@@ -476,6 +584,6 @@ export default function AtpModelsPage() {
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user