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'; ...@@ -43,6 +43,7 @@ import { createAppTypeMap } from '@/pageComponents/app/constants';
import { useUserStore } from '@/web/support/user/useUserStore'; import { useUserStore } from '@/web/support/user/useUserStore';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard'; import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard';
import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList';
const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal')); const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal'));
const ConfigPerModal = dynamic(() => import('@/components/support/permission/ConfigPerModal')); const ConfigPerModal = dynamic(() => import('@/components/support/permission/ConfigPerModal'));
...@@ -79,6 +80,13 @@ const List = () => { ...@@ -79,6 +80,13 @@ const List = () => {
const [editedApp, setEditedApp] = useState<EditResourceInfoFormType>(); const [editedApp, setEditedApp] = useState<EditResourceInfoFormType>();
const [editPerAppId, setEditPerAppId] = useState<string>(); 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( const editPerApp = useMemo(
() => () =>
...@@ -152,42 +160,7 @@ const List = () => { ...@@ -152,42 +160,7 @@ const List = () => {
} }
} }
); );
if (myApps.length === 0 && isFetchingApps) return null; const renderAppCard = (app: (typeof myApps)[number]) => {
return (
<>
{myApps.length === 0 && !folderDetail ? (
searchKey ? (
<EmptyTip />
) : isPc && hasCreatePer ? (
<CreateButton appType={appType} />
) : (
<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 />}
</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 isAgent = AppTypeList.includes(app.type);
const isTool = ToolTypeList.includes(app.type); const isTool = ToolTypeList.includes(app.type);
const isFolder = AppFolderTypeList.includes(app.type); const isFolder = AppFolderTypeList.includes(app.type);
...@@ -203,6 +176,7 @@ const List = () => { ...@@ -203,6 +176,7 @@ const List = () => {
} }
> >
<MyBox <MyBox
data-virtual-item=""
py={4} py={4}
px={5} px={5}
cursor={'pointer'} cursor={'pointer'}
...@@ -370,8 +344,7 @@ const List = () => { ...@@ -370,8 +344,7 @@ const List = () => {
} }
}, },
...(folderDetail?.type === AppTypeEnum.httpPlugin && ...(folderDetail?.type === AppTypeEnum.httpPlugin &&
!(parentApp ? parentApp.permission : app.permission) !(parentApp ? parentApp.permission : app.permission).hasManagePer
.hasManagePer
? [] ? []
: [ : [
{ {
...@@ -427,8 +400,7 @@ const List = () => { ...@@ -427,8 +400,7 @@ const List = () => {
onClick: () => onClick: () =>
openConfirmDelete({ openConfirmDelete({
customContent: (() => { customContent: (() => {
if (isFolder) if (isFolder) return t('app:confirm_delete_folder_tip');
return t('app:confirm_delete_folder_tip');
if (isAgent) return t('app:confirm_del_app_tip'); if (isAgent) return t('app:confirm_del_app_tip');
if (isTool) return t('app:confirm_del_tool_tip'); if (isTool) return t('app:confirm_del_tool_tip');
return t('app:confirm_del_app_tip'); return t('app:confirm_del_app_tip');
...@@ -451,8 +423,48 @@ const List = () => { ...@@ -451,8 +423,48 @@ const List = () => {
</MyBox> </MyBox>
</MyTooltip> </MyTooltip>
); );
})} };
if (myApps.length === 0 && isFetchingApps) return null;
return (
<>
{myApps.length === 0 && !folderDetail ? (
searchKey ? (
<EmptyTip />
) : isPc && hasCreatePer ? (
<CreateButton appType={appType} />
) : (
<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 />}
</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> </Grid>
</>
)} )}
<DeleteConfirmModal /> <DeleteConfirmModal />
<ConfirmCopyModal /> <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 { Box, Grid, IconButton, HStack, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
...@@ -46,6 +46,7 @@ import type { ...@@ -46,6 +46,7 @@ import type {
} from '@fastgpt/global/common/parentFolder/type'; } from '@fastgpt/global/common/parentFolder/type';
import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard'; import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard';
import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList';
const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal')); const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal'));
const MoveModal = dynamic(() => import('@/components/common/folder/MoveModal')); const MoveModal = dynamic(() => import('@/components/common/folder/MoveModal'));
...@@ -202,6 +203,13 @@ const List = ({ ...@@ -202,6 +203,13 @@ const List = ({
const [editedSkill, setEditedSkill] = useState<EditResourceInfoFormType>(); const [editedSkill, setEditedSkill] = useState<EditResourceInfoFormType>();
const [moveSkillId, setMoveSkillId] = useState<string>(); const [moveSkillId, setMoveSkillId] = useState<string>();
const [editPerSkillId, setEditPerSkillId] = 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( const selectedSkill = useMemo(
() => () =>
...@@ -289,35 +297,13 @@ const List = ({ ...@@ -289,35 +297,13 @@ const List = ({
} }
); );
if (skills.length === 0 && isFetchingSkills) return null; const renderSkillCard = (skill: (typeof skills)[number]) => {
if (skills.length === 0 && (!onClickCreate || !!searchKey)) {
return <EmptyTip text={searchKey ? undefined : t('skill:no_skills')} />;
}
return (
<>
<Grid
py={4}
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={3}
alignItems={'stretch'}
>
{onClickCreate && !searchKey && <ListCreateCard onClick={onClickCreate} />}
{skills.map((skill) => {
const isFolder = skill.type === AgentSkillTypeEnum.folder; const isFolder = skill.type === AgentSkillTypeEnum.folder;
const isPersonal = skill.source === AgentSkillSourceEnum.personal; const isPersonal = skill.source === AgentSkillSourceEnum.personal;
const relatedAppsCount = skill.appCount ?? 0; const relatedAppsCount = skill.appCount ?? 0;
const isSkillReady = const isSkillReady =
isFolder || isFolder ||
(skill.creationStatus === AgentSkillCreationStatusEnum.ready && (skill.creationStatus === AgentSkillCreationStatusEnum.ready && !!skill.currentVersionId);
!!skill.currentVersionId);
const isSkillCreating = skill.creationStatus === AgentSkillCreationStatusEnum.creating; const isSkillCreating = skill.creationStatus === AgentSkillCreationStatusEnum.creating;
const isSkillCreateFailed = skill.creationStatus === AgentSkillCreationStatusEnum.failed; const isSkillCreateFailed = skill.creationStatus === AgentSkillCreationStatusEnum.failed;
const menuList = [ const menuList = [
...@@ -330,18 +316,13 @@ const List = ({ ...@@ -330,18 +316,13 @@ const List = ({
type: 'grayBg' as const, type: 'grayBg' as const,
label: t('common:dataset.Edit Info'), label: t('common:dataset.Edit Info'),
onClick: () => { onClick: () => {
if ( if (!isFolder && guardSkillSandboxOperation && !guardSkillSandboxOperation()) {
!isFolder &&
guardSkillSandboxOperation &&
!guardSkillSandboxOperation()
) {
return; return;
} }
setEditedSkill({ setEditedSkill({
id: skill._id, id: skill._id,
avatar: avatar:
skill.avatar ?? skill.avatar ?? (isFolder ? 'common/folderFill' : 'core/skill/default'),
(isFolder ? 'common/folderFill' : 'core/skill/default'),
name: skill.name, name: skill.name,
intro: skill.description intro: skill.description
}); });
...@@ -416,6 +397,7 @@ const List = ({ ...@@ -416,6 +397,7 @@ const List = ({
return ( return (
<MyBox <MyBox
key={skill._id} key={skill._id}
data-virtual-item=""
py={4} py={4}
px={5} px={5}
cursor={'pointer'} cursor={'pointer'}
...@@ -439,21 +421,14 @@ const List = ({ ...@@ -439,21 +421,14 @@ const List = ({
if (isFolder) { if (isFolder) {
router.push({ query: { ...router.query, parentId: skill._id } }); router.push({ query: { ...router.query, parentId: skill._id } });
} else { } else {
if (isSkillReady && guardSkillSandboxOperation && !guardSkillSandboxOperation()) if (isSkillReady && guardSkillSandboxOperation && !guardSkillSandboxOperation()) return;
return;
router.push(`/skill/detail?skillId=${skill._id}`); router.push(`/skill/detail?skillId=${skill._id}`);
} }
}} }}
> >
{/* Top row: avatar + name */}
<Flex alignItems={'center'} gap={2}> <Flex alignItems={'center'} gap={2}>
{isFolder ? ( {isFolder ? (
<MyIcon <MyIcon name={'common/folderFill'} w={'1.5rem'} flexShrink={0} color={'myGray.500'} />
name={'common/folderFill'}
w={'1.5rem'}
flexShrink={0}
color={'myGray.500'}
/>
) : ( ) : (
<Avatar <Avatar
src={skill.avatar || 'core/skill/default'} src={skill.avatar || 'core/skill/default'}
...@@ -480,7 +455,6 @@ const List = ({ ...@@ -480,7 +455,6 @@ const List = ({
)} )}
</Flex> </Flex>
{/* Description */}
<Box <Box
flex={'1 0 56px'} flex={'1 0 56px'}
mt={3} mt={3}
...@@ -494,7 +468,6 @@ const List = ({ ...@@ -494,7 +468,6 @@ const List = ({
</Box> </Box>
</Box> </Box>
{/* Bottom row */}
<HStack h={'24px'} fontSize={'mini'} color={'myGray.500'} w="full"> <HStack h={'24px'} fontSize={'mini'} color={'myGray.500'} w="full">
<HStack flex={'1 0 0'} spacing={3}> <HStack flex={'1 0 0'} spacing={3}>
<UserBox <UserBox
...@@ -528,11 +501,7 @@ const List = ({ ...@@ -528,11 +501,7 @@ const List = ({
</HStack> </HStack>
)} )}
{isPersonal && ( {isPersonal && (
<Box <Box className="more" display={['', 'none']} onClick={(e) => e.stopPropagation()}>
className="more"
display={['', 'none']}
onClick={(e) => e.stopPropagation()}
>
<MyMenu <MyMenu
Button={ Button={
<IconButton <IconButton
...@@ -550,7 +519,31 @@ const List = ({ ...@@ -550,7 +519,31 @@ const List = ({
</HStack> </HStack>
</MyBox> </MyBox>
); );
})} };
if (skills.length === 0 && isFetchingSkills) return null;
if (skills.length === 0 && (!onClickCreate || !!searchKey)) {
return <EmptyTip text={searchKey ? undefined : t('skill:no_skills')} />;
}
return (
<>
<Grid
ref={gridRef}
py={4}
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={3}
alignItems={'stretch'}
>
{onClickCreate && !searchKey && <ListCreateCard onClick={onClickCreate} />}
{renderVirtualGridItems(renderSkillCard)}
</Grid> </Grid>
<DeleteConfirmModal /> <DeleteConfirmModal />
<ConfirmCopyModal /> <ConfirmCopyModal />
......
...@@ -31,6 +31,7 @@ import { useSystem } from '@fastgpt/web/hooks/useSystem'; ...@@ -31,6 +31,7 @@ import { useSystem } from '@fastgpt/web/hooks/useSystem';
import SideTag from './SideTag'; import SideTag from './SideTag';
import UserBox from '@fastgpt/web/components/common/UserBox'; import UserBox from '@fastgpt/web/components/common/UserBox';
import { ReadRoleVal } from '@fastgpt/global/support/permission/constant'; import { ReadRoleVal } from '@fastgpt/global/support/permission/constant';
import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList';
const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal')); const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal'));
...@@ -48,11 +49,32 @@ function List() { ...@@ -48,11 +49,32 @@ function List() {
onUpdateDataset, onUpdateDataset,
myDatasets, myDatasets,
folderDetail, folderDetail,
searchKey,
setSearchKey setSearchKey
} = useContextSelector(DatasetsContext, (v) => v); } = useContextSelector(DatasetsContext, (v) => v);
const [editPerDatasetId, setEditPerDatasetId] = useState<string>(); const [editPerDatasetId, setEditPerDatasetId] = useState<string>();
const router = useRouter(); const router = useRouter();
const { parentId = null } = router.query as { parentId?: string | null }; 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( const parentDataset = useMemo(
() => myDatasets.find((item) => item._id === parentId), () => myDatasets.find((item) => item._id === parentId),
[parentId, myDatasets] [parentId, myDatasets]
...@@ -108,43 +130,22 @@ function List() { ...@@ -108,43 +130,22 @@ function List() {
} }
); );
const DeleteTipsMap = useRef({ const DeleteTipsMap = useRef<Record<DatasetTypeEnum, string>>({
[DatasetTypeEnum.folder]: t('common:dataset.deleteFolderTips'), [DatasetTypeEnum.folder]: t('common:dataset.deleteFolderTips'),
[DatasetTypeEnum.dataset]: t('common:core.dataset.Delete Confirm'), [DatasetTypeEnum.dataset]: t('common:core.dataset.Delete Confirm'),
[DatasetTypeEnum.websiteDataset]: 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({ const { openConfirm, ConfirmModal } = useConfirm({
type: 'delete' type: 'delete'
}); });
return ( const renderDatasetCard = (dataset: (typeof formatDatasets)[number]) => {
<>
{formatDatasets.length > 0 && (
<Grid
py={4}
gridTemplateColumns={
folderDetail
? ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)']
: ['1fr', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']
}
gridGap={5}
alignItems={'stretch'}
>
{formatDatasets.map((dataset, index) => {
const vectorModelAvatar = getModelProvider(dataset.vectorModel.provider)?.avatar; const vectorModelAvatar = getModelProvider(dataset.vectorModel.provider)?.avatar;
return ( return (
...@@ -161,6 +162,7 @@ function List() { ...@@ -161,6 +162,7 @@ function List() {
} }
> >
<MyBox <MyBox
data-virtual-item=""
display={'flex'} display={'flex'}
flexDirection={'column'} flexDirection={'column'}
lineHeight={1.5} lineHeight={1.5}
...@@ -322,8 +324,7 @@ function List() { ...@@ -322,8 +324,7 @@ function List() {
avatar: dataset.avatar avatar: dataset.avatar
}) })
}, },
...((parentDataset ? parentDataset : dataset)?.permission ...((parentDataset ? parentDataset : dataset)?.permission.hasManagePer
.hasManagePer
? [ ? [
{ {
icon: 'common/file/move', icon: 'common/file/move',
...@@ -367,7 +368,7 @@ function List() { ...@@ -367,7 +368,7 @@ function List() {
{ {
icon: 'delete', icon: 'delete',
label: t('common:Delete'), label: t('common:Delete'),
type: 'danger' as 'danger', type: 'danger' as const,
onClick: () => onClick: () =>
openConfirm({ openConfirm({
onConfirm: () => onConfirm: () =>
...@@ -375,8 +376,7 @@ function List() { ...@@ -375,8 +376,7 @@ function List() {
refetchPaths(); refetchPaths();
loadMyDatasets(); loadMyDatasets();
}), }),
customContent: customContent: DeleteTipsMap.current[dataset.type],
DeleteTipsMap.current[DatasetTypeEnum.dataset],
inputConfirmText: dataset.name inputConfirmText: dataset.name
})() })()
} }
...@@ -393,7 +393,23 @@ function List() { ...@@ -393,7 +393,23 @@ function List() {
</MyBox> </MyBox>
</MyTooltip> </MyTooltip>
); );
})} };
return (
<>
{formatDatasets.length > 0 && (
<Grid
ref={gridRef}
py={4}
gridTemplateColumns={
folderDetail
? ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)']
: ['1fr', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']
}
gridGap={5}
alignItems={'stretch'}
>
{renderVirtualGridItems(renderDatasetCard)}
</Grid> </Grid>
)} )}
{myDatasets.length === 0 && ( {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