Gate new (#4928)
* feat: Add portal management related icons * feat: Add portal configuration pages and related translations * feat: Add new gateway configuration components and icons - Introduced `ConfigButtons` component for save and share actions with new SVG icons. - Added `CopyrightTable` and `HomeTable` components for managing copyright and home settings. - Implemented `SectionHeader` for consistent section titles in the gateway configuration. - Updated `FillRowTabs` to support new tabs for home and copyright configurations. - Modified translations for gateway-related terms in English, Simplified Chinese, and Traditional Chinese. - Removed unused gateway tab from `AccountContainer`. * feat(gate): add API and schema for team gate configurations - Introduced new TypeScript definitions for gate configuration parameters and data structures. - Created constants for gate status and tools. - Implemented MongoDB schema for team gate configurations. - Added API functions for getting, creating, updating, and deleting team gate configurations and logos. - Developed ShareGateModal component for sharing portal links and custom domains. - Updated ConfigButtons component to handle saving configurations and opening the share modal. - Added new icons for gate functionalities. - Updated English and Chinese translations for gateway-related texts. * feat(gate): refactor gate configuration API and remove unused logo handling * feat(gate): enhance team gate configuration with new error handling and chat features - Added new error codes to CommonErrEnum for method not allowed, system error, and unauthorized access. - Updated datasetErr to include corresponding error messages for new error codes. - Refactored API to support updating team gate configurations and copyright information. - Introduced ChatInputBox component for chat functionalities, including file and image uploads. - Enhanced HomeTable and CopyrightTable components to manage settings more effectively. - Updated translations for new terms in English and Chinese. - Improved layout and user experience in the gateway configuration pages. * feat: Refactor gateway configuration and chat components - Replaced direct API calls with Zustand store for gate configuration management. - Introduced `useGateStore` for managing gate and copyright configurations. - Updated `GatewayConfig` component to utilize the new store and remove redundant state management. - Enhanced chat functionality in `application.tsx` and `index.tsx` to support gate model. - Created new `application.tsx` for handling chat interactions with the gate application. - Improved error handling and loading states in chat components. - Added dynamic imports for better performance and code splitting. * feat(gate): update GateSideBar to conditionally render recent apps based on chat page state * fix(HomeTable): comment out unused FormControl for better readability * feat(gate): enhance copyright configuration and file upload functionality - Updated ConfigButtons to handle team avatar updates and save copyright configurations. - Refactored CopyrightTable to integrate file selection for team avatars and improve form handling. - Added animations and hover effects for better user experience during file uploads. - Improved toast notifications for success and error handling in configuration processes. * feat(gate): add gate service availability check and update translations - Implemented gate service availability check in application and index pages, redirecting users if the service is unavailable. - Added new translation keys for gate service status in English and Chinese. - Refactored GateSideBar to improve rendering logic for recent apps based on gate status. * feat(chat): add route check to ToolMenu for app detail visibility - Implemented a check to prevent displaying app details when the current route starts with '/chat/gate'. - Updated menu rendering logic to conditionally show app details based on the new route check. * feat(constants): add 'gate' type to AppTypeEnum * refactor: rename "Portal" to "Gate" across the application - Updated schema to remove the slogan field from GateConfigSchema. - Modified SVG icon dimensions for gateLight.svg. - Changed localization keys and values from "Portal" to "Gate" in various JSON files. - Added support for gate applications in the app creation and management logic. - Enhanced ChatBox component to handle gate-specific routes and configurations. - Updated ConfigButtons to manage gate configurations and intros. - Adjusted ShareGateModal to generate correct gate URLs. - Expanded emptyTemplates to include gate-specific templates and configurations. - Refactored chatItemContext to include intro for gate applications. - Updated useGateStore to initialize gate configurations with intros from existing gate applications. * fix: add isResponseDetail prop to ChatItemContextProvider * feat: refactor gate-related API and components for improved functionality * feat: 添加工具选择和工具选择模态框组件 * refactor: Update GateConfig related types, remove unnecessary constants and enums * feat: Enhance Gate configuration components and API integration - Updated ConfigButtons and HomeTable to use string arrays for tools instead of GateTool type. - Implemented batch plugin loading in HomeTable with error handling. - Added ToolSelect and ToolSelectModal components for improved tool management. - Introduced AppCard and ChatTest components for app detail editing. - Enhanced Edit and EditForm components for better app configuration management. - Added new API endpoint for batch plugin retrieval. - Improved overall structure and styling for better user experience. * fix: Update ChatBoxDataType to make intro optional in chatItemContext.tsx * fix: Add isResponseDetail prop to ChatItemContextProvider in ChatPage component * feat: Enhance ToolSelectModal and GatewayConfig with new functionalities - Updated ToolSelectModal to handle tool selection and configuration, integrating new props for selected tools and chat configuration. - Implemented loading and error handling for Gate applications in GatewayConfig, including a retry mechanism for fetching apps. - Added selectedTool parameter to chat completions API to enable tool activation during chat. - Refactored chat component to support app form context and debug mode for testing. - Enhanced useGateStore to manage gate applications, including loading and updating functionalities. * feat: Refactor GateSideBar to enhance recent apps display and add resource selection * refactor: 移除门户删除确认功能 * feat: 更新 Chat 组件以使用 AppContextProvider 并修正 localAppDetail 的类型 * refactor: Remove the tool menu logic in the GateChatInput component to simplify the code structure * refactor: Remove the tool menu logic from the GateChatInput component to simplify the code structure * feat: Simplify the ShareGateModal component by removing unused states and logic * fix: Update chatGray.svg to remove fill attributes for paths, improving SVG structure * feat: Added new chat icons and updated internationalized text to support new chat features * feat: Refactor chat components and introduce GateChat functionality - Updated ChatHistorySlider to remove isGateRoute check for PC view. - Added new GateChatHistorySlider component for handling chat history in gate context. - Removed obsolete ChatPage component related to gate chat. - Modified GateSideBar styles for improved UI consistency. - Implemented new API endpoint for chat gate functionality. - Refactored chat gate index page to utilize GateChatHistorySlider and streamline chat initialization. - Cleaned up unused imports and code related to debugging and legacy chat handling. * feat: Update GateSideBar styles for improved responsiveness and animation - Adjusted width and padding for collapsed and expanded states. - Enhanced transition effects for smoother UI interactions. - Modified alignment and positioning of navigation items and user profile for better layout consistency. - Improved accessibility by ensuring elements are centered when collapsed. * feat: 添加新的聊天图标和更新分享门户组件样式以提升用户体验 * Refactor chat gate components and implement sidebar functionality - Updated ChatGate component to use ChatItemContextProvider and ChatRecordContextProvider for better context management. - Introduced FoldButton component for sidebar collapsing functionality. - Created GateNavBar component to replace GateSideBar for improved navigation. - Refactored GateSideBar to handle folding state and external triggers. - Updated application and index pages to integrate new components and manage sidebar state. - Enhanced useChatGate hook to include appDetail.intro. * feat: Updated team structure, set default banner image and refactored LogoBox component to support diagonal background * feat: Enhance GateNavBar with user popover functionality and logout feature - Added user popover for displaying user information and logout option. - Implemented mouse enter/leave handlers for popover visibility. - Updated user profile section to include popover and improved layout. - Modified index page to include 'account' in server-side props for better context management. * feat: Add a bottom line statement in the ChatBox component to remind users that the content is generated by third-party AI * feat: Update placeholder text in ChatBox and GateChatInput components for better user guidance - Added internationalized placeholder text for user input in both English and Chinese. - Updated ChatBox and GateChatInput components to utilize the new placeholder text from localization files. * feat: Add upload icon and enhance ChatBox layout for better user experience - Introduced a new upload icon in the Icon component for improved visual representation. - Updated ChatBox layout to enhance responsiveness and user interaction, including adjustments to padding and structure. - Added hover overlay effect for logo upload areas in the CopyrightTable component to improve user guidance. * feat: Refactor Chat component to integrate GateSideBar and GateChatHistorySlider for improved layout and functionality * refactor: Update imports to use 'import type' for type-only imports across multiple files - Changed standard imports to type imports for better clarity and performance in TypeScript. - Updated files in the global support, service, and app components to reflect this change. * feat: Update localization strings and improve toast messages for better user feedback - Added new success and failure messages for create, delete, save, and update actions in English and Chinese localization files. - Refactored toast message keys in the ConfigButtons, CopyrightTable, HomeTable, ToolSelect, and other components to use updated localization keys for consistency. - Enhanced user experience by providing clearer feedback on actions performed within the application. * feat: Implement tag management functionality with CRUD operations - Added new Tag schema and controller for managing application tags. - Implemented API endpoints for creating, updating, deleting, and listing tags. - Enhanced the App schema to include a reference to tags. - Updated localization files for new tag-related messages. - Improved user experience by providing clear feedback on tag operations. * feat: Enhance ChatWelcome and GateNavBar components with conditional rendering for team avatars - Updated ChatWelcome and GateNavBar components to conditionally render avatars based on availability. - Improved layout by using Flex components for better alignment and responsiveness. - Ensured consistent styling and structure for avatar display across both components. * fix: Update parameter name in getBatchPlugins API for consistency - Changed parameter name from 'id' to 'appId' in getChildAppPreviewNode function call for better clarity and consistency with the rest of the codebase. * feat: Enhance ToolSelectModal with gate plugins integration and improved filtering - Added useEffect to load plugins from gateStore and set them in state. - Introduced ExtendedNodeTemplateItemType to include cost-related properties. - Updated filtering logic for plugins based on search input. - Refactored RenderList to display plugins with cost information and improved layout. * refactor: Update ToolSelect and ToolSelectModal components for improved UI and state management - Replaced Button with Flex component in ToolSelect for better styling and hover effects. - Adjusted layout and styling in ToolSelect for a more responsive design. - Removed ExtendedNodeTemplateItemType and reverted to NodeTemplateListItemType in ToolSelectModal for simplified state management. - Updated RenderList to reflect changes in template type and maintain consistency. * refactor: Replace Flex with Button for add tool action and enhance loading state UI * feat: Enhance application tag management and localization support - Added 'tags' property to AppListItemType for better tag management. - Updated localization files for English and Chinese to include new tag-related strings. - Implemented new AppTable component in the gateway for managing applications. - Adjusted routes and components to support the new app management features. * feat: Update localization and refactor chat components - Added new localization strings for "enlarge" in English, Simplified Chinese, and Traditional Chinese. - Refactored chat components to replace `quoteData` with `datasetCiteData` for improved state management. - Enhanced `ToolSelect` and related components by removing error handling logic for a cleaner UI. - Updated `AppTable` component to remove unnecessary props for better clarity. * feat: Initialize copyright configuration in GateNavBar component * feat: Add appDetail property to ChatGate component and update related logic * feat: Update GateNavBar routing logic for chat page refresh and enhance avatar display * feat: Enhance tag management and app detail handling in Chat component * feat: 更新聊天组件中的国际化文本和输入逻辑,优化用户体验 * feat: Refactor gate configuration management - Updated API endpoints for fetching and updating gate configurations. - Changed `avatar` field to `logo` and added `banner` in gate configuration types. - Implemented new controller methods for creating, retrieving, updating, and deleting gate configurations. - Enhanced `ConfigButtons` and `CopyrightTable` components to handle new configuration fields. - Added new SVG icon for sidebar collapse button. - Improved internationalization support by adding new translation keys. - Refactored `HomeTable` to manage gate configuration state and handle updates. - Updated `ShareGateModal` to accept gate configuration as props. - Cleaned up unused imports and optimized component structures. * feat: 加载和管理 Gate 配置及版权信息,优化相关组件逻辑 * feat: 更新国际化文本,优化聊天组件中的配置和状态检查逻辑 * feat: Update template configuration and adjust default open state to improve user experience * feat: Enhance gate management features and update related components - Added `featuredApps` and `quickApps` fields to `GateSchemaType` for better app management. - Implemented new methods for updating and managing featured and quick apps in the `controller` and `featureApp` modules. - Introduced `AddFeatureAppModal` for selecting and adding featured apps. - Updated `AppTable` and `HomeTable` components to integrate new app management functionalities. - Enhanced internationalization support by adding new translation keys for app management features. - Refactored existing components to improve code clarity and maintainability. * feat: Enhance chat tool selection and quick app management features - Added `selectedToolIds` and `onSelectedToolIdsChange` props to `ChatBox` and `GateChatInput` components for better tool management. - Introduced `GateToolSelect` component for selecting tools with improved UI and functionality. - Implemented `AddQuickAppModal` for managing quick apps, including selection and drag-and-drop functionality. - Updated `HomeTable` to integrate quick app management and display selected apps. - Refactored related components to improve code clarity and maintainability. * refactor: Remove unused AppContext import in useChatGate.tsx to clean up code * refactor: Update plugin ID handling and clean up unused imports - Renamed `splitCombinePluginId` to `splitCombineToolId` for consistency in plugin ID processing. - Removed unused `checkNode` import from `featureApp/detail.ts` and `quickApp/detail.ts` files to streamline the code. - Added `ownerTmbId` to the parameters in `rewriteAppWorkflowToDetail` for better context management. * refactor: Rename storeEdgesRenderEdge to storeEdge2RenderEdge for consistency - Updated the function name from `storeEdgesRenderEdge` to `storeEdge2RenderEdge` in the Header component to maintain naming consistency. - Adjusted the mapping of edges to use the new function name for improved clarity in the workflow processing.
This commit is contained in:
@@ -22,7 +22,8 @@ export enum TabEnum {
|
||||
'apikey' = 'apikey',
|
||||
'loginout' = 'loginout',
|
||||
'team' = 'team',
|
||||
'model' = 'model'
|
||||
'model' = 'model',
|
||||
gateway = 'gateway'
|
||||
}
|
||||
|
||||
const AccountContainer = ({
|
||||
@@ -60,6 +61,11 @@ const AccountContainer = ({
|
||||
icon: 'support/usage/usageRecordLight',
|
||||
label: t('account:usage_records'),
|
||||
value: TabEnum.usage
|
||||
},
|
||||
{
|
||||
icon: 'support/gate/gateLight',
|
||||
label: t('account:gateways'),
|
||||
value: TabEnum.gateway
|
||||
}
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { ModalBody, ModalFooter, Button, Text, Flex, Box, IconButton } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import type { SelectAppItemType } from '@fastgpt/global/core/workflow/template/system/abandoned/runApp/type';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import SelectMultipleResource from './SelectMultipleResource';
|
||||
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag';
|
||||
import {
|
||||
type GetResourceFolderListProps,
|
||||
type GetResourceListItemResponse
|
||||
} from '@fastgpt/global/common/parentFolder/type';
|
||||
import { getMyApps } from '@/web/core/app/api';
|
||||
import { listFeatureApps, batchUpdateFeaturedApps } from '@/web/support/user/team/gate/featureApp';
|
||||
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
|
||||
// 扩展的应用类型,包含显示所需的属性
|
||||
type ExtendedSelectAppItemType = SelectAppItemType & {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
const AddFeatureAppModal = ({
|
||||
isOpen = true,
|
||||
value,
|
||||
filterAppIds = [],
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
value?: ExtendedSelectAppItemType[];
|
||||
filterAppIds?: string[];
|
||||
onClose: () => void;
|
||||
onSuccess: (e: ExtendedSelectAppItemType[]) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedApps, setSelectedApps] = useState<ExtendedSelectAppItemType[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
|
||||
// 使用 listFeatureApps 初始化已选择的应用数组
|
||||
const { data: featureApps = [], loading: loadingFeatureApps } = useRequest2(
|
||||
() => listFeatureApps(),
|
||||
{
|
||||
manual: false,
|
||||
onSuccess: (data) => {
|
||||
const initialSelectedApps = data.map((app) => ({
|
||||
id: app._id,
|
||||
name: app.name,
|
||||
avatar: app.avatar
|
||||
}));
|
||||
setSelectedApps(initialSelectedApps);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const getAppList = useCallback(
|
||||
async ({ parentId }: GetResourceFolderListProps) => {
|
||||
return getMyApps({
|
||||
parentId,
|
||||
searchKey,
|
||||
type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow]
|
||||
}).then((res) =>
|
||||
res
|
||||
.filter((item) => !filterAppIds.includes(item._id))
|
||||
.map<GetResourceListItemResponse>((item) => ({
|
||||
id: item._id,
|
||||
name: item.name,
|
||||
avatar: item.avatar,
|
||||
isFolder: item.type === AppTypeEnum.folder
|
||||
}))
|
||||
);
|
||||
},
|
||||
[filterAppIds, searchKey]
|
||||
);
|
||||
|
||||
const handleAppSelect = useCallback((appId: string, appData: GetResourceListItemResponse) => {
|
||||
setSelectedApps((prev) => {
|
||||
const exists = prev.find((app) => app.id === appId);
|
||||
if (exists) {
|
||||
// 如果已存在,则移除
|
||||
return prev.filter((app) => app.id !== appId);
|
||||
} else {
|
||||
// 如果不存在,则添加到末尾
|
||||
return [...prev, { id: appId, name: appData.name, avatar: appData.avatar }];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAppUnselect = useCallback((appId: string) => {
|
||||
setSelectedApps((prev) => prev.filter((app) => app.id !== appId));
|
||||
}, []);
|
||||
|
||||
// 处理拖拽排序
|
||||
const handleDragEnd = useCallback((reorderedList: ExtendedSelectAppItemType[]) => {
|
||||
setSelectedApps(reorderedList);
|
||||
}, []);
|
||||
|
||||
// 批量更新特色应用
|
||||
const { runAsync: updateFeaturedApps, loading: isUpdating } = useRequest2(
|
||||
() => {
|
||||
const updates = [{ featuredApps: selectedApps.map((app) => app.id) }];
|
||||
return batchUpdateFeaturedApps(updates);
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
onSuccess: () => {
|
||||
onSuccess(selectedApps);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('更新特色应用失败:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={isOpen}
|
||||
title={t('common:core.module.Select app')}
|
||||
iconSrc="/imgs/workflow/ai.svg"
|
||||
onClose={onClose}
|
||||
position={'relative'}
|
||||
w={'900px'}
|
||||
maxW={'90vw'}
|
||||
>
|
||||
<ModalBody flex={'1 0 0'} overflow={'hidden'} minH={'500px'} position={'relative'}>
|
||||
<Flex h="100%" gap={4}>
|
||||
{/* 左侧应用选择区域 */}
|
||||
<Flex direction="column" flex={1} h="100%">
|
||||
{/* 搜索框 */}
|
||||
<Box mb={4}>
|
||||
<SearchInput
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder={t('app:search_app')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 应用选择区域 */}
|
||||
<Box flex={1} overflow="auto">
|
||||
<SelectMultipleResource
|
||||
selectedIds={selectedApps.map((app) => app.id)}
|
||||
onSelect={handleAppSelect}
|
||||
server={getAppList}
|
||||
searchKey={searchKey}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 右侧已选择应用排序区域 */}
|
||||
<Box w="300px" h="100%" borderLeft="1px solid" borderColor="gray.200" pl={4}>
|
||||
<Flex direction="column" h="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={3}>
|
||||
{t('common:selected')} {selectedApps.length}
|
||||
</Text>
|
||||
|
||||
{selectedApps.length > 0 ? (
|
||||
<Box flex={1} overflow="auto">
|
||||
<DndDrag<ExtendedSelectAppItemType>
|
||||
onDragEndCb={handleDragEnd}
|
||||
dataList={selectedApps}
|
||||
>
|
||||
{({ provided }) => (
|
||||
<Flex
|
||||
flexDirection={'column'}
|
||||
gap={2}
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{selectedApps.map((app, index) => (
|
||||
<Draggable key={app.id} draggableId={String(app.id)} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<Flex
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
opacity: snapshot.isDragging ? 0.8 : 1
|
||||
}}
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
p={2}
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="gray.200"
|
||||
fontSize="sm"
|
||||
_hover={{
|
||||
bg: 'gray.50',
|
||||
borderColor: 'gray.300'
|
||||
}}
|
||||
>
|
||||
{/* 拖拽图标 */}
|
||||
<Flex
|
||||
{...provided.dragHandleProps}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="16px"
|
||||
h="16px"
|
||||
cursor="grab"
|
||||
_active={{ cursor: 'grabbing' }}
|
||||
>
|
||||
<MyIcon name="drag" w={'10px'} h={'12px'} color={'gray.500'} />
|
||||
</Flex>
|
||||
|
||||
{/* 应用图标 */}
|
||||
<Avatar src={app.avatar} w="20px" h="20px" borderRadius="4px" />
|
||||
|
||||
{/* 应用名称 */}
|
||||
<Text flex={1} fontSize="12px" fontWeight="500" noOfLines={1}>
|
||||
{app.name}
|
||||
</Text>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon={<MyIcon name="delete" w="12px" />}
|
||||
aria-label="remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAppUnselect(app.id);
|
||||
}}
|
||||
_hover={{ bg: 'red.50', color: 'red.500' }}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Flex>
|
||||
)}
|
||||
</DndDrag>
|
||||
</Box>
|
||||
) : (
|
||||
<Flex
|
||||
flex={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
color="gray.500"
|
||||
fontSize="sm"
|
||||
>
|
||||
<Text>{t('common:no_selected_apps')}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant={'whiteBase'} onClick={onClose}>
|
||||
{t('common:Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
ml={2}
|
||||
isDisabled={selectedApps.length === 0 || loadingFeatureApps || isUpdating}
|
||||
isLoading={isUpdating}
|
||||
onClick={() => {
|
||||
if (selectedApps.length === 0) return;
|
||||
updateFeaturedApps();
|
||||
}}
|
||||
>
|
||||
{t('common:Confirm')} ({selectedApps.length})
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AddFeatureAppModal);
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { ModalBody, ModalFooter, Button, Text, Flex, Box, IconButton } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import type { SelectAppItemType } from '@fastgpt/global/core/workflow/template/system/abandoned/runApp/type';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import SelectMultipleResource from './SelectMultipleResource';
|
||||
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag';
|
||||
import {
|
||||
type GetResourceFolderListProps,
|
||||
type GetResourceListItemResponse
|
||||
} from '@fastgpt/global/common/parentFolder/type';
|
||||
import { getMyApps } from '@/web/core/app/api';
|
||||
import { listQuickApps, batchUpdateQuickApps } from '@/web/support/user/team/gate/quickApp';
|
||||
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
|
||||
// 扩展的应用类型,包含显示所需的属性
|
||||
type ExtendedSelectAppItemType = SelectAppItemType & {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
const AddQuickAppModal = ({
|
||||
isOpen = true,
|
||||
value,
|
||||
filterAppIds = [],
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
value?: ExtendedSelectAppItemType[];
|
||||
filterAppIds?: string[];
|
||||
onClose: () => void;
|
||||
onSuccess: (e: ExtendedSelectAppItemType[]) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedApps, setSelectedApps] = useState<ExtendedSelectAppItemType[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
|
||||
// 使用 listQuickApps 初始化已选择的应用数组
|
||||
const { data: quickApps = [], loading: loadingQuickApps } = useRequest2(() => listQuickApps(), {
|
||||
manual: false,
|
||||
onSuccess: (data) => {
|
||||
const initialSelectedApps = data.map((app) => ({
|
||||
id: app._id,
|
||||
name: app.name,
|
||||
avatar: app.avatar
|
||||
}));
|
||||
setSelectedApps(initialSelectedApps);
|
||||
}
|
||||
});
|
||||
|
||||
const getAppList = useCallback(
|
||||
async ({ parentId }: GetResourceFolderListProps) => {
|
||||
return getMyApps({
|
||||
parentId,
|
||||
searchKey,
|
||||
type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow]
|
||||
}).then((res) =>
|
||||
res
|
||||
.filter((item) => !filterAppIds.includes(item._id))
|
||||
.map<GetResourceListItemResponse>((item) => ({
|
||||
id: item._id,
|
||||
name: item.name,
|
||||
avatar: item.avatar,
|
||||
isFolder: item.type === AppTypeEnum.folder
|
||||
}))
|
||||
);
|
||||
},
|
||||
[filterAppIds, searchKey]
|
||||
);
|
||||
|
||||
const handleAppSelect = useCallback((appId: string, appData: GetResourceListItemResponse) => {
|
||||
setSelectedApps((prev) => {
|
||||
const exists = prev.find((app) => app.id === appId);
|
||||
if (exists) {
|
||||
// 如果已存在,则移除
|
||||
return prev.filter((app) => app.id !== appId);
|
||||
} else {
|
||||
// 如果不存在,则添加到末尾
|
||||
return [...prev, { id: appId, name: appData.name, avatar: appData.avatar }];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAppUnselect = useCallback((appId: string) => {
|
||||
setSelectedApps((prev) => prev.filter((app) => app.id !== appId));
|
||||
}, []);
|
||||
|
||||
// 处理拖拽排序
|
||||
const handleDragEnd = useCallback((reorderedList: ExtendedSelectAppItemType[]) => {
|
||||
setSelectedApps(reorderedList);
|
||||
}, []);
|
||||
|
||||
// 批量更新快速应用
|
||||
const { runAsync: updateQuickApps, loading: isUpdating } = useRequest2(
|
||||
() => {
|
||||
const updates = [{ quickApps: selectedApps.map((app) => app.id) }];
|
||||
return batchUpdateQuickApps(updates);
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
onSuccess: () => {
|
||||
onSuccess(selectedApps);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('更新快速应用失败:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={isOpen}
|
||||
title={t('common:core.module.Select app')}
|
||||
iconSrc="/imgs/workflow/ai.svg"
|
||||
onClose={onClose}
|
||||
position={'relative'}
|
||||
w={'900px'}
|
||||
maxW={'90vw'}
|
||||
>
|
||||
<ModalBody flex={'1 0 0'} overflow={'hidden'} minH={'500px'} position={'relative'}>
|
||||
<Flex h="100%" gap={4}>
|
||||
{/* 左侧应用选择区域 */}
|
||||
<Flex direction="column" flex={1} h="100%">
|
||||
{/* 搜索框 */}
|
||||
<Box mb={4}>
|
||||
<SearchInput
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder={t('app:search_app')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 应用选择区域 */}
|
||||
<Box flex={1} overflow="auto">
|
||||
<SelectMultipleResource
|
||||
selectedIds={selectedApps.map((app) => app.id)}
|
||||
onSelect={handleAppSelect}
|
||||
server={getAppList}
|
||||
searchKey={searchKey}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 右侧已选择应用排序区域 */}
|
||||
<Box w="300px" h="100%" borderLeft="1px solid" borderColor="gray.200" pl={4}>
|
||||
<Flex direction="column" h="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={3}>
|
||||
{t('common:selected')} {selectedApps.length}
|
||||
</Text>
|
||||
|
||||
{selectedApps.length > 0 ? (
|
||||
<Box flex={1} overflow="auto">
|
||||
<DndDrag<ExtendedSelectAppItemType>
|
||||
onDragEndCb={handleDragEnd}
|
||||
dataList={selectedApps}
|
||||
>
|
||||
{({ provided }) => (
|
||||
<Flex
|
||||
flexDirection={'column'}
|
||||
gap={2}
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{selectedApps.map((app, index) => (
|
||||
<Draggable key={app.id} draggableId={String(app.id)} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<Flex
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
opacity: snapshot.isDragging ? 0.8 : 1
|
||||
}}
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
p={2}
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="gray.200"
|
||||
fontSize="sm"
|
||||
_hover={{
|
||||
bg: 'gray.50',
|
||||
borderColor: 'gray.300'
|
||||
}}
|
||||
>
|
||||
{/* 拖拽图标 */}
|
||||
<Flex
|
||||
{...provided.dragHandleProps}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="16px"
|
||||
h="16px"
|
||||
cursor="grab"
|
||||
_active={{ cursor: 'grabbing' }}
|
||||
>
|
||||
<MyIcon name="drag" w={'10px'} h={'12px'} color={'gray.500'} />
|
||||
</Flex>
|
||||
|
||||
{/* 应用图标 */}
|
||||
<Avatar src={app.avatar} w="20px" h="20px" borderRadius="4px" />
|
||||
|
||||
{/* 应用名称 */}
|
||||
<Text flex={1} fontSize="12px" fontWeight="500" noOfLines={1}>
|
||||
{app.name}
|
||||
</Text>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon={<MyIcon name="delete" w="12px" />}
|
||||
aria-label="remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAppUnselect(app.id);
|
||||
}}
|
||||
_hover={{ bg: 'red.50', color: 'red.500' }}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Flex>
|
||||
)}
|
||||
</DndDrag>
|
||||
</Box>
|
||||
) : (
|
||||
<Flex
|
||||
flex={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
color="gray.500"
|
||||
fontSize="sm"
|
||||
>
|
||||
<Text>{t('common:no_selected_apps')}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant={'whiteBase'} onClick={onClose}>
|
||||
{t('common:Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
ml={2}
|
||||
isDisabled={selectedApps.length === 0 || loadingQuickApps || isUpdating}
|
||||
isLoading={isUpdating}
|
||||
onClick={() => {
|
||||
if (selectedApps.length === 0) return;
|
||||
updateQuickApps();
|
||||
}}
|
||||
>
|
||||
{t('common:Confirm')} ({selectedApps.length})
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AddQuickAppModal);
|
||||
688
projects/app/src/pageComponents/account/gateway/AppTable.tsx
Normal file
688
projects/app/src/pageComponents/account/gateway/AppTable.tsx
Normal file
@@ -0,0 +1,688 @@
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Button,
|
||||
useDisclosure,
|
||||
Tooltip,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
Checkbox,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||
import MultipleSelect, {
|
||||
useMultipleSelect
|
||||
} from '@fastgpt/web/components/common/MySelect/MultipleSelect';
|
||||
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { delAppById } from '@/web/core/app/api';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import type { AppListItemType } from '@fastgpt/global/core/app/type.d';
|
||||
import { getTeamTags } from '@/web/core/app/api/tags';
|
||||
import type { TagSchemaType } from '@fastgpt/global/core/app/tags';
|
||||
import GateAppInfoModal from './GateAppInfoModal';
|
||||
import TagManageModal from './TagManageModal';
|
||||
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import { listFeatureApps, reorderFeatureApps } from '@/web/support/user/team/gate/featureApp';
|
||||
import AddFeatureAppModal from './AddFeatureAppModal';
|
||||
|
||||
// 设置最大可见标签数
|
||||
const MAX_VISIBLE_TAGS = 2;
|
||||
|
||||
// 自定义 hook:应用选择逻辑
|
||||
const useAppSelection = (filteredApps: AppListItemType[]) => {
|
||||
const [selectedAppIds, setSelectedAppIds] = useState<string[]>([]);
|
||||
|
||||
const handleAppSelect = useCallback((appId: string, isSelected: boolean) => {
|
||||
setSelectedAppIds((prev) =>
|
||||
isSelected ? [...prev.filter((id) => id !== appId), appId] : prev.filter((id) => id !== appId)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = useCallback(
|
||||
(isSelected: boolean) => {
|
||||
setSelectedAppIds(isSelected ? filteredApps.map((app) => app._id) : []);
|
||||
},
|
||||
[filteredApps]
|
||||
);
|
||||
|
||||
const isAllSelected = useMemo(
|
||||
() => filteredApps.length > 0 && filteredApps.every((app) => selectedAppIds.includes(app._id)),
|
||||
[filteredApps, selectedAppIds]
|
||||
);
|
||||
|
||||
const isIndeterminate = useMemo(() => {
|
||||
const selectedCount = filteredApps.filter((app) => selectedAppIds.includes(app._id)).length;
|
||||
return selectedCount > 0 && selectedCount < filteredApps.length;
|
||||
}, [filteredApps, selectedAppIds]);
|
||||
|
||||
return {
|
||||
selectedAppIds,
|
||||
handleAppSelect,
|
||||
handleSelectAll,
|
||||
isAllSelected,
|
||||
isIndeterminate
|
||||
};
|
||||
};
|
||||
|
||||
// 标签组件
|
||||
const AppTags = ({ tags, tagMap }: { tags?: string[]; tagMap: Map<string, TagSchemaType> }) => {
|
||||
if (!tags?.length) return null;
|
||||
|
||||
const validTags = tags.filter((tagId) => tagMap.get(tagId));
|
||||
const visibleTags = validTags.slice(0, MAX_VISIBLE_TAGS);
|
||||
const remainingCount = Math.max(0, validTags.length - MAX_VISIBLE_TAGS);
|
||||
|
||||
const TagItem = ({ tagId }: { tagId: string }) => {
|
||||
const tag = tagMap.get(tagId);
|
||||
if (!tag) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
padding="10px 8px"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="22px"
|
||||
minWidth="32px"
|
||||
borderRadius="6px"
|
||||
backgroundColor="#F4F4F5"
|
||||
>
|
||||
<Text
|
||||
fontSize="12px"
|
||||
fontWeight="500"
|
||||
lineHeight="16px"
|
||||
color="#525252"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{tag.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack spacing={2} wrap="wrap">
|
||||
{visibleTags.map((tagId) => (
|
||||
<TagItem key={tagId} tagId={tagId} />
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Tooltip
|
||||
label={
|
||||
<Wrap spacing={2} maxW="300px" p={2}>
|
||||
{validTags.slice(MAX_VISIBLE_TAGS).map((tagId) => (
|
||||
<WrapItem key={tagId}>
|
||||
<TagItem tagId={tagId} />
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
}
|
||||
hasArrow
|
||||
placement="top"
|
||||
bg="white"
|
||||
boxShadow="lg"
|
||||
>
|
||||
<Flex
|
||||
padding="10px 8px"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="22px"
|
||||
width="31px"
|
||||
borderRadius="6px"
|
||||
backgroundColor="#F4F4F5"
|
||||
>
|
||||
<Text fontSize="12px" fontWeight="500" color="#525252">
|
||||
+{remainingCount}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 应用行组件
|
||||
const AppRow = ({
|
||||
app,
|
||||
index,
|
||||
tagMap,
|
||||
selectedAppIds,
|
||||
onAppSelect,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: {
|
||||
app: AppListItemType;
|
||||
index: number;
|
||||
tagMap: Map<string, TagSchemaType>;
|
||||
selectedAppIds: string[];
|
||||
onAppSelect: (appId: string, isSelected: boolean) => void;
|
||||
onEdit: (app: AppListItemType) => void;
|
||||
onDelete: (appId: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Draggable key={app._id} draggableId={String(app._id)} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<MyBox
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
opacity: snapshot.isDragging ? 0.8 : 1
|
||||
}}
|
||||
display="flex"
|
||||
pl={2}
|
||||
bg="white"
|
||||
h={12}
|
||||
w="full"
|
||||
borderBottom="1px solid var(--Gray-Modern-150, #F0F1F6)"
|
||||
_hover={{
|
||||
bg: 'white',
|
||||
border: '1px solid var(--Gray-Modern-200, #E8EBF0)',
|
||||
boxShadow:
|
||||
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)',
|
||||
borderRadius: '6px',
|
||||
zIndex: 2
|
||||
}}
|
||||
fontSize="mini"
|
||||
alignItems="center"
|
||||
>
|
||||
{/* 名称列 */}
|
||||
<Box display="flex" w="20%">
|
||||
<Flex alignItems="center" gap="10px" width="100%" pl="24px">
|
||||
<Checkbox
|
||||
isChecked={selectedAppIds.includes(app._id)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onAppSelect(app._id, e.target.checked);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Flex {...provided.dragHandleProps} cursor="grab">
|
||||
<MyIcon name="drag" w="10.5px" h="14px" color="#667085" />
|
||||
</Flex>
|
||||
|
||||
<Flex gap="6px" alignItems="center">
|
||||
<Flex
|
||||
w="20px"
|
||||
h="20px"
|
||||
borderRadius="4px"
|
||||
overflow="hidden"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
bg={
|
||||
app.avatar
|
||||
? 'transparent'
|
||||
: 'linear-gradient(200.75deg, #61D2C4 13.74%, #40CAA1 89.76%)'
|
||||
}
|
||||
boxShadow="sm"
|
||||
>
|
||||
{app.avatar ? (
|
||||
<Avatar src={app.avatar} alt={app.name} w="100%" h="100%" />
|
||||
) : (
|
||||
<Text color="white" fontSize="16px" fontWeight="bold">
|
||||
{app.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Text
|
||||
fontSize="12px"
|
||||
fontWeight="500"
|
||||
color="#111824"
|
||||
maxWidth="60px"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 介绍列 */}
|
||||
<Box w="40%" pl={4}>
|
||||
<Text color="myGray.500" noOfLines={1}>
|
||||
{app.intro}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 标签列 */}
|
||||
<Box w="30%" pl={4}>
|
||||
<AppTags tags={app.tags} tagMap={tagMap} />
|
||||
</Box>
|
||||
|
||||
{/* 操作列 */}
|
||||
<Flex w="10%" justifyContent="center">
|
||||
<HStack spacing={2}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<MyIcon name="edit" w="14px" />}
|
||||
aria-label="edit"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(app);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
icon={<MyIcon name="delete" w="14px" />}
|
||||
aria-label="delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(app._id);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MyBox>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
|
||||
const AppTable = () => {
|
||||
const { t } = useTranslation();
|
||||
const [editingApp, setEditingApp] = useState<AppListItemType | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [localAppList, setLocalAppList] = useState<AppListItemType[]>([]);
|
||||
|
||||
// 使用多选标签的 hook
|
||||
const {
|
||||
value: selectedTags,
|
||||
setValue: setSelectedTags,
|
||||
isSelectAll,
|
||||
setIsSelectAll
|
||||
} = useMultipleSelect<string>([], false);
|
||||
|
||||
// 模态框状态
|
||||
const tagModal = useDisclosure();
|
||||
const addAppModal = useDisclosure();
|
||||
|
||||
// API 请求
|
||||
const {
|
||||
data: appList = [],
|
||||
loading: loadingApps,
|
||||
refresh: refreshApps
|
||||
} = useRequest2(() => listFeatureApps(), { manual: false });
|
||||
|
||||
const {
|
||||
data: tagList = [],
|
||||
loading: loadingTags,
|
||||
refresh: refreshTags
|
||||
} = useRequest2(() => getTeamTags(), { manual: false });
|
||||
|
||||
const { runAsync: onReorderApps } = useRequest2(
|
||||
({ appId, toIndex }: { appId: string; toIndex: number }) => reorderFeatureApps(appId, toIndex),
|
||||
{
|
||||
onSuccess: refreshApps,
|
||||
errorToast: t('common:reorder_failed')
|
||||
}
|
||||
);
|
||||
|
||||
const { openConfirm: openConfirmDel, ConfirmModal: DelConfirmModal } = useConfirm({
|
||||
type: 'delete'
|
||||
});
|
||||
|
||||
const { runAsync: onDeleteApp } = useRequest2(delAppById, {
|
||||
onSuccess: refreshApps,
|
||||
successToast: t('common:delete_success'),
|
||||
errorToast: t('common:delete_failed')
|
||||
});
|
||||
|
||||
// 计算属性
|
||||
const loading = loadingApps || loadingTags;
|
||||
|
||||
const tagMap = useMemo(() => {
|
||||
const map = new Map<string, TagSchemaType>();
|
||||
(tagList as TagSchemaType[]).forEach((tag) => map.set(tag._id, tag));
|
||||
return map;
|
||||
}, [tagList]);
|
||||
|
||||
const filteredApps = useMemo(() => {
|
||||
return localAppList.filter((app) => {
|
||||
const searchMatch =
|
||||
!search ||
|
||||
app.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
app.intro?.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
// 多选标签筛选逻辑:如果选择了全部或没有选择任何标签,显示所有应用
|
||||
// 如果选择了特定标签,应用必须包含至少一个选中的标签
|
||||
const tagMatch =
|
||||
isSelectAll ||
|
||||
selectedTags.length === 0 ||
|
||||
(app.tags && app.tags.some((tag) => selectedTags.includes(tag)));
|
||||
|
||||
return searchMatch && tagMatch;
|
||||
});
|
||||
}, [localAppList, search, selectedTags, isSelectAll]);
|
||||
|
||||
const allTags = useMemo(
|
||||
() =>
|
||||
Array.from(new Set(appList.flatMap((app) => app.tags || []))).map((tag) => ({
|
||||
label: tagMap.get(tag)?.name || tag,
|
||||
value: tag
|
||||
})),
|
||||
[appList, tagMap]
|
||||
);
|
||||
|
||||
// 自定义 hooks
|
||||
const selection = useAppSelection(filteredApps);
|
||||
|
||||
// 副作用
|
||||
useEffect(() => {
|
||||
setLocalAppList(appList);
|
||||
}, [appList]);
|
||||
|
||||
// 事件处理
|
||||
const handleDragEnd = async (list: AppListItemType[]) => {
|
||||
// 先更新本地状态以提供即时反馈
|
||||
setLocalAppList(list);
|
||||
|
||||
// 找到被移动的应用 - 需要找到移动距离最大的那个应用
|
||||
let movedApp: AppListItemType | null = null;
|
||||
let originalIndex = -1;
|
||||
let newIndex = -1;
|
||||
let maxDistance = 0;
|
||||
|
||||
// 找到位置发生变化的应用中移动距离最大的(这个就是被拖拽的应用)
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const currentApp = list[i];
|
||||
const origIndex = filteredApps.findIndex((app) => app._id === currentApp._id);
|
||||
|
||||
if (origIndex !== -1 && origIndex !== i) {
|
||||
const distance = Math.abs(origIndex - i);
|
||||
if (distance > maxDistance) {
|
||||
maxDistance = distance;
|
||||
movedApp = currentApp;
|
||||
originalIndex = origIndex;
|
||||
newIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (movedApp && originalIndex !== -1 && newIndex !== -1) {
|
||||
try {
|
||||
// 计算在完整应用列表中的目标位置
|
||||
let targetIndex: number;
|
||||
|
||||
if (newIndex === 0) {
|
||||
// 移动到第一位
|
||||
targetIndex = 0;
|
||||
} else if (newIndex === list.length - 1) {
|
||||
// 移动到最后一位,找到最后一个应用在完整列表中的位置
|
||||
const lastApp = list[newIndex - 1];
|
||||
const lastAppIndexInFullList = appList.findIndex((app) => app._id === lastApp._id);
|
||||
targetIndex = lastAppIndexInFullList + 1;
|
||||
} else {
|
||||
// 移动到中间位置
|
||||
if (originalIndex < newIndex) {
|
||||
// 向下拖拽:目标位置是新位置后面那个应用在完整列表中的位置
|
||||
const nextApp = list[newIndex + 1];
|
||||
const nextAppIndexInFullList = appList.findIndex((app) => app._id === nextApp._id);
|
||||
targetIndex = nextAppIndexInFullList - 1;
|
||||
} else {
|
||||
// 向上拖拽:目标位置是新位置前面那个应用在完整列表中的位置+1
|
||||
const prevApp = list[newIndex - 1];
|
||||
const prevAppIndexInFullList = appList.findIndex((app) => app._id === prevApp._id);
|
||||
targetIndex = prevAppIndexInFullList + 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('拖拽信息:', {
|
||||
appName: movedApp.name,
|
||||
originalIndex,
|
||||
newIndex,
|
||||
targetIndex,
|
||||
direction: originalIndex < newIndex ? '向下' : '向上'
|
||||
});
|
||||
|
||||
await onReorderApps({
|
||||
appId: movedApp._id,
|
||||
toIndex: targetIndex
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('重新排序失败:', error);
|
||||
// 如果失败,恢复原始状态
|
||||
setLocalAppList(appList);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagModalClose = () => {
|
||||
tagModal.onClose();
|
||||
refreshTags();
|
||||
refreshApps();
|
||||
};
|
||||
|
||||
const handleAddAppSuccess = (selectedApps: any) => {
|
||||
console.log('Selected apps:', selectedApps);
|
||||
refreshApps();
|
||||
};
|
||||
|
||||
return (
|
||||
<MyBox flex="1 0 0" isLoading={loading}>
|
||||
<Flex flexDirection="column" h="100%">
|
||||
{/* 筛选控件 */}
|
||||
<Flex
|
||||
gap={4}
|
||||
mb={4}
|
||||
flexDirection={{ base: 'column', md: 'row' }}
|
||||
alignItems={{ base: 'stretch', md: 'center' }}
|
||||
>
|
||||
<Flex flex={1} gap={4}>
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('app:search_app')}
|
||||
flex={1}
|
||||
/>
|
||||
<Box w="200px">
|
||||
<Menu closeOnSelect={false}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<MyIcon name={'core/chat/chevronDown'} w={4} color={'myGray.500'} />}
|
||||
variant={'outline'}
|
||||
size={'sm'}
|
||||
fontSize={'sm'}
|
||||
textAlign={'left'}
|
||||
w="100%"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
{isSelectAll
|
||||
? t('common:All')
|
||||
: selectedTags.length === 0
|
||||
? t('common:select_tag')
|
||||
: `已选择: ${selectedTags.length}`}
|
||||
</MenuButton>
|
||||
<MenuList maxH="300px" overflowY="auto">
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (isSelectAll) {
|
||||
setSelectedTags([]);
|
||||
setIsSelectAll(false);
|
||||
} else {
|
||||
setSelectedTags(allTags.map((tag) => tag.value));
|
||||
setIsSelectAll(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
isChecked={isSelectAll}
|
||||
mr={2}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isSelectAll) {
|
||||
setSelectedTags([]);
|
||||
setIsSelectAll(false);
|
||||
} else {
|
||||
setSelectedTags(allTags.map((tag) => tag.value));
|
||||
setIsSelectAll(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{t('common:All')}
|
||||
</MenuItem>
|
||||
{allTags.map((tag) => (
|
||||
<MenuItem
|
||||
key={tag.value}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (isSelectAll) {
|
||||
// 如果当前是全选状态,取消全选并只选择当前项
|
||||
setSelectedTags([tag.value]);
|
||||
setIsSelectAll(false);
|
||||
} else {
|
||||
// 正常的多选逻辑
|
||||
if (selectedTags.includes(tag.value)) {
|
||||
setSelectedTags(selectedTags.filter((t) => t !== tag.value));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, tag.value]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
isChecked={isSelectAll || selectedTags.includes(tag.value)}
|
||||
mr={2}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isSelectAll) {
|
||||
// 如果当前是全选状态,取消全选并只选择当前项
|
||||
setSelectedTags([tag.value]);
|
||||
setIsSelectAll(false);
|
||||
} else {
|
||||
// 正常的多选逻辑
|
||||
if (selectedTags.includes(tag.value)) {
|
||||
setSelectedTags(selectedTags.filter((t) => t !== tag.value));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, tag.value]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{tag.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex gap={3}>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
leftIcon={<MyIcon name="common/add2" w="14px" />}
|
||||
onClick={addAppModal.onOpen}
|
||||
minW="120px"
|
||||
>
|
||||
{t('common:add_app')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<MyIcon name="common/settingLight" w="14px" />}
|
||||
onClick={tagModal.onOpen}
|
||||
minW="120px"
|
||||
>
|
||||
{t('common:tag_manage')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* 表头 */}
|
||||
<Flex
|
||||
bg="white"
|
||||
h={8}
|
||||
mt={5}
|
||||
pl={8}
|
||||
rounded="md"
|
||||
alignItems="center"
|
||||
fontSize="mini"
|
||||
fontWeight="medium"
|
||||
>
|
||||
<Box w="20%">
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<Checkbox
|
||||
isChecked={selection.isAllSelected}
|
||||
isIndeterminate={selection.isIndeterminate}
|
||||
onChange={(e) => selection.handleSelectAll(e.target.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
{t('common:Name')}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box w="40%">{t('common:Intro')}</Box>
|
||||
<Box w="30%">{t('common:Tags')}</Box>
|
||||
<Box w="10%">{t('common:Action')}</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 应用列表 */}
|
||||
<Box overflow="auto" mt={4} maxH="calc(100vh - 200px)">
|
||||
{filteredApps.length > 0 ? (
|
||||
<DndDrag<AppListItemType> onDragEndCb={handleDragEnd} dataList={filteredApps}>
|
||||
{({ provided }) => (
|
||||
<Flex flexDirection="column" {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{filteredApps.map((app, index) => (
|
||||
<AppRow
|
||||
key={app._id}
|
||||
app={app}
|
||||
index={index}
|
||||
tagMap={tagMap}
|
||||
selectedAppIds={selection.selectedAppIds}
|
||||
onAppSelect={selection.handleAppSelect}
|
||||
onEdit={setEditingApp}
|
||||
onDelete={(appId) => openConfirmDel(() => onDeleteApp(appId))()}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Flex>
|
||||
)}
|
||||
</DndDrag>
|
||||
) : (
|
||||
<EmptyTip
|
||||
text={loading ? t('common:Loading') : t('common:no_matching_apps_found')}
|
||||
py={2}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 模态框 */}
|
||||
{editingApp && (
|
||||
<GateAppInfoModal
|
||||
app={editingApp}
|
||||
onClose={() => setEditingApp(null)}
|
||||
onUpdateSuccess={refreshApps}
|
||||
/>
|
||||
)}
|
||||
{tagModal.isOpen && <TagManageModal onClose={handleTagModalClose} />}
|
||||
{addAppModal.isOpen && (
|
||||
<AddFeatureAppModal
|
||||
isOpen={addAppModal.isOpen}
|
||||
onClose={addAppModal.onClose}
|
||||
onSuccess={handleAddAppSuccess}
|
||||
/>
|
||||
)}
|
||||
<DelConfirmModal />
|
||||
</Flex>
|
||||
</MyBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppTable;
|
||||
@@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
import { Button, Flex, useDisclosure } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import ShareGateModal from './ShareModol';
|
||||
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
|
||||
import { getMyAppsGate, postCreateApp, putAppById } from '@/web/core/app/api';
|
||||
import { useUserStore } from '@/web/support/user/useUserStore';
|
||||
import { emptyTemplates } from '@/web/core/app/templates';
|
||||
import { saveGateConfig } from './HomeTable';
|
||||
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
|
||||
import type { putUpdateGateConfigCopyRightData } from '@fastgpt/global/support/user/team/gate/api';
|
||||
import { saveCopyRightConfig } from './CopyrightTable';
|
||||
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
import { form2AppWorkflow } from '@/web/core/app/utils';
|
||||
|
||||
type Props = {
|
||||
tab: 'home' | 'copyright' | 'app' | 'logs';
|
||||
appForm?: AppSimpleEditFormType;
|
||||
gateConfig?: GateSchemaType;
|
||||
copyRightConfig?: putUpdateGateConfigCopyRightData;
|
||||
};
|
||||
|
||||
const ConfigButtons = ({ tab, appForm, gateConfig, copyRightConfig }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
// 保存配置
|
||||
const { runAsync: saveHomeConfig, loading: savingHome } = useRequest2(
|
||||
async () => {
|
||||
if (!!gateConfig) {
|
||||
await saveGateConfig(gateConfig);
|
||||
toast({
|
||||
title: t('common:save_success'),
|
||||
status: 'success'
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('common:save_failed'),
|
||||
status: 'error',
|
||||
description: err?.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log('buttons appForm', appForm);
|
||||
const { nodes, edges } = appForm
|
||||
? form2AppWorkflow(appForm, t)
|
||||
: {
|
||||
nodes: emptyTemplates[AppTypeEnum.gate].nodes,
|
||||
edges: emptyTemplates[AppTypeEnum.gate].edges
|
||||
};
|
||||
|
||||
// 保存版权配置
|
||||
const { runAsync: saveCopyrightConfig, loading: savingCopyright } = useRequest2(
|
||||
async () => {
|
||||
// 保存其他版权配置
|
||||
if (!!copyRightConfig) {
|
||||
await saveCopyRightConfig(copyRightConfig);
|
||||
toast({
|
||||
title: t('common:save_success'),
|
||||
status: 'success'
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('common:save_failed'),
|
||||
status: 'error',
|
||||
description: err?.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const checkAndCreateGateApp = async () => {
|
||||
try {
|
||||
// 获取应用列表
|
||||
const apps = await getMyAppsGate();
|
||||
const gateApp = apps.find((app) => app.type === AppTypeEnum.gate);
|
||||
const currentTeamAvatar = copyRightConfig?.logo;
|
||||
const currentSlogan = gateConfig?.slogan;
|
||||
console.log('gateApp', gateApp, currentTeamAvatar, currentSlogan, nodes, edges);
|
||||
if (gateApp) {
|
||||
if (
|
||||
gateApp.avatar !== currentTeamAvatar ||
|
||||
gateApp.intro !== currentSlogan ||
|
||||
nodes !== emptyTemplates[AppTypeEnum.gate].nodes ||
|
||||
edges !== emptyTemplates[AppTypeEnum.gate].edges
|
||||
) {
|
||||
await putAppById(gateApp._id, {
|
||||
avatar: currentTeamAvatar,
|
||||
intro: currentSlogan,
|
||||
name: gateConfig?.name,
|
||||
nodes,
|
||||
edges
|
||||
});
|
||||
toast({
|
||||
title: t('common:update_success'),
|
||||
status: 'success'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await postCreateApp({
|
||||
avatar: gateConfig?.logo,
|
||||
name: gateConfig?.name,
|
||||
intro: gateConfig?.slogan,
|
||||
type: AppTypeEnum.gate,
|
||||
modules: emptyTemplates[AppTypeEnum.gate].nodes,
|
||||
edges: emptyTemplates[AppTypeEnum.gate].edges,
|
||||
chatConfig: emptyTemplates[AppTypeEnum.gate].chatConfig
|
||||
});
|
||||
toast({
|
||||
title: t('common:create_success'),
|
||||
status: 'success'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('common:error.Create failed'),
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleSave = async () => {
|
||||
if (tab === 'home') {
|
||||
await saveHomeConfig();
|
||||
await checkAndCreateGateApp();
|
||||
} else if (tab === 'copyright') {
|
||||
await saveCopyrightConfig();
|
||||
await checkAndCreateGateApp();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex>
|
||||
<Button
|
||||
variant="primaryOutline"
|
||||
mr={2}
|
||||
leftIcon={<MyIcon name="support/gate/home/savePrimary" />}
|
||||
onClick={handleSave}
|
||||
isLoading={tab === 'home' ? savingHome : savingCopyright}
|
||||
>
|
||||
{t('account:gateway.save_config')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'primary'}
|
||||
mr={2}
|
||||
leftIcon={<MyIcon name="support/gate/home/shareLight" />}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{t('account:gateway.share')}
|
||||
</Button>
|
||||
|
||||
{/* 分享门户弹窗 */}
|
||||
<ShareGateModal gateConfig={gateConfig} isOpen={isOpen} onClose={onClose} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigButtons;
|
||||
@@ -0,0 +1,398 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, Text, Input, useBreakpointValue, Image } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import type { putUpdateGateConfigCopyRightData } from '@fastgpt/global/support/user/team/gate/api';
|
||||
import { updateTeamGateConfigCopyRight } from '@/web/support/user/team/gate/api';
|
||||
|
||||
type Props = {
|
||||
gateName: string;
|
||||
gateLogo: string;
|
||||
gateBanner: string;
|
||||
onNameChange?: (name: string) => void;
|
||||
onLogoChange?: (logo: string) => void;
|
||||
onBannerChange?: (banner: string) => void;
|
||||
};
|
||||
|
||||
export const saveCopyRightConfig = async (data: putUpdateGateConfigCopyRightData) => {
|
||||
try {
|
||||
await updateTeamGateConfigCopyRight(data);
|
||||
} catch (e) {
|
||||
console.error('Error saving copyright config:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 斜线背景样式
|
||||
const stripedBackgroundStyle = {
|
||||
backgroundImage:
|
||||
'linear-gradient(135deg, #f0f0f0 25%, transparent 25%, transparent 50%, #f0f0f0 50%, #f0f0f0 75%, transparent 75%, transparent)',
|
||||
backgroundSize: '5px 5px',
|
||||
padding: '12px',
|
||||
borderRadius: '16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
border: '1px dashed #e0e0e0'
|
||||
};
|
||||
|
||||
// 添加悬浮遮罩样式
|
||||
const uploadOverlayStyle = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
border: '1px dashed var(--Royal-Blue-200, #C5D7FF)',
|
||||
background: 'rgba(255, 255, 255, 0.5)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
zIndex: 10,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
_groupHover: {
|
||||
opacity: 1
|
||||
}
|
||||
};
|
||||
|
||||
const CopyrightTable = ({
|
||||
gateName,
|
||||
gateLogo,
|
||||
gateBanner,
|
||||
onNameChange,
|
||||
onLogoChange,
|
||||
onBannerChange
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 使用useForm管理表单数据
|
||||
const { setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
name: gateName,
|
||||
logo: gateLogo,
|
||||
banner: gateBanner
|
||||
}
|
||||
});
|
||||
|
||||
// 从表单中获取logo和banner值
|
||||
const logo = watch('logo');
|
||||
const banner = watch('banner');
|
||||
|
||||
const handleGateNameChange = (name: string) => {
|
||||
setValue('name', name);
|
||||
onNameChange?.(name);
|
||||
};
|
||||
const handleGateLogoChange = (logo: string) => {
|
||||
setValue('logo', logo);
|
||||
onLogoChange?.(logo);
|
||||
};
|
||||
const handleGateBannerChange = (banner: string) => {
|
||||
setValue('banner', banner);
|
||||
onBannerChange?.(banner);
|
||||
};
|
||||
|
||||
// 添加文件选择器 - 分别为左右两侧Logo创建选择器
|
||||
const {
|
||||
File: LogoFile,
|
||||
onOpen: onOpenLogoFile,
|
||||
onSelectImage: onSelectLogoImage
|
||||
} = useSelectFile({
|
||||
fileType: '.jpg,.png,.svg',
|
||||
multiple: false
|
||||
});
|
||||
|
||||
const {
|
||||
File: BannerFile,
|
||||
onOpen: onOpenBannerFile,
|
||||
onSelectImage: onSelectBannerImage
|
||||
} = useSelectFile({
|
||||
fileType: '.jpg,.png,.svg',
|
||||
multiple: false
|
||||
});
|
||||
|
||||
// 响应式尺寸 - 根据设计比例调整
|
||||
const logoBoxSize = useBreakpointValue({ md: '60px' });
|
||||
const logoBorderRadius = useBreakpointValue({ base: '5.8px', md: '15px' });
|
||||
const titleFontSize = useBreakpointValue({ base: '18px', md: '28px' });
|
||||
const dividerHeight = useBreakpointValue({ base: '70px', md: '84px' });
|
||||
|
||||
// 左侧带文本的Logo稍大一些
|
||||
const logoBoxSizeWithText = useBreakpointValue({ base: '28px', md: '60px' });
|
||||
|
||||
return (
|
||||
<Box flex={'1 0 0'} overflow={'hidden'} display="flex" justifyContent="center">
|
||||
<Box w="100%" maxW={{ base: '100%', md: '640px' }} py={{ base: 4, md: 6 }}>
|
||||
<Flex flexDirection={'column'} gap={{ base: 4, md: 6 }}>
|
||||
{/* 基础设置区域 */}
|
||||
<Flex flexDirection="column" gap={{ base: 3, md: 4 }}>
|
||||
<Flex alignItems="center" gap={3}>
|
||||
<Box w="4px" h="16px" bg="#3370FF" borderRadius="6px" />
|
||||
<Text fontSize={{ base: '14px', md: '16px' }} fontWeight={500}>
|
||||
{t('common:base_config')}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap={2}>
|
||||
<Text fontSize="14px" color="#485264" fontWeight={500}>
|
||||
{t('account_gate:gate_name')}
|
||||
</Text>
|
||||
<Input
|
||||
value={watch('name')}
|
||||
onChange={(e) => handleGateNameChange(e.target.value)}
|
||||
bg="#FBFBFC"
|
||||
border="1px solid #E8EBF0"
|
||||
borderRadius="8px"
|
||||
height={{ base: '36px', md: '40px' }}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Logo 设置区域 */}
|
||||
<Flex flexDirection="column" gap={{ base: 3, md: 4 }}>
|
||||
<Text fontSize="14px" color="#485264" fontWeight={500}>
|
||||
{t('account_gate:gate_logo')}
|
||||
</Text>
|
||||
|
||||
<Flex
|
||||
gap={{ base: 4, md: 8 }}
|
||||
alignItems="center"
|
||||
justifyContent="flex-start"
|
||||
flexDirection={{ base: 'column', md: 'row' }}
|
||||
>
|
||||
{/* 左侧 Banner 显示 - 带文字 */}
|
||||
<Flex direction="column" gap={2} alignItems="center">
|
||||
<Box
|
||||
sx={stripedBackgroundStyle}
|
||||
onClick={onOpenBannerFile}
|
||||
cursor="pointer"
|
||||
role="group"
|
||||
position="relative"
|
||||
width="100%"
|
||||
padding="20px"
|
||||
>
|
||||
<Flex gap={{ base: 3, md: 5 }} alignItems="center" width="100%">
|
||||
{banner ? (
|
||||
<Box
|
||||
width="100%"
|
||||
height={logoBoxSizeWithText}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
boxSizing="border-box"
|
||||
transition="all 0.3s ease"
|
||||
display="flex"
|
||||
>
|
||||
<Image
|
||||
src={banner}
|
||||
alt="Team Banner"
|
||||
width="100%"
|
||||
height="100%"
|
||||
objectFit="contain"
|
||||
objectPosition="center"
|
||||
style={
|
||||
{
|
||||
imageRendering: 'crisp-edges'
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
width="100%"
|
||||
height={logoBoxSizeWithText}
|
||||
bg="white"
|
||||
border="0.483px solid #ECECEC"
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
boxSizing="border-box"
|
||||
transition="all 0.3s ease"
|
||||
>
|
||||
<Flex
|
||||
width="40px"
|
||||
height="40px"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
flexShrink="0"
|
||||
aspectRatio="1/1"
|
||||
>
|
||||
<Image
|
||||
src={banner}
|
||||
alt="Team Banner"
|
||||
width="100%"
|
||||
height="100%"
|
||||
objectFit="contain"
|
||||
fallbackSrc="/icon/logo.svg"
|
||||
style={
|
||||
{
|
||||
imageRendering: 'crisp-edges'
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* 悬浮遮罩 - 4:1 */}
|
||||
<Box
|
||||
sx={{
|
||||
...uploadOverlayStyle,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
borderRadius="16px"
|
||||
>
|
||||
<Flex direction="column" alignItems="center" justifyContent="center">
|
||||
<MyIcon
|
||||
name="support/gate/home/upload"
|
||||
width="24px"
|
||||
height="24px"
|
||||
color="blue.500"
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text fontSize="12px" color="#667085" alignSelf="flex-start">
|
||||
{t('account_gate:suggestion_ratio_4_1')}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Box display={{ base: 'none', md: 'block' }} w="1px" h={dividerHeight} bg="#F0F1F6" />
|
||||
|
||||
{/* 右侧 Logo 显示 - 仅Logo */}
|
||||
<Flex direction="column" gap={2} alignItems="center">
|
||||
<Box
|
||||
sx={stripedBackgroundStyle}
|
||||
onClick={onOpenLogoFile}
|
||||
cursor="pointer"
|
||||
role="group"
|
||||
position="relative"
|
||||
>
|
||||
{logo ? (
|
||||
<Flex
|
||||
width={logoBoxSize}
|
||||
height={logoBoxSize}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
borderRadius={logoBorderRadius}
|
||||
boxSizing="border-box"
|
||||
transition="all 0.3s ease"
|
||||
>
|
||||
<Image
|
||||
src={logo}
|
||||
alt="Team Logo"
|
||||
width="100%"
|
||||
height="100%"
|
||||
objectFit="contain"
|
||||
style={
|
||||
{
|
||||
imageRendering: 'crisp-edges'
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box
|
||||
width={logoBoxSize}
|
||||
height={logoBoxSize}
|
||||
bg="white"
|
||||
border="0.483px solid #ECECEC"
|
||||
borderRadius={logoBorderRadius}
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
boxSizing="border-box"
|
||||
transition="all 0.3s ease"
|
||||
>
|
||||
<Flex
|
||||
width="40px"
|
||||
height="40px"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
flexShrink="0"
|
||||
aspectRatio="1/1"
|
||||
>
|
||||
<Image
|
||||
src={logo}
|
||||
alt="Team Logo"
|
||||
width="100%"
|
||||
height="100%"
|
||||
objectFit="contain"
|
||||
fallbackSrc="/icon/logo.svg"
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 悬浮遮罩 - 1:1 */}
|
||||
<Box
|
||||
sx={{
|
||||
...uploadOverlayStyle,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
borderRadius="16px"
|
||||
>
|
||||
<Flex direction="column" alignItems="center" justifyContent="center">
|
||||
<MyIcon
|
||||
name="support/gate/home/upload"
|
||||
width="24px"
|
||||
height="24px"
|
||||
color="blue.500"
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text fontSize="12px" color="#667085">
|
||||
{t('account_gate:suggestion_ratio_1_1')}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 文件选择器组件 */}
|
||||
<LogoFile
|
||||
onSelect={(e: File[]) =>
|
||||
onSelectLogoImage(e, {
|
||||
maxH: 300,
|
||||
maxW: 300,
|
||||
callback: (e: string) => {
|
||||
setValue('logo', e);
|
||||
handleGateLogoChange(e);
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<BannerFile
|
||||
onSelect={(e: File[]) =>
|
||||
onSelectBannerImage(e, {
|
||||
maxH: 300,
|
||||
maxW: 300,
|
||||
callback: (e: string) => {
|
||||
setValue('banner', e);
|
||||
handleGateBannerChange(e);
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyrightTable;
|
||||
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Textarea,
|
||||
HStack,
|
||||
Text,
|
||||
Tag as ChakraTag,
|
||||
TagCloseButton,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
import type { AppListItemType } from '@fastgpt/global/core/app/type.d';
|
||||
import type { TagSchemaType } from '@fastgpt/global/core/app/tags';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
|
||||
import { putAppById } from '@/web/core/app/api';
|
||||
import {
|
||||
getTeamTags,
|
||||
addTagToApp,
|
||||
removeTagFromApp,
|
||||
batchAddTagsToApp,
|
||||
batchRemoveTagsFromApp
|
||||
} from '@/web/core/app/api/tags';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
|
||||
interface AppInfoModalProps {
|
||||
app: AppListItemType;
|
||||
onClose: () => void;
|
||||
onUpdateSuccess?: () => void;
|
||||
}
|
||||
|
||||
const AppInfoModal = ({ app, onClose, onUpdateSuccess }: AppInfoModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { isOpen, onOpen, onClose: onClosePopover } = useDisclosure();
|
||||
const [appTags, setAppTags] = useState<string[]>(app.tags || []);
|
||||
const [availableTags, setAvailableTags] = useState<TagSchemaType[]>([]);
|
||||
const [initialTags, setInitialTags] = useState<string[]>(app.tags || []);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
const {
|
||||
File,
|
||||
onOpen: onOpenSelectFile,
|
||||
onSelectImage
|
||||
} = useSelectFile({
|
||||
fileType: '.jpg,.png',
|
||||
multiple: false
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
handleSubmit
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
name: app.name,
|
||||
avatar: app.avatar,
|
||||
intro: app.intro
|
||||
}
|
||||
});
|
||||
const avatar = watch('avatar');
|
||||
|
||||
// 获取所有标签
|
||||
const { data: tags = [], loading: loadingTags } = useRequest2(
|
||||
async () => {
|
||||
const result = await getTeamTags();
|
||||
return result as TagSchemaType[];
|
||||
},
|
||||
{
|
||||
manual: false,
|
||||
refreshDeps: [refreshTrigger],
|
||||
onSuccess: (data) => {
|
||||
setAvailableTags(data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 如果应用的标签有变化,通知父组件刷新
|
||||
if (app.tags && initialTags && JSON.stringify(app.tags) !== JSON.stringify(initialTags)) {
|
||||
setInitialTags([...app.tags]);
|
||||
if (onUpdateSuccess) onUpdateSuccess();
|
||||
}
|
||||
}, [app.tags, initialTags, onUpdateSuccess]);
|
||||
|
||||
// 添加标签到应用
|
||||
const { runAsync: addTag, loading: addTagLoading } = useRequest2(
|
||||
async (tagId: string) => {
|
||||
if (!appTags.includes(tagId)) {
|
||||
setAppTags([...appTags, tagId]);
|
||||
}
|
||||
return tagId;
|
||||
},
|
||||
{
|
||||
onSuccess: (tagId) => {
|
||||
onClosePopover();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 从应用移除标签
|
||||
const { runAsync: removeTag, loading: removeTagLoading } = useRequest2(async (tagId: string) => {
|
||||
setAppTags(appTags.filter((id) => id !== tagId));
|
||||
return tagId;
|
||||
});
|
||||
|
||||
// 保存所有标签更改
|
||||
const saveTagChanges = useCallback(async () => {
|
||||
const tagsToAdd = appTags.filter((tagId) => !initialTags.includes(tagId));
|
||||
const tagsToRemove = initialTags.filter((tagId) => !appTags.includes(tagId));
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
if (tagsToAdd.length > 0) {
|
||||
await batchAddTagsToApp(app._id, tagsToAdd);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (tagsToRemove.length > 0) {
|
||||
await batchRemoveTagsFromApp(app._id, tagsToRemove);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
setInitialTags([...appTags]);
|
||||
|
||||
return hasChanges;
|
||||
}, [appTags, initialTags, app._id]);
|
||||
|
||||
// submit config
|
||||
const { runAsync: saveSubmitSuccess, loading: btnLoading } = useRequest2(
|
||||
async (data: { name: string; avatar: string; intro: string }) => {
|
||||
// 使用正确的 API 函数 putAppById
|
||||
await putAppById(app._id, {
|
||||
name: data.name,
|
||||
avatar: data.avatar,
|
||||
intro: data.intro
|
||||
});
|
||||
|
||||
// 保存标签变更
|
||||
const tagsChanged = await saveTagChanges();
|
||||
|
||||
return tagsChanged; // 返回标签是否有变更
|
||||
},
|
||||
{
|
||||
onSuccess(tagsChanged) {
|
||||
toast({
|
||||
title: t('common:update_success'),
|
||||
status: 'success'
|
||||
});
|
||||
if (onUpdateSuccess) onUpdateSuccess();
|
||||
onClose();
|
||||
},
|
||||
errorToast: t('common:update_failed')
|
||||
}
|
||||
);
|
||||
|
||||
const saveSubmitError = useCallback(() => {
|
||||
const deepSearch = (obj: any): string => {
|
||||
if (!obj) return t('common:submit_failed');
|
||||
if (!!obj.message) {
|
||||
return obj.message;
|
||||
}
|
||||
return deepSearch(Object.values(obj)[0]);
|
||||
};
|
||||
toast({
|
||||
title: deepSearch(errors),
|
||||
status: 'error',
|
||||
duration: 4000,
|
||||
isClosable: true
|
||||
});
|
||||
}, [errors, t, toast]);
|
||||
|
||||
const saveUpdateModel = useCallback(
|
||||
() => handleSubmit((data) => saveSubmitSuccess(data), saveSubmitError)(),
|
||||
[handleSubmit, saveSubmitError, saveSubmitSuccess]
|
||||
);
|
||||
|
||||
// 获取标签样式
|
||||
const getTagStyle = (color: string) => {
|
||||
// 处理自定义颜色 (#XXXXXX)
|
||||
if (color.startsWith('#')) {
|
||||
return {
|
||||
bg: `${color}15`, // 15 表示透明度
|
||||
color: color
|
||||
};
|
||||
}
|
||||
// 预设颜色
|
||||
const colorMap: Record<string, { bg: string; color: string }> = {
|
||||
blue: { bg: 'blue.50', color: 'blue.600' },
|
||||
green: { bg: 'green.50', color: 'green.600' },
|
||||
red: { bg: 'red.50', color: 'red.600' },
|
||||
yellow: { bg: 'yellow.50', color: 'yellow.600' },
|
||||
purple: { bg: 'purple.50', color: 'purple.600' },
|
||||
teal: { bg: 'teal.50', color: 'teal.600' }
|
||||
};
|
||||
return colorMap[color] || colorMap.blue;
|
||||
};
|
||||
|
||||
// 获取当前选中的标签
|
||||
const getSelectedTags = useCallback(() => {
|
||||
return tags.filter((tag) => appTags.includes(tag._id));
|
||||
}, [tags, appTags]);
|
||||
|
||||
// 获取未选中的标签
|
||||
const getUnselectedTags = useCallback(() => {
|
||||
return tags.filter((tag) => !appTags.includes(tag._id));
|
||||
}, [tags, appTags]);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
iconSrc="/imgs/workflow/ai.svg"
|
||||
title={t('common:core.app.setting')}
|
||||
>
|
||||
<ModalBody>
|
||||
<Box fontSize={'sm'}>{t('common:core.app.Name and avatar')}</Box>
|
||||
<Flex mt={2} alignItems={'center'}>
|
||||
<Avatar
|
||||
src={avatar}
|
||||
w={['26px', '34px']}
|
||||
h={['26px', '34px']}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'md'}
|
||||
mr={4}
|
||||
title={t('common:set_avatar')}
|
||||
onClick={() => onOpenSelectFile()}
|
||||
/>
|
||||
<FormControl>
|
||||
<Input
|
||||
bg={'myWhite.600'}
|
||||
placeholder={t('common:core.app.Set a name for your app')}
|
||||
{...register('name', {
|
||||
required: true
|
||||
})}
|
||||
></Input>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
<Box mt={4} mb={1} fontSize={'sm'}>
|
||||
{t('common:core.app.App intro')}
|
||||
</Box>
|
||||
<Textarea
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
placeholder={t('common:core.app.Make a brief introduction of your app')}
|
||||
bg={'myWhite.600'}
|
||||
{...register('intro')}
|
||||
/>
|
||||
|
||||
{/* 标签管理部分 */}
|
||||
<Box mt={4} mb={2} fontSize={'sm'}>
|
||||
标签
|
||||
</Box>
|
||||
<Flex direction="column" gap={2}>
|
||||
<Flex wrap="wrap" gap={2} mb={2} minH="30px">
|
||||
{getSelectedTags().map((tag) => (
|
||||
<ChakraTag
|
||||
key={tag._id}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
{...getTagStyle(tag.color)}
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
>
|
||||
{tag.name}
|
||||
<TagCloseButton onClick={() => removeTag(tag._id)} isDisabled={removeTagLoading} />
|
||||
</ChakraTag>
|
||||
))}
|
||||
|
||||
<Popover isOpen={isOpen} onClose={onClosePopover} placement="bottom-start">
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
leftIcon={<MyIcon name="common/addLight" w="12px" />}
|
||||
onClick={onOpen}
|
||||
isLoading={loadingTags}
|
||||
fontWeight="normal"
|
||||
h="30px"
|
||||
>
|
||||
添加标签
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w="200px">
|
||||
<PopoverBody p={2}>
|
||||
{getUnselectedTags().length === 0 ? (
|
||||
<Text fontSize="sm" color="gray.500" textAlign="center" p={2}>
|
||||
没有可添加的标签
|
||||
</Text>
|
||||
) : (
|
||||
<Flex direction="column" gap={1}>
|
||||
{getUnselectedTags().map((tag) => (
|
||||
<ChakraTag
|
||||
key={tag._id}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
{...getTagStyle(tag.color)}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
cursor="pointer"
|
||||
onClick={() => addTag(tag._id)}
|
||||
_hover={{ opacity: 0.8 }}
|
||||
>
|
||||
{tag.name}
|
||||
</ChakraTag>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
|
||||
{t('common:Close')}
|
||||
</Button>
|
||||
<Button isLoading={btnLoading} onClick={saveUpdateModel}>
|
||||
{t('common:Save')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
<File
|
||||
onSelect={(e) =>
|
||||
onSelectImage(e, {
|
||||
maxH: 300,
|
||||
maxW: 300,
|
||||
callback: (e) => setValue('avatar', e)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AppInfoModal);
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, Text, Avatar, Heading, Button } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import type { AppListItemType } from '@fastgpt/global/core/app/type';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
type Props = {
|
||||
gateApps: AppListItemType[];
|
||||
};
|
||||
|
||||
const GateAppsList = ({ gateApps }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const handleGateClick = (appId: string) => {
|
||||
router.push(`/app/detail?appId=${appId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w="220px" h="100%" bg="#FBFBFC" borderRight="1px solid #E8EBF0" p={5} overflowY="auto">
|
||||
<Flex justifyContent="space-between" alignItems="center" mb={4}>
|
||||
<Heading size="sm">{t('account_gate:gate_list')}</Heading>
|
||||
</Flex>
|
||||
|
||||
{gateApps.length === 0 ? (
|
||||
<Flex direction="column" justify="center" align="center" h="180px" gap={4}>
|
||||
<Text color="gray.500" fontSize="sm" textAlign="center">
|
||||
{t('account_gate:no_gate_available')}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex direction="column" gap={3}>
|
||||
{gateApps.map((gate) => (
|
||||
<Flex
|
||||
key={gate._id}
|
||||
align="center"
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s ease"
|
||||
bg="white"
|
||||
border="1px solid"
|
||||
borderColor="gray.100"
|
||||
boxShadow="0 2px 8px rgba(0,0,0,0.06)"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
|
||||
borderColor: 'primary.300'
|
||||
}}
|
||||
onClick={() => handleGateClick(gate._id)}
|
||||
>
|
||||
<Avatar src={gate.avatar} size="sm" mr={3} borderRadius="md" />
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="medium" className="textEllipsis">
|
||||
{gate.name}
|
||||
</Text>
|
||||
{gate.intro && (
|
||||
<Text fontSize="xs" color="gray.500" className="textEllipsis">
|
||||
{gate.intro}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GateAppsList;
|
||||
607
projects/app/src/pageComponents/account/gateway/HomeTable.tsx
Normal file
607
projects/app/src/pageComponents/account/gateway/HomeTable.tsx
Normal file
@@ -0,0 +1,607 @@
|
||||
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Link
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import ToolSelect from './ToolSelect';
|
||||
import type { putUpdateGateConfigData } from '@fastgpt/global/support/user/team/gate/api';
|
||||
import { updateTeamGateConfig } from '@/web/support/user/team/gate/api';
|
||||
import { appWorkflow2Form, getDefaultAppForm } from '@fastgpt/global/core/app/utils';
|
||||
import type { SimpleAppSnapshotType } from '@/pageComponents/app/detail/SimpleApp/useSnapshots';
|
||||
import { getAppConfigByDiff } from '@/web/core/app/diff';
|
||||
import { v1Workflow2V2 } from '@/web/core/workflow/adapt';
|
||||
import { useMount } from 'ahooks';
|
||||
import type { AppDetailType, AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
import { useSimpleAppSnapshots } from '@/pageComponents/app/detail/Gate/useSnapshots';
|
||||
import { Dropdown } from 'react-day-picker';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import AddQuickAppModal from './AddQuickAppModal';
|
||||
import { listQuickApps } from '@/web/support/user/team/gate/quickApp';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import type { AppListItemType } from '@fastgpt/global/core/app/type';
|
||||
|
||||
export const saveGateConfig = async (data: putUpdateGateConfigData) => {
|
||||
try {
|
||||
await updateTeamGateConfig(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to save gate config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
appDetail: AppDetailType;
|
||||
tools: string[];
|
||||
slogan: string;
|
||||
placeholderText: string;
|
||||
onStatusChange?: (status: boolean) => void;
|
||||
onSloganChange?: (slogan: string) => void;
|
||||
onPlaceholderChange?: (text: string) => void;
|
||||
onToolsChange?: (tools: string[]) => void;
|
||||
onAppFormChange?: (appForm: AppSimpleEditFormType) => void;
|
||||
};
|
||||
|
||||
const HomeTable = ({
|
||||
appDetail,
|
||||
slogan,
|
||||
placeholderText,
|
||||
onStatusChange,
|
||||
onSloganChange,
|
||||
onPlaceholderChange,
|
||||
onAppFormChange
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
// 批量获取插件信息
|
||||
const [appForm, setAppForm] = useState(getDefaultAppForm());
|
||||
// 快捷应用modal状态
|
||||
const [isQuickAppModalOpen, setIsQuickAppModalOpen] = useState(false);
|
||||
|
||||
// 获取快捷应用数据
|
||||
const {
|
||||
data: quickApps = [],
|
||||
loading: loadingQuickApps,
|
||||
refresh: refreshQuickApps
|
||||
} = useRequest2(() => listQuickApps(), {
|
||||
manual: false
|
||||
});
|
||||
|
||||
const { forbiddenSaveSnapshot, past, setPast, saveSnapshot } = useSimpleAppSnapshots(
|
||||
appDetail._id
|
||||
);
|
||||
useMount(() => {
|
||||
if (appDetail.version !== 'v2') {
|
||||
const form = appWorkflow2Form({
|
||||
nodes: v1Workflow2V2((appDetail.modules || []) as any)?.nodes,
|
||||
chatConfig: appDetail.chatConfig
|
||||
});
|
||||
return updateAppForm(form);
|
||||
}
|
||||
|
||||
// 读取旧的存储记录
|
||||
const pastSnapshot = (() => {
|
||||
try {
|
||||
const pastSnapshot = localStorage.getItem(`${appDetail._id}-past`);
|
||||
return pastSnapshot ? (JSON.parse(pastSnapshot) as SimpleAppSnapshotType[]) : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const defaultState = pastSnapshot?.[pastSnapshot.length - 1]?.state;
|
||||
if (pastSnapshot?.[0]?.diff && defaultState) {
|
||||
setPast(
|
||||
pastSnapshot
|
||||
.map((item) => {
|
||||
if (!item.state && !item.diff) return;
|
||||
if (!item.diff) {
|
||||
return {
|
||||
title: t('app:initial_form'),
|
||||
isSaved: true,
|
||||
appForm: defaultState
|
||||
};
|
||||
}
|
||||
|
||||
const currentState = getAppConfigByDiff(defaultState, item.diff);
|
||||
return {
|
||||
title: item.title,
|
||||
isSaved: item.isSaved,
|
||||
appForm: currentState
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as SimpleAppSnapshotType[]
|
||||
);
|
||||
|
||||
const pastState = getAppConfigByDiff(defaultState, pastSnapshot[0].diff);
|
||||
localStorage.removeItem(`${appDetail._id}-past`);
|
||||
return updateAppForm(pastState);
|
||||
}
|
||||
|
||||
// 无旧的记录,正常初始化
|
||||
if (past.length === 0) {
|
||||
const appForm = appWorkflow2Form({
|
||||
nodes: appDetail.modules,
|
||||
chatConfig: appDetail.chatConfig
|
||||
});
|
||||
saveSnapshot({
|
||||
appForm,
|
||||
title: t('app:initial_form'),
|
||||
isSaved: true
|
||||
});
|
||||
updateAppForm(appForm);
|
||||
} else {
|
||||
updateAppForm(past[0].appForm);
|
||||
}
|
||||
});
|
||||
|
||||
// 通用样式变量
|
||||
const spacing = {
|
||||
xs: '4px',
|
||||
sm: '8px',
|
||||
md: '12px',
|
||||
lg: '16px',
|
||||
xl: '20px'
|
||||
};
|
||||
|
||||
const formStyles = {
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
fontWeight: '500',
|
||||
letterSpacing: '0.1px'
|
||||
};
|
||||
|
||||
const inputStyles = {
|
||||
padding: '10px 12px',
|
||||
height: '40px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
letterSpacing: '0.25px'
|
||||
};
|
||||
|
||||
// 响应式工具布局
|
||||
|
||||
const handleStatusChange = (val: string) => {
|
||||
onStatusChange?.(val === 'enabled');
|
||||
};
|
||||
|
||||
const handleSloganChange = (val: string) => {
|
||||
onSloganChange?.(val);
|
||||
};
|
||||
|
||||
const handlePlaceholderChange = (val: string) => {
|
||||
onPlaceholderChange?.(val);
|
||||
};
|
||||
|
||||
// 快捷应用相关处理函数
|
||||
const handleOpenQuickAppModal = () => {
|
||||
setIsQuickAppModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseQuickAppModal = () => {
|
||||
setIsQuickAppModalOpen(false);
|
||||
};
|
||||
|
||||
const handleQuickAppSuccess = (selectedApps: any[]) => {
|
||||
// 刷新快捷应用列表
|
||||
refreshQuickApps();
|
||||
console.log('快捷应用更新成功:', selectedApps);
|
||||
setIsQuickAppModalOpen(false);
|
||||
};
|
||||
|
||||
// 修改 setAppForm,使其同时调用父组件的回调
|
||||
const updateAppForm = useCallback(
|
||||
(newAppForm: AppSimpleEditFormType) => {
|
||||
setAppForm(newAppForm);
|
||||
onAppFormChange?.(newAppForm);
|
||||
},
|
||||
[onAppFormChange]
|
||||
);
|
||||
|
||||
// 渲染快捷应用项
|
||||
const renderQuickAppItem = (app: AppListItemType, index: number) => {
|
||||
const gradients = [
|
||||
'linear-gradient(200.75deg, #67BFFF 13.74%, #5BA6FF 89.76%)', // 蓝色渐变
|
||||
'linear-gradient(200.75deg, #7895FE 13.74%, #7177FF 89.76%)', // 紫色渐变
|
||||
'linear-gradient(200.75deg, #67BFFF 13.74%, #5BA6FF 89.76%)', // 蓝色渐变
|
||||
'linear-gradient(200.75deg, #67BFFF 13.74%, #5BA6FF 89.76%)' // 蓝色渐变
|
||||
];
|
||||
|
||||
const gradient = gradients[index % gradients.length];
|
||||
|
||||
return (
|
||||
<React.Fragment key={app._id}>
|
||||
{/* 应用项 */}
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
alignItems="flex-start"
|
||||
padding="4px 0px"
|
||||
gap="10px"
|
||||
w="80px"
|
||||
h="28px"
|
||||
borderRadius="6px"
|
||||
>
|
||||
<Flex alignItems="center" gap="4px" w="80px" h="20px">
|
||||
<Box
|
||||
w="20px"
|
||||
h="20px"
|
||||
background={gradient}
|
||||
borderRadius="6px"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{app.avatar ? (
|
||||
<Avatar src={app.avatar} alt={app.name} w="100%" h="100%" borderRadius="6px" />
|
||||
) : (
|
||||
<MyIcon
|
||||
name="core/app/type/simple"
|
||||
position="absolute"
|
||||
left="15%"
|
||||
right="15%"
|
||||
top="15%"
|
||||
bottom="15%"
|
||||
color="white"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
w="56px"
|
||||
h="16px"
|
||||
fontFamily="PingFang SC"
|
||||
fontWeight={400}
|
||||
fontSize="12px"
|
||||
lineHeight="16px"
|
||||
letterSpacing="0.004em"
|
||||
color="#111824"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* 分隔线 - 除了最后一个应用之外都显示 */}
|
||||
{index < Math.min(quickApps.length - 1, 3) && (
|
||||
<Box w="11.46px" h="0px" border="1px solid #DFE2EA" transform="rotate(90deg)" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flex="1 0 0" overflow="auto" px={spacing.sm}>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
gap={spacing.xl}
|
||||
maxW="640px"
|
||||
mx="auto"
|
||||
pb={6}
|
||||
pt={{ base: 4, md: 6 }}
|
||||
>
|
||||
{/* 状态选择 */}
|
||||
<FormControl display="flex" flexDirection="column" gap={spacing.sm} w="full">
|
||||
<FormLabel
|
||||
fontWeight={formStyles.fontWeight}
|
||||
fontSize={formStyles.fontSize}
|
||||
lineHeight={formStyles.lineHeight}
|
||||
letterSpacing={formStyles.letterSpacing}
|
||||
color="myGray.700"
|
||||
mb="0"
|
||||
>
|
||||
{t('account_gate:status')}
|
||||
</FormLabel>
|
||||
<RadioGroup value={status ? 'enabled' : 'disabled'} onChange={handleStatusChange}>
|
||||
<Stack direction={{ base: 'column', sm: 'row' }} spacing={spacing.md}>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
p={`${spacing.sm} ${spacing.lg} ${spacing.sm} ${spacing.md}`}
|
||||
borderWidth="1px"
|
||||
borderColor={status ? 'primary.500' : 'myGray.200'}
|
||||
borderRadius="7px"
|
||||
bg={status ? 'blue.50' : 'white'}
|
||||
transition="all 0.2s ease-in-out"
|
||||
_hover={{
|
||||
bg: status ? 'blue.100' : 'myGray.50',
|
||||
borderColor: status ? 'primary.600' : 'myGray.300',
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
>
|
||||
<Radio value="enabled" colorScheme="blue">
|
||||
<Text
|
||||
fontSize={formStyles.fontSize}
|
||||
lineHeight={formStyles.lineHeight}
|
||||
fontWeight={formStyles.fontWeight}
|
||||
letterSpacing={formStyles.letterSpacing}
|
||||
>
|
||||
{t('account_gate:enabled')}
|
||||
</Text>
|
||||
</Radio>
|
||||
</Flex>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
p={`${spacing.sm} ${spacing.lg} ${spacing.sm} ${spacing.md}`}
|
||||
borderWidth="1px"
|
||||
borderColor={!status ? 'primary.500' : 'myGray.200'}
|
||||
borderRadius="7px"
|
||||
bg={!status ? 'blue.50' : 'white'}
|
||||
transition="all 0.2s ease-in-out"
|
||||
_hover={{
|
||||
bg: !status ? 'blue.100' : 'myGray.50',
|
||||
borderColor: !status ? 'primary.600' : 'myGray.300',
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
>
|
||||
<Radio value="disabled" colorScheme="blue">
|
||||
<Text
|
||||
fontSize={formStyles.fontSize}
|
||||
lineHeight={formStyles.lineHeight}
|
||||
fontWeight={formStyles.fontWeight}
|
||||
letterSpacing={formStyles.letterSpacing}
|
||||
>
|
||||
{t('account_gate:disabled')}
|
||||
</Text>
|
||||
</Radio>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
{/* 快捷应用 */}
|
||||
<FormControl
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
justifyContent={'center'}
|
||||
alignItems={'flex-start'}
|
||||
gap={'8px'}
|
||||
>
|
||||
{/* 标题行 */}
|
||||
<Flex alignItems={'center'} gap={'4px'}>
|
||||
<Text
|
||||
color={'var(--Gray-Modern-600, #485264)'}
|
||||
fontFamily={'PingFang SC'}
|
||||
fontSize={'14px'}
|
||||
fontWeight={500}
|
||||
lineHeight={'20px'}
|
||||
letterSpacing={'0.1px'}
|
||||
>
|
||||
{t('account_gate:quick_app')}
|
||||
</Text>
|
||||
<MyIcon name="common/help" w="16px" h="16px" color="#667085" />
|
||||
</Flex>
|
||||
|
||||
{/* 下拉框区域 */}
|
||||
<Flex alignItems="center" gap="8px" w="640px" h="40px">
|
||||
{/* 应用容器 */}
|
||||
<Box
|
||||
position="relative"
|
||||
w="600px"
|
||||
h="40px"
|
||||
bg="#FBFBFC"
|
||||
border="1px solid #E8EBF0"
|
||||
borderRadius="8px"
|
||||
>
|
||||
{/* 应用列表 */}
|
||||
<Flex
|
||||
position="absolute"
|
||||
alignItems="center"
|
||||
gap="8px"
|
||||
w="560px"
|
||||
h="28px"
|
||||
left="12px"
|
||||
top="calc(50% - 14px)"
|
||||
>
|
||||
{quickApps.length > 0 ? (
|
||||
quickApps.slice(0, 4).map((app, index) => renderQuickAppItem(app, index))
|
||||
) : (
|
||||
<Text fontSize="12px" color="#667085" fontFamily="PingFang SC">
|
||||
{loadingQuickApps ? '加载中...' : '暂无快捷应用'}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 设置按钮 */}
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding="7px"
|
||||
gap="6px"
|
||||
w="32px"
|
||||
h="32px"
|
||||
borderRadius="6px"
|
||||
cursor="pointer"
|
||||
onClick={handleOpenQuickAppModal}
|
||||
_hover={{
|
||||
bg: 'myGray.100'
|
||||
}}
|
||||
>
|
||||
<MyIcon name="common/settingLight" w="18px" h="18px" color="#667085" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
{/* 可用工具选择 */}
|
||||
<FormControl display="flex" flexDirection="column" gap={spacing.sm} w="full">
|
||||
<ToolSelect
|
||||
appForm={appForm}
|
||||
setAppForm={updateAppForm} // 使用 updateAppForm 替代 setAppForm
|
||||
/>
|
||||
</FormControl>
|
||||
{/* slogan设置 */}
|
||||
<FormControl display="flex" flexDirection="column" gap={spacing.sm} w="full">
|
||||
<Flex alignItems="center" gap={spacing.xs}>
|
||||
<Text
|
||||
fontWeight={formStyles.fontWeight}
|
||||
fontSize={formStyles.fontSize}
|
||||
lineHeight={formStyles.lineHeight}
|
||||
letterSpacing={formStyles.letterSpacing}
|
||||
color="myGray.700"
|
||||
>
|
||||
{t('account_gate:slogan')}
|
||||
</Text>
|
||||
<Link
|
||||
color="primary.500"
|
||||
fontSize={formStyles.fontSize}
|
||||
fontWeight={formStyles.fontWeight}
|
||||
textDecoration="underline"
|
||||
>
|
||||
{t('account_gate:example')}
|
||||
</Link>
|
||||
</Flex>
|
||||
<Input
|
||||
value={slogan}
|
||||
onChange={(e) => handleSloganChange(e.target.value)}
|
||||
bg="myGray.50"
|
||||
borderWidth="1px"
|
||||
borderColor="myGray.200"
|
||||
borderRadius="8px"
|
||||
p={inputStyles.padding}
|
||||
h={inputStyles.height}
|
||||
fontSize={inputStyles.fontSize}
|
||||
lineHeight={inputStyles.lineHeight}
|
||||
letterSpacing={inputStyles.letterSpacing}
|
||||
color="gray.900"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* 对话提示文字 */}
|
||||
<FormControl display="flex" flexDirection="column" gap={spacing.sm} w="full">
|
||||
<Flex alignItems="center" gap={spacing.xs}>
|
||||
<Text
|
||||
fontWeight={formStyles.fontWeight}
|
||||
fontSize={formStyles.fontSize}
|
||||
lineHeight={formStyles.lineHeight}
|
||||
letterSpacing={formStyles.letterSpacing}
|
||||
color="myGray.700"
|
||||
>
|
||||
{t('account_gate:dialog_prompt_text')}
|
||||
</Text>
|
||||
<Link
|
||||
color="primary.500"
|
||||
fontSize={formStyles.fontSize}
|
||||
fontWeight={formStyles.fontWeight}
|
||||
textDecoration="underline"
|
||||
>
|
||||
{t('account_gate:example')}
|
||||
</Link>
|
||||
</Flex>
|
||||
<Input
|
||||
value={placeholderText}
|
||||
onChange={(e) => handlePlaceholderChange(e.target.value)}
|
||||
bg="myGray.50"
|
||||
borderWidth="1px"
|
||||
borderColor="myGray.200"
|
||||
borderRadius="8px"
|
||||
p={inputStyles.padding}
|
||||
h={inputStyles.height}
|
||||
fontSize={inputStyles.fontSize}
|
||||
lineHeight={inputStyles.lineHeight}
|
||||
letterSpacing={inputStyles.letterSpacing}
|
||||
color="gray.900"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* 可用工具 */}
|
||||
{/* <FormControl display="flex" flexDirection="column" gap={spacing.sm} w="full">
|
||||
<Flex gap={spacing.xs}>
|
||||
<FormLabel
|
||||
fontWeight={formStyles.fontWeight}
|
||||
fontSize={formStyles.fontSize}
|
||||
lineHeight={formStyles.lineHeight}
|
||||
letterSpacing={formStyles.letterSpacing}
|
||||
color="myGray.700"
|
||||
mb="0"
|
||||
>
|
||||
{t('account_gate:available_tools')}
|
||||
</FormLabel>
|
||||
<QuestionTip />
|
||||
</Flex>
|
||||
<CheckboxGroup colorScheme="blue" value={tools} onChange={handleToolsChange}>
|
||||
<Wrap spacing={toolsSpacing}>
|
||||
{[
|
||||
{ value: 'webSearch', label: t('account_gate:web_search') },
|
||||
{ value: 'deepThinking', label: t('account_gate:deep_thinking') },
|
||||
{ value: 'fileUpload', label: t('account_gate:file_upload') },
|
||||
{ value: 'imageUpload', label: t('account_gate:image_upload') },
|
||||
{ value: 'voiceInput', label: t('account_gate:voice_input') }
|
||||
].map((item) => (
|
||||
<WrapItem key={item.value}>
|
||||
<Flex
|
||||
p={`${spacing.sm} ${spacing.lg} ${spacing.sm} ${spacing.md}`}
|
||||
borderWidth="1px"
|
||||
borderColor={
|
||||
tools.includes(item.value as GateTool) ? 'primary.500' : 'myGray.200'
|
||||
}
|
||||
borderRadius="7px"
|
||||
bg={tools.includes(item.value as GateTool) ? 'blue.50' : 'white'}
|
||||
transition="all 0.2s ease-in-out"
|
||||
_hover={{
|
||||
bg: tools.includes(item.value as GateTool) ? 'blue.100' : 'myGray.50',
|
||||
borderColor: tools.includes(item.value as GateTool)
|
||||
? 'primary.600'
|
||||
: 'myGray.300',
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
value={item.value}
|
||||
colorScheme="blue"
|
||||
isChecked={tools.includes(item.value as GateTool)}
|
||||
>
|
||||
<Text
|
||||
fontSize={formStyles.fontSize}
|
||||
lineHeight={formStyles.lineHeight}
|
||||
fontWeight={formStyles.fontWeight}
|
||||
letterSpacing={formStyles.letterSpacing}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</Flex>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</CheckboxGroup>
|
||||
</FormControl> */}
|
||||
</Flex>
|
||||
|
||||
{/* 快捷应用配置Modal */}
|
||||
{isQuickAppModalOpen && (
|
||||
<AddQuickAppModal
|
||||
isOpen={isQuickAppModalOpen}
|
||||
onClose={handleCloseQuickAppModal}
|
||||
onSuccess={handleQuickAppSuccess}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeTable;
|
||||
|
||||
// 导出常量供其他组件使用
|
||||
export const spacing = {
|
||||
xs: '4px',
|
||||
sm: '8px',
|
||||
md: '12px',
|
||||
lg: '16px',
|
||||
xl: '20px'
|
||||
};
|
||||
|
||||
export const formStyles = {
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
fontWeight: '500',
|
||||
letterSpacing: '0.1px'
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Box, type BoxProps, Flex, Checkbox } from '@chakra-ui/react';
|
||||
import {
|
||||
type GetResourceFolderListProps,
|
||||
type GetResourceListItemResponse,
|
||||
type ParentIdType
|
||||
} from '@fastgpt/global/common/parentFolder/type';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import Loading from '@fastgpt/web/components/common/MyLoading';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { FolderImgUrl } from '@fastgpt/global/common/file/image/constants';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
type ResourceItemType = GetResourceListItemResponse & {
|
||||
open: boolean;
|
||||
children?: ResourceItemType[];
|
||||
};
|
||||
|
||||
const rootId = 'root';
|
||||
|
||||
const SelectMultipleResource = ({
|
||||
server,
|
||||
selectedIds = [],
|
||||
onSelect,
|
||||
maxH = ['80vh', '600px'],
|
||||
searchKey = ''
|
||||
}: {
|
||||
server: (e: GetResourceFolderListProps) => Promise<GetResourceListItemResponse[]>;
|
||||
selectedIds?: string[];
|
||||
onSelect: (id: string, appData: GetResourceListItemResponse) => any;
|
||||
maxH?: BoxProps['maxH'];
|
||||
searchKey?: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [dataList, setDataList] = useState<ResourceItemType[]>([]);
|
||||
const [requestingIdList, setRequestingIdList] = useState<ParentIdType[]>([]);
|
||||
|
||||
const concatRoot = useMemo(() => {
|
||||
const root: ResourceItemType = {
|
||||
id: rootId,
|
||||
open: true,
|
||||
avatar: FolderImgUrl,
|
||||
name: t('common:root_folder'),
|
||||
isFolder: true,
|
||||
children: dataList
|
||||
};
|
||||
return [root];
|
||||
}, [dataList, t]);
|
||||
|
||||
const { runAsync: requestServer } = useRequest2((e: GetResourceFolderListProps) => {
|
||||
if (requestingIdList.includes(e.parentId)) return Promise.reject(null);
|
||||
|
||||
setRequestingIdList((state) => [...state, e.parentId]);
|
||||
return server(e).finally(() =>
|
||||
setRequestingIdList((state) => state.filter((id) => id !== e.parentId))
|
||||
);
|
||||
}, {});
|
||||
|
||||
const { loading, refresh } = useRequest2(() => requestServer({ parentId: null }), {
|
||||
manual: false,
|
||||
onSuccess: (data) => {
|
||||
setDataList(
|
||||
data.map((item) => ({
|
||||
...item,
|
||||
open: false
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 当搜索关键词变化时,重新加载数据
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [searchKey, refresh]);
|
||||
|
||||
const Render = useMemoizedFn(
|
||||
({ list, index = 0 }: { list: ResourceItemType[]; index?: number }) => {
|
||||
return (
|
||||
<>
|
||||
{list.map((item) => (
|
||||
<Box key={item.id} _notLast={{ mb: 0.5 }} userSelect={'none'}>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
cursor={'pointer'}
|
||||
py={1}
|
||||
pl={index === 0 ? '0.5rem' : `${1.75 * (index - 1) + 0.5}rem`}
|
||||
pr={2}
|
||||
borderRadius={'md'}
|
||||
_hover={{
|
||||
bg: 'myGray.100'
|
||||
}}
|
||||
onClick={async () => {
|
||||
if (item.id === rootId) return;
|
||||
// folder => open(request children) or close
|
||||
if (item.isFolder) {
|
||||
if (!item.children) {
|
||||
const data = await requestServer({ parentId: item.id });
|
||||
item.children = data.map((childItem) => ({
|
||||
...childItem,
|
||||
open: false
|
||||
}));
|
||||
}
|
||||
|
||||
item.open = !item.open;
|
||||
setDataList([...dataList]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Checkbox for non-folder items */}
|
||||
{!item.isFolder && item.id !== rootId && (
|
||||
<Checkbox
|
||||
isChecked={selectedIds.includes(item.id)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id, item);
|
||||
}}
|
||||
mr={2}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{index !== 0 && (
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
visibility={item.isFolder ? 'visible' : 'hidden'}
|
||||
w={'1.25rem'}
|
||||
h={'1.25rem'}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'xs'}
|
||||
_hover={{
|
||||
bg: 'rgba(31, 35, 41, 0.08)'
|
||||
}}
|
||||
>
|
||||
<MyIcon
|
||||
name={
|
||||
requestingIdList.includes(item.id)
|
||||
? 'common/loading'
|
||||
: 'common/rightArrowFill'
|
||||
}
|
||||
w={'14px'}
|
||||
color={'myGray.500'}
|
||||
transform={item.open ? 'rotate(90deg)' : 'none'}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<Avatar
|
||||
ml={index !== 0 ? '0.5rem' : 0}
|
||||
src={item.avatar}
|
||||
w={'1.25rem'}
|
||||
borderRadius={'sm'}
|
||||
/>
|
||||
<Box
|
||||
fontSize={['md', 'sm']}
|
||||
ml={2}
|
||||
className="textEllipsis"
|
||||
color={selectedIds.includes(item.id) ? 'primary.600' : 'inherit'}
|
||||
fontWeight={selectedIds.includes(item.id) ? 'medium' : 'normal'}
|
||||
>
|
||||
{item.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
{item.children && item.open && (
|
||||
<Box mt={0.5}>
|
||||
<Render list={item.children} index={index + 1} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return loading ? (
|
||||
<Loading fixed={false} />
|
||||
) : (
|
||||
<Box maxH={maxH} h={'100%'} overflow={'auto'}>
|
||||
<Render list={concatRoot} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectMultipleResource;
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Box, type BoxProps, Flex } from '@chakra-ui/react';
|
||||
import {
|
||||
type GetResourceFolderListProps,
|
||||
type GetResourceListItemResponse,
|
||||
type ParentIdType
|
||||
} from '@fastgpt/global/common/parentFolder/type';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import Loading from '@fastgpt/web/components/common/MyLoading';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { FolderImgUrl } from '@fastgpt/global/common/file/image/constants';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
type ResourceItemType = GetResourceListItemResponse & {
|
||||
open: boolean;
|
||||
children?: ResourceItemType[];
|
||||
};
|
||||
|
||||
const rootId = 'root';
|
||||
|
||||
const SelectOneResource = ({
|
||||
server,
|
||||
value,
|
||||
onSelect,
|
||||
maxH = ['80vh', '600px']
|
||||
}: {
|
||||
server: (e: GetResourceFolderListProps) => Promise<GetResourceListItemResponse[]>;
|
||||
value?: ParentIdType;
|
||||
onSelect: (e?: string) => any;
|
||||
maxH?: BoxProps['maxH'];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [dataList, setDataList] = useState<ResourceItemType[]>([]);
|
||||
const [requestingIdList, setRequestingIdList] = useState<ParentIdType[]>([]);
|
||||
|
||||
const concatRoot = useMemo(() => {
|
||||
const root: ResourceItemType = {
|
||||
id: rootId,
|
||||
open: true,
|
||||
avatar: FolderImgUrl,
|
||||
name: t('common:root_folder'),
|
||||
isFolder: true,
|
||||
children: dataList
|
||||
};
|
||||
return [root];
|
||||
}, [dataList, t]);
|
||||
|
||||
const { runAsync: requestServer } = useRequest2((e: GetResourceFolderListProps) => {
|
||||
if (requestingIdList.includes(e.parentId)) return Promise.reject(null);
|
||||
|
||||
setRequestingIdList((state) => [...state, e.parentId]);
|
||||
return server(e).finally(() =>
|
||||
setRequestingIdList((state) => state.filter((id) => id !== e.parentId))
|
||||
);
|
||||
}, {});
|
||||
|
||||
const { loading } = useRequest2(() => requestServer({ parentId: null }), {
|
||||
manual: false,
|
||||
onSuccess: (data) => {
|
||||
setDataList(
|
||||
data.map((item) => ({
|
||||
...item,
|
||||
open: false
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const Render = useMemoizedFn(
|
||||
({ list, index = 0 }: { list: ResourceItemType[]; index?: number }) => {
|
||||
return (
|
||||
<>
|
||||
{list.map((item) => (
|
||||
<Box key={item.id} _notLast={{ mb: 0.5 }} userSelect={'none'}>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
cursor={'pointer'}
|
||||
py={1}
|
||||
pl={index === 0 ? '0.5rem' : `${1.75 * (index - 1) + 0.5}rem`}
|
||||
pr={2}
|
||||
borderRadius={'md'}
|
||||
_hover={{
|
||||
bg: 'myGray.100'
|
||||
}}
|
||||
{...(item.id === value
|
||||
? {
|
||||
bg: 'primary.50 !important',
|
||||
onClick: () => onSelect(undefined)
|
||||
}
|
||||
: {
|
||||
onClick: async () => {
|
||||
if (item.id === rootId) return;
|
||||
// folder => open(request children) or close
|
||||
if (item.isFolder) {
|
||||
if (!item.children) {
|
||||
const data = await requestServer({ parentId: item.id });
|
||||
item.children = data.map((item) => ({
|
||||
...item,
|
||||
open: false
|
||||
}));
|
||||
}
|
||||
|
||||
item.open = !item.open;
|
||||
setDataList([...dataList]);
|
||||
} else {
|
||||
onSelect(item.id);
|
||||
}
|
||||
}
|
||||
})}
|
||||
>
|
||||
{index !== 0 && (
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
visibility={item.isFolder ? 'visible' : 'hidden'}
|
||||
w={'1.25rem'}
|
||||
h={'1.25rem'}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'xs'}
|
||||
_hover={{
|
||||
bg: 'rgba(31, 35, 41, 0.08)'
|
||||
}}
|
||||
>
|
||||
<MyIcon
|
||||
name={
|
||||
requestingIdList.includes(item.id)
|
||||
? 'common/loading'
|
||||
: 'common/rightArrowFill'
|
||||
}
|
||||
w={'14px'}
|
||||
color={'myGray.500'}
|
||||
transform={item.open ? 'rotate(90deg)' : 'none'}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<Avatar
|
||||
ml={index !== 0 ? '0.5rem' : 0}
|
||||
src={item.avatar}
|
||||
w={'1.25rem'}
|
||||
borderRadius={'sm'}
|
||||
/>
|
||||
<Box fontSize={['md', 'sm']} ml={2} className="textEllipsis">
|
||||
{item.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
{item.children && item.open && (
|
||||
<Box mt={0.5}>
|
||||
<Render list={item.children} index={index + 1} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return loading ? (
|
||||
<Loading fixed={false} />
|
||||
) : (
|
||||
<Box maxH={maxH} h={'100%'} overflow={'auto'}>
|
||||
<Render list={concatRoot} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectOneResource;
|
||||
196
projects/app/src/pageComponents/account/gateway/ShareModol.tsx
Normal file
196
projects/app/src/pageComponents/account/gateway/ShareModol.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Flex, Text, IconButton, Input } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { CopyIcon } from '@chakra-ui/icons';
|
||||
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
|
||||
|
||||
// 分享门户组件
|
||||
const ShareGateModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
gateConfig
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
gateConfig: GateSchemaType | undefined;
|
||||
}) => {
|
||||
const { copyData } = useCopyData();
|
||||
|
||||
// 门户链接和自定义域名
|
||||
const [defaultGateUrl] = useState(`${window.location.origin}/chat/gate`);
|
||||
|
||||
// 复制链接
|
||||
const handleCopyLink = (link: string) => {
|
||||
copyData(link, '链接已复制');
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const handleSave = () => {
|
||||
// 保存自定义域名的逻辑
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 获取门户状态
|
||||
const isGateEnabled = gateConfig?.status || false;
|
||||
|
||||
return (
|
||||
<MyModal isOpen={isOpen} onClose={onClose} maxW="500px">
|
||||
<Box
|
||||
position="relative"
|
||||
width="500px"
|
||||
maxHeight="80vh"
|
||||
gap={'20px'}
|
||||
bg="#FFFFFF"
|
||||
boxShadow="0px 32px 64px -12px rgba(19, 51, 107, 0.2), 0px 0px 1px rgba(19, 51, 107, 0.2)"
|
||||
borderRadius="10px"
|
||||
overflowY="auto"
|
||||
>
|
||||
{/* 弹窗头部 */}
|
||||
<Flex
|
||||
boxSizing="border-box"
|
||||
w="500px"
|
||||
h="48px"
|
||||
bg="#FBFBFC"
|
||||
borderBottom="1px solid #F4F4F7"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
px="20px"
|
||||
borderTopLeftRadius="10px"
|
||||
borderTopRightRadius="10px"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Flex alignItems="center" gap="10px">
|
||||
<MyIcon name="support/gate/home/sharePrimary" color="#3370FF" />
|
||||
<Text
|
||||
fontFamily="PingFang SC"
|
||||
fontWeight="500"
|
||||
fontSize="16px"
|
||||
lineHeight="24px"
|
||||
letterSpacing="0.15px"
|
||||
color="#24282C"
|
||||
>
|
||||
分享门户
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<Flex
|
||||
direction="column"
|
||||
alignItems="flex-start"
|
||||
padding="24px 36px"
|
||||
gap="24px"
|
||||
w="100%"
|
||||
h="100%"
|
||||
>
|
||||
{/* 上部内容区 */}
|
||||
<Flex direction="column" gap="20px" w="428px">
|
||||
{/* 提示信息 */}
|
||||
<Flex
|
||||
bg="#F0F4FF"
|
||||
borderRadius="6px"
|
||||
p="6px 12px"
|
||||
alignItems="center"
|
||||
w="100%"
|
||||
h="44px"
|
||||
>
|
||||
<Text
|
||||
fontFamily="PingFang SC"
|
||||
fontWeight="500"
|
||||
fontSize="12px"
|
||||
lineHeight="16px"
|
||||
letterSpacing="0.5px"
|
||||
color="#3370FF"
|
||||
>
|
||||
通过门户进入的用户仍需登录账号及应用鉴权。
|
||||
门户仅支持与已配置的应用对话,对话记录与站内聊天记录互通。
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 门户状态 */}
|
||||
<Flex alignItems="center" gap="12px">
|
||||
<Text
|
||||
fontFamily="PingFang SC"
|
||||
fontWeight="500"
|
||||
fontSize="14px"
|
||||
lineHeight="20px"
|
||||
letterSpacing="0.1px"
|
||||
color="#111824"
|
||||
>
|
||||
门户状态:
|
||||
</Text>
|
||||
<Flex
|
||||
bg={isGateEnabled ? '#EDFBF3' : '#FFF0F0'}
|
||||
borderRadius="6px"
|
||||
p="4px 8px"
|
||||
alignItems="center"
|
||||
gap="4px"
|
||||
>
|
||||
<Box
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="50%"
|
||||
bg={isGateEnabled ? '#039855' : '#D92D20'}
|
||||
></Box>
|
||||
<Text
|
||||
fontFamily="PingFang SC"
|
||||
fontWeight="500"
|
||||
fontSize="12px"
|
||||
lineHeight="16px"
|
||||
letterSpacing="0.5px"
|
||||
color={isGateEnabled ? '#039855' : '#D92D20'}
|
||||
>
|
||||
{isGateEnabled ? '已启用' : '已禁用'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* 默认地址 */}
|
||||
<Flex direction="column" alignItems="flex-start" gap="8px" w="100%">
|
||||
<Text
|
||||
fontFamily="PingFang SC"
|
||||
fontWeight="500"
|
||||
fontSize="14px"
|
||||
lineHeight="20px"
|
||||
letterSpacing="0.1px"
|
||||
color="#24282C"
|
||||
>
|
||||
默认地址
|
||||
</Text>
|
||||
<Flex w="100%" alignItems="center" gap="8px">
|
||||
<Input
|
||||
value={defaultGateUrl}
|
||||
readOnly
|
||||
h="32px"
|
||||
bg="#FFFFFF"
|
||||
border="1px solid #3370FF"
|
||||
boxShadow="0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)"
|
||||
borderRadius="6px"
|
||||
fontSize="12px"
|
||||
color="#111824"
|
||||
pl="12px"
|
||||
flex="1"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="复制链接"
|
||||
icon={<CopyIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="gray"
|
||||
onClick={() => handleCopyLink(defaultGateUrl)}
|
||||
h="32px"
|
||||
w="32px"
|
||||
minW="32px"
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareGateModal;
|
||||
@@ -0,0 +1,779 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Input,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useToast,
|
||||
HStack,
|
||||
IconButton,
|
||||
Container,
|
||||
Divider,
|
||||
Text,
|
||||
Checkbox
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import {
|
||||
getTeamTags,
|
||||
createTag,
|
||||
updateTag,
|
||||
deleteTag,
|
||||
batchAddTagsToApp,
|
||||
batchRemoveTagsFromApp,
|
||||
batchAddAppsToTag
|
||||
} from '@/web/core/app/api/tags';
|
||||
import type { TagWithCountType } from '@fastgpt/global/core/app/tags';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
|
||||
import SelectMultipleResource from './SelectMultipleResource';
|
||||
import {
|
||||
type GetResourceFolderListProps,
|
||||
type GetResourceListItemResponse
|
||||
} from '@fastgpt/global/common/parentFolder/type';
|
||||
import { getMyApps } from '@/web/core/app/api';
|
||||
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
|
||||
import type { AppListItemType } from '@fastgpt/global/core/app/type.d';
|
||||
|
||||
interface TagManageModalProps {
|
||||
onClose: () => void;
|
||||
onTagsUpdated?: () => void;
|
||||
}
|
||||
|
||||
type ViewMode = 'tagList' | 'appSelection';
|
||||
|
||||
const TagManageModal = ({ onClose, onTagsUpdated }: TagManageModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
const [editingTag, setEditingTag] = useState<{
|
||||
_id?: string;
|
||||
name: string;
|
||||
}>({ name: '' });
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('tagList');
|
||||
const [selectedTagForAddApps, setSelectedTagForAddApps] = useState<TagWithCountType | null>(null);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [selectedAppIds, setSelectedAppIds] = useState<string[]>([]);
|
||||
const [allApps, setAllApps] = useState<AppListItemType[]>([]);
|
||||
const [initialAppsWithTag, setInitialAppsWithTag] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 获取标签列表
|
||||
const { data: tags = [], loading: loadingTags } = useRequest2(
|
||||
async () => {
|
||||
const result = await getTeamTags(true);
|
||||
return result as TagWithCountType[];
|
||||
},
|
||||
{
|
||||
manual: false,
|
||||
refreshDeps: [refreshTrigger],
|
||||
onSuccess: (data) => {
|
||||
console.log('getTeamTags success', data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 创建标签
|
||||
const { runAsync: createTagMutate, loading: createLoading } = useRequest2(
|
||||
(data: { name: string }) => createTag(data),
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: '标签创建成功',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
setIsCreating(false);
|
||||
setEditingTag({ name: '' });
|
||||
onTagsUpdated?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 更新标签
|
||||
const { runAsync: updateTagMutate, loading: updateLoading } = useRequest2(
|
||||
(data: { tagId: string; name: string }) => updateTag(data),
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: '标签更新成功',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
setIsEditing(false);
|
||||
setEditingTag({ name: '' });
|
||||
onTagsUpdated?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 删除标签
|
||||
const { runAsync: deleteTagMutate, loading: deleteLoading } = useRequest2(
|
||||
(tagId: string) => deleteTag(tagId),
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: '标签删除成功',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
onTagsUpdated?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 当创建或编辑模式激活时,聚焦输入框
|
||||
useEffect(() => {
|
||||
if ((isCreating || isEditing) && inputRef.current) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [isCreating, isEditing]);
|
||||
|
||||
// 处理创建标签
|
||||
const handleCreateTag = () => {
|
||||
if (!editingTag.name.trim()) {
|
||||
toast({
|
||||
title: '标签名称不能为空',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createTagMutate({
|
||||
name: editingTag.name
|
||||
});
|
||||
};
|
||||
|
||||
// 处理更新标签
|
||||
const handleUpdateTag = () => {
|
||||
if (!editingTag.name.trim()) {
|
||||
toast({
|
||||
title: '标签名称不能为空',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingTag._id) return;
|
||||
|
||||
updateTagMutate({
|
||||
tagId: editingTag._id,
|
||||
name: editingTag.name
|
||||
});
|
||||
};
|
||||
|
||||
// 处理删除标签
|
||||
const handleDeleteTag = (tagId: string) => {
|
||||
deleteTagMutate(tagId);
|
||||
};
|
||||
|
||||
// 开始编辑标签
|
||||
const startEditTag = (tag: TagWithCountType) => {
|
||||
if (isEditing && editingTag._id === tag._id) {
|
||||
cancelEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingTag({
|
||||
_id: tag._id,
|
||||
name: tag.name
|
||||
});
|
||||
setIsEditing(true);
|
||||
setIsCreating(false);
|
||||
};
|
||||
|
||||
// 开始创建新标签
|
||||
const startCreateTag = () => {
|
||||
setEditingTag({ name: '' });
|
||||
setIsCreating(true);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// 取消编辑或创建
|
||||
const cancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setIsCreating(false);
|
||||
setEditingTag({ name: '' });
|
||||
};
|
||||
|
||||
// 获取应用列表的函数
|
||||
const getAppList = useCallback(
|
||||
async ({ parentId }: GetResourceFolderListProps) => {
|
||||
const apps = await getMyApps({
|
||||
parentId,
|
||||
searchKey,
|
||||
type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow, AppTypeEnum.plugin]
|
||||
});
|
||||
|
||||
// 保存所有应用数据,用于后续判断哪些应用已经有当前标签
|
||||
setAllApps(apps);
|
||||
|
||||
// 如果是第一次加载(parentId 为 null)且有选中的标签,保存初始有标签的应用
|
||||
if (parentId === null && selectedTagForAddApps && initialAppsWithTag.length === 0) {
|
||||
const appsWithCurrentTag = apps
|
||||
.filter((app) => app.tags?.includes(selectedTagForAddApps._id))
|
||||
.map((app) => app._id);
|
||||
setInitialAppsWithTag(appsWithCurrentTag);
|
||||
}
|
||||
|
||||
return apps.map<GetResourceListItemResponse>((item) => ({
|
||||
id: item._id,
|
||||
name: item.name,
|
||||
avatar: item.avatar,
|
||||
isFolder: item.type === AppTypeEnum.folder
|
||||
}));
|
||||
},
|
||||
[searchKey, selectedTagForAddApps, initialAppsWithTag.length]
|
||||
);
|
||||
|
||||
// 处理应用选择
|
||||
const handleAppSelect = useCallback(
|
||||
(appId: string, appData: GetResourceListItemResponse) => {
|
||||
if (!selectedTagForAddApps) return;
|
||||
|
||||
// 获取当前目录中有标签的应用
|
||||
const currentAppsWithTag = allApps
|
||||
.filter((app) => app.tags?.includes(selectedTagForAddApps._id))
|
||||
.map((app) => app._id);
|
||||
|
||||
// 判断这个应用是否初始就被选中(包括初始有标签的 + 当前目录中有标签的)
|
||||
const allInitialSelected = [...new Set([...initialAppsWithTag, ...currentAppsWithTag])];
|
||||
const isInitiallySelected = allInitialSelected.includes(appId);
|
||||
const isCurrentlyInSelectedIds = selectedAppIds.includes(appId);
|
||||
|
||||
setSelectedAppIds((prev) => {
|
||||
if (isInitiallySelected) {
|
||||
// 如果是初始就选中的应用
|
||||
if (isCurrentlyInSelectedIds) {
|
||||
// 当前在 selectedAppIds 中,移除它(表示取消选择)
|
||||
return prev.filter((id) => id !== appId);
|
||||
} else {
|
||||
// 当前不在 selectedAppIds 中,添加它(表示取消选择)
|
||||
return [...prev, appId];
|
||||
}
|
||||
} else {
|
||||
// 如果是初始没有选中的应用
|
||||
if (isCurrentlyInSelectedIds) {
|
||||
// 当前已选中,取消选择
|
||||
return prev.filter((id) => id !== appId);
|
||||
} else {
|
||||
// 当前未选中,添加选择
|
||||
return [...prev, appId];
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
[selectedTagForAddApps, initialAppsWithTag, allApps, selectedAppIds]
|
||||
);
|
||||
|
||||
// 获取当前选中的应用ID列表(包括已有标签的应用)
|
||||
const getSelectedIds = useCallback(() => {
|
||||
if (!selectedTagForAddApps) return [];
|
||||
|
||||
// 获取当前目录中有标签的应用
|
||||
const currentAppsWithTag = allApps
|
||||
.filter((app) => app.tags?.includes(selectedTagForAddApps._id))
|
||||
.map((app) => app._id);
|
||||
|
||||
// 合并:初始有标签的应用 + 当前目录中有标签的应用 + 用户手动选中的应用
|
||||
// 然后减去用户手动取消选择的应用
|
||||
const allInitialSelected = [...new Set([...initialAppsWithTag, ...currentAppsWithTag])];
|
||||
|
||||
// 计算最终选中的应用:
|
||||
// 1. 从所有初始选中的应用开始
|
||||
// 2. 加上用户新选中的应用
|
||||
// 3. 减去用户取消选择的应用
|
||||
const finalSelected = new Set(allInitialSelected);
|
||||
|
||||
// 处理用户的选择变更
|
||||
selectedAppIds.forEach((appId) => {
|
||||
if (allInitialSelected.includes(appId)) {
|
||||
// 如果这个应用初始是选中的,现在在 selectedAppIds 中表示用户取消了选择
|
||||
finalSelected.delete(appId);
|
||||
} else {
|
||||
// 如果这个应用初始不是选中的,现在在 selectedAppIds 中表示用户新选择了它
|
||||
finalSelected.add(appId);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(finalSelected);
|
||||
}, [selectedTagForAddApps, initialAppsWithTag, allApps, selectedAppIds]);
|
||||
|
||||
// 批量更新应用标签
|
||||
const { runAsync: updateAppTags, loading: isUpdating } = useRequest2(
|
||||
async () => {
|
||||
if (!selectedTagForAddApps) return;
|
||||
|
||||
// 直接使用 getSelectedIds 获取最终应该拥有该标签的应用列表
|
||||
const finalSelectedIds = getSelectedIds();
|
||||
|
||||
// 使用新的批量添加应用到标签的 API 进行全量更新
|
||||
// 传入最终选中的所有应用 ID
|
||||
await batchAddAppsToTag(selectedTagForAddApps._id, finalSelectedIds);
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: '标签应用更新成功',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
onTagsUpdated?.();
|
||||
// 返回标签列表视图
|
||||
setViewMode('tagList');
|
||||
setSelectedTagForAddApps(null);
|
||||
setSelectedAppIds([]);
|
||||
setSearchKey('');
|
||||
setInitialAppsWithTag([]); // 清理初始应用列表
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('更新标签应用失败:', error);
|
||||
toast({
|
||||
title: '更新标签应用失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 开始添加应用到标签
|
||||
const startAddAppsToTag = (tag: TagWithCountType) => {
|
||||
setSelectedTagForAddApps(tag);
|
||||
setViewMode('appSelection');
|
||||
setSelectedAppIds([]);
|
||||
setSearchKey('');
|
||||
setInitialAppsWithTag([]); // 重置初始应用列表,将在 getAppList 中重新设置
|
||||
};
|
||||
|
||||
// 返回标签列表
|
||||
const backToTagList = () => {
|
||||
setViewMode('tagList');
|
||||
setSelectedTagForAddApps(null);
|
||||
setSelectedAppIds([]);
|
||||
setSearchKey('');
|
||||
setInitialAppsWithTag([]); // 清理初始应用列表
|
||||
};
|
||||
|
||||
const isLoading = loadingTags || createLoading || updateLoading || deleteLoading;
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
iconSrc="/imgs/modal/tag.svg"
|
||||
title={viewMode === 'tagList' ? '分类管理' : `为标签"${selectedTagForAddApps?.name}"添加应用`}
|
||||
w="580px"
|
||||
maxW="100%"
|
||||
isLoading={isLoading || isUpdating}
|
||||
>
|
||||
<ModalBody px={9} py={6}>
|
||||
<Container maxW="100%" p={0}>
|
||||
{viewMode === 'tagList' ? (
|
||||
<>
|
||||
{/* 标签列表视图 */}
|
||||
{/* 头部区域 */}
|
||||
<Flex direction="column" gap={4} w="100%" h="40px" mb={4}>
|
||||
<Flex justifyContent="space-between" alignItems="center" w="100%" h="32px">
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<MyIcon name="common/list" w="20px" h="20px" color="#111824" />
|
||||
<Box
|
||||
fontSize="16px"
|
||||
fontWeight="500"
|
||||
lineHeight="24px"
|
||||
letterSpacing="0.15px"
|
||||
color="#111824"
|
||||
>
|
||||
共 {tags.length} 个分类
|
||||
</Box>
|
||||
</Flex>
|
||||
<Button
|
||||
leftIcon={<MyIcon name="common/addLight" w="16px" h="16px" color="#485264" />}
|
||||
onClick={startCreateTag}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
bg="white"
|
||||
border="1px solid #DFE2EA"
|
||||
boxShadow="0px 1px 2px rgba(19, 51, 107, 0.05), 0px 0px 1px rgba(19, 51, 107, 0.08)"
|
||||
borderRadius="6px"
|
||||
h="32px"
|
||||
px="14px"
|
||||
fontSize="12px"
|
||||
fontWeight="500"
|
||||
lineHeight="16px"
|
||||
letterSpacing="0.5px"
|
||||
color="#485264"
|
||||
isDisabled={isCreating}
|
||||
_hover={{
|
||||
bg: 'gray.50'
|
||||
}}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
</Flex>
|
||||
<Divider borderColor="#E8EBF0" />
|
||||
</Flex>
|
||||
|
||||
{/* 标签列表区域 */}
|
||||
<Flex direction="column" gap={2} w="100%" maxH="304px" overflowY="auto">
|
||||
{/* 创建新标签表单 */}
|
||||
{isCreating && (
|
||||
<Flex
|
||||
alignItems="center"
|
||||
p="4px 8px"
|
||||
gap={2}
|
||||
w="100%"
|
||||
h="36px"
|
||||
borderRadius="4px"
|
||||
bg="transparent"
|
||||
>
|
||||
<Flex alignItems="center" gap={2} w="195px" h="28px">
|
||||
<Box
|
||||
position="relative"
|
||||
w="168px"
|
||||
h="28px"
|
||||
bg="white"
|
||||
border="1px solid #3370FF"
|
||||
boxShadow="0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)"
|
||||
borderRadius="4px"
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editingTag.name}
|
||||
onChange={(e) => setEditingTag({ ...editingTag, name: e.target.value })}
|
||||
placeholder="新建分类"
|
||||
maxLength={20}
|
||||
bg="transparent"
|
||||
border="none"
|
||||
h="100%"
|
||||
w="100%"
|
||||
px="8px"
|
||||
fontSize="12px"
|
||||
fontWeight="400"
|
||||
lineHeight="16px"
|
||||
letterSpacing="0.004em"
|
||||
color="#111824"
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
_placeholder={{ color: '#667085' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
fontSize="14px"
|
||||
fontWeight="400"
|
||||
lineHeight="20px"
|
||||
letterSpacing="0.25px"
|
||||
color="#667085"
|
||||
>
|
||||
(0)
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 标签列表 */}
|
||||
{(tags as TagWithCountType[]).map((tag, index) => (
|
||||
<React.Fragment key={tag._id}>
|
||||
{isEditing && editingTag._id === tag._id ? (
|
||||
// 编辑模式
|
||||
<Flex
|
||||
alignItems="center"
|
||||
p="4px 8px"
|
||||
gap={2}
|
||||
w="100%"
|
||||
h="36px"
|
||||
borderRadius="4px"
|
||||
bg="transparent"
|
||||
>
|
||||
<Flex alignItems="center" gap={2} w="195px" h="28px">
|
||||
<Box
|
||||
position="relative"
|
||||
w="168px"
|
||||
h="28px"
|
||||
bg="white"
|
||||
border="1px solid #3370FF"
|
||||
boxShadow="0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)"
|
||||
borderRadius="4px"
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editingTag.name}
|
||||
onChange={(e) =>
|
||||
setEditingTag({ ...editingTag, name: e.target.value })
|
||||
}
|
||||
maxLength={20}
|
||||
bg="transparent"
|
||||
border="none"
|
||||
h="100%"
|
||||
w="100%"
|
||||
px="8px"
|
||||
fontSize="12px"
|
||||
fontWeight="400"
|
||||
lineHeight="16px"
|
||||
letterSpacing="0.004em"
|
||||
color="#111824"
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
_placeholder={{ color: '#667085' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
fontSize="14px"
|
||||
fontWeight="400"
|
||||
lineHeight="20px"
|
||||
letterSpacing="0.25px"
|
||||
color="#667085"
|
||||
>
|
||||
({tag.count || 0})
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
// 普通显示模式
|
||||
<Flex
|
||||
alignItems="center"
|
||||
p="4px 8px"
|
||||
gap={2}
|
||||
w="100%"
|
||||
h="36px"
|
||||
borderRadius="4px"
|
||||
bg="transparent"
|
||||
_hover={{ bg: '#F9F9F9' }}
|
||||
>
|
||||
<Flex alignItems="center" gap={2} flex={1}>
|
||||
<Flex
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
p="10px 8px"
|
||||
h="28px"
|
||||
bg="#F4F4F5"
|
||||
borderRadius="6px"
|
||||
minW="fit-content"
|
||||
>
|
||||
<Box
|
||||
fontSize="12px"
|
||||
fontWeight="500"
|
||||
lineHeight="16px"
|
||||
color="#525252"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{tag.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box fontSize="14px" color="#667085">
|
||||
({tag.count || 0})
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<IconButton
|
||||
aria-label="添加"
|
||||
icon={
|
||||
<MyIcon name="common/addLight" w="16px" h="16px" color="#485264" />
|
||||
}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
w="24px"
|
||||
h="24px"
|
||||
borderRadius="6px"
|
||||
onClick={() => startAddAppsToTag(tag)}
|
||||
isDisabled={isCreating}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="编辑"
|
||||
icon={<MyIcon name="edit" w="16px" h="16px" color="#485264" />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
w="24px"
|
||||
h="24px"
|
||||
borderRadius="6px"
|
||||
onClick={() => startEditTag(tag)}
|
||||
isDisabled={isCreating}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="删除"
|
||||
icon={<MyIcon name="delete" w="16px" h="16px" color="#485264" />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
w="24px"
|
||||
h="24px"
|
||||
borderRadius="6px"
|
||||
onClick={() => handleDeleteTag(tag._id)}
|
||||
isDisabled={isCreating}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
{index < tags.length - 1 && <Divider borderColor="#E8EBF0" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{tags.length === 0 && !loadingTags && (
|
||||
<Flex
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
h="100px"
|
||||
color="gray.500"
|
||||
fontSize="14px"
|
||||
>
|
||||
暂无标签
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 应用选择视图 */}
|
||||
<Flex direction="column" h="500px" gap={4}>
|
||||
{/* 头部区域 */}
|
||||
<Flex direction="column" gap={4} w="100%" h="46px">
|
||||
<Flex justifyContent="space-between" alignItems="center" w="100%" h="38px">
|
||||
<Flex alignItems="center" gap={3}>
|
||||
<IconButton
|
||||
aria-label="返回"
|
||||
icon={
|
||||
<MyIcon name="common/leftArrowLight" w="18px" h="18px" color="#485264" />
|
||||
}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
w="32px"
|
||||
h="32px"
|
||||
borderRadius="6px"
|
||||
onClick={backToTagList}
|
||||
_hover={{
|
||||
bg: 'rgba(31, 35, 41, 0.08)'
|
||||
}}
|
||||
/>
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<Flex
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
px="8px"
|
||||
py="6px"
|
||||
h="28px"
|
||||
bg="#F4F4F5"
|
||||
borderRadius="6px"
|
||||
minW="fit-content"
|
||||
>
|
||||
<Box
|
||||
fontSize="12px"
|
||||
fontWeight="500"
|
||||
lineHeight="16px"
|
||||
color="#525252"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{selectedTagForAddApps?.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box
|
||||
fontSize="14px"
|
||||
fontWeight="400"
|
||||
lineHeight="20px"
|
||||
letterSpacing="0.25px"
|
||||
color="#667085"
|
||||
>
|
||||
({selectedTagForAddApps?.count || 0})
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<Box w="200px" h="32px">
|
||||
<SearchInput
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder="搜索"
|
||||
bg="#F7F8FA"
|
||||
border="1px solid #E8EBF0"
|
||||
borderRadius="6px"
|
||||
h="32px"
|
||||
fontSize="12px"
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
leftIcon={<MyIcon name="save" w="16px" h="16px" color="#FFFFFF" />}
|
||||
onClick={() => updateAppTags()}
|
||||
size="sm"
|
||||
bg="#3370FF"
|
||||
color="white"
|
||||
boxShadow="0px 1px 2px rgba(19, 51, 107, 0.05), 0px 0px 1px rgba(19, 51, 107, 0.08)"
|
||||
borderRadius="6px"
|
||||
h="32px"
|
||||
px="14px"
|
||||
fontSize="12px"
|
||||
fontWeight="500"
|
||||
lineHeight="16px"
|
||||
letterSpacing="0.5px"
|
||||
isLoading={isUpdating}
|
||||
_hover={{
|
||||
bg: '#2C5CE6'
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider borderColor="#E8EBF0" />
|
||||
</Flex>
|
||||
|
||||
{/* 应用选择区域 */}
|
||||
<Box flex={1} overflow="auto" w="100%" maxH="400px">
|
||||
<SelectMultipleResource
|
||||
selectedIds={getSelectedIds()}
|
||||
onSelect={handleAppSelect}
|
||||
server={getAppList}
|
||||
searchKey={searchKey}
|
||||
maxH="400px"
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter borderTopWidth="1px" py={4}>
|
||||
<Flex gap={3}>
|
||||
{(isCreating || isEditing) && viewMode === 'tagList' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={cancelEdit}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={isCreating ? handleCreateTag : handleUpdateTag}
|
||||
>
|
||||
{isCreating ? '创建' : '保存'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isCreating && !isEditing && viewMode === 'tagList' && (
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
)}
|
||||
{viewMode === 'appSelection' && <Button onClick={backToTagList}>关闭</Button>}
|
||||
</Flex>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagManageModal;
|
||||
328
projects/app/src/pageComponents/account/gateway/ToolSelect.tsx
Normal file
328
projects/app/src/pageComponents/account/gateway/ToolSelect.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { Box, Button, Flex, Grid, useDisclosure, Text } from '@chakra-ui/react';
|
||||
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { SmallAddIcon } from '@chakra-ui/icons';
|
||||
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import { theme } from '@fastgpt/web/styles/theme';
|
||||
import {
|
||||
FlowNodeInputTypeEnum,
|
||||
FlowNodeTypeEnum
|
||||
} from '@fastgpt/global/core/workflow/node/constant';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
|
||||
import { getWebLLMModel } from '@/web/common/system/utils';
|
||||
import ToolSelectModal, {
|
||||
childAppSystemKey
|
||||
} from '@/pageComponents/app/detail/Gate/components/ToolSelectModal';
|
||||
import ConfigToolModal from '@/pageComponents/app/detail/Gate/components/ConfigToolModal';
|
||||
|
||||
// 定义粉碎动画关键帧
|
||||
const shatterKeyframes = keyframes`
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.7) rotate(5deg) translateY(10px);
|
||||
filter: blur(2px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.2) rotate(-5deg) translateY(15px);
|
||||
filter: blur(4px);
|
||||
}
|
||||
`;
|
||||
|
||||
// 定义淡入动画关键帧
|
||||
const fadeInKeyframes = keyframes`
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
// 样式常量
|
||||
const spacing = {
|
||||
xs: 2
|
||||
};
|
||||
|
||||
const formStyles = {
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
letterSpacing: '0.1px'
|
||||
};
|
||||
|
||||
const ToolSelect = ({
|
||||
appForm,
|
||||
setAppForm
|
||||
}: {
|
||||
appForm: AppSimpleEditFormType;
|
||||
setAppForm: (newAppForm: AppSimpleEditFormType) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [configTool, setConfigTool] = useState<
|
||||
AppSimpleEditFormType['selectedTools'][number] | null
|
||||
>(null);
|
||||
|
||||
// 添加删除状态管理
|
||||
const [deletingToolIds, setDeletingToolIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
isOpen: isOpenToolsSelect,
|
||||
onOpen: onOpenToolsSelect,
|
||||
onClose: onCloseToolsSelect
|
||||
} = useDisclosure();
|
||||
const selectedModel = getWebLLMModel(appForm.aiSettings.model);
|
||||
|
||||
// 使用 useCallback 缓存删除函数
|
||||
const handleDeleteTool = useCallback(
|
||||
(toolId: string) => {
|
||||
// 先设置删除标记,触发动画
|
||||
setDeletingToolIds((prev) => new Set([...prev, toolId]));
|
||||
|
||||
// 设置延时,等待动画完成后再从数组中移除
|
||||
setTimeout(() => {
|
||||
const newAppForm = {
|
||||
...appForm,
|
||||
selectedTools: appForm.selectedTools.filter((tool) => tool.id !== toolId)
|
||||
};
|
||||
setAppForm(newAppForm);
|
||||
|
||||
// 清除删除标记
|
||||
setDeletingToolIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(toolId);
|
||||
return newSet;
|
||||
});
|
||||
}, 150); // 动画持续时间缩短到150ms
|
||||
},
|
||||
[appForm, setAppForm]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 标题区域 */}
|
||||
<Flex alignItems="center" justifyContent="space-between" width="100%">
|
||||
<Flex alignItems="center" gap={spacing.xs}>
|
||||
<Text
|
||||
ml={2}
|
||||
fontWeight={formStyles.fontWeight}
|
||||
fontSize={formStyles.fontSize}
|
||||
lineHeight={formStyles.lineHeight}
|
||||
letterSpacing={formStyles.letterSpacing}
|
||||
color="myGray.700"
|
||||
>
|
||||
{t('common:core.app.Tool call')}
|
||||
</Text>
|
||||
<QuestionTip ml={1} label={t('app:plugin_dispatch_tip')} />
|
||||
</Flex>
|
||||
|
||||
{/* 已有工具时显示新增按钮 */}
|
||||
{appForm.selectedTools.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="primary"
|
||||
variant="outline"
|
||||
leftIcon={<SmallAddIcon />}
|
||||
onClick={onOpenToolsSelect}
|
||||
_hover={{ bg: 'blue.50' }}
|
||||
>
|
||||
{t('common:Add')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* 工具容器 */}
|
||||
{appForm.selectedTools.length > 0 ? (
|
||||
<Box mt={2}>
|
||||
<Grid gridTemplateColumns={'repeat(3, minmax(0, 1fr))'} gridGap={[2, 4]}>
|
||||
{appForm.selectedTools.map((item) => {
|
||||
const isDeleting = deletingToolIds.has(item.id);
|
||||
|
||||
return (
|
||||
<MyTooltip key={item.id} label={item.intro}>
|
||||
<Flex
|
||||
overflow={'hidden'}
|
||||
display={'flex'}
|
||||
height={'40px'}
|
||||
padding={'8px 12px'}
|
||||
flexDirection={'row'}
|
||||
justifyContent={'flex-start'}
|
||||
alignItems={'center'}
|
||||
flex={'1 0 0'}
|
||||
borderRadius={'6px'}
|
||||
border={'0.5px solid var(--Gray-Modern-200, #E8EBF0)'}
|
||||
background={'#FFF'}
|
||||
boxShadow={
|
||||
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
|
||||
}
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
borderRadius: '6px',
|
||||
border: '0.5px solid var(--Gray-Modern-200, #E8EBF0)',
|
||||
background: '#FFF',
|
||||
boxShadow:
|
||||
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
|
||||
}}
|
||||
cursor={'pointer'}
|
||||
transition="all 0.2s ease"
|
||||
position="relative"
|
||||
role="group"
|
||||
animation={isDeleting ? `${shatterKeyframes} 0.15s ease forwards` : undefined}
|
||||
onClick={() => {
|
||||
if (
|
||||
item.inputs
|
||||
.filter((input) => !childAppSystemKey.includes(input.key))
|
||||
.every(
|
||||
(input) =>
|
||||
input.toolDescription ||
|
||||
input.renderTypeList.includes(FlowNodeInputTypeEnum.selectLLMModel) ||
|
||||
input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)
|
||||
) ||
|
||||
item.flowNodeType === FlowNodeTypeEnum.tool ||
|
||||
item.flowNodeType === FlowNodeTypeEnum.toolSet
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setConfigTool(item);
|
||||
}}
|
||||
>
|
||||
<Flex alignItems="center" width="100%">
|
||||
<Avatar src={item.avatar} borderRadius={'6px'} w={'20px'} h={'20px'} />
|
||||
<Box
|
||||
ml={'6px'}
|
||||
className={'textEllipsis'}
|
||||
fontSize={'sm'}
|
||||
fontWeight="medium"
|
||||
color={'myGray.900'}
|
||||
flex="1"
|
||||
>
|
||||
{item.name}
|
||||
</Box>
|
||||
<Flex
|
||||
className="delete"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
ml={2}
|
||||
w="22px"
|
||||
h="22px"
|
||||
borderRadius="sm"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
background: 'rgba(17, 24, 36, 0.05)',
|
||||
color: 'red.600'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteTool(item.id);
|
||||
}}
|
||||
opacity="0"
|
||||
_groupHover={{
|
||||
opacity: 1,
|
||||
animation: `${fadeInKeyframes} 0.2s ease`
|
||||
}}
|
||||
>
|
||||
<MyIcon
|
||||
className="delete"
|
||||
name={'delete' as any}
|
||||
w={'16px'}
|
||||
h={'16px'}
|
||||
color={'inherit'}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</MyTooltip>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
mt={2}
|
||||
display="flex"
|
||||
width="100%"
|
||||
height="80px"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
borderRadius="4px"
|
||||
border="1px dashed var(--Gray-Modern-250, #DFE2EA)"
|
||||
cursor="pointer"
|
||||
onClick={onOpenToolsSelect}
|
||||
_hover={{
|
||||
borderColor: 'primary.300',
|
||||
bg: 'gray.100',
|
||||
'.hoverContent': { color: 'primary.500' }
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
position="relative"
|
||||
>
|
||||
<Flex
|
||||
className="hoverContent"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
flexDirection="row"
|
||||
gap={'6px'}
|
||||
color="gray.500"
|
||||
>
|
||||
<SmallAddIcon boxSize={5} />
|
||||
<Box fontSize="sm" fontWeight="medium">
|
||||
{t('common:Choose')}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isOpenToolsSelect && (
|
||||
<ToolSelectModal
|
||||
selectedTools={appForm.selectedTools}
|
||||
chatConfig={appForm.chatConfig}
|
||||
selectedModel={selectedModel}
|
||||
onAddTool={(e) => {
|
||||
const newAppForm = {
|
||||
...appForm,
|
||||
selectedTools: [...appForm.selectedTools, e]
|
||||
};
|
||||
setAppForm(newAppForm);
|
||||
}}
|
||||
onRemoveTool={(e) => {
|
||||
const newAppForm = {
|
||||
...appForm,
|
||||
selectedTools: appForm.selectedTools.filter((item) => item.pluginId !== e.id)
|
||||
};
|
||||
setAppForm(newAppForm);
|
||||
}}
|
||||
onClose={onCloseToolsSelect}
|
||||
/>
|
||||
)}
|
||||
{configTool && (
|
||||
<ConfigToolModal
|
||||
configTool={configTool}
|
||||
onCloseConfigTool={() => setConfigTool(null)}
|
||||
onAddTool={(e) => {
|
||||
const newAppForm = {
|
||||
...appForm,
|
||||
selectedTools: appForm.selectedTools.map((item) =>
|
||||
item.pluginId === configTool.pluginId ? e : item
|
||||
)
|
||||
};
|
||||
setAppForm(newAppForm);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ToolSelect);
|
||||
@@ -0,0 +1,535 @@
|
||||
import React, { useCallback, useMemo, useState, useEffect } from 'react';
|
||||
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Button,
|
||||
css,
|
||||
Flex,
|
||||
Grid
|
||||
} from '@chakra-ui/react';
|
||||
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import type {
|
||||
NodeTemplateListItemType,
|
||||
NodeTemplateListType
|
||||
} from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import {
|
||||
getPluginGroups,
|
||||
getPreviewPluginNode,
|
||||
getSystemPlugTemplates,
|
||||
getSystemPluginPaths
|
||||
} from '@/web/core/app/api/plugin';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { getTeamPlugTemplates } from '@/web/core/app/api/plugin';
|
||||
import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
|
||||
import { getAppFolderPath } from '@/web/core/app/api/app';
|
||||
import FolderPath from '@/components/common/folder/Path';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import CostTooltip from '@/components/core/app/plugin/CostTooltip';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from '@/pageComponents/app/detail/context';
|
||||
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import MyAvatar from '@fastgpt/web/components/common/Avatar';
|
||||
import type { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
|
||||
type Props = {
|
||||
selectedPluginIds: string[];
|
||||
onSelectPlugins: (plugins: NodeTemplateListItemType[]) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
enum TemplateTypeEnum {
|
||||
'systemPlugin' = 'systemPlugin',
|
||||
'teamPlugin' = 'teamPlugin'
|
||||
}
|
||||
|
||||
const ToolSelectModal = ({ selectedPluginIds, onSelectPlugins, onCancel }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
|
||||
const [tempSelectedIds, setTempSelectedIds] = useState<string[]>([...selectedPluginIds]);
|
||||
const [templateType, setTemplateType] = useState(TemplateTypeEnum.systemPlugin);
|
||||
const [parentId, setParentId] = useState<ParentIdType>('');
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
|
||||
// 监听 ESC 键关闭弹窗
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
// 添加事件监听
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// 组件卸载时清除事件监听
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [onCancel]);
|
||||
|
||||
const {
|
||||
data: templates = [],
|
||||
runAsync: loadTemplates,
|
||||
loading: isLoading
|
||||
} = useRequest2(
|
||||
async ({
|
||||
type = templateType,
|
||||
parentId = '',
|
||||
searchVal = searchKey
|
||||
}: {
|
||||
type?: TemplateTypeEnum;
|
||||
parentId?: ParentIdType;
|
||||
searchVal?: string;
|
||||
}) => {
|
||||
if (type === TemplateTypeEnum.systemPlugin) {
|
||||
return getSystemPlugTemplates({ parentId, searchKey: searchVal });
|
||||
} else if (type === TemplateTypeEnum.teamPlugin) {
|
||||
return getTeamPlugTemplates({
|
||||
parentId,
|
||||
searchKey: searchVal
|
||||
}).then((res) => res.filter((app) => app.id !== appDetail._id));
|
||||
}
|
||||
},
|
||||
{
|
||||
onSuccess(_, [{ type = templateType, parentId = '' }]) {
|
||||
setTemplateType(type);
|
||||
setParentId(parentId);
|
||||
},
|
||||
refreshDeps: [templateType, searchKey, parentId],
|
||||
errorToast: t('common:core.module.templates.Load plugin error')
|
||||
}
|
||||
);
|
||||
|
||||
const { data: paths = [] } = useRequest2(
|
||||
() => {
|
||||
if (templateType === TemplateTypeEnum.teamPlugin)
|
||||
return getAppFolderPath({ sourceId: parentId, type: 'current' });
|
||||
return getSystemPluginPaths({ sourceId: parentId, type: 'current' });
|
||||
},
|
||||
{
|
||||
manual: false,
|
||||
refreshDeps: [parentId]
|
||||
}
|
||||
);
|
||||
|
||||
const onUpdateParentId = useCallback(
|
||||
(parentId: ParentIdType) => {
|
||||
loadTemplates({
|
||||
parentId
|
||||
});
|
||||
},
|
||||
[loadTemplates]
|
||||
);
|
||||
|
||||
useRequest2(() => loadTemplates({ searchVal: searchKey }), {
|
||||
manual: false,
|
||||
throttleWait: 300,
|
||||
refreshDeps: [searchKey]
|
||||
});
|
||||
|
||||
// 处理确认选择,获取完整的插件信息
|
||||
const handleConfirm = async () => {
|
||||
console.log('即将保存的插件选择:', tempSelectedIds);
|
||||
|
||||
try {
|
||||
// 从当前已加载的模板中直接获取信息,避免额外的 API 调用
|
||||
const selectedPlugins = templates
|
||||
.filter((template) => tempSelectedIds.includes(template.id))
|
||||
.map((template) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
avatar: template.avatar,
|
||||
intro: template.intro || '',
|
||||
isFolder: template.isFolder || false,
|
||||
flowNodeType: template.flowNodeType,
|
||||
templateType: template.templateType
|
||||
}));
|
||||
|
||||
// 对于不在当前模板中的插件(可能来自其他路径或之前选择),需要获取信息
|
||||
const missingIds = tempSelectedIds.filter(
|
||||
(id) => !selectedPlugins.some((plugin) => plugin.id === id)
|
||||
);
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
// 批量获取缺失的插件信息,而不是一个个调用 API
|
||||
const promises = missingIds.map((pluginId) =>
|
||||
getPreviewPluginNode({ appId: pluginId })
|
||||
.then((template) => ({
|
||||
id: pluginId,
|
||||
name: template.name,
|
||||
avatar: template.avatar,
|
||||
intro: template.intro || '',
|
||||
isFolder: false,
|
||||
flowNodeType: template.flowNodeType,
|
||||
templateType: template.templateType
|
||||
}))
|
||||
.catch((error) => {
|
||||
console.error('获取插件信息失败:', pluginId, error);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
// 明确指定类型,排除 null 值
|
||||
const additionalPlugins = (await Promise.all(promises)).filter(
|
||||
(
|
||||
item
|
||||
): item is {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string | undefined;
|
||||
intro: string;
|
||||
isFolder: boolean;
|
||||
flowNodeType: FlowNodeTypeEnum;
|
||||
templateType: string;
|
||||
} => Boolean(item)
|
||||
);
|
||||
|
||||
selectedPlugins.push(...additionalPlugins);
|
||||
}
|
||||
|
||||
console.log('处理后的插件列表:', selectedPlugins);
|
||||
onSelectPlugins(selectedPlugins);
|
||||
} catch (error) {
|
||||
console.error('处理插件选择时出错:', error);
|
||||
} finally {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
title={t('common:core.app.Tool call')}
|
||||
iconSrc="core/app/toolCall"
|
||||
onClose={onCancel}
|
||||
maxW={['90vw', '700px']}
|
||||
w={'700px'}
|
||||
h={['90vh', '80vh']}
|
||||
>
|
||||
{/* Header: row and search */}
|
||||
<Box px={[3, 6]} pt={4} display={'flex'} justifyContent={'space-between'} w={'full'}>
|
||||
<FillRowTabs
|
||||
list={[
|
||||
{
|
||||
icon: 'phoneTabbar/tool',
|
||||
label: t('common:navbar.Toolkit'),
|
||||
value: TemplateTypeEnum.systemPlugin
|
||||
},
|
||||
{
|
||||
icon: 'core/modules/teamPlugin',
|
||||
label: t('common:core.module.template.Team app'),
|
||||
value: TemplateTypeEnum.teamPlugin
|
||||
}
|
||||
]}
|
||||
py={'5px'}
|
||||
px={'15px'}
|
||||
value={templateType}
|
||||
onChange={(e) =>
|
||||
loadTemplates({
|
||||
type: e as TemplateTypeEnum,
|
||||
parentId: null
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Box w={300}>
|
||||
<SearchInput
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder={
|
||||
templateType === TemplateTypeEnum.systemPlugin
|
||||
? t('common:plugin.Search plugin')
|
||||
: t('app:search_app')
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* route components */}
|
||||
{!searchKey && parentId && (
|
||||
<Flex mt={2} px={[3, 6]}>
|
||||
<FolderPath paths={paths} FirstPathDom={null} onClick={onUpdateParentId} />
|
||||
</Flex>
|
||||
)}
|
||||
<MyBox isLoading={isLoading} mt={2} px={[3, 6]} pb={3} flex={'1 0 0'} overflowY={'auto'}>
|
||||
<RenderList
|
||||
templates={templates}
|
||||
type={templateType}
|
||||
setParentId={onUpdateParentId}
|
||||
selectedIds={tempSelectedIds}
|
||||
toggleSelection={(id) => {
|
||||
setTempSelectedIds((prev) => {
|
||||
return prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</MyBox>
|
||||
|
||||
{/* Footer buttons - 改用内部内容替代 footer 属性 */}
|
||||
<Flex
|
||||
px={[3, 6]}
|
||||
py={4}
|
||||
justify="flex-end"
|
||||
w="full"
|
||||
gap={3}
|
||||
borderTop="1px solid"
|
||||
borderColor="gray.100"
|
||||
>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{t('common:Cancel')}
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={handleConfirm}>
|
||||
{t('common:Confirm')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ToolSelectModal);
|
||||
|
||||
const RenderList = React.memo(function RenderList({
|
||||
templates,
|
||||
type,
|
||||
setParentId,
|
||||
selectedIds,
|
||||
toggleSelection
|
||||
}: {
|
||||
templates: NodeTemplateListItemType[];
|
||||
type: TemplateTypeEnum;
|
||||
setParentId: (parentId: ParentIdType) => any;
|
||||
selectedIds: string[];
|
||||
toggleSelection: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: pluginGroups = [] } = useRequest2(getPluginGroups, {
|
||||
manual: false
|
||||
});
|
||||
|
||||
const formatTemplatesArray = useMemo(() => {
|
||||
const data = (() => {
|
||||
if (type === TemplateTypeEnum.systemPlugin) {
|
||||
return pluginGroups.map((group) => {
|
||||
const copy: NodeTemplateListType = group.groupTypes.map((type) => ({
|
||||
list: [],
|
||||
type: type.typeId,
|
||||
label: type.typeName
|
||||
}));
|
||||
templates.forEach((item) => {
|
||||
const index = copy.findIndex((template) => template.type === item.templateType);
|
||||
if (index === -1) return;
|
||||
copy[index].list.push(item);
|
||||
});
|
||||
return {
|
||||
label: group.groupName,
|
||||
list: copy.filter((item) => item.list.length > 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
list: [
|
||||
{
|
||||
list: templates,
|
||||
type: '',
|
||||
label: ''
|
||||
}
|
||||
],
|
||||
label: ''
|
||||
}
|
||||
];
|
||||
})();
|
||||
|
||||
return data.filter(({ list }) => list.length > 0);
|
||||
}, [pluginGroups, templates, type]);
|
||||
|
||||
const gridStyle = useMemo(() => {
|
||||
if (type === TemplateTypeEnum.teamPlugin) {
|
||||
return {
|
||||
gridTemplateColumns: ['1fr', '1fr'],
|
||||
py: 2,
|
||||
avatarSize: '2rem'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
gridTemplateColumns: ['1fr', '1fr 1fr'],
|
||||
py: 3,
|
||||
avatarSize: '1.75rem'
|
||||
};
|
||||
}, [type]);
|
||||
|
||||
const PluginListRender = useMemoizedFn(({ list = [] }: { list: NodeTemplateListType }) => {
|
||||
return (
|
||||
<>
|
||||
{list.map((item, i) => {
|
||||
return (
|
||||
<Box
|
||||
key={item.type}
|
||||
css={css({
|
||||
span: {
|
||||
display: 'block'
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Flex>
|
||||
<Box fontSize={'sm'} my={2} fontWeight={'500'} flex={1} color={'myGray.900'}>
|
||||
{t(item.label as any)}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Grid gridTemplateColumns={gridStyle.gridTemplateColumns} rowGap={2} columnGap={3}>
|
||||
{item.list.map((template) => {
|
||||
const selected = selectedIds.includes(template.id);
|
||||
|
||||
// 判断是否是嵌套插件
|
||||
const isNestedPlugin = template.isFolder;
|
||||
|
||||
return (
|
||||
<MyTooltip
|
||||
key={template.id}
|
||||
placement={'right'}
|
||||
label={
|
||||
<Box py={2}>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyAvatar
|
||||
src={template.avatar}
|
||||
w={'1.75rem'}
|
||||
objectFit={'contain'}
|
||||
borderRadius={'sm'}
|
||||
/>
|
||||
<Box fontWeight={'bold'} ml={3} color={'myGray.900'}>
|
||||
{t(template.name as any)}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box mt={2} color={'myGray.500'} maxH={'100px'} overflow={'hidden'}>
|
||||
{t(template.intro as any) || t('common:core.workflow.Not intro')}
|
||||
</Box>
|
||||
{type === TemplateTypeEnum.systemPlugin && (
|
||||
<CostTooltip
|
||||
cost={template.currentCost}
|
||||
hasTokenFee={template.hasTokenFee}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
py={gridStyle.py}
|
||||
px={3}
|
||||
_hover={{ bg: 'myWhite.600' }}
|
||||
borderRadius={'sm'}
|
||||
whiteSpace={'nowrap'}
|
||||
overflow={'hidden'}
|
||||
textOverflow={'ellipsis'}
|
||||
>
|
||||
<MyAvatar
|
||||
src={template.avatar}
|
||||
w={gridStyle.avatarSize}
|
||||
objectFit={'contain'}
|
||||
borderRadius={'sm'}
|
||||
flexShrink={0}
|
||||
/>
|
||||
<Box
|
||||
color={'myGray.900'}
|
||||
fontWeight={'500'}
|
||||
fontSize={'sm'}
|
||||
flex={'1 0 0'}
|
||||
ml={3}
|
||||
className="textEllipsis"
|
||||
>
|
||||
{t(template.name as any)}
|
||||
</Box>
|
||||
|
||||
{selected ? (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'grayDanger'}
|
||||
leftIcon={<MyIcon name={'delete'} w={'16px'} mr={-1} />}
|
||||
onClick={() => toggleSelection(template.id)}
|
||||
px={2}
|
||||
fontSize={'mini'}
|
||||
>
|
||||
{t('common:Remove')}
|
||||
</Button>
|
||||
) : isNestedPlugin ? (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'whiteBase'}
|
||||
leftIcon={<MyIcon name={'common/arrowRight'} w={'16px'} mr={-1.5} />}
|
||||
onClick={() => setParentId(template.id)}
|
||||
px={2}
|
||||
fontSize={'mini'}
|
||||
>
|
||||
{t('common:Open')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'primaryOutline'}
|
||||
leftIcon={<MyIcon name={'common/addLight'} w={'16px'} mr={-1.5} />}
|
||||
onClick={() => toggleSelection(template.id)}
|
||||
px={2}
|
||||
fontSize={'mini'}
|
||||
>
|
||||
{t('common:Add')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</MyTooltip>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
return templates.length === 0 ? (
|
||||
<EmptyTip text={t('app:module.No Modules')} />
|
||||
) : (
|
||||
<Box flex={'1 0 0'} overflow={'overlay'}>
|
||||
<Accordion defaultIndex={[0]} allowMultiple reduceMotion>
|
||||
{formatTemplatesArray.length > 1 ? (
|
||||
<>
|
||||
{formatTemplatesArray.map(({ list, label }, index) => (
|
||||
<AccordionItem key={index} border={'none'}>
|
||||
<AccordionButton
|
||||
fontSize={'sm'}
|
||||
fontWeight={'500'}
|
||||
color={'myGray.900'}
|
||||
justifyContent={'space-between'}
|
||||
alignItems={'center'}
|
||||
borderRadius={'md'}
|
||||
px={3}
|
||||
>
|
||||
{t(label as any)}
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel py={0}>
|
||||
<PluginListRender list={list} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<PluginListRender list={formatTemplatesArray?.[0]?.list} />
|
||||
)}
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
295
projects/app/src/pageComponents/account/gateway/logs.tsx
Normal file
295
projects/app/src/pageComponents/account/gateway/logs.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Flex,
|
||||
Box,
|
||||
TableContainer,
|
||||
Table,
|
||||
Thead,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Tbody,
|
||||
HStack,
|
||||
Button
|
||||
} from '@chakra-ui/react';
|
||||
import UserBox from '@fastgpt/web/components/common/UserBox';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { getAppChatLogs } from '@/web/core/app/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { ChatSourceEnum, ChatSourceMap } from '@fastgpt/global/core/chat/constants';
|
||||
import { addDays } from 'date-fns';
|
||||
import { usePagination } from '@fastgpt/web/hooks/usePagination';
|
||||
import DateRangePicker, {
|
||||
type DateRangeType
|
||||
} from '@fastgpt/web/components/common/DateRangePicker';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import { cardStyles } from '@/pageComponents/app/detail/constants';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import MultipleSelect, {
|
||||
useMultipleSelect
|
||||
} from '@fastgpt/web/components/common/MySelect/MultipleSelect';
|
||||
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
|
||||
import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { downloadFetch } from '@/web/common/system/utils';
|
||||
|
||||
const DetailLogsModal = dynamic(() => import('@/pageComponents/app/detail/Logs/DetailLogsModal'));
|
||||
|
||||
// 修改:将组件改为接收gateAppId作为属性
|
||||
type LogsProps = {
|
||||
gateAppId: string;
|
||||
};
|
||||
|
||||
const Logs = ({ gateAppId }: LogsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [dateRange, setDateRange] = useState<DateRangeType>({
|
||||
from: addDays(new Date(), -7),
|
||||
to: new Date()
|
||||
});
|
||||
console.log('gateAppId', gateAppId);
|
||||
|
||||
const [detailLogsId, setDetailLogsId] = useState<string>();
|
||||
const [logTitle, setLogTitle] = useState<string>();
|
||||
|
||||
// 不再需要获取gateAppId的useEffect
|
||||
|
||||
const {
|
||||
value: chatSources,
|
||||
setValue: setChatSources,
|
||||
isSelectAll: isSelectAllSource,
|
||||
setIsSelectAll: setIsSelectAllSource
|
||||
} = useMultipleSelect<ChatSourceEnum>(Object.values(ChatSourceEnum), true);
|
||||
|
||||
const sourceList = useMemo(
|
||||
() =>
|
||||
Object.entries(ChatSourceMap).map(([key, value]) => ({
|
||||
label: t(value.name as any),
|
||||
value: key as ChatSourceEnum
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const {
|
||||
data: logs,
|
||||
isLoading,
|
||||
Pagination,
|
||||
getData,
|
||||
pageNum,
|
||||
total
|
||||
} = usePagination(getAppChatLogs, {
|
||||
pageSize: 20,
|
||||
params: {
|
||||
appId: gateAppId, // 现在gateAppId始终是字符串类型
|
||||
dateStart: dateRange.from || new Date(),
|
||||
dateEnd: addDays(dateRange.to || new Date(), 1),
|
||||
sources: isSelectAllSource ? undefined : chatSources,
|
||||
logTitle
|
||||
},
|
||||
refreshDeps: [gateAppId, chatSources, logTitle]
|
||||
});
|
||||
|
||||
const { runAsync: exportLogs } = useRequest2(
|
||||
async () => {
|
||||
if (!gateAppId) return; // 即使gateAppId是空字符串,此检查仍然有效
|
||||
|
||||
await downloadFetch({
|
||||
url: '/api/core/app/exportChatLogs',
|
||||
filename: 'chat_logs.csv',
|
||||
body: {
|
||||
// 修复:使用gateAppId替代未定义的appId
|
||||
appId: gateAppId,
|
||||
dateStart: dateRange.from || new Date(),
|
||||
dateEnd: addDays(dateRange.to || new Date(), 1),
|
||||
sources: isSelectAllSource ? undefined : chatSources,
|
||||
logTitle,
|
||||
|
||||
title: t('app:logs_export_title'),
|
||||
sourcesMap: Object.fromEntries(
|
||||
Object.entries(ChatSourceMap).map(([key, config]) => [
|
||||
key,
|
||||
{
|
||||
label: t(config.name as any)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
refreshDeps: [gateAppId, chatSources, logTitle]
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex flexDirection={'column'} h={'100%'} flex={'1 0 0'}>
|
||||
<Flex flexDir={['column', 'row']} alignItems={['flex-start', 'center']} gap={3}>
|
||||
<Flex alignItems={'center'} gap={2}>
|
||||
<Box fontSize={'mini'} fontWeight={'medium'} color={'myGray.900'}>
|
||||
{t('app:logs_source')}
|
||||
</Box>
|
||||
<Box>
|
||||
<MultipleSelect<ChatSourceEnum>
|
||||
list={sourceList}
|
||||
value={chatSources}
|
||||
onSelect={setChatSources}
|
||||
isSelectAll={isSelectAllSource}
|
||||
setIsSelectAll={setIsSelectAllSource}
|
||||
itemWrap={false}
|
||||
height={'32px'}
|
||||
bg={'myGray.50'}
|
||||
w={'160px'}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} gap={2}>
|
||||
<Box fontSize={'mini'} fontWeight={'medium'} color={'myGray.900'}>
|
||||
{t('common:user.Time')}
|
||||
</Box>
|
||||
<DateRangePicker
|
||||
defaultDate={dateRange}
|
||||
position="bottom"
|
||||
onChange={setDateRange}
|
||||
onSuccess={() => getData(1)}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} gap={2}>
|
||||
<Box fontSize={'mini'} fontWeight={'medium'} color={'myGray.900'} whiteSpace={'nowrap'}>
|
||||
{t('app:logs_title')}
|
||||
</Box>
|
||||
<SearchInput
|
||||
placeholder={t('app:logs_title')}
|
||||
w={'240px'}
|
||||
value={logTitle}
|
||||
onChange={(e) => setLogTitle(e.target.value)}
|
||||
/>
|
||||
</Flex>
|
||||
<Box flex={'1'} />
|
||||
<PopoverConfirm
|
||||
Trigger={<Button size={'md'}>{t('common:Export')}</Button>}
|
||||
showCancel
|
||||
content={t('app:logs_export_confirm_tip', { total })}
|
||||
onConfirm={exportLogs}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<TableContainer mt={[2, 4]} flex={'1 0 0'} h={0} overflowY={'auto'}>
|
||||
<Table variant={'simple'} fontSize={'sm'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('common:core.app.logs.Source And Time')}</Th>
|
||||
<Th>{t('app:logs_chat_user')}</Th>
|
||||
<Th>{t('app:logs_title')}</Th>
|
||||
<Th>{t('app:logs_message_total')}</Th>
|
||||
<Th>{t('app:feedback_count')}</Th>
|
||||
<Th>{t('common:core.app.feedback.Custom feedback')}</Th>
|
||||
<Th>
|
||||
<Flex gap={1} alignItems={'center'}>
|
||||
{t('app:mark_count')}
|
||||
<QuestionTip label={t('common:core.chat.Mark Description')} />
|
||||
</Flex>
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody fontSize={'xs'}>
|
||||
{logs.map((item) => (
|
||||
<Tr
|
||||
key={item._id}
|
||||
_hover={{ bg: 'myWhite.600' }}
|
||||
cursor={'pointer'}
|
||||
title={t('common:core.view_chat_detail')}
|
||||
onClick={() => setDetailLogsId(item.id)}
|
||||
>
|
||||
<Td>
|
||||
{/* @ts-ignore */}
|
||||
<Box>{item.sourceName || t(ChatSourceMap[item.source]?.name) || item.source}</Box>
|
||||
<Box color={'myGray.500'}>{dayjs(item.time).format('YYYY/MM/DD HH:mm')}</Box>
|
||||
</Td>
|
||||
<Td>
|
||||
<Box>
|
||||
{!!item.outLinkUid ? (
|
||||
item.outLinkUid
|
||||
) : (
|
||||
<UserBox sourceMember={item.sourceMember} />
|
||||
)}
|
||||
</Box>
|
||||
</Td>
|
||||
<Td className="textEllipsis" maxW={'250px'}>
|
||||
{item.customTitle || item.title}
|
||||
</Td>
|
||||
<Td>{item.messageCount}</Td>
|
||||
<Td w={'100px'}>
|
||||
{!!item?.userGoodFeedbackCount && (
|
||||
<Flex
|
||||
mb={item?.userGoodFeedbackCount ? 1 : 0}
|
||||
bg={'green.100'}
|
||||
color={'green.600'}
|
||||
px={3}
|
||||
py={1}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
borderRadius={'md'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
<MyIcon
|
||||
mr={1}
|
||||
name={'core/chat/feedback/goodLight'}
|
||||
color={'green.600'}
|
||||
w={'14px'}
|
||||
/>
|
||||
{item.userGoodFeedbackCount}
|
||||
</Flex>
|
||||
)}
|
||||
{!!item?.userBadFeedbackCount && (
|
||||
<Flex
|
||||
bg={'#FFF2EC'}
|
||||
color={'#C96330'}
|
||||
px={3}
|
||||
py={1}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
borderRadius={'md'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
<MyIcon
|
||||
mr={1}
|
||||
name={'core/chat/feedback/badLight'}
|
||||
color={'#C96330'}
|
||||
w={'14px'}
|
||||
/>
|
||||
{item.userBadFeedbackCount}
|
||||
</Flex>
|
||||
)}
|
||||
{!item?.userGoodFeedbackCount && !item?.userBadFeedbackCount && <>-</>}
|
||||
</Td>
|
||||
<Td>{item.customFeedbacksCount || '-'}</Td>
|
||||
<Td>{item.markCount}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{logs.length === 0 && !isLoading && <EmptyTip text={t('app:logs_empty')}></EmptyTip>}
|
||||
</TableContainer>
|
||||
|
||||
<HStack w={'100%'} mt={3} justifyContent={'center'}>
|
||||
<Pagination />
|
||||
</HStack>
|
||||
|
||||
{!!detailLogsId && (
|
||||
<DetailLogsModal
|
||||
appId={gateAppId} // 现在已经是字符串类型
|
||||
chatId={detailLogsId}
|
||||
onClose={() => {
|
||||
setDetailLogsId(undefined);
|
||||
getData(pageNum);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Logs);
|
||||
Reference in New Issue
Block a user