Commit f8264ec9 authored by Andy-bubu's avatar Andy-bubu

feat: align model groups with HeroUI file tree

parent 00b6e86c
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { Edit, Eye } from '@/components/AppIcons'; import { Delete, Edit, Eye, FolderOpenIcon, More, PlusIcon } from '@/components/AppIcons';
import { import {
AlertDialog, AlertDialog,
Button, Button,
Checkbox, Checkbox,
Description, Description,
Dropdown,
Input, Input,
Label, Label,
ProgressBar, ProgressBar,
ScrollShadow, ScrollShadow,
Surface, Surface,
Tabs,
TextArea, TextArea,
TextField, TextField,
} from '@heroui/react'; } from '@heroui/react';
import { DataGrid } from '@heroui-pro/react/data-grid'; import { DataGrid } from '@heroui-pro/react/data-grid';
import { FileTree } from '@heroui-pro/react/file-tree';
import { ListView } from '@heroui-pro/react/list-view'; import { ListView } from '@heroui-pro/react/list-view';
import { Stepper } from '@heroui-pro/react/stepper'; import { Stepper } from '@heroui-pro/react/stepper';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
...@@ -76,13 +77,45 @@ function flattenGroupOptions(nodes, depth = 0) { ...@@ -76,13 +77,45 @@ function flattenGroupOptions(nodes, depth = 0) {
]); ]);
} }
function flattenGroupTabs(nodes, ancestors = []) { function groupTreeItemClassName({ isHovered, isSelected }) {
return nodes.flatMap((node) => { const stateClass = isSelected ? '!bg-surface-secondary' : isHovered ? '!bg-surface-tertiary' : '';
const path = [...ancestors, node.groupName]; return ['mb-1', stateClass].filter(Boolean).join(' ');
return [ }
{ value: node.id, label: path.join(' / ') },
...flattenGroupTabs(node.children || [], path), function GroupTreeItems({ nodes, onEditGroup, onRequestDelete }) {
]; return nodes.map((node) => {
const hasChildItems = Boolean(node.children?.length);
return (
<FileTree.Item
hasChildItems={hasChildItems}
icon={<FolderOpenIcon />}
id={node.id}
key={node.id}
className={groupTreeItemClassName}
textValue={node.groupName}
title={(
<span className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate">{node.groupName}</span>
<Dropdown>
<Dropdown.Trigger aria-label={`分组操作 ${node.groupName}`} isIconOnly size="sm" variant="ghost">
<More />
</Dropdown.Trigger>
<Dropdown.Popover className="w-40 min-w-0" placement="bottom end">
<Dropdown.Menu aria-label={`${node.groupName}操作`} className="w-full min-w-0" onAction={(key) => {
if (key === 'edit') onEditGroup(node);
if (key === 'delete') onRequestDelete(node);
}}>
<Dropdown.Item id="edit" textValue="编辑分组"><Edit slot="icon" />编辑分组</Dropdown.Item>
<Dropdown.Item id="delete" textValue="删除分组" variant="danger"><Delete slot="icon" />删除分组</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</span>
)}
>
{hasChildItems ? <GroupTreeItems nodes={node.children} onEditGroup={onEditGroup} onRequestDelete={onRequestDelete} /> : null}
</FileTree.Item>
);
}); });
} }
...@@ -106,6 +139,7 @@ export default function Models() { ...@@ -106,6 +139,7 @@ export default function Models() {
const [models, setModels] = useState([]); const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [activeGroupId, setActiveGroupId] = useState(ROOT_GROUP_ID); const [activeGroupId, setActiveGroupId] = useState(ROOT_GROUP_ID);
const [expandedGroupIds, setExpandedGroupIds] = useState(new Set([ROOT_GROUP_ID]));
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
...@@ -115,6 +149,7 @@ export default function Models() { ...@@ -115,6 +149,7 @@ export default function Models() {
const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false); const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false);
const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false); const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false);
const [deleteTargetGroup, setDeleteTargetGroup] = useState(null);
const [groupForm, setGroupForm] = useState({ recordName: '', groupName: '', parentGroupId: '' }); const [groupForm, setGroupForm] = useState({ recordName: '', groupName: '', parentGroupId: '' });
const [isModelDialogOpen, setIsModelDialogOpen] = useState(false); const [isModelDialogOpen, setIsModelDialogOpen] = useState(false);
const [editingModel, setEditingModel] = useState(null); const [editingModel, setEditingModel] = useState(null);
...@@ -155,7 +190,6 @@ export default function Models() { ...@@ -155,7 +190,6 @@ export default function Models() {
const groupTree = useMemo(() => buildGroupTree(groups), [groups]); const groupTree = useMemo(() => buildGroupTree(groups), [groups]);
const groupOptions = useMemo(() => flattenGroupOptions(groupTree), [groupTree]); const groupOptions = useMemo(() => flattenGroupOptions(groupTree), [groupTree]);
const groupTabs = useMemo(() => flattenGroupTabs(groupTree), [groupTree]);
const selectedGroup = groups.find((group) => group.id === activeGroupId); const selectedGroup = groups.find((group) => group.id === activeGroupId);
const filteredModels = useMemo(() => { const filteredModels = useMemo(() => {
const groupIds = activeGroupId === ROOT_GROUP_ID ? null : collectDescendantIds(groups, activeGroupId); const groupIds = activeGroupId === ROOT_GROUP_ID ? null : collectDescendantIds(groups, activeGroupId);
...@@ -195,7 +229,7 @@ export default function Models() { ...@@ -195,7 +229,7 @@ export default function Models() {
{ id: 'status', header: '同步状态', minWidth: 120, cell: (model) => <SyncStatusTag status={model.lastSyncStatus} /> }, { id: 'status', header: '同步状态', minWidth: 120, cell: (model) => <SyncStatusTag status={model.lastSyncStatus} /> },
{ id: 'syncedAt', header: '最近同步', minWidth: 180, allowsSorting: true, cell: (model) => formatDateTime(model.lastSyncedAt) }, { id: 'syncedAt', header: '最近同步', minWidth: 180, allowsSorting: true, cell: (model) => formatDateTime(model.lastSyncedAt) },
{ {
id: 'actions', header: '操作', width: 112, id: 'actions', header: '操作', width: 112, minWidth: 112, maxWidth: 112, pinned: 'end',
cell: (model) => <div className="flex items-center gap-2"><DataGridActionButton label={`查看 ${model.modelName}`} onPress={() => history.push(`/models/${model.id}/batches`)}><Eye /></DataGridActionButton><DataGridActionButton label={`编辑 ${model.modelName}`} onPress={() => openModelDialog(model)}><Edit /></DataGridActionButton></div>, cell: (model) => <div className="flex items-center gap-2"><DataGridActionButton label={`查看 ${model.modelName}`} onPress={() => history.push(`/models/${model.id}/batches`)}><Eye /></DataGridActionButton><DataGridActionButton label={`编辑 ${model.modelName}`} onPress={() => openModelDialog(model)}><Edit /></DataGridActionButton></div>,
}, },
], [groups]); ], [groups]);
...@@ -205,9 +239,9 @@ export default function Models() { ...@@ -205,9 +239,9 @@ export default function Models() {
setIsGroupDialogOpen(true); setIsGroupDialogOpen(true);
} }
function openEditGroup() { function openEditGroup(group = selectedGroup) {
if (!selectedGroup) return; if (!group) return;
setGroupForm({ recordName: selectedGroup.recordName, groupName: selectedGroup.groupName, parentGroupId: selectedGroup.parentGroupId }); setGroupForm({ recordName: group.recordName, groupName: group.groupName, parentGroupId: group.parentGroupId });
setIsGroupDialogOpen(true); setIsGroupDialogOpen(true);
} }
...@@ -233,19 +267,21 @@ export default function Models() { ...@@ -233,19 +267,21 @@ export default function Models() {
} }
async function handleDeleteGroup() { async function handleDeleteGroup() {
if (!selectedGroup) return; const group = deleteTargetGroup || selectedGroup;
const hasChildren = groups.some((group) => group.parentGroupId === selectedGroup.id); if (!group) return;
const hasModels = models.some((model) => model.groupId === selectedGroup.id); const hasChildren = groups.some((item) => item.parentGroupId === group.id);
const hasModels = models.some((model) => model.groupId === group.id);
if (hasChildren || hasModels) { if (hasChildren || hasModels) {
setError('请先迁移当前分组中的子分组和模型。'); setError('请先迁移当前分组中的子分组和模型。');
return; return;
} }
try { try {
await deleteModelGroup(selectedGroup); await deleteModelGroup(group);
setActiveGroupId(ROOT_GROUP_ID); setActiveGroupId(ROOT_GROUP_ID);
await refresh(); await refresh();
setNotice('空分组已删除。'); setNotice('空分组已删除。');
setIsDeleteGroupDialogOpen(false); setIsDeleteGroupDialogOpen(false);
setDeleteTargetGroup(null);
} catch (deleteError) { } catch (deleteError) {
setError(deleteError?.message || '模型分组删除失败。'); setError(deleteError?.message || '模型分组删除失败。');
} }
...@@ -427,41 +463,78 @@ export default function Models() { ...@@ -427,41 +463,78 @@ export default function Models() {
{!loading ? ( {!loading ? (
<> <>
<div className="min-w-0 max-w-full"> <div className="grid min-w-0 gap-5 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start">
<Tabs className="w-fit max-w-full min-w-0" selectedKey={activeGroupId} variant="primary" onSelectionChange={(key) => { setActiveGroupId(String(key)); setPage(0); }}> <Surface className="min-w-0 rounded-3xl p-4" variant="default">
<Tabs.ListContainer className="w-fit max-w-full min-w-0"> <div className="flex min-w-0 items-center justify-between gap-3">
<Tabs.List aria-label="模型分组" className="w-max min-w-max"> <h2 className="rc-module-title min-w-0 truncate">视图分组</h2>
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={ROOT_GROUP_ID}>全部模型<Tabs.Indicator /></Tabs.Tab> <Button aria-label="新建分组" isIconOnly size="sm" variant="tertiary" onPress={() => openCreateGroup()}>
{groupTabs.map((group) => <Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={group.value} key={group.value}>{group.label}<Tabs.Indicator /></Tabs.Tab>)} <PlusIcon />
</Tabs.List> </Button>
</Tabs.ListContainer>
</Tabs>
</div>
<section className="w-full min-w-0 space-y-4">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="w-full min-w-0 flex-none sm:w-80 lg:w-96">
<SearchInput value={keyword} onChange={(value) => { setKeyword(value); setPage(0); }} placeholder="搜索模型名称、编码、说明或标签" variant="primary" />
</div> </div>
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2"> <div className="mt-4 min-w-0 overflow-x-auto">
{selectedGroup ? <Button size="sm" variant="secondary" onPress={openEditGroup}>编辑分组</Button> : null} <FileTree.Root
{selectedGroup ? <Button size="sm" variant="danger-soft" onPress={() => setIsDeleteGroupDialogOpen(true)}>删除分组</Button> : null} aria-label="模型分组"
<Button size="sm" variant="secondary" onPress={() => openCreateGroup()}>新建分组</Button> className="min-w-max"
<Button size="sm" variant="primary" onPress={openSyncDialog}>同步模型数据</Button> expandedKeys={expandedGroupIds}
selectedKeys={new Set([activeGroupId])}
selectionBehavior="replace"
selectionMode="single"
showGuideLines="hover"
size="md"
onExpandedChange={setExpandedGroupIds}
onSelectionChange={(keys) => {
if (keys === 'all') return;
const nextKey = [...keys][0];
if (nextKey === undefined) return;
setActiveGroupId(String(nextKey));
setPage(0);
}}
>
<FileTree.Item
className={groupTreeItemClassName}
hasChildItems={groupTree.length > 0}
icon={<FolderOpenIcon />}
id={ROOT_GROUP_ID}
textValue="全部模型"
title="全部模型"
>
{groupTree.length ? (
<GroupTreeItems
nodes={groupTree}
onEditGroup={openEditGroup}
onRequestDelete={(group) => {
setDeleteTargetGroup(group);
setIsDeleteGroupDialogOpen(true);
}}
/>
) : null}
</FileTree.Item>
</FileTree.Root>
</div> </div>
</div> </Surface>
<DataGrid aria-label="模型列表" columns={modelColumns} contentClassName="min-w-max" data={visibleModels} getRowId={(model) => model.id} renderEmptyState={() => <EmptyCollection title="当前分组还没有模型" description="点击“同步模型数据”,从外部模型与批次列表中选择一项完成首次同步。" action={<Button variant="primary" onPress={openSyncDialog}>同步模型数据</Button>} />} verticalAlign="middle" />
{filteredModels.length > 0 ? ( <section className="w-full min-w-0 space-y-4">
<BusinessPagination <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
page={page + 1} <div className="w-full min-w-0 flex-none sm:w-80 lg:w-96">
pageSize={pageSize} <SearchInput value={keyword} onChange={(value) => { setKeyword(value); setPage(0); }} placeholder="搜索模型名称、编码、说明或标签" variant="primary" />
pageSizeOptions={PAGE_SIZE_OPTIONS} </div>
total={filteredModels.length} <div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2">
onPageChange={(nextPage) => setPage(nextPage - 1)} <Button size="sm" variant="primary" onPress={openSyncDialog}>同步模型数据</Button>
onPageSizeChange={(nextPageSize) => { setPageSize(nextPageSize); setPage(0); }} </div>
/> </div>
) : null} <DataGrid aria-label="模型列表" columns={modelColumns} contentClassName="min-w-max" data={visibleModels} getRowId={(model) => model.id} renderEmptyState={() => <EmptyCollection title="当前分组还没有模型" description="点击“同步模型数据”,从外部模型与批次列表中选择一项完成首次同步。" action={<Button variant="primary" onPress={openSyncDialog}>同步模型数据</Button>} />} verticalAlign="middle" />
</section> {filteredModels.length > 0 ? (
<BusinessPagination
page={page + 1}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={filteredModels.length}
onPageChange={(nextPage) => setPage(nextPage - 1)}
onPageSizeChange={(nextPageSize) => { setPageSize(nextPageSize); setPage(0); }}
/>
) : null}
</section>
</div>
</> </>
) : null} ) : null}
</div> </div>
...@@ -485,8 +558,8 @@ export default function Models() { ...@@ -485,8 +558,8 @@ export default function Models() {
<AlertDialog.Container placement="center" size="md"> <AlertDialog.Container placement="center" size="md">
<AlertDialog.Dialog className="xy-modal-dialog"> <AlertDialog.Dialog className="xy-modal-dialog">
<AlertDialog.Header><AlertDialog.Icon status="danger" /><AlertDialog.Heading>删除模型分组</AlertDialog.Heading></AlertDialog.Header> <AlertDialog.Header><AlertDialog.Icon status="danger" /><AlertDialog.Heading>删除模型分组</AlertDialog.Heading></AlertDialog.Header>
<AlertDialog.Body>{`确认删除空分组“${selectedGroup?.groupName || ''}”吗?此操作不会删除模型或批次。`}</AlertDialog.Body> <AlertDialog.Body>{`确认删除空分组“${deleteTargetGroup?.groupName || selectedGroup?.groupName || ''}”吗?此操作不会删除模型或批次。`}</AlertDialog.Body>
<AlertDialog.Footer><Button variant="secondary" onPress={() => setIsDeleteGroupDialogOpen(false)}>取消</Button><Button variant="danger" onPress={handleDeleteGroup}>确认删除</Button></AlertDialog.Footer> <AlertDialog.Footer><Button variant="secondary" onPress={() => { setIsDeleteGroupDialogOpen(false); setDeleteTargetGroup(null); }}>取消</Button><Button variant="danger" onPress={handleDeleteGroup}>确认删除</Button></AlertDialog.Footer>
</AlertDialog.Dialog> </AlertDialog.Dialog>
</AlertDialog.Container> </AlertDialog.Container>
</AlertDialog.Backdrop> </AlertDialog.Backdrop>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment