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 XinyuanModal from '@/components/XinyuanModal';
import { UserContext } from '@/wrapper/Auth';
import { Edit, Eye } from '@/components/AppIcons';
import { Delete, Edit, Eye, FolderOpenIcon, More, PlusIcon } from '@/components/AppIcons';
import {
AlertDialog,
Button,
Checkbox,
Description,
Dropdown,
Input,
Label,
ProgressBar,
ScrollShadow,
Surface,
Tabs,
TextArea,
TextField,
} from '@heroui/react';
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 { Stepper } from '@heroui-pro/react/stepper';
import { useContext, useEffect, useMemo, useState } from 'react';
......@@ -76,13 +77,45 @@ function flattenGroupOptions(nodes, depth = 0) {
]);
}
function flattenGroupTabs(nodes, ancestors = []) {
return nodes.flatMap((node) => {
const path = [...ancestors, node.groupName];
return [
{ value: node.id, label: path.join(' / ') },
...flattenGroupTabs(node.children || [], path),
];
function groupTreeItemClassName({ isHovered, isSelected }) {
const stateClass = isSelected ? '!bg-surface-secondary' : isHovered ? '!bg-surface-tertiary' : '';
return ['mb-1', stateClass].filter(Boolean).join(' ');
}
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() {
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const [activeGroupId, setActiveGroupId] = useState(ROOT_GROUP_ID);
const [expandedGroupIds, setExpandedGroupIds] = useState(new Set([ROOT_GROUP_ID]));
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
......@@ -115,6 +149,7 @@ export default function Models() {
const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false);
const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false);
const [deleteTargetGroup, setDeleteTargetGroup] = useState(null);
const [groupForm, setGroupForm] = useState({ recordName: '', groupName: '', parentGroupId: '' });
const [isModelDialogOpen, setIsModelDialogOpen] = useState(false);
const [editingModel, setEditingModel] = useState(null);
......@@ -155,7 +190,6 @@ export default function Models() {
const groupTree = useMemo(() => buildGroupTree(groups), [groups]);
const groupOptions = useMemo(() => flattenGroupOptions(groupTree), [groupTree]);
const groupTabs = useMemo(() => flattenGroupTabs(groupTree), [groupTree]);
const selectedGroup = groups.find((group) => group.id === activeGroupId);
const filteredModels = useMemo(() => {
const groupIds = activeGroupId === ROOT_GROUP_ID ? null : collectDescendantIds(groups, activeGroupId);
......@@ -195,7 +229,7 @@ export default function Models() {
{ id: 'status', header: '同步状态', minWidth: 120, cell: (model) => <SyncStatusTag status={model.lastSyncStatus} /> },
{ 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>,
},
], [groups]);
......@@ -205,9 +239,9 @@ export default function Models() {
setIsGroupDialogOpen(true);
}
function openEditGroup() {
if (!selectedGroup) return;
setGroupForm({ recordName: selectedGroup.recordName, groupName: selectedGroup.groupName, parentGroupId: selectedGroup.parentGroupId });
function openEditGroup(group = selectedGroup) {
if (!group) return;
setGroupForm({ recordName: group.recordName, groupName: group.groupName, parentGroupId: group.parentGroupId });
setIsGroupDialogOpen(true);
}
......@@ -233,19 +267,21 @@ export default function Models() {
}
async function handleDeleteGroup() {
if (!selectedGroup) return;
const hasChildren = groups.some((group) => group.parentGroupId === selectedGroup.id);
const hasModels = models.some((model) => model.groupId === selectedGroup.id);
const group = deleteTargetGroup || selectedGroup;
if (!group) return;
const hasChildren = groups.some((item) => item.parentGroupId === group.id);
const hasModels = models.some((model) => model.groupId === group.id);
if (hasChildren || hasModels) {
setError('请先迁移当前分组中的子分组和模型。');
return;
}
try {
await deleteModelGroup(selectedGroup);
await deleteModelGroup(group);
setActiveGroupId(ROOT_GROUP_ID);
await refresh();
setNotice('空分组已删除。');
setIsDeleteGroupDialogOpen(false);
setDeleteTargetGroup(null);
} catch (deleteError) {
setError(deleteError?.message || '模型分组删除失败。');
}
......@@ -427,16 +463,55 @@ export default function Models() {
{!loading ? (
<>
<div className="min-w-0 max-w-full">
<Tabs className="w-fit max-w-full min-w-0" selectedKey={activeGroupId} variant="primary" onSelectionChange={(key) => { setActiveGroupId(String(key)); setPage(0); }}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="模型分组" className="w-max min-w-max">
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={ROOT_GROUP_ID}>全部模型<Tabs.Indicator /></Tabs.Tab>
{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>)}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
<div className="grid min-w-0 gap-5 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start">
<Surface className="min-w-0 rounded-3xl p-4" variant="default">
<div className="flex min-w-0 items-center justify-between gap-3">
<h2 className="rc-module-title min-w-0 truncate">视图分组</h2>
<Button aria-label="新建分组" isIconOnly size="sm" variant="tertiary" onPress={() => openCreateGroup()}>
<PlusIcon />
</Button>
</div>
<div className="mt-4 min-w-0 overflow-x-auto">
<FileTree.Root
aria-label="模型分组"
className="min-w-max"
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>
</Surface>
<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">
......@@ -444,9 +519,6 @@ export default function Models() {
<SearchInput value={keyword} onChange={(value) => { setKeyword(value); setPage(0); }} placeholder="搜索模型名称、编码、说明或标签" variant="primary" />
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2">
{selectedGroup ? <Button size="sm" variant="secondary" onPress={openEditGroup}>编辑分组</Button> : null}
{selectedGroup ? <Button size="sm" variant="danger-soft" onPress={() => setIsDeleteGroupDialogOpen(true)}>删除分组</Button> : null}
<Button size="sm" variant="secondary" onPress={() => openCreateGroup()}>新建分组</Button>
<Button size="sm" variant="primary" onPress={openSyncDialog}>同步模型数据</Button>
</div>
</div>
......@@ -462,6 +534,7 @@ export default function Models() {
/>
) : null}
</section>
</div>
</>
) : null}
</div>
......@@ -485,8 +558,8 @@ export default function Models() {
<AlertDialog.Container placement="center" size="md">
<AlertDialog.Dialog className="xy-modal-dialog">
<AlertDialog.Header><AlertDialog.Icon status="danger" /><AlertDialog.Heading>删除模型分组</AlertDialog.Heading></AlertDialog.Header>
<AlertDialog.Body>{`确认删除空分组“${selectedGroup?.groupName || ''}”吗?此操作不会删除模型或批次。`}</AlertDialog.Body>
<AlertDialog.Footer><Button variant="secondary" onPress={() => setIsDeleteGroupDialogOpen(false)}>取消</Button><Button variant="danger" onPress={handleDeleteGroup}>确认删除</Button></AlertDialog.Footer>
<AlertDialog.Body>{`确认删除空分组“${deleteTargetGroup?.groupName || selectedGroup?.groupName || ''}”吗?此操作不会删除模型或批次。`}</AlertDialog.Body>
<AlertDialog.Footer><Button variant="secondary" onPress={() => { setIsDeleteGroupDialogOpen(false); setDeleteTargetGroup(null); }}>取消</Button><Button variant="danger" onPress={handleDeleteGroup}>确认删除</Button></AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</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