From a5cff3274316fa1bf4a0370624564760bb7fe6f9 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Wed, 25 Mar 2026 21:29:06 +0800 Subject: [PATCH] feat(web): snippet info operations --- .../app-sidebar/snippet-info/dropdown.tsx | 198 ++++++++++++++++++ .../app-sidebar/snippet-info/index.tsx | 38 ++-- .../snippets/components/snippet-main.tsx | 15 +- .../snippets/hooks/use-snippet-init.ts | 3 +- .../workflow/create-snippet-dialog.tsx | 22 +- .../workflow/selection-contextmenu.tsx | 18 +- web/i18n/en-US/snippet.json | 13 ++ web/i18n/zh-Hans/snippet.json | 13 ++ web/service/use-snippets.ts | 11 + 9 files changed, 290 insertions(+), 41 deletions(-) create mode 100644 web/app/components/app-sidebar/snippet-info/dropdown.tsx diff --git a/web/app/components/app-sidebar/snippet-info/dropdown.tsx b/web/app/components/app-sidebar/snippet-info/dropdown.tsx new file mode 100644 index 0000000000..52ab64d817 --- /dev/null +++ b/web/app/components/app-sidebar/snippet-info/dropdown.tsx @@ -0,0 +1,198 @@ +'use client' + +import type { AppIconSelection } from '@/app/components/base/app-icon-picker' +import type { SnippetDetail } from '@/models/snippet' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@/app/components/base/ui/alert-dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' +import { toast } from '@/app/components/base/ui/toast' +import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog' +import { useRouter } from '@/next/navigation' +import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets' +import { cn } from '@/utils/classnames' +import { downloadBlob } from '@/utils/download' + +type SnippetInfoDropdownProps = { + snippet: SnippetDetail +} + +const FALLBACK_ICON: AppIconSelection = { + type: 'emoji', + icon: '🤖', + background: '#FFEAD5', +} + +const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { + const { t } = useTranslation('snippet') + const { replace } = useRouter() + const [open, setOpen] = React.useState(false) + const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false) + const updateSnippetMutation = useUpdateSnippetMutation() + const exportSnippetMutation = useExportSnippetMutation() + const deleteSnippetMutation = useDeleteSnippetMutation() + + const initialValue = React.useMemo(() => ({ + name: snippet.name, + description: snippet.description, + icon: snippet.icon + ? { + type: 'emoji' as const, + icon: snippet.icon, + background: snippet.iconBackground || FALLBACK_ICON.background, + } + : FALLBACK_ICON, + }), [snippet.description, snippet.icon, snippet.iconBackground, snippet.name]) + + const handleOpenEditDialog = React.useCallback(() => { + setOpen(false) + setIsEditDialogOpen(true) + }, []) + + const handleExportSnippet = React.useCallback(async () => { + setOpen(false) + try { + const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id }) + const file = new Blob([data], { type: 'application/yaml' }) + downloadBlob({ data: file, fileName: `${snippet.name}.yml` }) + } + catch { + toast.error(t('exportFailed')) + } + }, [exportSnippetMutation, snippet.id, snippet.name, t]) + + const handleEditSnippet = React.useCallback(async ({ name, description, icon }: { + name: string + description: string + icon: AppIconSelection + }) => { + updateSnippetMutation.mutate({ + params: { snippetId: snippet.id }, + body: { + name, + description: description || undefined, + icon_info: { + icon: icon.type === 'emoji' ? icon.icon : icon.fileId, + icon_type: icon.type, + icon_background: icon.type === 'emoji' ? icon.background : undefined, + icon_url: icon.type === 'image' ? icon.url : undefined, + }, + }, + }, { + onSuccess: () => { + toast.success(t('editDone')) + setIsEditDialogOpen(false) + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : t('editFailed')) + }, + }) + }, [snippet.id, t, updateSnippetMutation]) + + const handleDeleteSnippet = React.useCallback(() => { + deleteSnippetMutation.mutate({ + params: { snippetId: snippet.id }, + }, { + onSuccess: () => { + toast.success(t('deleted')) + setIsDeleteDialogOpen(false) + replace('/snippets') + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : t('deleteFailed')) + }, + }) + }, [deleteSnippetMutation, replace, snippet.id, t]) + + return ( + <> + + + + + + + + {t('menu.editInfo')} + + + + {t('menu.exportSnippet')} + + + { + setOpen(false) + setIsDeleteDialogOpen(true) + }} + > + + {t('menu.deleteSnippet')} + + + + + {isEditDialogOpen && ( + setIsEditDialogOpen(false)} + onConfirm={handleEditSnippet} + /> + )} + + + +
+ + {t('deleteConfirmTitle')} + + + {t('deleteConfirmContent')} + +
+ + + {t('operation.cancel', { ns: 'common' })} + + + {t('menu.deleteSnippet')} + + +
+
+ + ) +} + +export default React.memo(SnippetInfoDropdown) diff --git a/web/app/components/app-sidebar/snippet-info/index.tsx b/web/app/components/app-sidebar/snippet-info/index.tsx index f0d0c59957..e47f14fe9e 100644 --- a/web/app/components/app-sidebar/snippet-info/index.tsx +++ b/web/app/components/app-sidebar/snippet-info/index.tsx @@ -2,9 +2,10 @@ import type { SnippetDetail } from '@/models/snippet' import * as React from 'react' +import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' -import Badge from '@/app/components/base/badge' import { cn } from '@/utils/classnames' +import SnippetInfoDropdown from './dropdown' type SnippetInfoProps = { expand: boolean @@ -15,11 +16,13 @@ const SnippetInfo = ({ expand, snippet, }: SnippetInfoProps) => { + const { t } = useTranslation('snippet') + return ( -
-
-
-
+
+
+
+
- {expand && ( -
-
- {snippet.name} -
- {snippet.status && ( -
- {snippet.status} -
- )} -
- )} + {expand && }
+ {expand && ( +
+
+ {snippet.name} +
+
+ {t('typeLabel')} +
+
+ )} {expand && snippet.description && ( -

+

{snippet.description}

)} diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index d1f936a0c5..b2ffdf71ea 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -6,8 +6,8 @@ import type { SnippetDetailPayload, SnippetInputField, SnippetSection } from '@/ import { RiFlaskFill, RiFlaskLine, - RiGitBranchFill, - RiGitBranchLine, + RiTerminalWindowFill, + RiTerminalWindowLine, } from '@remixicon/react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -16,7 +16,7 @@ import AppSideBar from '@/app/components/app-sidebar' import NavLink from '@/app/components/app-sidebar/nav-link' import SnippetInfo from '@/app/components/app-sidebar/snippet-info' import { useStore as useAppStore } from '@/app/components/app/store' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import Evaluation from '@/app/components/evaluation' import { WorkflowWithInnerContext } from '@/app/components/workflow' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -30,8 +30,8 @@ type SnippetMainProps = { } & Pick const ORCHESTRATE_ICONS: { normal: NavIcon, selected: NavIcon } = { - normal: RiGitBranchLine, - selected: RiGitBranchFill, + normal: RiTerminalWindowLine, + selected: RiTerminalWindowFill, } const EVALUATION_ICONS: { normal: NavIcon, selected: NavIcon } = { @@ -107,10 +107,7 @@ const SnippetMain = ({ const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable) if (duplicated) { - Toast.notify({ - type: 'error', - message: t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }), - }) + toast.error(t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' })) return } diff --git a/web/app/components/snippets/hooks/use-snippet-init.ts b/web/app/components/snippets/hooks/use-snippet-init.ts index a5978603cd..1468c65beb 100644 --- a/web/app/components/snippets/hooks/use-snippet-init.ts +++ b/web/app/components/snippets/hooks/use-snippet-init.ts @@ -1,4 +1,5 @@ -import { useSnippetDetail } from '@/service/use-snippets' +// import { useSnippetDetail } from '@/service/use-snippets' +import { useSnippetDetail } from '@/service/use-snippets.mock' export const useSnippetInit = (snippetId: string) => { return useSnippetDetail(snippetId) diff --git a/web/app/components/workflow/create-snippet-dialog.tsx b/web/app/components/workflow/create-snippet-dialog.tsx index 8c72602668..2da326bc27 100644 --- a/web/app/components/workflow/create-snippet-dialog.tsx +++ b/web/app/components/workflow/create-snippet-dialog.tsx @@ -20,12 +20,21 @@ export type CreateSnippetDialogPayload = { selectedNodeIds: string[] } +export type CreateSnippetDialogInitialValue = { + name?: string + description?: string + icon?: AppIconSelection +} + type CreateSnippetDialogProps = { isOpen: boolean selectedNodeIds: string[] onClose: () => void onConfirm: (payload: CreateSnippetDialogPayload) => void isSubmitting?: boolean + title?: string + confirmText?: string + initialValue?: CreateSnippetDialogInitialValue } const defaultIcon: AppIconSelection = { @@ -40,11 +49,14 @@ const CreateSnippetDialog: FC = ({ onClose, onConfirm, isSubmitting = false, + title, + confirmText, + initialValue, }) => { const { t } = useTranslation() - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [icon, setIcon] = useState(defaultIcon) + const [name, setName] = useState(initialValue?.name ?? '') + const [description, setDescription] = useState(initialValue?.description ?? '') + const [icon, setIcon] = useState(initialValue?.icon ?? defaultIcon) const [showAppIconPicker, setShowAppIconPicker] = useState(false) const resetForm = useCallback(() => { @@ -94,7 +106,7 @@ const CreateSnippetDialog: FC = ({
- {t('snippet.createDialogTitle', { ns: 'workflow' })} + {title || t('snippet.createDialogTitle', { ns: 'workflow' })}
@@ -148,7 +160,7 @@ const CreateSnippetDialog: FC = ({ loading={isSubmitting} onClick={handleConfirm} > - {t('snippet.confirm', { ns: 'workflow' })} + {confirmText || t('snippet.confirm', { ns: 'workflow' })}
diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 4b8935c526..434f4bc4a5 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -439,14 +439,16 @@ const SelectionContextmenu = () => { ))} - { - void payload - }} - /> + {isCreateSnippetDialogOpen && ( + { + void payload + }} + /> + )}
) } diff --git a/web/i18n/en-US/snippet.json b/web/i18n/en-US/snippet.json index 8ce20472eb..3bc86cfe43 100644 --- a/web/i18n/en-US/snippet.json +++ b/web/i18n/en-US/snippet.json @@ -3,9 +3,20 @@ "createFailed": "Failed to create snippet", "createFromBlank": "Create from blank", "defaultName": "Untitled Snippet", + "deleteConfirmContent": "Deleting this snippet is irreversible. Its draft workflow and published content will no longer be available.", + "deleteConfirmTitle": "Delete Snippet?", + "deleteFailed": "Failed to delete snippet", + "deleted": "Snippet deleted", + "editDialogTitle": "Edit Snippet Info", + "editDone": "Snippet info updated", + "editFailed": "Failed to update snippet info", + "exportFailed": "Export snippet failed.", "importFailed": "Failed to import snippet DSL", "importSuccess": "Snippet imported", "inputFieldButton": "Input Field", + "menu.deleteSnippet": "Delete", + "menu.editInfo": "Edit Info", + "menu.exportSnippet": "Export Snippet", "notFoundDescription": "The requested snippet mock was not found.", "notFoundTitle": "Snippet not found", "panelDescription": "Defines the input fields that allow the snippet to receive data from other nodes.", @@ -14,9 +25,11 @@ "panelTitle": "Input Field", "publishButton": "Publish", "publishMenuCurrentDraft": "Current draft unpublished", + "save": "Save", "sectionEvaluation": "Evaluation", "sectionOrchestrate": "Orchestrate", "testRunButton": "Test run", + "typeLabel": "Snippet", "usageCount": "Used {{count}} times", "variableInspect": "Variable Inspect" } diff --git a/web/i18n/zh-Hans/snippet.json b/web/i18n/zh-Hans/snippet.json index eeb38faf44..bb4514d622 100644 --- a/web/i18n/zh-Hans/snippet.json +++ b/web/i18n/zh-Hans/snippet.json @@ -3,9 +3,20 @@ "createFailed": "创建 Snippet 失败", "createFromBlank": "创建空白 Snippet", "defaultName": "未命名 Snippet", + "deleteConfirmContent": "删除后不可恢复,草稿工作流和已发布内容都将无法继续使用。", + "deleteConfirmTitle": "删除 Snippet?", + "deleteFailed": "删除 Snippet 失败", + "deleted": "Snippet 已删除", + "editDialogTitle": "编辑 Snippet 信息", + "editDone": "Snippet 信息已更新", + "editFailed": "更新 Snippet 信息失败", + "exportFailed": "导出 Snippet 失败。", "importFailed": "导入 Snippet DSL 失败", "importSuccess": "Snippet 导入成功", "inputFieldButton": "输入字段", + "menu.deleteSnippet": "删除", + "menu.editInfo": "编辑信息", + "menu.exportSnippet": "导出 Snippet", "notFoundDescription": "未找到对应的 snippet 静态数据。", "notFoundTitle": "未找到 Snippet", "panelDescription": "定义允许 snippet 从其他节点接收数据的输入字段。", @@ -14,9 +25,11 @@ "panelTitle": "输入字段", "publishButton": "发布", "publishMenuCurrentDraft": "当前草稿未发布", + "save": "保存", "sectionEvaluation": "评测", "sectionOrchestrate": "编排", "testRunButton": "测试运行", + "typeLabel": "Snippet", "usageCount": "已使用 {{count}} 次", "variableInspect": "变量查看" } diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts index f8fc8537ef..076510a5c9 100644 --- a/web/service/use-snippets.ts +++ b/web/service/use-snippets.ts @@ -263,6 +263,17 @@ export const useDeleteSnippetMutation = () => { }) } +export const useExportSnippetMutation = () => { + return useMutation({ + mutationFn: ({ snippetId, include = false }) => { + return consoleClient.snippets.export({ + params: { snippetId }, + query: { include_secret: include ? 'true' : 'false' }, + }) + }, + }) +} + export const usePublishSnippetMutation = () => { const queryClient = useQueryClient()