* 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:
Theresa
2025-05-30 10:37:48 +08:00
committed by archer
parent 165b783a95
commit 3b0f0a8108
134 changed files with 15066 additions and 46 deletions

View File

@@ -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
}
]
: []),

View File

@@ -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);

View File

@@ -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);

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View 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'
};

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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);

View File

@@ -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>
);
});

View 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);