Commit 3869ea07 by light5980 Committed by GitHub

feat(app): Implement local virtual pagination for dashboard app lists (#7021)

* feat(app): Implement local virtual pagination for dashboard app lists

* fix: add tsx

* fix(app): align skill virtual grid row gap

---------

Co-authored-by: archer <545436317@qq.com>
parent e4259e54
import { Box } from '@chakra-ui/react';
import {
type ReactNode,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
type UseVirtualGridListParams<T> = {
list: T[];
/** 列表上下文变化时用于重置虚拟窗口,例如目录、搜索词或 tab 变化。 */
listKey: string;
/** Grid 中不属于 list 的固定卡片数量,例如“新建”入口。 */
reservedSlotCount?: number;
/** 每次加载的行数批次 */
batchRows?: number;
/** 默认列数 */
defaultColumnCount?: number;
/** 预估行高 */
estimatedRowHeight?: number;
/** 预估行间距 */
estimatedRowGap?: number;
/** IntersectionObserver 预加载边距 */
preloadRootMargin?: string;
/** 视口外额外渲染的行数(上下各一半) */
overscanRows?: number;
/** 最大同时渲染行数,防止内存溢出 */
maxRenderRows?: number;
};
type UseVirtualGridListReturn<T> = {
gridRef: RefObject<HTMLDivElement>;
renderVirtualGridItems: (renderItem: VirtualGridItemRenderer<T>) => ReactNode;
};
type VirtualGridItemRenderer<T> = (item: T) => ReactNode;
type VirtualGridItemsState<T> = {
leadingList: T[];
visibleList: T[];
hasMore: boolean;
topPlaceholderHeight: number;
bottomPlaceholderHeight: number;
loadMoreRef: RefObject<HTMLDivElement>;
};
type VirtualGridItemsProps<T> = VirtualGridItemsState<T> & {
renderItem: VirtualGridItemRenderer<T>;
};
const defaultBatchRows = 15;
const defaultGridColumnCount = 1;
const defaultEstimatedRowHeight = 160;
const defaultEstimatedRowGap = 20;
const defaultPreloadRootMargin = '0px 0px 800px 0px';
const defaultOverscanRows = 6;
/**
* 计算占位符高度
* @param rowCount 行数
* @param rowHeight 行高
* @param rowGap 行间距
*/
const getPlaceholderHeight = (rowCount: number, rowHeight: number, rowGap: number) =>
rowCount > 0 ? rowCount * rowHeight + (rowCount - 1) * rowGap : 0;
/**
* 解析 rootMargin 字符串,提取底部预加载距离
* IntersectionObserver 的 rootMargin 兼容 CSS 简写,这里只需要底部预加载距离。
* @param rootMargin CSS margin 字符串
*/
const getRootMarginBottom = (rootMargin: string) => {
const parts = rootMargin.trim().split(/\s+/);
// CSS margin 简写规则:1值(全), 2值(上下/左右), 3值(上/左右/下), 4值(上/右/下/左)
const bottom = parts.length === 3 || parts.length === 4 ? parts[2] : parts[0];
const value = Number.parseFloat(bottom);
return Number.isNaN(value) ? 0 : value;
};
/**
* 虚拟网格列表渲染组件
* 负责渲染固定项、可见项以及用于撑开滚动高度的占位符
*/
const VirtualGridItems = <T,>({
leadingList,
visibleList,
renderItem,
hasMore,
topPlaceholderHeight,
bottomPlaceholderHeight,
loadMoreRef
}: VirtualGridItemsProps<T>) => {
return (
<>
{/* 渲染首行固定项(如新建按钮等) */}
{leadingList.map(renderItem)}
{/* 顶部占位符,模拟已滚动过的内容高度 */}
{topPlaceholderHeight > 0 && (
<Box gridColumn={'1 / -1'} h={`${topPlaceholderHeight}px`} pointerEvents={'none'} />
)}
{/* 渲染当前视口内的可见项 */}
{visibleList.map(renderItem)}
{/* 底部占位符及加载更多触发器 */}
{hasMore && (
<Box
gridColumn={'1 / -1'}
h={`${Math.max(bottomPlaceholderHeight, 1)}px`}
position={'relative'}
>
{/* 用于 IntersectionObserver 监听的触发元素 */}
<Box ref={loadMoreRef} position={'absolute'} top={0} left={0} right={0} h={'1px'} />
</Box>
)}
</>
);
};
/**
* 为响应式 Grid 列表提供本地虚拟分页能力。
*
* Hook 会保留首行固定项,并只渲染视口附近的数据窗口,通过顶部/底部占位维持
* 接近完整列表的滚动高度。创建卡片等固定 Grid 项可通过 reservedSlotCount
* 计入首行布局,但不会出现在返回的数据列表中。
*/
export function useVirtualGridList<T>({
list,
listKey,
reservedSlotCount = 0,
batchRows = defaultBatchRows,
defaultColumnCount = defaultGridColumnCount,
estimatedRowHeight = defaultEstimatedRowHeight,
estimatedRowGap = defaultEstimatedRowGap,
preloadRootMargin = defaultPreloadRootMargin,
overscanRows = defaultOverscanRows,
maxRenderRows
}: UseVirtualGridListParams<T>): UseVirtualGridListReturn<T> {
const gridRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
/** requestAnimationFrame ID,用于防抖同步窗口 */
const syncWindowFrameRef = useRef<number>();
/** 标记下次同步是否需要使用预加载边距 */
const shouldUsePreloadRef = useRef(false);
const [gridColumnCount, setGridColumnCount] = useState(defaultColumnCount);
const [rowHeight, setRowHeight] = useState(estimatedRowHeight);
const [rowGap, setRowGap] = useState(estimatedRowGap);
const [windowRowsState, setWindowRowsState] = useState({
key: listKey,
startRow: 0,
endRow: batchRows
});
// 计算最大渲染行数,至少为 batchRows,默认不超过 batchRows * 2 或 30
const resolvedMaxRenderRows = Math.max(maxRenderRows ?? Math.max(batchRows * 2, 30), batchRows);
/**
* 更新网格度量信息(列数、行高、行间距)
* 通过读取 DOM 样式和实际元素尺寸来动态适配响应式布局
*/
const updateGridMetrics = useCallback(() => {
const grid = gridRef.current;
if (!grid) return;
const gridStyle = getComputedStyle(grid);
const gridTemplateColumns = gridStyle.gridTemplateColumns;
// 计算当前实际列数
const columnCount =
gridTemplateColumns && gridTemplateColumns !== 'none'
? gridTemplateColumns.split(' ').filter(Boolean).length
: defaultColumnCount;
setGridColumnCount((prev) => (prev === columnCount ? prev : columnCount));
// 获取行间距
const nextRowGap = Number.parseFloat(gridStyle.rowGap);
if (!Number.isNaN(nextRowGap)) {
setRowGap((prev) => (prev === nextRowGap ? prev : nextRowGap));
}
// 通过第一个带有 data-virtual-item 标记的元素测量实际行高
const measuredNode = grid.querySelector('[data-virtual-item]');
if (measuredNode instanceof HTMLElement) {
const nextRowHeight = measuredNode.getBoundingClientRect().height;
if (nextRowHeight > 0) {
setRowHeight((prev) => (prev === nextRowHeight ? prev : nextRowHeight));
}
}
}, [defaultColumnCount]);
// 监听网格尺寸变化和窗口 resize,更新度量信息
useEffect(() => {
updateGridMetrics();
const grid = gridRef.current;
if (!grid) return;
const resizeObserver =
typeof ResizeObserver === 'undefined'
? undefined
: new ResizeObserver(() => {
updateGridMetrics();
});
resizeObserver?.observe(grid);
window.addEventListener('resize', updateGridMetrics);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateGridMetrics);
};
}, [list.length, updateGridMetrics]);
// 计算总槽位数(数据项 + 固定项)
const totalSlotCount = list.length + reservedSlotCount;
// 计算总行数
const totalRows = Math.ceil(totalSlotCount / gridColumnCount);
/**
* 计算首行需要常驻渲染的数据项数量
* 当固定卡片占用首行部分位置时,需要补齐同一行的剩余数据项,避免滚动占位错位
*/
const leadingItemCount = useMemo(() => {
const remainingSlotCount = reservedSlotCount % gridColumnCount;
if (remainingSlotCount === 0) {
return 0;
}
return Math.min(list.length, gridColumnCount - remainingSlotCount);
}, [gridColumnCount, list.length, reservedSlotCount]);
// 固定区域占用的行数
const fixedRowCount = Math.ceil((reservedSlotCount + leadingItemCount) / gridColumnCount);
// 虚拟滚动区域的总行数(扣除固定行)
const totalVirtualRows = Math.max(totalRows - fixedRowCount, 0);
// 每行占据的垂直空间(行高 + 间距)
const rowFullHeight = Math.max(rowHeight + rowGap, 1);
// 视口换算成虚拟行号时,需要扣掉固定区域已占用的高度
const fixedSectionOffset = fixedRowCount * rowFullHeight;
// 底部预加载边距像素值
const preloadBottomMargin = getRootMarginBottom(preloadRootMargin);
/**
* 同步可视窗口行范围
* 根据当前滚动位置和视口大小,计算需要渲染的起始行和结束行
* @param usePreload 是否启用预加载边距(用于 IntersectionObserver 触发时提前加载)
*/
const syncWindowRows = useCallback(
({ usePreload = false }: { usePreload?: boolean } = {}) => {
const grid = gridRef.current;
if (!grid) return;
// 如果没有虚拟行,重置状态
if (totalVirtualRows === 0) {
setWindowRowsState((state) => {
if (state.key === listKey && state.startRow === 0 && state.endRow === 0) {
return state;
}
return {
key: listKey,
startRow: 0,
endRow: 0
};
});
return;
}
const rect = grid.getBoundingClientRect();
// 计算虚拟视口相对于固定区域顶部的偏移量
const virtualViewportTop = Math.max(-rect.top - fixedSectionOffset, 0);
const virtualViewportBottom = Math.max(
window.innerHeight - rect.top - fixedSectionOffset + (usePreload ? preloadBottomMargin : 0),
0
);
// 计算可见区域的起始行和结束行
const visibleStartRow = Math.min(
Math.floor(virtualViewportTop / rowFullHeight),
totalVirtualRows
);
const visibleEndRow = Math.min(
Math.ceil(virtualViewportBottom / rowFullHeight),
totalVirtualRows
);
// 视口内可见行数
const viewportRowCount = Math.max(visibleEndRow - visibleStartRow, 1);
// 目标渲染行数(可见行数 + overscan,但不超过总行数)
const targetRenderRows = Math.min(
totalVirtualRows,
Math.max(batchRows, viewportRowCount + overscanRows * 2)
);
// 初步计算渲染范围(包含 overscan)
let nextStartRow = Math.max(visibleStartRow - overscanRows, 0);
let nextEndRow = Math.min(
Math.max(visibleEndRow + overscanRows, nextStartRow + batchRows),
totalVirtualRows
);
// 如果当前范围小于目标渲染行数,尝试扩展范围
const missingRows = targetRenderRows - (nextEndRow - nextStartRow);
if (missingRows > 0) {
const appendRows = Math.min(missingRows, totalVirtualRows - nextEndRow);
nextEndRow += appendRows;
nextStartRow = Math.max(nextStartRow - (missingRows - appendRows), 0);
}
// 如果渲染范围超过最大限制,以视口为中心进行裁剪
if (nextEndRow - nextStartRow > resolvedMaxRenderRows) {
const centeredStartRow = Math.max(
Math.min(
visibleStartRow - Math.floor((resolvedMaxRenderRows - viewportRowCount) / 2),
totalVirtualRows - resolvedMaxRenderRows
),
0
);
nextStartRow = centeredStartRow;
nextEndRow = Math.min(centeredStartRow + resolvedMaxRenderRows, totalVirtualRows);
}
// 更新状态,仅在值变化时触发重渲染
setWindowRowsState((state) => {
if (
state.key === listKey &&
state.startRow === nextStartRow &&
state.endRow === nextEndRow
) {
return state;
}
return {
key: listKey,
startRow: nextStartRow,
endRow: nextEndRow
};
});
},
[
batchRows,
fixedSectionOffset,
listKey,
overscanRows,
preloadBottomMargin,
resolvedMaxRenderRows,
rowFullHeight,
totalVirtualRows
]
);
/**
* 立即执行窗口同步,清除 pending 的 frame
*/
const flushSyncWindowRows = useCallback(() => {
syncWindowFrameRef.current = undefined;
const shouldUsePreload = shouldUsePreloadRef.current;
shouldUsePreloadRef.current = false;
syncWindowRows({
usePreload: shouldUsePreload
});
}, [syncWindowRows]);
/**
* 调度窗口同步,使用 requestAnimationFrame 防抖
* @param usePreload 是否启用预加载
*/
const scheduleSyncWindowRows = useCallback(
({ usePreload = false }: { usePreload?: boolean } = {}) => {
shouldUsePreloadRef.current = shouldUsePreloadRef.current || usePreload;
// 如果已有 pending 的 frame,不再重复调度
if (syncWindowFrameRef.current !== undefined) {
return;
}
syncWindowFrameRef.current = window.requestAnimationFrame(flushSyncWindowRows);
},
[flushSyncWindowRows]
);
/** 调度带预加载的窗口同步 */
const schedulePreloadSyncWindowRows = useCallback(() => {
scheduleSyncWindowRows({
usePreload: true
});
}, [scheduleSyncWindowRows]);
/** 调度普通窗口同步 */
const scheduleNormalSyncWindowRows = useCallback(() => {
scheduleSyncWindowRows();
}, [scheduleSyncWindowRows]);
// 当列表关键数据变化时,立即同步一次窗口
useEffect(() => {
updateGridMetrics();
schedulePreloadSyncWindowRows();
}, [leadingItemCount, list.length, listKey, schedulePreloadSyncWindowRows, updateGridMetrics]);
// 监听全局 scroll 和 resize 事件,调度窗口同步
useEffect(() => {
window.addEventListener('scroll', scheduleNormalSyncWindowRows, {
capture: true,
passive: true
});
window.addEventListener('resize', scheduleNormalSyncWindowRows, {
passive: true
});
return () => {
window.removeEventListener('scroll', scheduleNormalSyncWindowRows, {
capture: true
});
window.removeEventListener('resize', scheduleNormalSyncWindowRows);
};
}, [scheduleNormalSyncWindowRows]);
// 组件卸载时取消 pending 的 animation frame
useEffect(() => {
return () => {
if (syncWindowFrameRef.current !== undefined) {
window.cancelAnimationFrame(syncWindowFrameRef.current);
}
};
}, []);
// 获取当前有效的窗口行状态,如果 key 不匹配则重置
const activeWindowRows =
windowRowsState.key === listKey
? windowRowsState
: {
key: listKey,
startRow: 0,
endRow: batchRows
};
// 确保行范围在合法区间内
const startRow = Math.min(activeWindowRows.startRow, totalVirtualRows);
const endRow = Math.min(Math.max(activeWindowRows.endRow, startRow), totalVirtualRows);
// 计算可见项在原始 list 中的索引范围
const visibleStartIndex = leadingItemCount + startRow * gridColumnCount;
const visibleEndIndex = Math.min(leadingItemCount + endRow * gridColumnCount, list.length);
// 首行固定项列表
const leadingList = useMemo(() => list.slice(0, leadingItemCount), [leadingItemCount, list]);
// 当前可见项列表
const visibleList = useMemo(
() => list.slice(visibleStartIndex, visibleEndIndex),
[list, visibleEndIndex, visibleStartIndex]
);
// 是否还有更多数据未渲染
const hasMore = endRow < totalVirtualRows;
// 计算顶部和底部占位符高度
const topPlaceholderHeight = getPlaceholderHeight(startRow, rowHeight, rowGap);
const bottomPlaceholderHeight = getPlaceholderHeight(
Math.max(totalVirtualRows - endRow, 0),
rowHeight,
rowGap
);
// 聚合虚拟网格状态
const virtualGridItemsState = useMemo<VirtualGridItemsState<T>>(
() => ({
leadingList,
visibleList,
hasMore,
topPlaceholderHeight,
bottomPlaceholderHeight,
loadMoreRef
}),
[bottomPlaceholderHeight, hasMore, leadingList, topPlaceholderHeight, visibleList]
);
// 渲染函数,接收 renderItem 回调
const renderVirtualGridItems = useCallback(
(renderItem: VirtualGridItemRenderer<T>) => (
<VirtualGridItems {...virtualGridItemsState} renderItem={renderItem} />
),
[virtualGridItemsState]
);
// 使用 IntersectionObserver 监听底部触发器,实现预加载
useEffect(() => {
if (!hasMore) return;
const target = loadMoreRef.current;
if (!target || typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
schedulePreloadSyncWindowRows();
}
},
{
rootMargin: preloadRootMargin,
threshold: 0.1
}
);
observer.observe(target);
return () => {
observer.disconnect();
};
}, [hasMore, preloadRootMargin, schedulePreloadSyncWindowRows, visibleList.length]);
return {
gridRef,
renderVirtualGridItems
};
}
Subproject commit f500e41b5c6dd29ab937b82267dd15749cd17201
Subproject commit 69af7442cacaf436a45cf28998edcd4a0cd189be
......@@ -43,6 +43,7 @@ import { createAppTypeMap } from '@/pageComponents/app/constants';
import { useUserStore } from '@/web/support/user/useUserStore';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard';
import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList';
const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal'));
const ConfigPerModal = dynamic(() => import('@/components/support/permission/ConfigPerModal'));
......@@ -79,6 +80,13 @@ const List = () => {
const [editedApp, setEditedApp] = useState<EditResourceInfoFormType>();
const [editPerAppId, setEditPerAppId] = useState<string>();
const { gridRef, renderVirtualGridItems } = useVirtualGridList({
list: myApps,
listKey: `${router.pathname}-${appType}-${parentId || ''}-${searchKey}`,
reservedSlotCount: 1,
estimatedRowHeight: 160,
estimatedRowGap: 20
});
const editPerApp = useMemo(
() =>
......@@ -152,6 +160,271 @@ const List = () => {
}
}
);
const renderAppCard = (app: (typeof myApps)[number]) => {
const isAgent = AppTypeList.includes(app.type);
const isTool = ToolTypeList.includes(app.type);
const isFolder = AppFolderTypeList.includes(app.type);
return (
<MyTooltip
key={app._id}
label={
app.type === AppTypeEnum.folder
? t('common:open_folder')
: app.permission.hasWritePer || app.permission.hasReadChatLogPer
? t('app:edit_app')
: t('app:go_to_chat')
}
>
<MyBox
data-virtual-item=""
py={4}
px={5}
cursor={'pointer'}
border={'base'}
bg={'white'}
borderRadius={'10px'}
position={'relative'}
display={'flex'}
flexDirection={'column'}
_hover={{
borderColor: 'primary.300',
boxShadow: '1.5',
'& .more': {
display: 'flex'
},
'& .time': {
display: ['flex', 'none']
}
}}
onClick={() => {
if (AppFolderTypeList.includes(app.type)) {
setSearchKey('');
router.push({
query: {
...router.query,
parentId: app._id
}
});
} else if (app.permission.hasWritePer || app.permission.hasReadChatLogPer) {
router.push(`/app/detail?appId=${app._id}`);
} else {
window.open(
`/chat?appId=${app._id}&pane=${ChatSidebarPaneEnum.RECENTLY_USED_APPS}`,
'_blank'
);
}
}}
{...getBoxProps({
dataId: app._id,
isFolder: app.type === AppTypeEnum.folder || app.type === AppTypeEnum.toolFolder
})}
>
<Grid templateColumns="auto 1fr auto" alignItems="center" width="100%" gap={2}>
<Avatar src={app.avatar} borderRadius={'sm'} w={'1.5rem'} />
<Box color={'myGray.900'} fontWeight={'medium'} minWidth={0} overflow="hidden">
<Box className={'textEllipsis'}>{app.name}</Box>
</Box>
<Box justifySelf="end" mr={-5}>
<AppTypeTag type={app.type} />
</Box>
</Grid>
<Box
flex={'1 0 56px'}
mt={3}
textAlign={'justify'}
wordBreak={'break-all'}
fontSize={'xs'}
color={'myGray.500'}
>
<Box className={'textEllipsis2'} whiteSpace={'pre-wrap'} lineHeight={1.3}>
{app.intro || t('common:no_intro')}
</Box>
</Box>
<HStack h={'24px'} fontSize={'mini'} color={'myGray.500'} w="full">
<HStack flex={'1 0 0'}>
<UserBox
sourceMember={app.sourceMember}
fontSize="xs"
avatarSize="1rem"
spacing={0.5}
/>
<PermissionIconText
private={app.private}
color={'myGray.500'}
iconColor={'myGray.400'}
w={'0.875rem'}
/>
</HStack>
<HStack>
{isPc && (
<HStack spacing={0.5} className="time">
<MyIcon name={'history'} w={'0.85rem'} color={'myGray.400'} />
<Box color={'myGray.500'}>
{t(formatTimeToChatTime(app.updateTime) as any).replace('#', ':')}
</Box>
</HStack>
)}
{(AppFolderTypeList.includes(app.type)
? app.permission.hasManagePer
: app.permission.hasWritePer || app.permission.hasReadChatLogPer) && (
<Box className="more" display={['', 'none']}>
<MyMenu
Button={
<IconButton
size={'xsSquare'}
variant={'transparentBase'}
icon={<MyIcon name={'more'} w={'0.875rem'} color={'myGray.500'} />}
aria-label={''}
/>
}
menuList={[
...([
AppTypeEnum.simple,
AppTypeEnum.workflow,
AppTypeEnum.chatAgent
].includes(app.type)
? [
{
children: [
{
icon: 'core/chat/chatLight',
type: 'grayBg' as MenuItemType,
label: t('app:go_to_chat'),
onClick: () => {
window.open(
`/chat?appId=${app._id}&pane=${ChatSidebarPaneEnum.RECENTLY_USED_APPS}`,
'_blank'
);
}
}
]
}
]
: []),
...([AppTypeEnum.workflowTool].includes(app.type)
? [
{
children: [
{
icon: 'core/chat/chatLight',
type: 'grayBg' as MenuItemType,
label: t('app:go_to_run'),
onClick: () => {
window.open(
`/chat?appId=${app._id}&pane=${ChatSidebarPaneEnum.RECENTLY_USED_APPS}`,
'_blank'
);
}
}
]
}
]
: []),
...(app.permission.hasManagePer
? [
{
children: [
{
icon: 'edit',
type: 'grayBg' as MenuItemType,
label: t('common:dataset.Edit Info'),
onClick: () => {
if (app.type === AppTypeEnum.httpPlugin) {
toast({
title: t('app:type.Http plugin_deprecated'),
status: 'warning'
});
}
setEditedApp({
id: app._id,
avatar: app.avatar,
name: app.name,
intro: app.intro
});
}
},
...(folderDetail?.type === AppTypeEnum.httpPlugin &&
!(parentApp ? parentApp.permission : app.permission).hasManagePer
? []
: [
{
icon: 'common/file/move',
type: 'grayBg' as MenuItemType,
label: t('common:move_to'),
onClick: () => setMoveAppId(app._id)
}
]),
...(app.permission.hasManagePer
? [
{
icon: 'key',
type: 'grayBg' as MenuItemType,
label: t('common:permission.Permission'),
onClick: () => setEditPerAppId(app._id)
}
]
: [])
]
}
]
: []),
...(!app.permission?.hasWritePer ||
app.type === AppTypeEnum.mcpToolSet ||
app.type === AppTypeEnum.folder ||
app.type === AppTypeEnum.httpToolSet ||
app.type === AppTypeEnum.httpPlugin
? []
: [
{
children: [
{
icon: 'copy',
type: 'grayBg' as MenuItemType,
label: t('app:copy_one_app'),
onClick: () =>
openConfirmCopy({
onConfirm: () => onclickCopy({ appId: app._id })
})()
}
]
}
]),
...(app.permission.isOwner
? [
{
children: [
{
type: 'danger' as const,
icon: 'delete',
label: t('common:Delete'),
onClick: () =>
openConfirmDelete({
customContent: (() => {
if (isFolder) return t('app:confirm_delete_folder_tip');
if (isAgent) return t('app:confirm_del_app_tip');
if (isTool) return t('app:confirm_del_tool_tip');
return t('app:confirm_del_app_tip');
})(),
onConfirm: () => onclickDelApp(app._id),
confirmButtonVariant: 'dangerFill',
inputConfirmText: app.name
})()
}
]
}
]
: [])
]}
/>
</Box>
)}
</HStack>
</HStack>
</MyBox>
</MyTooltip>
);
};
if (myApps.length === 0 && isFetchingApps) return null;
return (
......@@ -176,283 +449,22 @@ const List = () => {
</Grid>
)
) : (
<Grid
py={4}
gridTemplateColumns={
folderDetail
? ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)']
: ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']
}
gridGap={5}
alignItems={'stretch'}
>
{hasCreatePer ? <ListCreateButton appType={appType} /> : <ForbiddenCreateButton />}
{myApps.map((app) => {
const isAgent = AppTypeList.includes(app.type);
const isTool = ToolTypeList.includes(app.type);
const isFolder = AppFolderTypeList.includes(app.type);
return (
<MyTooltip
key={app._id}
label={
app.type === AppTypeEnum.folder
? t('common:open_folder')
: app.permission.hasWritePer || app.permission.hasReadChatLogPer
? t('app:edit_app')
: t('app:go_to_chat')
}
>
<MyBox
py={4}
px={5}
cursor={'pointer'}
border={'base'}
bg={'white'}
borderRadius={'10px'}
position={'relative'}
display={'flex'}
flexDirection={'column'}
_hover={{
borderColor: 'primary.300',
boxShadow: '1.5',
'& .more': {
display: 'flex'
},
'& .time': {
display: ['flex', 'none']
}
}}
onClick={() => {
if (AppFolderTypeList.includes(app.type)) {
setSearchKey('');
router.push({
query: {
...router.query,
parentId: app._id
}
});
} else if (app.permission.hasWritePer || app.permission.hasReadChatLogPer) {
router.push(`/app/detail?appId=${app._id}`);
} else {
window.open(
`/chat?appId=${app._id}&pane=${ChatSidebarPaneEnum.RECENTLY_USED_APPS}`,
'_blank'
);
}
}}
{...getBoxProps({
dataId: app._id,
isFolder: app.type === AppTypeEnum.folder || app.type === AppTypeEnum.toolFolder
})}
>
<Grid templateColumns="auto 1fr auto" alignItems="center" width="100%" gap={2}>
<Avatar src={app.avatar} borderRadius={'sm'} w={'1.5rem'} />
<Box color={'myGray.900'} fontWeight={'medium'} minWidth={0} overflow="hidden">
<Box className={'textEllipsis'}>{app.name}</Box>
</Box>
<Box justifySelf="end" mr={-5}>
<AppTypeTag type={app.type} />
</Box>
</Grid>
<Box
flex={'1 0 56px'}
mt={3}
textAlign={'justify'}
wordBreak={'break-all'}
fontSize={'xs'}
color={'myGray.500'}
>
<Box className={'textEllipsis2'} whiteSpace={'pre-wrap'} lineHeight={1.3}>
{app.intro || t('common:no_intro')}
</Box>
</Box>
<HStack h={'24px'} fontSize={'mini'} color={'myGray.500'} w="full">
<HStack flex={'1 0 0'}>
<UserBox
sourceMember={app.sourceMember}
fontSize="xs"
avatarSize="1rem"
spacing={0.5}
/>
<PermissionIconText
private={app.private}
color={'myGray.500'}
iconColor={'myGray.400'}
w={'0.875rem'}
/>
</HStack>
<HStack>
{isPc && (
<HStack spacing={0.5} className="time">
<MyIcon name={'history'} w={'0.85rem'} color={'myGray.400'} />
<Box color={'myGray.500'}>
{t(formatTimeToChatTime(app.updateTime) as any).replace('#', ':')}
</Box>
</HStack>
)}
{(AppFolderTypeList.includes(app.type)
? app.permission.hasManagePer
: app.permission.hasWritePer || app.permission.hasReadChatLogPer) && (
<Box className="more" display={['', 'none']}>
<MyMenu
Button={
<IconButton
size={'xsSquare'}
variant={'transparentBase'}
icon={<MyIcon name={'more'} w={'0.875rem'} color={'myGray.500'} />}
aria-label={''}
/>
}
menuList={[
...([
AppTypeEnum.simple,
AppTypeEnum.workflow,
AppTypeEnum.chatAgent
].includes(app.type)
? [
{
children: [
{
icon: 'core/chat/chatLight',
type: 'grayBg' as MenuItemType,
label: t('app:go_to_chat'),
onClick: () => {
window.open(
`/chat?appId=${app._id}&pane=${ChatSidebarPaneEnum.RECENTLY_USED_APPS}`,
'_blank'
);
}
}
]
}
]
: []),
...([AppTypeEnum.workflowTool].includes(app.type)
? [
{
children: [
{
icon: 'core/chat/chatLight',
type: 'grayBg' as MenuItemType,
label: t('app:go_to_run'),
onClick: () => {
window.open(
`/chat?appId=${app._id}&pane=${ChatSidebarPaneEnum.RECENTLY_USED_APPS}`,
'_blank'
);
}
}
]
}
]
: []),
...(app.permission.hasManagePer
? [
{
children: [
{
icon: 'edit',
type: 'grayBg' as MenuItemType,
label: t('common:dataset.Edit Info'),
onClick: () => {
if (app.type === AppTypeEnum.httpPlugin) {
toast({
title: t('app:type.Http plugin_deprecated'),
status: 'warning'
});
}
setEditedApp({
id: app._id,
avatar: app.avatar,
name: app.name,
intro: app.intro
});
}
},
...(folderDetail?.type === AppTypeEnum.httpPlugin &&
!(parentApp ? parentApp.permission : app.permission)
.hasManagePer
? []
: [
{
icon: 'common/file/move',
type: 'grayBg' as MenuItemType,
label: t('common:move_to'),
onClick: () => setMoveAppId(app._id)
}
]),
...(app.permission.hasManagePer
? [
{
icon: 'key',
type: 'grayBg' as MenuItemType,
label: t('common:permission.Permission'),
onClick: () => setEditPerAppId(app._id)
}
]
: [])
]
}
]
: []),
...(!app.permission?.hasWritePer ||
app.type === AppTypeEnum.mcpToolSet ||
app.type === AppTypeEnum.folder ||
app.type === AppTypeEnum.httpToolSet ||
app.type === AppTypeEnum.httpPlugin
? []
: [
{
children: [
{
icon: 'copy',
type: 'grayBg' as MenuItemType,
label: t('app:copy_one_app'),
onClick: () =>
openConfirmCopy({
onConfirm: () => onclickCopy({ appId: app._id })
})()
}
]
}
]),
...(app.permission.isOwner
? [
{
children: [
{
type: 'danger' as const,
icon: 'delete',
label: t('common:Delete'),
onClick: () =>
openConfirmDelete({
customContent: (() => {
if (isFolder)
return t('app:confirm_delete_folder_tip');
if (isAgent) return t('app:confirm_del_app_tip');
if (isTool) return t('app:confirm_del_tool_tip');
return t('app:confirm_del_app_tip');
})(),
onConfirm: () => onclickDelApp(app._id),
confirmButtonVariant: 'dangerFill',
inputConfirmText: app.name
})()
}
]
}
]
: [])
]}
/>
</Box>
)}
</HStack>
</HStack>
</MyBox>
</MyTooltip>
);
})}
</Grid>
<>
<Grid
ref={gridRef}
py={4}
gridTemplateColumns={
folderDetail
? ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)']
: ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']
}
gridGap={5}
alignItems={'stretch'}
>
{hasCreatePer ? <ListCreateButton appType={appType} /> : <ForbiddenCreateButton />}
{renderVirtualGridItems(renderAppCard)}
</Grid>
</>
)}
<DeleteConfirmModal />
<ConfirmCopyModal />
......
import React, { useState, useMemo, useEffect } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Grid, IconButton, HStack, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
......@@ -46,6 +46,7 @@ import type {
} from '@fastgpt/global/common/parentFolder/type';
import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard';
import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList';
const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal'));
const MoveModal = dynamic(() => import('@/components/common/folder/MoveModal'));
......@@ -202,6 +203,13 @@ const List = ({
const [editedSkill, setEditedSkill] = useState<EditResourceInfoFormType>();
const [moveSkillId, setMoveSkillId] = useState<string>();
const [editPerSkillId, setEditPerSkillId] = useState<string>();
const { gridRef, renderVirtualGridItems } = useVirtualGridList({
list: skills,
listKey: `${router.pathname}-${router.query.parentId || ''}-${searchKey}`,
reservedSlotCount: onClickCreate && !searchKey ? 1 : 0,
estimatedRowHeight: 160,
estimatedRowGap: 12
});
const selectedSkill = useMemo(
() =>
......@@ -289,6 +297,230 @@ const List = ({
}
);
const renderSkillCard = (skill: (typeof skills)[number]) => {
const isFolder = skill.type === AgentSkillTypeEnum.folder;
const isPersonal = skill.source === AgentSkillSourceEnum.personal;
const relatedAppsCount = skill.appCount ?? 0;
const isSkillReady =
isFolder ||
(skill.creationStatus === AgentSkillCreationStatusEnum.ready && !!skill.currentVersionId);
const isSkillCreating = skill.creationStatus === AgentSkillCreationStatusEnum.creating;
const isSkillCreateFailed = skill.creationStatus === AgentSkillCreationStatusEnum.failed;
const menuList = [
...(isFolder || isSkillReady
? [
{
children: [
{
icon: 'edit',
type: 'grayBg' as const,
label: t('common:dataset.Edit Info'),
onClick: () => {
if (!isFolder && guardSkillSandboxOperation && !guardSkillSandboxOperation()) {
return;
}
setEditedSkill({
id: skill._id,
avatar:
skill.avatar ?? (isFolder ? 'common/folderFill' : 'core/skill/default'),
name: skill.name,
intro: skill.description
});
}
},
{
icon: 'common/file/move',
type: 'grayBg' as const,
label: t('common:move_to'),
onClick: () => setMoveSkillId(skill._id)
},
{
icon: 'key',
type: 'grayBg' as const,
label: t('skill:permission_settings'),
onClick: () => {
setEditPerSkillId(skill._id);
}
}
]
},
...(!isFolder
? [
{
children: [
{
icon: 'export',
type: 'grayBg' as const,
label: t('skill:export_config'),
onClick: () => onExportSkill(skill._id, skill.name)
},
{
icon: 'copy',
type: 'grayBg' as const,
label: t('skill:copy_skill'),
onClick: () =>
openConfirmCopy({
onConfirm: () => onclickCopySkill(skill._id)
})()
}
]
}
]
: [])
]
: []),
{
children: [
{
type: 'danger' as const,
icon: 'delete',
label: t('common:Delete'),
onClick: () =>
openConfirmDelete({
customContent: (
<Trans
i18nKey={'skill:confirm_delete_with_refs'}
values={{ count: isFolder ? 0 : relatedAppsCount }}
components={{ bold: <Box as={'span'} fontWeight={'600'} /> }}
/>
),
onConfirm: () => onClickDeleteSkill(skill._id),
confirmText: t('skill:confirm_delete_action'),
confirmButtonVariant: 'dangerFill',
inputConfirmText: skill.name
})()
}
]
}
];
return (
<MyBox
key={skill._id}
data-virtual-item=""
py={4}
px={5}
cursor={'pointer'}
border={'base'}
bg={'white'}
borderRadius={'10px'}
position={'relative'}
display={'flex'}
flexDirection={'column'}
_hover={{
borderColor: 'primary.300',
boxShadow: '1.5',
'& .more': {
display: 'flex'
},
'& .time': {
display: ['flex', 'none']
}
}}
onClick={() => {
if (isFolder) {
router.push({ query: { ...router.query, parentId: skill._id } });
} else {
if (isSkillReady && guardSkillSandboxOperation && !guardSkillSandboxOperation()) return;
router.push(`/skill/detail?skillId=${skill._id}`);
}
}}
>
<Flex alignItems={'center'} gap={2}>
{isFolder ? (
<MyIcon name={'common/folderFill'} w={'1.5rem'} flexShrink={0} color={'myGray.500'} />
) : (
<Avatar
src={skill.avatar || 'core/skill/default'}
borderRadius={'sm'}
w={'1.5rem'}
flexShrink={0}
/>
)}
<Box className="textEllipsis" color={'myGray.900'} fontWeight={'medium'}>
{skill.name}
</Box>
{(isSkillCreating || isSkillCreateFailed) && (
<Box
px={2}
py={0.5}
borderRadius={'sm'}
fontSize={'10px'}
color={isSkillCreateFailed ? 'red.600' : 'primary.600'}
bg={isSkillCreateFailed ? 'red.50' : 'primary.50'}
flexShrink={0}
>
{isSkillCreateFailed ? t('common:failed') : t('skill:generating')}
</Box>
)}
</Flex>
<Box
flex={'1 0 56px'}
mt={3}
textAlign={'justify'}
wordBreak={'break-all'}
fontSize={'xs'}
color={'myGray.500'}
>
<Box className={'textEllipsis2'} whiteSpace={'pre-wrap'} lineHeight={1.3}>
{skill.description}
</Box>
</Box>
<HStack h={'24px'} fontSize={'mini'} color={'myGray.500'} w="full">
<HStack flex={'1 0 0'} spacing={3}>
<UserBox
sourceMember={skill.sourceMember}
fontSize="xs"
avatarSize="1rem"
spacing={1}
/>
{!isFolder && isSkillReady && (
<>
{relatedAppsCount > 0 ? (
<RelatedAppsPopover skillId={skill._id} count={relatedAppsCount} />
) : (
<HStack spacing={1}>
<Box color={'myGray.500'}>{t('skill:related_count')}</Box>
<Box color={'myGray.500'} fontWeight={'medium'}>
0
</Box>
</HStack>
)}
</>
)}
</HStack>
<HStack>
{isPc && (
<HStack className="time" spacing={0.5}>
<MyIcon name={'history'} w={'0.85rem'} color={'myGray.400'} />
<Box color={'myGray.500'}>
{t(formatTimeToChatTime(skill.updateTime) as any).replace('#', ':')}
</Box>
</HStack>
)}
{isPersonal && (
<Box className="more" display={['', 'none']} onClick={(e) => e.stopPropagation()}>
<MyMenu
Button={
<IconButton
size={'xsSquare'}
variant={'transparentBase'}
icon={<MyIcon name={'more'} w={'0.875rem'} color={'myGray.500'} />}
aria-label={''}
/>
}
menuList={menuList}
/>
</Box>
)}
</HStack>
</HStack>
</MyBox>
);
};
if (skills.length === 0 && isFetchingSkills) return null;
if (skills.length === 0 && (!onClickCreate || !!searchKey)) {
......@@ -298,6 +530,7 @@ const List = ({
return (
<>
<Grid
ref={gridRef}
py={4}
gridTemplateColumns={[
'1fr',
......@@ -310,247 +543,7 @@ const List = ({
alignItems={'stretch'}
>
{onClickCreate && !searchKey && <ListCreateCard onClick={onClickCreate} />}
{skills.map((skill) => {
const isFolder = skill.type === AgentSkillTypeEnum.folder;
const isPersonal = skill.source === AgentSkillSourceEnum.personal;
const relatedAppsCount = skill.appCount ?? 0;
const isSkillReady =
isFolder ||
(skill.creationStatus === AgentSkillCreationStatusEnum.ready &&
!!skill.currentVersionId);
const isSkillCreating = skill.creationStatus === AgentSkillCreationStatusEnum.creating;
const isSkillCreateFailed = skill.creationStatus === AgentSkillCreationStatusEnum.failed;
const menuList = [
...(isFolder || isSkillReady
? [
{
children: [
{
icon: 'edit',
type: 'grayBg' as const,
label: t('common:dataset.Edit Info'),
onClick: () => {
if (
!isFolder &&
guardSkillSandboxOperation &&
!guardSkillSandboxOperation()
) {
return;
}
setEditedSkill({
id: skill._id,
avatar:
skill.avatar ??
(isFolder ? 'common/folderFill' : 'core/skill/default'),
name: skill.name,
intro: skill.description
});
}
},
{
icon: 'common/file/move',
type: 'grayBg' as const,
label: t('common:move_to'),
onClick: () => setMoveSkillId(skill._id)
},
{
icon: 'key',
type: 'grayBg' as const,
label: t('skill:permission_settings'),
onClick: () => {
setEditPerSkillId(skill._id);
}
}
]
},
...(!isFolder
? [
{
children: [
{
icon: 'export',
type: 'grayBg' as const,
label: t('skill:export_config'),
onClick: () => onExportSkill(skill._id, skill.name)
},
{
icon: 'copy',
type: 'grayBg' as const,
label: t('skill:copy_skill'),
onClick: () =>
openConfirmCopy({
onConfirm: () => onclickCopySkill(skill._id)
})()
}
]
}
]
: [])
]
: []),
{
children: [
{
type: 'danger' as const,
icon: 'delete',
label: t('common:Delete'),
onClick: () =>
openConfirmDelete({
customContent: (
<Trans
i18nKey={'skill:confirm_delete_with_refs'}
values={{ count: isFolder ? 0 : relatedAppsCount }}
components={{ bold: <Box as={'span'} fontWeight={'600'} /> }}
/>
),
onConfirm: () => onClickDeleteSkill(skill._id),
confirmText: t('skill:confirm_delete_action'),
confirmButtonVariant: 'dangerFill',
inputConfirmText: skill.name
})()
}
]
}
];
return (
<MyBox
key={skill._id}
py={4}
px={5}
cursor={'pointer'}
border={'base'}
bg={'white'}
borderRadius={'10px'}
position={'relative'}
display={'flex'}
flexDirection={'column'}
_hover={{
borderColor: 'primary.300',
boxShadow: '1.5',
'& .more': {
display: 'flex'
},
'& .time': {
display: ['flex', 'none']
}
}}
onClick={() => {
if (isFolder) {
router.push({ query: { ...router.query, parentId: skill._id } });
} else {
if (isSkillReady && guardSkillSandboxOperation && !guardSkillSandboxOperation())
return;
router.push(`/skill/detail?skillId=${skill._id}`);
}
}}
>
{/* Top row: avatar + name */}
<Flex alignItems={'center'} gap={2}>
{isFolder ? (
<MyIcon
name={'common/folderFill'}
w={'1.5rem'}
flexShrink={0}
color={'myGray.500'}
/>
) : (
<Avatar
src={skill.avatar || 'core/skill/default'}
borderRadius={'sm'}
w={'1.5rem'}
flexShrink={0}
/>
)}
<Box className="textEllipsis" color={'myGray.900'} fontWeight={'medium'}>
{skill.name}
</Box>
{(isSkillCreating || isSkillCreateFailed) && (
<Box
px={2}
py={0.5}
borderRadius={'sm'}
fontSize={'10px'}
color={isSkillCreateFailed ? 'red.600' : 'primary.600'}
bg={isSkillCreateFailed ? 'red.50' : 'primary.50'}
flexShrink={0}
>
{isSkillCreateFailed ? t('common:failed') : t('skill:generating')}
</Box>
)}
</Flex>
{/* Description */}
<Box
flex={'1 0 56px'}
mt={3}
textAlign={'justify'}
wordBreak={'break-all'}
fontSize={'xs'}
color={'myGray.500'}
>
<Box className={'textEllipsis2'} whiteSpace={'pre-wrap'} lineHeight={1.3}>
{skill.description}
</Box>
</Box>
{/* Bottom row */}
<HStack h={'24px'} fontSize={'mini'} color={'myGray.500'} w="full">
<HStack flex={'1 0 0'} spacing={3}>
<UserBox
sourceMember={skill.sourceMember}
fontSize="xs"
avatarSize="1rem"
spacing={1}
/>
{!isFolder && isSkillReady && (
<>
{relatedAppsCount > 0 ? (
<RelatedAppsPopover skillId={skill._id} count={relatedAppsCount} />
) : (
<HStack spacing={1}>
<Box color={'myGray.500'}>{t('skill:related_count')}</Box>
<Box color={'myGray.500'} fontWeight={'medium'}>
0
</Box>
</HStack>
)}
</>
)}
</HStack>
<HStack>
{isPc && (
<HStack className="time" spacing={0.5}>
<MyIcon name={'history'} w={'0.85rem'} color={'myGray.400'} />
<Box color={'myGray.500'}>
{t(formatTimeToChatTime(skill.updateTime) as any).replace('#', ':')}
</Box>
</HStack>
)}
{isPersonal && (
<Box
className="more"
display={['', 'none']}
onClick={(e) => e.stopPropagation()}
>
<MyMenu
Button={
<IconButton
size={'xsSquare'}
variant={'transparentBase'}
icon={<MyIcon name={'more'} w={'0.875rem'} color={'myGray.500'} />}
aria-label={''}
/>
}
menuList={menuList}
/>
</Box>
)}
</HStack>
</HStack>
</MyBox>
);
})}
{renderVirtualGridItems(renderSkillCard)}
</Grid>
<DeleteConfirmModal />
<ConfirmCopyModal />
......
......@@ -31,6 +31,7 @@ import { useSystem } from '@fastgpt/web/hooks/useSystem';
import SideTag from './SideTag';
import UserBox from '@fastgpt/web/components/common/UserBox';
import { ReadRoleVal } from '@fastgpt/global/support/permission/constant';
import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList';
const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal'));
......@@ -48,11 +49,32 @@ function List() {
onUpdateDataset,
myDatasets,
folderDetail,
searchKey,
setSearchKey
} = useContextSelector(DatasetsContext, (v) => v);
const [editPerDatasetId, setEditPerDatasetId] = useState<string>();
const router = useRouter();
const { parentId = null } = router.query as { parentId?: string | null };
const formatDatasets = useMemo(
() =>
myDatasets.map((item) => {
return {
...item,
label: DatasetTypeMap[item.type]?.label,
icon: DatasetTypeMap[item.type]?.icon
};
}),
[myDatasets]
);
const { gridRef, renderVirtualGridItems } = useVirtualGridList({
list: formatDatasets,
listKey: `${router.pathname}-${parentId || ''}-${searchKey}`,
estimatedRowHeight: 160,
estimatedRowGap: 20
});
const parentDataset = useMemo(
() => myDatasets.find((item) => item._id === parentId),
[parentId, myDatasets]
......@@ -108,33 +130,276 @@ function List() {
}
);
const DeleteTipsMap = useRef({
const DeleteTipsMap = useRef<Record<DatasetTypeEnum, string>>({
[DatasetTypeEnum.folder]: t('common:dataset.deleteFolderTips'),
[DatasetTypeEnum.dataset]: t('common:core.dataset.Delete Confirm'),
[DatasetTypeEnum.websiteDataset]: t('common:core.dataset.Delete Confirm'),
[DatasetTypeEnum.externalFile]: t('common:core.dataset.Delete Confirm')
[DatasetTypeEnum.externalFile]: t('common:core.dataset.Delete Confirm'),
[DatasetTypeEnum.apiDataset]: t('common:core.dataset.Delete Confirm'),
[DatasetTypeEnum.feishu]: t('common:core.dataset.Delete Confirm'),
[DatasetTypeEnum.yuque]: t('common:core.dataset.Delete Confirm'),
[DatasetTypeEnum.dingtalk]: t('common:core.dataset.Delete Confirm')
});
const formatDatasets = useMemo(
() =>
myDatasets.map((item) => {
return {
...item,
label: DatasetTypeMap[item.type]?.label,
icon: DatasetTypeMap[item.type]?.icon
};
}),
[myDatasets]
);
const { openConfirm, ConfirmModal } = useConfirm({
type: 'delete'
});
const renderDatasetCard = (dataset: (typeof formatDatasets)[number]) => {
const vectorModelAvatar = getModelProvider(dataset.vectorModel.provider)?.avatar;
return (
<MyTooltip
key={dataset._id}
label={
<Flex flexDirection={'column'} alignItems={'center'}>
<Box fontSize={'xs'} color={'myGray.500'}>
{dataset.type === DatasetTypeEnum.folder
? t('common:open_folder')
: t('common:folder.open_dataset')}
</Box>
</Flex>
}
>
<MyBox
data-virtual-item=""
display={'flex'}
flexDirection={'column'}
lineHeight={1.5}
h="100%"
pt={5}
pb={3}
px={5}
cursor={'pointer'}
borderWidth={1.5}
border={'base'}
boxShadow={'2'}
bg={'white'}
borderRadius={'lg'}
position={'relative'}
minH={'150px'}
{...getBoxProps({
dataId: dataset._id,
isFolder: dataset.type === DatasetTypeEnum.folder
})}
_hover={{
borderColor: 'primary.300',
boxShadow: '1.5',
'& .delete': {
display: 'block'
},
'& .more': {
display: 'flex'
},
'& .time': {
display: ['flex', 'none']
}
}}
onClick={() => {
if (dataset.type === DatasetTypeEnum.folder) {
setSearchKey('');
router.push({
pathname: '/dataset/list',
query: {
parentId: dataset._id
}
});
} else {
router.push({
pathname: '/dataset/detail',
query: {
datasetId: dataset._id
}
});
}
}}
>
<Flex w="100%">
<Avatar src={dataset.avatar} borderRadius={6} w={'28px'} flexShrink={0} />
<Box width="0" flex="1" className="textEllipsis" color={'myGray.900'} ml={2}>
{dataset.name}
</Box>
{dataset.type !== DatasetTypeEnum.folder && (
<Box flexShrink={0} mr={-5}>
<SideTag
type={dataset.type}
py={0.5}
px={2}
borderLeftRadius={'sm'}
borderRightRadius={0}
/>
</Box>
)}
</Flex>
<Box
flex={1}
className={'textEllipsis3'}
whiteSpace={'pre-wrap'}
py={3}
fontSize={'xs'}
color={'myGray.500'}
>
{dataset.intro ||
(dataset.type === DatasetTypeEnum.folder
? t('common:core.dataset.Folder placeholder')
: t('common:core.dataset.Intro Placeholder'))}
</Box>
<Flex
h={'24px'}
alignItems={'center'}
justifyContent={'space-between'}
fontSize={'sm'}
fontWeight={500}
color={'myGray.500'}
>
<HStack spacing={3.5}>
<UserBox
sourceMember={dataset.sourceMember}
fontSize="xs"
avatarSize="1rem"
spacing={0.5}
/>
<PermissionIconText
flexShrink={0}
private={dataset.private}
iconColor="myGray.400"
color={'myGray.500'}
/>
</HStack>
<HStack>
{isPc && dataset.type !== DatasetTypeEnum.folder && (
<HStack spacing={1} className="time">
<Avatar src={vectorModelAvatar} w={'0.85rem'} />
<Box color={'myGray.500'} fontSize={'mini'}>
{dataset.vectorModel.name}
</Box>
</HStack>
)}
{(dataset.type === DatasetTypeEnum.folder
? dataset.permission.hasManagePer
: dataset.permission.hasWritePer) && (
<Box
className="more"
display={['', 'none']}
borderRadius={'md'}
_hover={{
'& .icon': {
bg: 'myGray.100'
}
}}
onClick={(e) => {
e.stopPropagation();
}}
>
<MyMenu
Button={
<Box w={'22px'} h={'22px'}>
<MyIcon
className="icon"
name={'more'}
h={'16px'}
w={'16px'}
px={1}
py={1}
borderRadius={'md'}
cursor={'pointer'}
/>
</Box>
}
menuList={[
{
children: [
{
icon: 'edit',
label: t('common:dataset.Edit Info'),
onClick: () =>
setEditedDataset({
id: dataset._id,
name: dataset.name,
intro: dataset.intro,
avatar: dataset.avatar
})
},
...((parentDataset ? parentDataset : dataset)?.permission.hasManagePer
? [
{
icon: 'common/file/move',
label: t('common:Move'),
onClick: () => {
setMoveDatasetId(dataset._id);
}
}
]
: []),
...(dataset.permission.hasManagePer
? [
{
icon: 'key',
label: t('common:permission.Permission'),
onClick: () => setEditPerDatasetId(dataset._id)
}
]
: [])
]
},
...(dataset.type != DatasetTypeEnum.folder
? [
{
children: [
{
icon: 'export',
label: t('common:Export'),
onClick: () => {
exportDataset(dataset);
}
}
]
}
]
: []),
...(dataset.permission.hasManagePer
? [
{
children: [
{
icon: 'delete',
label: t('common:Delete'),
type: 'danger' as const,
onClick: () =>
openConfirm({
onConfirm: () =>
onDelDataset(dataset._id).then(() => {
refetchPaths();
loadMyDatasets();
}),
customContent: DeleteTipsMap.current[dataset.type],
inputConfirmText: dataset.name
})()
}
]
}
]
: [])
]}
/>
</Box>
)}
</HStack>
</Flex>
</MyBox>
</MyTooltip>
);
};
return (
<>
{formatDatasets.length > 0 && (
<Grid
ref={gridRef}
py={4}
gridTemplateColumns={
folderDetail
......@@ -144,256 +409,7 @@ function List() {
gridGap={5}
alignItems={'stretch'}
>
{formatDatasets.map((dataset, index) => {
const vectorModelAvatar = getModelProvider(dataset.vectorModel.provider)?.avatar;
return (
<MyTooltip
key={dataset._id}
label={
<Flex flexDirection={'column'} alignItems={'center'}>
<Box fontSize={'xs'} color={'myGray.500'}>
{dataset.type === DatasetTypeEnum.folder
? t('common:open_folder')
: t('common:folder.open_dataset')}
</Box>
</Flex>
}
>
<MyBox
display={'flex'}
flexDirection={'column'}
lineHeight={1.5}
h="100%"
pt={5}
pb={3}
px={5}
cursor={'pointer'}
borderWidth={1.5}
border={'base'}
boxShadow={'2'}
bg={'white'}
borderRadius={'lg'}
position={'relative'}
minH={'150px'}
{...getBoxProps({
dataId: dataset._id,
isFolder: dataset.type === DatasetTypeEnum.folder
})}
_hover={{
borderColor: 'primary.300',
boxShadow: '1.5',
'& .delete': {
display: 'block'
},
'& .more': {
display: 'flex'
},
'& .time': {
display: ['flex', 'none']
}
}}
onClick={() => {
if (dataset.type === DatasetTypeEnum.folder) {
setSearchKey('');
router.push({
pathname: '/dataset/list',
query: {
parentId: dataset._id
}
});
} else {
router.push({
pathname: '/dataset/detail',
query: {
datasetId: dataset._id
}
});
}
}}
>
<Flex w="100%">
<Avatar src={dataset.avatar} borderRadius={6} w={'28px'} flexShrink={0} />
<Box width="0" flex="1" className="textEllipsis" color={'myGray.900'} ml={2}>
{dataset.name}
</Box>
{dataset.type !== DatasetTypeEnum.folder && (
<Box flexShrink={0} mr={-5}>
<SideTag
type={dataset.type}
py={0.5}
px={2}
borderLeftRadius={'sm'}
borderRightRadius={0}
/>
</Box>
)}
</Flex>
<Box
flex={1}
className={'textEllipsis3'}
whiteSpace={'pre-wrap'}
py={3}
fontSize={'xs'}
color={'myGray.500'}
>
{dataset.intro ||
(dataset.type === DatasetTypeEnum.folder
? t('common:core.dataset.Folder placeholder')
: t('common:core.dataset.Intro Placeholder'))}
</Box>
<Flex
h={'24px'}
alignItems={'center'}
justifyContent={'space-between'}
fontSize={'sm'}
fontWeight={500}
color={'myGray.500'}
>
<HStack spacing={3.5}>
<UserBox
sourceMember={dataset.sourceMember}
fontSize="xs"
avatarSize="1rem"
spacing={0.5}
/>
<PermissionIconText
flexShrink={0}
private={dataset.private}
iconColor="myGray.400"
color={'myGray.500'}
/>
</HStack>
<HStack>
{isPc && dataset.type !== DatasetTypeEnum.folder && (
<HStack spacing={1} className="time">
<Avatar src={vectorModelAvatar} w={'0.85rem'} />
<Box color={'myGray.500'} fontSize={'mini'}>
{dataset.vectorModel.name}
</Box>
</HStack>
)}
{(dataset.type === DatasetTypeEnum.folder
? dataset.permission.hasManagePer
: dataset.permission.hasWritePer) && (
<Box
className="more"
display={['', 'none']}
borderRadius={'md'}
_hover={{
'& .icon': {
bg: 'myGray.100'
}
}}
onClick={(e) => {
e.stopPropagation();
}}
>
<MyMenu
Button={
<Box w={'22px'} h={'22px'}>
<MyIcon
className="icon"
name={'more'}
h={'16px'}
w={'16px'}
px={1}
py={1}
borderRadius={'md'}
cursor={'pointer'}
/>
</Box>
}
menuList={[
{
children: [
{
icon: 'edit',
label: t('common:dataset.Edit Info'),
onClick: () =>
setEditedDataset({
id: dataset._id,
name: dataset.name,
intro: dataset.intro,
avatar: dataset.avatar
})
},
...((parentDataset ? parentDataset : dataset)?.permission
.hasManagePer
? [
{
icon: 'common/file/move',
label: t('common:Move'),
onClick: () => {
setMoveDatasetId(dataset._id);
}
}
]
: []),
...(dataset.permission.hasManagePer
? [
{
icon: 'key',
label: t('common:permission.Permission'),
onClick: () => setEditPerDatasetId(dataset._id)
}
]
: [])
]
},
...(dataset.type != DatasetTypeEnum.folder
? [
{
children: [
{
icon: 'export',
label: t('common:Export'),
onClick: () => {
exportDataset(dataset);
}
}
]
}
]
: []),
...(dataset.permission.hasManagePer
? [
{
children: [
{
icon: 'delete',
label: t('common:Delete'),
type: 'danger' as 'danger',
onClick: () =>
openConfirm({
onConfirm: () =>
onDelDataset(dataset._id).then(() => {
refetchPaths();
loadMyDatasets();
}),
customContent:
DeleteTipsMap.current[DatasetTypeEnum.dataset],
inputConfirmText: dataset.name
})()
}
]
}
]
: [])
]}
/>
</Box>
)}
</HStack>
</Flex>
</MyBox>
</MyTooltip>
);
})}
{renderVirtualGridItems(renderDatasetCard)}
</Grid>
)}
{myDatasets.length === 0 && (
......
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 sign in to comment