feat(web): snippet info operations

This commit is contained in:
JzoNg
2026-03-25 21:29:06 +08:00
parent d418dd8eec
commit a5cff32743
9 changed files with 290 additions and 41 deletions

View File

@ -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 (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[180px] p-1"
>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="!my-1 bg-divider-subtle" />
<DropdownMenuItem
className="mx-0 gap-2"
destructive
onClick={() => {
setOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="grow">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
selectedNodeIds={[]}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={t('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleEditSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-[400px]">
<div className="space-y-2 p-6">
<AlertDialogTitle className="text-text-primary title-lg-semi-bold">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="text-text-tertiary system-sm-regular">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default React.memo(SnippetInfoDropdown)

View File

@ -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 (
<div className={cn('flex flex-col', expand ? '' : 'p-1')}>
<div className="flex flex-col gap-2 p-2">
<div className="flex items-start gap-3">
<div className={cn(!expand && 'ml-1')}>
<div className={cn('flex flex-col', expand ? 'px-2 pb-1 pt-2' : 'p-1')}>
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
<div className={cn('shrink-0', !expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
@ -27,21 +30,20 @@ const SnippetInfo = ({
background={snippet.iconBackground}
/>
</div>
{expand && (
<div className="min-w-0 flex-1">
<div className="truncate text-text-secondary system-md-semibold">
{snippet.name}
</div>
{snippet.status && (
<div className="pt-1">
<Badge>{snippet.status}</Badge>
</div>
)}
</div>
)}
{expand && <SnippetInfoDropdown snippet={snippet} />}
</div>
{expand && (
<div className="min-w-0">
<div className="truncate text-text-secondary system-md-semibold">
{snippet.name}
</div>
<div className="pt-1 text-text-tertiary system-2xs-medium-uppercase">
{t('typeLabel')}
</div>
</div>
)}
{expand && snippet.description && (
<p className="line-clamp-3 text-text-tertiary system-xs-regular">
<p className="line-clamp-3 break-words text-text-tertiary system-xs-regular">
{snippet.description}
</p>
)}

View File

@ -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<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
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
}

View File

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

View File

@ -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<CreateSnippetDialogProps> = ({
onClose,
onConfirm,
isSubmitting = false,
title,
confirmText,
initialValue,
}) => {
const { t } = useTranslation()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [icon, setIcon] = useState<AppIconSelection>(defaultIcon)
const [name, setName] = useState(initialValue?.name ?? '')
const [description, setDescription] = useState(initialValue?.description ?? '')
const [icon, setIcon] = useState<AppIconSelection>(initialValue?.icon ?? defaultIcon)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const resetForm = useCallback(() => {
@ -94,7 +106,7 @@ const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
<div className="px-6 pb-3 pt-6">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('snippet.createDialogTitle', { ns: 'workflow' })}
{title || t('snippet.createDialogTitle', { ns: 'workflow' })}
</DialogTitle>
</div>
@ -148,7 +160,7 @@ const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
loading={isSubmitting}
onClick={handleConfirm}
>
{t('snippet.confirm', { ns: 'workflow' })}
{confirmText || t('snippet.confirm', { ns: 'workflow' })}
<ShortcutsName className="ml-1" keys={['ctrl', 'enter']} bgColor="white" />
</Button>
</div>

View File

@ -439,14 +439,16 @@ const SelectionContextmenu = () => {
))}
</ContextMenuContent>
</ContextMenu>
<CreateSnippetDialog
isOpen={isCreateSnippetDialogOpen}
selectedNodeIds={selectedNodeIdsSnapshot}
onClose={handleCloseCreateSnippetDialog}
onConfirm={(payload) => {
void payload
}}
/>
{isCreateSnippetDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateSnippetDialogOpen}
selectedNodeIds={selectedNodeIdsSnapshot}
onClose={handleCloseCreateSnippetDialog}
onConfirm={(payload) => {
void payload
}}
/>
)}
</div>
)
}

View File

@ -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"
}

View File

@ -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": "变量查看"
}

View File

@ -263,6 +263,17 @@ export const useDeleteSnippetMutation = () => {
})
}
export const useExportSnippetMutation = () => {
return useMutation<string, Error, { snippetId: string, include?: boolean }>({
mutationFn: ({ snippetId, include = false }) => {
return consoleClient.snippets.export({
params: { snippetId },
query: { include_secret: include ? 'true' : 'false' },
})
},
})
}
export const usePublishSnippetMutation = () => {
const queryClient = useQueryClient()