Parcourir la source

feat: 添加画布右键菜单、图片上传组件缩略图展示

Co-authored-by: Copilot <copilot@github.com>
jiaxing.liao il y a 1 semaine
Parent
commit
1cc9d646eb

+ 2 - 0
apps/web/components.d.ts

@@ -35,6 +35,7 @@ declare module 'vue' {
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
+    ElImage: typeof import('element-plus/es')['ElImage']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
     ElInputTag: typeof import('element-plus/es')['ElInputTag']
@@ -102,6 +103,7 @@ declare global {
   const ElForm: typeof import('element-plus/es')['ElForm']
   const ElFormItem: typeof import('element-plus/es')['ElFormItem']
   const ElIcon: typeof import('element-plus/es')['ElIcon']
+  const ElImage: typeof import('element-plus/es')['ElImage']
   const ElInput: typeof import('element-plus/es')['ElInput']
   const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
   const ElInputTag: typeof import('element-plus/es')['ElInputTag']

+ 166 - 7
apps/web/src/features/fileUpload/FileUploadInput.vue

@@ -39,7 +39,17 @@
 			>
 				<div class="file-item__left">
 					<div class="file-badge" :class="`file-badge--${getFileVisualType(file)}`">
-						{{ getFileBadgeText(file) }}
+						<el-image
+							v-if="getImagePreviewSrc(file)"
+							class="file-badge__image"
+							:src="getImagePreviewSrc(file)"
+							:preview-src-list="[getImagePreviewSrc(file)]"
+							:initial-index="0"
+							fit="cover"
+							preview-teleported
+							hide-on-click-modal
+						/>
+						<span v-else>{{ getFileBadgeText(file) }}</span>
 					</div>
 					<div class="file-meta">
 						<div class="file-name">{{ file.name }}</div>
@@ -61,7 +71,17 @@
 			<div v-for="file in normalizedFiles" :key="file.id" class="file-item">
 				<div class="file-item__left">
 					<div class="file-badge" :class="`file-badge--${getFileVisualType(file)}`">
-						{{ getFileBadgeText(file) }}
+						<el-image
+							v-if="getImagePreviewSrc(file)"
+							class="file-badge__image"
+							:src="getImagePreviewSrc(file)"
+							:preview-src-list="[getImagePreviewSrc(file)]"
+							:initial-index="0"
+							fit="cover"
+							preview-teleported
+							hide-on-click-modal
+						/>
+						<span v-else>{{ getFileBadgeText(file) }}</span>
 					</div>
 					<div class="file-meta">
 						<div class="file-name">{{ file.name }}</div>
@@ -113,11 +133,11 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue'
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
 import { ElMessage, type UploadProgressEvent, type UploadRequestOptions } from 'element-plus'
 import { UploadFilled } from '@element-plus/icons-vue'
 import { Icon, IconButton } from '@repo/ui'
-import { UploadFile as uploadWorkflowFile } from '@/api'
+import { GetImage, UploadFile as uploadWorkflowFile } from '@/api'
 import {
 	formatFileSize,
 	getAllowedExtensions,
@@ -142,10 +162,12 @@ interface Emits {
 }
 
 type UploadRequestError = Parameters<UploadRequestOptions['onError']>[0]
-type UploadListFile = Pick<WorkflowUploadFile, 'id' | 'name' | 'extensionName' | 'size'>
+type UploadListFile = Pick<WorkflowUploadFile, 'id' | 'name' | 'extensionName' | 'size'> &
+	Partial<Pick<WorkflowUploadFile, 'path'>>
 
 interface PendingUploadFile extends UploadListFile {
 	progress: number
+	previewUrl?: string
 }
 
 const props = withDefaults(defineProps<Props>(), {
@@ -164,6 +186,9 @@ const uploadingCount = ref(0)
 const localFiles = ref<WorkflowUploadFile[]>([])
 const pendingUploads = ref<PendingUploadFile[]>([])
 const activeUploadKeys = ref<string[]>([])
+const imagePreviewUrls = ref<Record<string, string>>({})
+const loadingImageKeys = new Set<string>()
+const objectImageKeys = new Set<string>()
 
 const isUploading = computed(() => uploadingCount.value > 0)
 
@@ -267,6 +292,98 @@ const getFileTypeText = (file: Partial<WorkflowUploadFile>) => {
 
 const isLinkPath = (path?: string) => /^https?:\/\//i.test(`${path || ''}`.trim())
 
+const isImageFile = (file: Partial<WorkflowUploadFile>) => getFileVisualType(file) === 'image'
+
+const getImagePreviewSrc = (file: Partial<WorkflowUploadFile>) => {
+	if (!isImageFile(file)) {
+		return ''
+	}
+
+	if ('progress' in file) {
+		return (file as PendingUploadFile).previewUrl || ''
+	}
+
+	return imagePreviewUrls.value[file.id || ''] || ''
+}
+
+const revokeImagePreviewUrl = (key: string) => {
+	const url = imagePreviewUrls.value[key]
+	if (url && objectImageKeys.has(key)) {
+		URL.revokeObjectURL(url)
+		objectImageKeys.delete(key)
+	}
+
+	const { [key]: _removed, ...nextUrls } = imagePreviewUrls.value
+	imagePreviewUrls.value = nextUrls
+	loadingImageKeys.delete(key)
+}
+
+const setImagePreviewUrl = (key: string, url: string, isObjectUrl = false) => {
+	const currentUrl = imagePreviewUrls.value[key]
+	if (currentUrl && currentUrl !== url && objectImageKeys.has(key)) {
+		URL.revokeObjectURL(currentUrl)
+		objectImageKeys.delete(key)
+	}
+
+	imagePreviewUrls.value = {
+		...imagePreviewUrls.value,
+		[key]: url
+	}
+
+	if (isObjectUrl) {
+		objectImageKeys.add(key)
+	}
+}
+
+const ensureImagePreviewUrl = async (file: WorkflowUploadFile) => {
+	if (!isImageFile(file) || !file.id || !file.path || imagePreviewUrls.value[file.id]) {
+		return
+	}
+
+	if (isLinkPath(file.path)) {
+		setImagePreviewUrl(file.id, file.path)
+		return
+	}
+
+	if (loadingImageKeys.has(file.id)) {
+		return
+	}
+
+	loadingImageKeys.add(file.id)
+	try {
+		const blob = (await GetImage({ fileId: file.path })) as Blob | undefined
+		if (!blob || !normalizedFiles.value.some((item) => item.id === file.id)) {
+			return
+		}
+
+		setImagePreviewUrl(file.id, URL.createObjectURL(blob), true)
+	} catch (error) {
+		console.error('get image preview error', error)
+	} finally {
+		loadingImageKeys.delete(file.id)
+	}
+}
+
+watch(
+	normalizedFiles,
+	(files) => {
+		const fileIds = new Set(files.map((file) => file.id))
+		Object.keys(imagePreviewUrls.value).forEach((key) => {
+			if (!fileIds.has(key)) {
+				revokeImagePreviewUrl(key)
+			}
+		})
+
+		files.forEach((file) => {
+			void ensureImagePreviewUrl(file)
+		})
+	},
+	{
+		immediate: true,
+		deep: true
+	}
+)
+
 const getWorkflowFileDuplicateKey = (file: Partial<WorkflowUploadFile>) => {
 	const path = `${file.path || ''}`.trim()
 	const source = isLinkPath(path) ? 'link' : 'local'
@@ -298,14 +415,22 @@ const hasDuplicateFile = (duplicateKey: string) =>
 
 const createPendingUpload = (file: File): PendingUploadFile => {
 	const extension = getFileExtension(file.name)
-
-	return {
+	const pendingFile = {
 		id: createFileId(),
 		name: file.name,
 		extensionName: extension.replace(/^\./, '').toUpperCase(),
 		size: file.size,
 		progress: 0
 	}
+
+	if (!isImageFile(pendingFile)) {
+		return pendingFile
+	}
+
+	return {
+		...pendingFile,
+		previewUrl: URL.createObjectURL(file)
+	}
 }
 
 const normalizeProgress = (value: number) => {
@@ -327,6 +452,11 @@ const updatePendingUploadProgress = (id: string, progress: number) => {
 }
 
 const removePendingUpload = (id: string) => {
+	const target = pendingUploads.value.find((item) => item.id === id)
+	if (target?.previewUrl) {
+		URL.revokeObjectURL(target.previewUrl)
+	}
+
 	pendingUploads.value = pendingUploads.value.filter((item) => item.id !== id)
 }
 
@@ -453,8 +583,19 @@ const handleConfirmLink = () => {
 }
 
 const removeFile = (id: string) => {
+	revokeImagePreviewUrl(id)
 	updateFiles(normalizedFiles.value.filter((item) => item.id !== id))
 }
+
+onBeforeUnmount(() => {
+	pendingUploads.value.forEach((file) => {
+		if (file.previewUrl) {
+			URL.revokeObjectURL(file.previewUrl)
+		}
+	})
+
+	Object.keys(imagePreviewUrls.value).forEach(revokeImagePreviewUrl)
+})
 </script>
 
 <style scoped lang="less">
@@ -552,11 +693,29 @@ const removeFile = (id: string) => {
 	width: 42px;
 	height: 42px;
 	border-radius: 10px;
+	overflow: hidden;
 	font-size: 12px;
 	font-weight: 700;
 	flex-shrink: 0;
 }
 
+.file-badge__image {
+	width: 100%;
+	height: 100%;
+	cursor: zoom-in;
+}
+
+.file-badge__image :deep(.el-image__inner) {
+	width: 100%;
+	height: 100%;
+}
+
+:global(.el-image-viewer__img) {
+	max-width: min(92vw, 960px);
+	max-height: min(88vh, 720px);
+	object-fit: contain;
+}
+
 .file-badge--document {
 	background: #eaf7ee;
 	color: #0f9d58;

+ 258 - 45
apps/web/src/views/editor/NodeView.vue

@@ -1,5 +1,10 @@
 <template>
-	<div class="h-full w-full" ref="workflowWrapperRef" @drop="onDrop">
+	<div
+		class="h-full w-full"
+		ref="workflowWrapperRef"
+		@drop="onDrop"
+		@contextmenu="handleWorkflowContextMenu"
+	>
 		<Workflow
 			ref="workflowRef"
 			:id="workflow?.id"
@@ -61,17 +66,44 @@
 	>
 		<div ref="nodeLibraryPanelRef">
 			<NodeLibary
-				@add-node="handleNodeCreate"
+				@add-node="handleNodeCreateFromLibrary"
 				:parent-node-type="nodeLibaryParentType"
 				hide-start
 				ignore-drag
 			/>
 		</div>
 	</el-popover>
+	<Teleport to="body">
+		<div
+			v-if="contextMenuVisible"
+			ref="contextMenuRef"
+			class="workflow-context-menu"
+			:style="contextMenuStyle"
+			@contextmenu.prevent
+		>
+			<button type="button" class="workflow-context-menu__item" @click="handleContextAddNode">
+				<Icon icon="lucide:plus" class="workflow-context-menu__icon">+</Icon>
+				<span>添加节点</span>
+			</button>
+			<button type="button" class="workflow-context-menu__item" @click="handleContextAddStickyNote">
+				<Icon icon="lucide:file-plus-corner" class="workflow-context-menu__icon"></Icon>
+				<span>添加注释</span>
+			</button>
+			<button
+				type="button"
+				class="workflow-context-menu__item"
+				:class="{ 'is-disabled': !selectedNodeId }"
+				@click="handleContextRunNode"
+			>
+				<Icon icon="lucide:play" class="workflow-context-menu__icon"></Icon>
+				<span>测试运行</span>
+			</button>
+		</div>
+	</Teleport>
 </template>
 
 <script setup lang="ts">
-import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { agent } from '@repo/api-service'
 
@@ -86,6 +118,7 @@ import { useRunnerStore } from '@/store/modules/runner.store'
 import { Workflow, useDragAndDrop } from '@repo/workflow'
 import { useDebounceFn } from '@vueuse/core'
 import { isEqual } from 'lodash-es'
+import { Icon } from '@repo/ui'
 
 import type {
 	CanvasExecutionStatus,
@@ -133,6 +166,10 @@ const showNodeLibary = ref(false)
 const libaryRefferenceRef = ref<HTMLElement>()
 const nodeLibaryPopoverAnchorRef = ref<HTMLElement>()
 const nodeLibraryPanelRef = ref<HTMLElement>()
+const contextMenuRef = ref<HTMLElement>()
+const contextMenuVisible = ref(false)
+const contextMenuPosition = ref({ x: 0, y: 0 })
+const contextMenuFlowPosition = ref<XYPosition>({ x: 0, y: 0 })
 const runVisible = ref(false)
 const closeRunWorkflowOnSubmit = ref(false)
 const runWorkflowInputOnly = ref(false)
@@ -154,11 +191,22 @@ const peddingHandlePayload = ref<{
 
 const runningStatusStartedAt = new Map<string, number>()
 const pendingNodeStatusTimers = new Map<string, number>()
+let hideNodeLibaryTimer: number | undefined
 const pendingEdges = ref<
 	Array<Connection & { id: string; type?: string; data?: Record<string, unknown> }>
 >([])
 const pendingNodes = ref<IWorkflowNode[]>([])
 
+const selectedNodeId = computed(() => {
+	const selectedNode = props.workflow?.nodes?.find((node) => node.selected)
+	return selectedNode?.id || ''
+})
+
+const contextMenuStyle = computed(() => ({
+	left: `${contextMenuPosition.value.x}px`,
+	top: `${contextMenuPosition.value.y}px`
+}))
+
 const removeNodeLibaryPopoverAnchor = () => {
 	nodeLibaryPopoverAnchorRef.value?.remove()
 	nodeLibaryPopoverAnchorRef.value = undefined
@@ -184,6 +232,31 @@ const createNodeLibaryPopoverAnchor = (position?: { x: number; y: number }) => {
 	return anchor
 }
 
+const closeContextMenu = () => {
+	contextMenuVisible.value = false
+}
+
+const getFlowPositionFromMouseEvent = (event: MouseEvent): XYPosition => {
+	const screenToFlowCoordinate = workflowRef.value?.getVueFlow()?.screenToFlowCoordinate
+	if (screenToFlowCoordinate) {
+		return screenToFlowCoordinate({
+			x: event.clientX,
+			y: event.clientY
+		})
+	}
+
+	const viewport = workflowRef.value?.getVueFlow()?.viewport?.value
+	const bounds = workflowWrapperRef.value?.getBoundingClientRect()
+	if (viewport && bounds) {
+		return {
+			x: (event.clientX - bounds.left - viewport.x) / viewport.zoom,
+			y: (event.clientY - bounds.top - viewport.y) / viewport.zoom
+		}
+	}
+
+	return { x: 0, y: 0 }
+}
+
 // 在为节点安排新的状态切换前,先清理旧的延时任务。
 const clearPendingNodeStatusTimer = (nodeId: string) => {
 	const timer = pendingNodeStatusTimers.get(nodeId)
@@ -538,6 +611,31 @@ const handleRunAgent = (id?: string) => {
 	handleRunNode(targetNode.id)
 }
 
+const createStickyNoteNode = (position: XYPosition = { x: 600, y: 300 }) => {
+	props.workflow?.nodes.push({
+		appAgentId: props.workflow.id,
+		type: 'canvas-node',
+		zIndex: -1,
+		nodeType: 'stickyNote',
+		position,
+		id: `stickyNote_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
+		name: t('pages.nodeView.stickyNote.name'),
+		remark: '',
+		data: {
+			id: '',
+			version: ['1.0.0'],
+			inputs: [],
+			outputs: [],
+			position,
+			nodeType: 'stickyNote',
+			content: t('pages.nodeView.stickyNote.content'),
+			width: 400,
+			height: 200,
+			color: '#fff5d6'
+		}
+	})
+}
+
 watch(
 	() => props.workflow?.nodes,
 	async (nodes) => {
@@ -559,32 +657,16 @@ watch(
 const handleNodeCreate = (value: { type: string; position?: XYPosition } | string) => {
 	if (typeof value === 'string') {
 		if (value === 'stickyNote') {
-			props.workflow?.nodes.push({
-				appAgentId: props.workflow.id,
-				type: 'canvas-node',
-				zIndex: -1,
-				nodeType: 'stickyNote',
-				position: { x: 600, y: 300 },
-				id: 'stickyNote',
-				name: t('pages.nodeView.stickyNote.name'),
-				remark: '',
-				data: {
-					id: '',
-					version: ['1.0.0'],
-					inputs: [],
-					outputs: [],
-					position: { x: 600, y: 300 },
-					nodeType: 'stickyNote',
-					content: t('pages.nodeView.stickyNote.content'),
-					width: 400,
-					height: 200,
-					color: '#fff5d6'
-				}
-			})
+			createStickyNoteNode()
 		}
 		return
 	}
 
+	if (value.type === 'stickyNote') {
+		createStickyNoteNode(value.position)
+		return
+	}
+
 	const nodeToAdd = nodeMap[value.type]?.schema
 	const viewport = workflowRef.value?.getVueFlow()?.viewport
 	const parentNodeType = getNodeTypeById(peddingHandlePayload.value?.parentId)
@@ -716,6 +798,77 @@ const handleNodeCreate = (value: { type: string; position?: XYPosition } | strin
 	}
 }
 
+const handleNodeCreateFromLibrary = (value: { type: string; position?: XYPosition } | string) => {
+	if (typeof value === 'string') {
+		handleNodeCreate(value)
+		return
+	}
+
+	handleNodeCreate({
+		...value,
+		position: value.position || contextMenuFlowPosition.value
+	})
+}
+
+const openNodeLibraryAtContextMenu = async () => {
+	await nextTick()
+	if (hideNodeLibaryTimer) {
+		window.clearTimeout(hideNodeLibaryTimer)
+		hideNodeLibaryTimer = undefined
+	}
+	libaryRefferenceRef.value = createNodeLibaryPopoverAnchor(contextMenuPosition.value)
+	peddingHandlePayload.value = {
+		position: contextMenuFlowPosition.value
+	}
+	showNodeLibary.value = true
+}
+
+const handleContextAddNode = () => {
+	closeContextMenu()
+	void openNodeLibraryAtContextMenu()
+}
+
+const handleContextAddStickyNote = () => {
+	closeContextMenu()
+	handleNodeCreate({
+		type: 'stickyNote',
+		position: contextMenuFlowPosition.value
+	})
+}
+
+const handleContextRunNode = () => {
+	if (!selectedNodeId.value) {
+		ElMessage.warning(t('pages.nodeView.messages.selectNodeFirst'))
+		closeContextMenu()
+		return
+	}
+
+	closeContextMenu()
+	void handleRunNode(selectedNodeId.value)
+}
+
+const handleWorkflowContextMenu = (event: MouseEvent) => {
+	const target = event.target as HTMLElement | null
+	if (target?.closest('.workflow-context-menu')) {
+		return
+	}
+
+	const wrapperEl = workflowWrapperRef.value
+	if (!wrapperEl?.contains(target)) {
+		return
+	}
+
+	event.preventDefault()
+	onHideNodeLibary()
+
+	contextMenuFlowPosition.value = getFlowPositionFromMouseEvent(event)
+	contextMenuPosition.value = {
+		x: event.clientX,
+		y: event.clientY
+	}
+	contextMenuVisible.value = true
+}
+
 // 双击节点时打开对应的配置面板。
 const handleNodeClick = (id: string, _position: XYPosition) => {
 	openSetter(id, 'setting')
@@ -1005,14 +1158,26 @@ const onConnectionOpenNodeLibary = async (payload: {
 const onHideNodeLibary = () => {
 	showNodeLibary.value = false
 
-	setTimeout(() => {
+	if (hideNodeLibaryTimer) {
+		window.clearTimeout(hideNodeLibaryTimer)
+	}
+
+	hideNodeLibaryTimer = window.setTimeout(() => {
 		peddingHandlePayload.value = undefined
 		libaryRefferenceRef.value = undefined
 		removeNodeLibaryPopoverAnchor()
+		hideNodeLibaryTimer = undefined
 	}, 500)
 }
 
 const onGlobalPointerDown = (event: PointerEvent) => {
+	if (contextMenuVisible.value) {
+		const target = event.target as Node | null
+		if (!target || !contextMenuRef.value?.contains(target)) {
+			closeContextMenu()
+		}
+	}
+
 	if (!showNodeLibary.value) {
 		return
 	}
@@ -1037,15 +1202,18 @@ const onGlobalPointerDown = (event: PointerEvent) => {
 }
 
 const onGlobalKeyDown = (event: KeyboardEvent) => {
-	if (!showNodeLibary.value) {
-		return
-	}
-
 	if (event.key === 'Escape') {
-		onHideNodeLibary()
+		closeContextMenu()
+		if (showNodeLibary.value) {
+			onHideNodeLibary()
+		}
 	}
 }
 
+const onGlobalScroll = () => {
+	closeContextMenu()
+}
+
 // 根据边上加号按钮的位置打开节点库弹层。
 const handleClickConectionAdd = (connection: Connection & { id: string }, parentId?: string) => {
 	const el = document.querySelector(`[edge-add-btn="${connection.id}"]`) as HTMLElement
@@ -1087,24 +1255,69 @@ const handleViewportChange = useDebounceFn((viewport: { x: number; y: number; zo
 }, 1000)
 
 onBeforeUnmount(() => {
+	if (hideNodeLibaryTimer) {
+		window.clearTimeout(hideNodeLibaryTimer)
+	}
 	removeNodeLibaryPopoverAnchor()
 	resetDisplayedNodeStatuses()
 	document.removeEventListener('pointerdown', onGlobalPointerDown, true)
 	document.removeEventListener('keydown', onGlobalKeyDown)
+	window.removeEventListener('scroll', onGlobalScroll, true)
 })
 
-watch(
-	showNodeLibary,
-	(visible) => {
-		if (visible) {
-			document.addEventListener('pointerdown', onGlobalPointerDown, true)
-			document.addEventListener('keydown', onGlobalKeyDown)
-			return
-		}
-
-		document.removeEventListener('pointerdown', onGlobalPointerDown, true)
-		document.removeEventListener('keydown', onGlobalKeyDown)
-	},
-	{ immediate: true }
-)
+onMounted(() => {
+	document.addEventListener('pointerdown', onGlobalPointerDown, true)
+	document.addEventListener('keydown', onGlobalKeyDown)
+	window.addEventListener('scroll', onGlobalScroll, true)
+})
 </script>
+
+<style scoped lang="less">
+.workflow-context-menu {
+	position: fixed;
+	z-index: 3000;
+	min-width: 148px;
+	padding: 6px;
+	border: 1px solid #eaecf0;
+	border-radius: 8px;
+	background: #fff;
+	box-shadow: 0 12px 28px rgba(16, 24, 40, 0.14);
+}
+
+.workflow-context-menu__item {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	width: 100%;
+	height: 34px;
+	padding: 0 10px;
+	border: 0;
+	border-radius: 6px;
+	background: transparent;
+	color: #344054;
+	font-size: 13px;
+	line-height: 1;
+	text-align: left;
+	cursor: pointer;
+}
+
+.workflow-context-menu__item:hover:not(.is-disabled) {
+	background: #f2f4f7;
+	color: #296dff;
+}
+
+.workflow-context-menu__item.is-disabled {
+	color: #98a2b3;
+	cursor: not-allowed;
+}
+
+.workflow-context-menu__icon {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	width: 16px;
+	height: 16px;
+	font-size: 12px;
+	font-weight: 700;
+}
+</style>