Selaa lähdekoodia

feat: 添加用户输入设置器

jiaxing.liao 1 viikko sitten
vanhempi
commit
1ec3eb8291

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

@@ -18,6 +18,7 @@ declare module 'vue' {
     ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElCard: typeof import('element-plus/es')['ElCard']
+    ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
     ElCol: typeof import('element-plus/es')['ElCol']
     ElCollapse: typeof import('element-plus/es')['ElCollapse']
     ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
@@ -57,6 +58,7 @@ declare module 'vue' {
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
@@ -80,6 +82,7 @@ declare global {
   const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
   const ElButton: typeof import('element-plus/es')['ElButton']
   const ElCard: typeof import('element-plus/es')['ElCard']
+  const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
   const ElCol: typeof import('element-plus/es')['ElCol']
   const ElCollapse: typeof import('element-plus/es')['ElCollapse']
   const ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
@@ -119,6 +122,7 @@ declare global {
   const ElTabs: typeof import('element-plus/es')['ElTabs']
   const ElTag: typeof import('element-plus/es')['ElTag']
   const ElTooltip: typeof import('element-plus/es')['ElTooltip']
+  const ElUpload: typeof import('element-plus/es')['ElUpload']
   const ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
   const RouterLink: typeof import('vue-router')['RouterLink']
   const RouterView: typeof import('vue-router')['RouterView']

+ 2 - 0
apps/web/package.json

@@ -24,6 +24,7 @@
     "pinia": "^3.0.4",
     "uuid": "^13.0.0",
     "vue": "^3.5.24",
+    "vue-draggable-plus": "^0.6.1",
     "vue-element-plus-x": "^1.3.98",
     "vue-hooks-plus": "^2.4.1",
     "vue-router": "4"
@@ -32,6 +33,7 @@
     "@repo/api-service": "workspace:*",
     "@repo/ui": "workspace:*",
     "@repo/workflow": "workspace:*",
+    "@repo/api-client": "workspace:*",
     "@types/lodash-es": "^4.17.12",
     "@types/nprogress": "^0.2.3",
     "@vitejs/plugin-vue": "^6.0.1",

+ 33 - 0
apps/web/src/api/index.ts

@@ -0,0 +1,33 @@
+import request from '@repo/api-client'
+/**
+ * 上传文件
+ * @param file
+ * @returns
+ */
+export const UploadFile = (data: FormData) => {
+	return request<{
+		code: number
+		result: {
+			id: string
+		}[]
+	}>('/api/fileApi/File/UploadFiles', {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'multipart/form-data'
+		},
+		data
+	})
+}
+
+/**
+ * 获取文件
+ * @param fileId 文件id
+ * @returns
+ */
+export const GetFile = (data: { fileId: string }) => {
+	return request('/api/File/GetImage', {
+		method: 'GET',
+		params: data,
+		responseType: 'blob'
+	})
+}

+ 509 - 0
apps/web/src/features/fileUpload/FileUploadInput.vue

@@ -0,0 +1,509 @@
+<template>
+	<div class="file-upload-input">
+		<div class="action-row" :class="{ 'action-row--single': !allowLinkInput }">
+			<el-upload
+				class="upload-trigger"
+				action="#"
+				drag
+				:show-file-list="false"
+				:multiple="multiple"
+				:accept="acceptAttr"
+				:disabled="isUploading"
+				:http-request="handleUploadRequest"
+				:before-upload="beforeUpload"
+			>
+				<el-icon class="el-icon--upload"><upload-filled /></el-icon>
+				<div class="el-upload__text">拖拽到此 或 <em>点击上传</em></div>
+				<template #tip>
+					<div class="el-upload__tip">{{ tip }}</div>
+				</template>
+			</el-upload>
+
+			<button
+				v-if="allowLinkInput"
+				type="button"
+				class="action-button"
+				:disabled="isUploading"
+				@click="linkDialogVisible = true"
+			>
+				<Icon icon="lucide:link-2" :size="18" />
+				<span>粘贴文件链接</span>
+			</button>
+		</div>
+
+		<div v-if="normalizedFiles.length" class="file-list">
+			<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) }}
+					</div>
+					<div class="file-meta">
+						<div class="file-name">{{ file.name }}</div>
+						<div class="file-info">
+							<span>{{ getFileTypeText(file) }}</span>
+							<span v-if="formatFileSize(file.size)">· {{ formatFileSize(file.size) }}</span>
+						</div>
+					</div>
+				</div>
+				<IconButton
+					link
+					icon="lucide:trash-2"
+					class="file-item__delete"
+					@click="removeFile(file.id)"
+				/>
+			</div>
+		</div>
+
+		<div v-else class="empty-tip">
+			{{ allowLinkInput ? '暂无文件,支持本地上传或粘贴文件链接。' : '暂无文件,支持本地上传。' }}
+		</div>
+	</div>
+
+	<el-dialog
+		v-if="allowLinkInput"
+		v-model="linkDialogVisible"
+		title="粘贴文件链接"
+		width="480px"
+		append-to-body
+		:close-on-click-modal="false"
+	>
+		<el-form label-position="top">
+			<el-form-item label="文件链接">
+				<el-input
+					v-model="linkValue"
+					placeholder="请输入可访问的文件链接,例如 https://example.com/demo.pdf"
+					clearable
+				/>
+			</el-form-item>
+		</el-form>
+
+		<template #footer>
+			<div class="dialog-footer">
+				<el-button @click="handleCloseLinkDialog">取消</el-button>
+				<el-button type="primary" @click="handleConfirmLink">确认</el-button>
+			</div>
+		</template>
+	</el-dialog>
+</template>
+
+<script setup lang="ts">
+import { computed, 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 {
+	formatFileSize,
+	getAllowedExtensions,
+	getFileExtension,
+	getFileVisualType,
+	normalizeFileExtension,
+	type UploadFileType,
+	type WorkflowUploadFile
+} from './shared'
+
+interface Props {
+	modelValue?: WorkflowUploadFile | WorkflowUploadFile[] | null
+	multiple?: boolean
+	fileTypes?: UploadFileType[]
+	fileExtensions?: string[]
+	allowLinkInput?: boolean
+	tip?: string
+}
+
+interface Emits {
+	(e: 'update:modelValue', value: WorkflowUploadFile | WorkflowUploadFile[] | null): void
+}
+
+type UploadRequestError = Parameters<UploadRequestOptions['onError']>[0]
+
+const props = withDefaults(defineProps<Props>(), {
+	modelValue: null,
+	multiple: false,
+	fileTypes: () => [],
+	fileExtensions: () => [],
+	allowLinkInput: true
+})
+
+const emit = defineEmits<Emits>()
+
+const linkDialogVisible = ref(false)
+const linkValue = ref('')
+const uploadingCount = ref(0)
+const localFiles = ref<WorkflowUploadFile[]>([])
+
+const isUploading = computed(() => uploadingCount.value > 0)
+
+const createFileId = () =>
+	`file_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
+
+const isRecord = (value: unknown): value is Record<string, unknown> =>
+	typeof value === 'object' && value !== null
+
+// Persisted link values may only contain a path, so derive a readable name when possible.
+const getNameFromPath = (path?: string) => {
+	const value = `${path || ''}`.trim()
+	if (!value) return ''
+
+	try {
+		const url = new URL(value)
+		const name = url.pathname.split('/').filter(Boolean).pop() || ''
+		return decodeURIComponent(name)
+	} catch {
+		return decodeURIComponent(value.split('/').filter(Boolean).pop() || '')
+	}
+}
+
+const normalizeUploadFile = (value: Partial<WorkflowUploadFile>) => {
+	const path = `${value.path || ''}`.trim()
+	const name = `${value.name || ''}`.trim() || getNameFromPath(path) || '未命名文件'
+	const extension = normalizeFileExtension(
+		value.extensionName || getFileExtension(name) || getFileExtension(path)
+	)
+
+	return {
+		id: value.id || createFileId(),
+		name,
+		extensionName: extension.replace(/^\./, '').toUpperCase(),
+		size: Number(value.size || 0),
+		path,
+		mimeType: value.mimeType,
+		source: value.source
+	} satisfies WorkflowUploadFile
+}
+
+const normalizeModelValue = (value: Props['modelValue']) => {
+	if (Array.isArray(value)) {
+		return value
+			.filter((item) => item && (item.name || item.path))
+			.map((item) => normalizeUploadFile(item))
+	}
+
+	if (value && typeof value === 'object' && (value.name || value.path)) {
+		return [normalizeUploadFile(value)]
+	}
+
+	return []
+}
+
+watch(
+	() => props.modelValue,
+	(value) => {
+		localFiles.value = normalizeModelValue(value)
+	},
+	{
+		immediate: true,
+		deep: true
+	}
+)
+
+const normalizedFiles = computed<WorkflowUploadFile[]>({
+	get: () => localFiles.value,
+	set: (value) => {
+		localFiles.value = value
+
+		if (props.multiple) {
+			emit('update:modelValue', value)
+			return
+		}
+
+		emit('update:modelValue', value[0] || null)
+	}
+})
+
+const allowedExtensions = computed(() =>
+	getAllowedExtensions(props.fileTypes, props.fileExtensions)
+)
+const acceptAttr = computed(() =>
+	allowedExtensions.value.length ? allowedExtensions.value.join(',') : undefined
+)
+
+const ensureExtensionAllowed = (extension: string) => {
+	const normalizedExtension = normalizeFileExtension(extension)
+	if (!allowedExtensions.value.length) {
+		return true
+	}
+
+	return allowedExtensions.value.includes(normalizedExtension)
+}
+
+const getFileBadgeText = (file: WorkflowUploadFile) => {
+	const extension = file.extensionName?.trim()
+	return extension ? extension.slice(0, 4).toUpperCase() : 'FILE'
+}
+
+const getFileTypeText = (file: WorkflowUploadFile) => {
+	const extension = file.extensionName?.trim()
+	return extension ? extension.toUpperCase() : 'FILE'
+}
+
+// The upload API currently returns a file id, so keep it in `path` as the persisted reference.
+const buildFileFromUploadedFile = (file: File, fileId: string): WorkflowUploadFile => {
+	const extension = getFileExtension(file.name)
+
+	return {
+		id: fileId || createFileId(),
+		name: file.name,
+		extensionName: extension.replace(/^\./, '').toUpperCase(),
+		size: file.size,
+		path: fileId,
+		mimeType: file.type,
+		source: 'local'
+	}
+}
+
+const buildFileFromLink = (value: string): WorkflowUploadFile => {
+	const url = new URL(value)
+	const name = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() || 'linked-file')
+	const extension = getFileExtension(name || value)
+
+	return {
+		id: createFileId(),
+		name,
+		extensionName: extension.replace(/^\./, '').toUpperCase(),
+		size: 0,
+		path: value,
+		source: 'link'
+	}
+}
+
+const extractUploadedFileId = (response: unknown) => {
+	if (!isRecord(response)) return ''
+
+	const result = response.result
+	if (!Array.isArray(result) || !result.length) {
+		return ''
+	}
+
+	const firstFile = result[0]
+	return isRecord(firstFile) ? `${firstFile.id || ''}`.trim() : ''
+}
+
+const updateFiles = (files: WorkflowUploadFile[]) => {
+	normalizedFiles.value = props.multiple ? files : files.slice(0, 1)
+}
+
+const appendFiles = (files: WorkflowUploadFile[]) => {
+	updateFiles(props.multiple ? [...normalizedFiles.value, ...files] : files.slice(0, 1))
+}
+
+const beforeUpload = (rawFile: File) => {
+	if (!ensureExtensionAllowed(getFileExtension(rawFile.name))) {
+		ElMessage.error('文件类型不符合当前字段配置')
+		return false
+	}
+
+	return true
+}
+
+const handleUploadRequest = async (options: UploadRequestOptions) => {
+	uploadingCount.value += 1
+
+	try {
+		const formData = new FormData()
+		formData.append('files', options.file)
+
+		const response = await uploadWorkflowFile(formData)
+		const fileId = extractUploadedFileId(response)
+		if (!fileId) {
+			throw new Error('upload file id is missing')
+		}
+
+		appendFiles([buildFileFromUploadedFile(options.file, fileId)])
+		options.onProgress({ percent: 100 } as UploadProgressEvent)
+		options.onSuccess(response)
+	} catch (error) {
+		console.error('upload workflow file error', error)
+		ElMessage.error('文件上传失败')
+		options.onError(error as UploadRequestError)
+	} finally {
+		uploadingCount.value = Math.max(0, uploadingCount.value - 1)
+	}
+}
+
+const handleCloseLinkDialog = () => {
+	linkDialogVisible.value = false
+	linkValue.value = ''
+}
+
+const handleConfirmLink = () => {
+	const value = linkValue.value.trim()
+	if (!value) {
+		ElMessage.warning('请输入文件链接')
+		return
+	}
+
+	try {
+		const file = buildFileFromLink(value)
+		const extension = normalizeFileExtension(file.extensionName)
+
+		if (allowedExtensions.value.length && (!extension || !ensureExtensionAllowed(extension))) {
+			ElMessage.error('链接文件类型不符合当前字段配置')
+			return
+		}
+
+		appendFiles([file])
+		handleCloseLinkDialog()
+	} catch (error) {
+		console.error('invalid file url', error)
+		ElMessage.error('请输入合法的文件链接')
+	}
+}
+
+const removeFile = (id: string) => {
+	updateFiles(normalizedFiles.value.filter((item) => item.id !== id))
+}
+</script>
+
+<style scoped lang="less">
+.file-upload-input {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.action-row {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 10px;
+}
+
+.action-row--single {
+	grid-template-columns: minmax(0, 1fr);
+}
+
+.upload-trigger {
+	display: block;
+}
+
+.upload-trigger :deep(.el-upload) {
+	display: block;
+	width: 100%;
+}
+
+.action-button {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	gap: 8px;
+	width: 100%;
+	height: 48px;
+	border-radius: 12px;
+	border: 1px solid #eaecf0;
+	background: #f8fafc;
+	color: #344054;
+	font-size: 14px;
+	font-weight: 500;
+	cursor: pointer;
+}
+
+.action-button:hover:not(:disabled) {
+	background: #eef4ff;
+	border-color: #b2ccff;
+	color: #296dff;
+}
+
+.action-button:disabled {
+	cursor: not-allowed;
+	opacity: 0.7;
+}
+
+.file-list {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.file-item {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+	padding: 12px 14px;
+	border: 1px solid #eaecf0;
+	border-radius: 14px;
+	background: #fff;
+	box-shadow: 0 4px 12px rgba(16, 24, 40, 0.05);
+}
+
+.file-item__left {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	min-width: 0;
+	flex: 1;
+}
+
+.file-badge {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	width: 42px;
+	height: 42px;
+	border-radius: 10px;
+	font-size: 12px;
+	font-weight: 700;
+	flex-shrink: 0;
+}
+
+.file-badge--document {
+	background: #eaf7ee;
+	color: #0f9d58;
+}
+
+.file-badge--image {
+	background: #eef4ff;
+	color: #296dff;
+}
+
+.file-badge--audio {
+	background: #fff1f3;
+	color: #dd2590;
+}
+
+.file-badge--video {
+	background: #eef2ff;
+	color: #4f46e5;
+}
+
+.file-badge--custom {
+	background: #f4f4f5;
+	color: #52525b;
+}
+
+.file-meta {
+	min-width: 0;
+}
+
+.file-name {
+	font-size: 14px;
+	font-weight: 500;
+	color: #344054;
+	word-break: break-all;
+}
+
+.file-info {
+	margin-top: 4px;
+	font-size: 12px;
+	color: #667085;
+}
+
+.file-item__delete {
+	color: #98a2b3;
+}
+
+.empty-tip {
+	padding: 14px 16px;
+	border: 1px dashed #d0d5dd;
+	border-radius: 12px;
+	font-size: 12px;
+	color: #667085;
+	background: #f8fafc;
+}
+
+.dialog-footer {
+	display: flex;
+	justify-content: flex-end;
+	gap: 8px;
+}
+</style>

+ 104 - 0
apps/web/src/features/fileUpload/shared.ts

@@ -0,0 +1,104 @@
+export type UploadFileType = 'document' | 'image' | 'audio' | 'video' | 'custom'
+
+export interface WorkflowUploadFile {
+	id: string
+	name: string
+	extensionName: string
+	size: number
+	path: string
+	mimeType?: string
+	source?: 'local' | 'link'
+}
+
+// 文件类型分组和后缀白名单集中维护,start 节点配置和上传组件共用这一份定义。
+export const FILE_EXTENSION_GROUPS: Record<Exclude<UploadFileType, 'custom'>, string[]> = {
+	document: [
+		'.txt',
+		'.md',
+		'.mdx',
+		'.markdown',
+		'.pdf',
+		'.html',
+		'.xlsx',
+		'.xls',
+		'.doc',
+		'.docx',
+		'.csv',
+		'.eml',
+		'.msg',
+		'.pptx',
+		'.ppt',
+		'.xml',
+		'.epub'
+	],
+	image: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
+	audio: ['.mp3', '.m4a', '.wav', '.amr', '.mpga'],
+	video: ['.mp4', '.mov', '.mpeg', '.webm']
+}
+
+export const normalizeFileExtension = (value: string) => {
+	const text = `${value || ''}`.trim().toLowerCase()
+	if (!text) return ''
+
+	return text.startsWith('.') ? text : `.${text}`
+}
+
+export const getFileExtension = (value?: string) => {
+	const text = `${value || ''}`.trim()
+	if (!text) return ''
+
+	const sanitized = text.split('?')[0]?.split('#')[0] || ''
+	const extension = sanitized.match(/(\.[^.\\/]+)$/)?.[1] || ''
+	return extension.toLowerCase()
+}
+
+// “分类文件类型”与“自定义后缀”会在这里合并成最终白名单。
+export const getAllowedExtensions = (fileTypes: UploadFileType[] = [], fileExtensions: string[] = []) => {
+	const normalizedTypes = Array.isArray(fileTypes) ? fileTypes : []
+	const normalizedExtensions = (Array.isArray(fileExtensions) ? fileExtensions : [])
+		.map(normalizeFileExtension)
+		.filter(Boolean)
+
+	const extensionSet = new Set<string>()
+	normalizedTypes.forEach((type) => {
+		if (type === 'custom') return
+		FILE_EXTENSION_GROUPS[type]?.forEach((extension) => extensionSet.add(extension))
+	})
+
+	normalizedExtensions.forEach((extension) => extensionSet.add(extension))
+	return Array.from(extensionSet)
+}
+
+export const formatFileSize = (size?: number) => {
+	if (!size || Number.isNaN(size) || size < 0) {
+		return ''
+	}
+
+	if (size < 1024) {
+		return `${size} B`
+	}
+
+	const kb = size / 1024
+	if (kb < 1024) {
+		return `${kb.toFixed(kb >= 100 ? 0 : 2)} KB`
+	}
+
+	const mb = kb / 1024
+	if (mb < 1024) {
+		return `${mb.toFixed(mb >= 100 ? 0 : 2)} MB`
+	}
+
+	const gb = mb / 1024
+	return `${gb.toFixed(gb >= 100 ? 0 : 2)} GB`
+}
+
+export const getFileVisualType = (file: Partial<WorkflowUploadFile>) => {
+	const extension = normalizeFileExtension(file.extensionName || getFileExtension(file.name || file.path))
+
+	if (FILE_EXTENSION_GROUPS.image.includes(extension)) return 'image'
+	if (FILE_EXTENSION_GROUPS.audio.includes(extension)) return 'audio'
+	if (FILE_EXTENSION_GROUPS.video.includes(extension)) return 'video'
+	if (FILE_EXTENSION_GROUPS.document.includes(extension)) return 'document'
+
+	return 'custom'
+}

+ 4 - 1
apps/web/src/nodes/_base/CodeEditor.vue

@@ -34,6 +34,8 @@ interface CodeEditorType {
 	formatValue?: 'string' | 'json'
 	// 插件内部配置
 	config?: editor.IStandaloneEditorConstructionOptions
+	// 是否可以切换语言
+	allowChangeLanguage?: boolean
 }
 
 const props = withDefaults(defineProps<CodeEditorType>(), {
@@ -43,6 +45,7 @@ const props = withDefaults(defineProps<CodeEditorType>(), {
 	readOnly: false,
 	allowFullscreen: true,
 	autoToggleTheme: true,
+	allowChangeLanguage: true,
 	language: 'javascript',
 	lineNumbers: 'on',
 	theme: 'vs-light',
@@ -279,7 +282,7 @@ defineExpose({
 					content="切换语言"
 				>
 					<div class="w-1/3">
-						<ElSelect v-model="componentConfig.language">
+						<ElSelect v-if="allowChangeLanguage" v-model="componentConfig.language">
 							<ElOption v-for="value in languageSource" :label="value.name" :value="value.id" />
 						</ElSelect>
 					</div>

+ 12 - 2
apps/web/src/nodes/src/index.ts

@@ -28,7 +28,12 @@ const loopStartNode = {
 	hideInLibary: true, // 不在物料库中展示
 	schema: {
 		...startNode.schema,
-		nodeType: 'loop-start'
+		nodeType: 'loop-start',
+		data: {
+			...startNode.schema.data,
+			type: 'loop-start',
+			title: '循环开始'
+		}
 	}
 }
 
@@ -41,7 +46,12 @@ const iterationStartNode = {
 	hideInLibary: true, // 不在物料库中展示
 	schema: {
 		...startNode.schema,
-		nodeType: 'iteration-start'
+		nodeType: 'iteration-start',
+		data: {
+			...startNode.schema.data,
+			type: 'iteration-start',
+			title: '迭代开始'
+		}
 	}
 }
 

+ 46 - 15
apps/web/src/nodes/src/start/index.ts

@@ -1,9 +1,10 @@
 import { NodeConnectionTypes, type INodeType, type INodeDataBaseSchema } from '../../Interface'
 import Setter from './setter.vue'
+import type { WorkflowUploadFile } from '@/features/fileUpload/shared'
 
-type FileType = 'document' | 'image' | 'audio' | 'video' | 'custom'
+export type FileType = 'document' | 'image' | 'audio' | 'video' | 'custom'
 
-type FormType =
+export type FormType =
 	| 'text-input'
 	| 'text-area'
 	| 'select'
@@ -13,19 +14,31 @@ type FormType =
 	| 'file-list'
 	| 'json_object'
 
+export type StartVariableDefaultValue =
+	| string
+	| number
+	| boolean
+	| Record<string, unknown>
+	| WorkflowUploadFile
+	| WorkflowUploadFile[]
+
+export interface StartVariable {
+	name: string
+	label: string
+	max_length?: number
+	default_value?: StartVariableDefaultValue
+	json?: Record<string, unknown>
+	is_require: boolean
+	is_hide: boolean
+	formType: FormType
+	options?: string[]
+	file_types?: FileType[]
+	file_extensions?: string[]
+	allow_link_input?: boolean
+}
+
 export type StartData = INodeDataBaseSchema & {
-	variables: Array<{
-		label: string
-		max_length: number
-		default_value: Record<string, any>
-		json: Record<string, any>
-		is_require: boolean
-		is_hide: boolean
-		formType: FormType
-		options: any[]
-		file_types: FileType[]
-		file_extensions: string[]
-	}>
+	variables: StartVariable[]
 }
 
 export const startNode: INodeType = {
@@ -52,6 +65,24 @@ export const startNode: INodeType = {
 		selected: false,
 		nodeType: 'start',
 		zIndex: 1,
-		data: {}
+		data: {
+			title: '用户输入',
+			type: 'start',
+			isInIteration: false,
+			iteration_id: '',
+			isInLoop: false,
+			loop_id: '',
+			variables: [],
+			retry_config: {
+				max_retries: 3,
+				retry_enabled: false,
+				retry_interval: 100
+			},
+			error_strategy: 'none',
+			fail_branch_node_id: '',
+			default_value: [],
+			output_can_alter: true,
+			outputs: []
+		}
 	}
 }

+ 919 - 5
apps/web/src/nodes/src/start/setter.vue

@@ -1,24 +1,938 @@
 <template>
-	<div></div>
+	<el-scrollbar class="w-full box-border p-12px">
+		<div class="start-setter">
+			<div class="w-full flex items-center justify-between beautify">
+				<label class="text-14px font-bold text-gray-700">输入</label>
+				<IconButton link icon="lucide:plus" class="text-#296dff" @click="handleAddVariable" />
+			</div>
+
+			<div v-if="!formData.variables?.length" class="empty-state">
+				<div class="empty-desc">点击右上角加号,添加启动工作流时需要的输入项。</div>
+			</div>
+			<VueDraggable
+				v-model="formData.variables"
+				:animation="150"
+				handle=".handle"
+				class="variable-list"
+			>
+				<div
+					v-for="(variable, index) in formData.variables"
+					:key="`${variable.name || 'variable'}-${index}`"
+					class="variable-card"
+				>
+					<div class="variable-left">
+						<Icon icon="lucide:grip-vertical" :size="16" class="handle drag-icon" />
+						<div class="field-icon">
+							<Icon :icon="getFieldIcon(variable.formType)" :size="16" />
+						</div>
+						<div class="min-w-0">
+							<div class="variable-title">
+								<span>{{ variable.name || `field_${index + 1}` }}</span>
+								<span class="title-separator">·</span>
+								<span>{{ variable.label || variable.name || `字段 ${index + 1}` }}</span>
+								<span v-if="variable.formType === 'file-list'" class="legacy-badge">LEGACY</span>
+							</div>
+							<div class="variable-subtitle">
+								<span>{{ getFormTypeLabel(variable.formType) }}</span>
+								<span v-if="supportsMaxLength(variable.formType)">
+									最大长度 {{ variable.max_length || 0 }}
+								</span>
+								<span v-if="variable.is_hide">已隐藏</span>
+							</div>
+						</div>
+					</div>
+
+					<div class="variable-right">
+						<span v-if="variable.is_require" class="required-tag">必填</span>
+						<span class="type-tag">{{ getValueTypeLabel(variable.formType) }}</span>
+						<span class="flex items-center ml-8px!">
+							<IconButton
+								link
+								icon="lucide:pencil"
+								class="text-#667085"
+								@click="handleEditVariable(index)"
+							/>
+							<IconButton
+								link
+								icon="lucide:trash-2"
+								class="text-#f04438 ml-0!"
+								@click="handleRemoveVariable(index)"
+							/>
+						</span>
+					</div>
+				</div>
+			</VueDraggable>
+		</div>
+	</el-scrollbar>
+
+	<el-dialog
+		v-model="dialogVisible"
+		:title="editingIndex === -1 ? '添加变量' : '修改变量'"
+		width="640px"
+		append-to-body
+		:close-on-click-modal="false"
+	>
+		<el-form ref="dialogFormRef" :model="dialogForm" :rules="dialogRules" label-position="top">
+			<el-form-item label="字段类型" prop="formType">
+				<div class="w-full type-picker pl-2px!">
+					<el-select
+						v-model="dialogForm.formType"
+						style="flex: 1"
+						placeholder="请选择字段类型"
+						@change="handleFormTypeChange"
+					>
+						<el-option
+							v-for="item in FORM_TYPE_OPTIONS"
+							:key="item.value"
+							:label="item.label"
+							:value="item.value"
+						/>
+					</el-select>
+					<div class="type-preview">
+						<div>
+							<div class="type-preview__title">{{ getFormTypeLabel(dialogForm.formType) }}</div>
+							<div class="type-preview__desc">{{ getValueTypeLabel(dialogForm.formType) }}</div>
+						</div>
+						<div class="field-icon field-icon--large">
+							<Icon :icon="getFieldIcon(dialogForm.formType)" :size="18" />
+						</div>
+					</div>
+				</div>
+			</el-form-item>
+
+			<el-form-item label="变量名称" prop="name">
+				<el-input v-model="dialogForm.name" placeholder="请输入变量名称" clearable />
+			</el-form-item>
+
+			<el-form-item label="显示名称" prop="label">
+				<el-input v-model="dialogForm.label" placeholder="请输入显示名称" clearable />
+			</el-form-item>
+
+			<el-form-item
+				v-if="supportsMaxLength(dialogForm.formType)"
+				label="最大长度"
+				prop="max_length"
+			>
+				<div class="w-full">
+					<el-input-number v-model="dialogForm.max_length" :min="1" :max="5000" class="w-full" />
+				</div>
+			</el-form-item>
+
+			<el-form-item v-if="dialogForm.formType === 'select'" label="可选项" prop="options">
+				<el-input-tag
+					v-model="dialogForm.options"
+					placeholder="输入内容后按回车添加选项"
+					aria-label="输入内容后按回车添加选项"
+				/>
+			</el-form-item>
+
+			<el-form-item v-if="supportsFileConfig(dialogForm.formType)" label="文件类型">
+				<el-select
+					v-model="dialogForm.file_types"
+					multiple
+					class="w-full"
+					placeholder="请选择允许上传的文件类型"
+					@change="handleFileTypesChange"
+				>
+					<el-option
+						v-for="item in FILE_TYPE_OPTIONS"
+						:key="item.value"
+						:label="item.label"
+						:value="item.value"
+					/>
+				</el-select>
+			</el-form-item>
+
+			<el-form-item
+				v-if="supportsFileConfig(dialogForm.formType) && dialogForm.file_types?.includes('custom')"
+				label="指定文件后缀"
+				prop="file_extensions"
+			>
+				<el-input-tag
+					v-model="dialogForm.file_extensions"
+					placeholder="例如 .zip、.sql、.json"
+					aria-label="请输入允许的文件后缀"
+				/>
+			</el-form-item>
+
+			<el-form-item
+				v-if="dialogForm.formType !== 'json_object'"
+				label="默认值"
+				prop="default_value"
+			>
+				<div class="w-full">
+					<el-input
+						v-if="isStringDefaultEditor(dialogForm.formType)"
+						v-model="stringDefaultValue"
+						:type="dialogForm.formType === 'text-area' ? 'textarea' : 'text'"
+						:rows="dialogForm.formType === 'text-area' ? 4 : undefined"
+						placeholder="请输入默认值"
+					/>
+					<div v-else-if="dialogForm.formType === 'number'" class="w-full">
+						<el-input-number
+							v-model="numberDefaultValue"
+							controls-position="right"
+							class="w-full"
+						/>
+					</div>
+					<div v-else-if="dialogForm.formType === 'checkbox'" class="switch-line">
+						<el-switch v-model="booleanDefaultValue" />
+						<span>{{ booleanDefaultValue ? 'true' : 'false' }}</span>
+					</div>
+					<FileUploadInput
+						v-else-if="dialogForm.formType === 'file'"
+						v-model="singleFileDefaultValue"
+						:file-types="dialogForm.file_types || []"
+						:file-extensions="dialogForm.file_extensions || []"
+						:allow-link-input="false"
+					/>
+					<FileUploadInput
+						v-else-if="dialogForm.formType === 'file-list'"
+						v-model="fileListDefaultValue"
+						multiple
+						:file-types="dialogForm.file_types || []"
+						:file-extensions="dialogForm.file_extensions || []"
+						:allow-link-input="false"
+					/>
+				</div>
+			</el-form-item>
+
+			<el-form-item v-else label="JSON Schema">
+				<div class="w-full">
+					<CodeEditor v-model="dialogForm.jsonText" :tools="false" language="json" />
+				</div>
+			</el-form-item>
+
+			<div class="check-line">
+				<el-checkbox v-model="dialogForm.is_require">必填</el-checkbox>
+			</div>
+			<div class="check-line">
+				<el-checkbox v-model="dialogForm.is_hide">隐藏</el-checkbox>
+			</div>
+		</el-form>
+
+		<template #footer>
+			<div class="dialog-footer">
+				<el-button @click="handleCloseDialog">取消</el-button>
+				<el-button type="primary" @click="handleSaveVariable">保存</el-button>
+			</div>
+		</template>
+	</el-dialog>
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { computed, reactive, ref } from 'vue'
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
+import { cloneDeep } from 'lodash-es'
+import { Icon, IconButton } from '@repo/ui'
 import { useSetterModel } from '../_shared/useSetterModel'
+import CodeEditor from '@/nodes/_base/CodeEditor.vue'
+import FileUploadInput from '@/features/fileUpload/FileUploadInput.vue'
+import { VueDraggable } from 'vue-draggable-plus'
+import type { WorkflowUploadFile } from '@/features/fileUpload/shared'
 
-import type { StartData } from './index'
+import type { FileType, FormType, StartData, StartVariable } from './index'
 
 interface Emits {
 	(e: 'update', value: StartData): void
 }
 
+interface StartVariableEditor extends StartVariable {
+	defaultValueText: string
+	jsonText: string
+}
+
+const FORM_TYPE_OPTIONS: Array<{ label: string; value: FormType }> = [
+	{ label: '文本', value: 'text-input' },
+	{ label: '多行文本', value: 'text-area' },
+	{ label: '下拉选项', value: 'select' },
+	{ label: '数字', value: 'number' },
+	{ label: '布尔值', value: 'checkbox' },
+	{ label: '文件', value: 'file' },
+	{ label: '文件列表', value: 'file-list' },
+	{ label: 'JSON 对象', value: 'json_object' }
+]
+
+const FILE_TYPE_OPTIONS: Array<{ label: string; value: FileType }> = [
+	{ label: '文档', value: 'document' },
+	{ label: '图片', value: 'image' },
+	{ label: '音频', value: 'audio' },
+	{ label: '视频', value: 'video' },
+	{ label: '自定义', value: 'custom' }
+]
+
+const FIELD_ICON_MAP: Record<FormType, string> = {
+	'text-input': 'lucide:type',
+	'text-area': 'lucide:align-left',
+	select: 'lucide:list-filter',
+	number: 'lucide:hash',
+	checkbox: 'lucide:check-square',
+	file: 'lucide:file',
+	'file-list': 'lucide:files',
+	json_object: 'lucide:braces'
+}
+
+const FIELD_LABEL_MAP: Record<FormType, string> = {
+	'text-input': '文本',
+	'text-area': '多行文本',
+	select: '下拉选择',
+	number: '数字',
+	checkbox: '布尔值',
+	file: '文件',
+	'file-list': '文件列表',
+	json_object: 'JSON 对象'
+}
+
+const FIELD_VALUE_TYPE_MAP: Record<FormType, string> = {
+	'text-input': 'string',
+	'text-area': 'string',
+	select: 'string',
+	number: 'number',
+	checkbox: 'boolean',
+	file: 'File',
+	'file-list': 'Array[File]',
+	json_object: 'object'
+}
+
+const VARIABLE_NAME_REGEXP = /^[A-Za-z][A-Za-z0-9_]*$/
+
 const props = defineProps<{
 	data: StartData
 }>()
 
 const emit = defineEmits<Emits>()
 
-const formData = useSetterModel(props, emit)
+const formData = useSetterModel<StartData>(props, emit)
+const dialogVisible = ref(false)
+const editingIndex = ref(-1)
+const dialogFormRef = ref<FormInstance>()
+
+const createDefaultValue = (formType: FormType): StartVariable['default_value'] => {
+	switch (formType) {
+		case 'number':
+			return 0
+		case 'checkbox':
+			return false
+		case 'file':
+			return {}
+		case 'file-list':
+			return []
+		case 'json_object':
+			return {}
+		default:
+			return ''
+	}
+}
+
+const createEmptyVariable = (formType: FormType = 'text-input'): StartVariable => ({
+	name: '',
+	label: '',
+	max_length: undefined,
+	default_value: createDefaultValue(formType),
+	json: {},
+	is_require: true,
+	is_hide: false,
+	formType,
+	options: [],
+	file_types: [],
+	file_extensions: []
+})
+
+// 兼容后端返回的历史结构,统一补齐缺省字段,减少界面层的判空分支。
+const normalizeVariable = (variable?: Partial<StartVariable>): StartVariable => {
+	const formType = variable?.formType || 'text-input'
+	const base = createEmptyVariable(formType)
+
+	const normalized: StartVariable = {
+		...base,
+		...variable,
+		name: (variable?.name || '').trim(),
+		label: (variable?.label || '').trim(),
+		formType,
+		options: Array.isArray(variable?.options)
+			? variable.options.map((item) => `${item}`.trim()).filter(Boolean)
+			: [],
+		file_types: Array.isArray(variable?.file_types) ? [...variable.file_types] : [],
+		file_extensions: Array.isArray(variable?.file_extensions)
+			? variable.file_extensions.map((item) => `${item}`.trim()).filter(Boolean)
+			: [],
+		json:
+			variable?.json && typeof variable.json === 'object' && !Array.isArray(variable.json)
+				? cloneDeep(variable.json)
+				: {}
+	}
+
+	if (!normalized.label && normalized.name) {
+		normalized.label = normalized.name
+	}
+	if (!normalized.name && normalized.label) {
+		normalized.name = normalized.label
+	}
+
+	if (normalized.default_value === undefined) {
+		normalized.default_value = createDefaultValue(formType)
+	}
+
+	return normalized
+}
+
+// 文件和 JSON 类默认值在弹窗中使用文本编辑,因此进入表单前先转成字符串。
+const serializeJsonValue = (value: unknown, fallback: string) => {
+	if (value === undefined || value === null || value === '') {
+		return fallback
+	}
+
+	try {
+		return JSON.stringify(value, null, 2)
+	} catch {
+		return fallback
+	}
+}
+
+const parseJsonValue = (value: string, fallback: unknown) => {
+	const text = value?.trim()
+	if (!text) {
+		return fallback
+	}
+
+	return JSON.parse(text)
+}
+
+const toEditorModel = (variable?: Partial<StartVariable>): StartVariableEditor => {
+	const normalized = normalizeVariable(variable)
+
+	return {
+		...normalized,
+		defaultValueText: serializeJsonValue(
+			normalized.default_value,
+			normalized.formType === 'file-list' ? '[]' : '{}'
+		),
+		jsonText: serializeJsonValue(normalized.json, '{}')
+	}
+}
+
+const dialogForm = reactive<StartVariableEditor>(toEditorModel())
+
+const dialogRules: FormRules<StartVariableEditor> = {
+	formType: [{ required: true, message: '请选择字段类型', trigger: 'change' }],
+	name: [
+		{
+			required: true,
+			message: '请输入变量名称',
+			trigger: 'blur'
+		},
+		{
+			trigger: 'blur',
+			validator: (_rule, value: string, callback) => {
+				const name = value?.trim()
+				if (!name) {
+					callback(new Error('请输入变量名称'))
+					return
+				}
+
+				if (!VARIABLE_NAME_REGEXP.test(name)) {
+					callback(new Error('变量名称需以英文字母开头,且只能包含字母、数字、下划线'))
+					return
+				}
+
+				const duplicateIndex = (formData.value.variables || []).findIndex(
+					(item, index) => index !== editingIndex.value && item.name?.trim() === name
+				)
+
+				if (duplicateIndex !== -1) {
+					callback(new Error('变量名称不能重复'))
+					return
+				}
+
+				callback()
+			}
+		}
+	],
+	label: [
+		{ required: true, message: '请输入显示名称', trigger: 'blur' },
+		{
+			trigger: 'blur',
+			validator: (_rule, value: string, callback) => {
+				const name = value?.trim()
+				if (!name) {
+					callback(new Error('请输入显示名称'))
+					return
+				}
+
+				const duplicateIndex = (formData.value.variables || []).findIndex(
+					(item: StartVariable, index) =>
+						index !== editingIndex.value && item.label?.trim() === name
+				)
+
+				if (duplicateIndex !== -1) {
+					callback(new Error('显示名称不能重复'))
+					return
+				}
+
+				callback()
+			}
+		}
+	],
+	max_length: [
+		{
+			trigger: 'blur',
+			validator: (_rule, value: number, callback) => {
+				if (!supportsMaxLength(dialogForm.formType)) {
+					callback()
+					return
+				}
+
+				if (!value || value < 1) {
+					callback(new Error('最大长度需要大于 0'))
+					return
+				}
+
+				callback()
+			}
+		}
+	],
+	options: [
+		{
+			trigger: 'change',
+			validator: (_rule, value: unknown[], callback) => {
+				if (dialogForm.formType !== 'select') {
+					callback()
+					return
+				}
+
+				if (!Array.isArray(value) || value.length === 0) {
+					callback(new Error('请至少添加一个可选项'))
+					return
+				}
+
+				callback()
+			}
+		}
+	],
+	file_extensions: [
+		{
+			trigger: 'blur',
+			validator: (_rule, value: string[], callback) => {
+				if (
+					!supportsFileConfig(dialogForm.formType) ||
+					!dialogForm.file_types?.includes('custom')
+				) {
+					callback()
+					return
+				}
+
+				if (!Array.isArray(value) || value.length === 0) {
+					callback(new Error('选择“自定义”后,请至少指定一个文件后缀'))
+					return
+				}
+
+				callback()
+			}
+		}
+	],
+	default_value: [
+		{
+			trigger: 'blur',
+			validator: (_rule, _value, callback) => {
+				if (dialogForm.formType === 'json_object') {
+					try {
+						const parsed = parseJsonValue(dialogForm.defaultValueText, {})
+						if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+							callback()
+							return
+						}
+					} catch {
+						// fall through
+					}
+					callback(new Error('默认值需要是合法的 JSON 对象'))
+					return
+				}
+
+				callback()
+			}
+		}
+	],
+	jsonText: [
+		{
+			trigger: 'blur',
+			validator: (_rule, value: string, callback) => {
+				if (dialogForm.formType !== 'json_object') {
+					callback()
+					return
+				}
+
+				try {
+					const parsed = parseJsonValue(value, {})
+					if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+						callback()
+						return
+					}
+				} catch {
+					// fall through
+				}
+
+				callback(new Error('JSON 配置需要是合法的 JSON 对象'))
+			}
+		}
+	]
+}
+
+const getFieldIcon = (formType: FormType) => FIELD_ICON_MAP[formType]
+const getFormTypeLabel = (formType: FormType) => FIELD_LABEL_MAP[formType]
+const getValueTypeLabel = (formType: FormType) => FIELD_VALUE_TYPE_MAP[formType]
+const supportsMaxLength = (formType: FormType) => ['text-input', 'text-area'].includes(formType)
+const supportsFileConfig = (formType: FormType) => ['file', 'file-list'].includes(formType)
+const isStringDefaultEditor = (formType: FormType) =>
+	['text-input', 'text-area', 'select'].includes(formType)
+
+const stringDefaultValue = computed({
+	get: () => (typeof dialogForm.default_value === 'string' ? dialogForm.default_value : ''),
+	set: (value: string) => {
+		dialogForm.default_value = value
+	}
+})
+
+const numberDefaultValue = computed({
+	get: () => (typeof dialogForm.default_value === 'number' ? dialogForm.default_value : 0),
+	set: (value: number | null | undefined) => {
+		dialogForm.default_value = Number(value ?? 0)
+	}
+})
+
+const booleanDefaultValue = computed({
+	get: () => (typeof dialogForm.default_value === 'boolean' ? dialogForm.default_value : false),
+	set: (value: boolean) => {
+		dialogForm.default_value = value
+	}
+})
+
+// 单文件上传对外用 null 表示“当前没有默认文件”,避免在 v-model 上透出空对象类型。
+const singleFileDefaultValue = computed<WorkflowUploadFile | null>({
+	get: () =>
+		dialogForm.default_value &&
+		typeof dialogForm.default_value === 'object' &&
+		!Array.isArray(dialogForm.default_value)
+			? (dialogForm.default_value as WorkflowUploadFile)
+			: null,
+	set: (value) => {
+		dialogForm.default_value = value || {}
+	}
+})
+
+// 文件列表始终收敛成数组,避免模板和保存逻辑混入对象分支。
+const fileListDefaultValue = computed<WorkflowUploadFile[]>({
+	get: () =>
+		Array.isArray(dialogForm.default_value)
+			? (dialogForm.default_value as WorkflowUploadFile[])
+			: [],
+	set: (value) => {
+		dialogForm.default_value = value
+	}
+})
+
+const resetDialogForm = (variable?: Partial<StartVariable>) => {
+	Object.assign(dialogForm, toEditorModel(variable))
+	dialogFormRef.value?.clearValidate()
+}
+
+const handleAddVariable = () => {
+	editingIndex.value = -1
+	resetDialogForm(createEmptyVariable())
+	dialogVisible.value = true
+}
+
+const handleEditVariable = (index: number) => {
+	editingIndex.value = index
+	resetDialogForm(formData.value.variables?.[index])
+	dialogVisible.value = true
+}
+
+const handleRemoveVariable = (index: number) => {
+	formData.value.variables.splice(index, 1)
+}
+
+const handleCloseDialog = () => {
+	dialogVisible.value = false
+	resetDialogForm(createEmptyVariable())
+	editingIndex.value = -1
+}
+
+const handleFileTypesChange = (value: FileType[]) => {
+	if (!value?.includes('custom')) {
+		dialogForm.file_extensions = []
+	}
+}
+
+const handleFormTypeChange = (formType: FormType) => {
+	const nextDefaultValue = createDefaultValue(formType)
+	dialogForm.formType = formType
+	dialogForm.max_length = supportsMaxLength(formType) ? dialogForm.max_length || 50 : 50
+	dialogForm.default_value = nextDefaultValue
+	dialogForm.defaultValueText = serializeJsonValue(
+		nextDefaultValue,
+		formType === 'file-list' ? '[]' : '{}'
+	)
+
+	if (formType !== 'select') {
+		dialogForm.options = []
+	}
+
+	if (!supportsFileConfig(formType)) {
+		dialogForm.file_types = []
+		dialogForm.file_extensions = []
+	}
+
+	if (formType !== 'json_object') {
+		dialogForm.json = {}
+		dialogForm.jsonText = '{}'
+	}
+}
+
+const normalizedDialogVariable = computed<StartVariable>(() => {
+	const formType = dialogForm.formType
+
+	// 保存前把不同控件里的值收敛回 StartVariable 约定的数据结构。
+	let defaultValue: StartVariable['default_value']
+	switch (formType) {
+		case 'number':
+			defaultValue = Number(dialogForm.default_value ?? 0)
+			break
+		case 'checkbox':
+			defaultValue = Boolean(dialogForm.default_value)
+			break
+		case 'file':
+			defaultValue = singleFileDefaultValue.value || {}
+			break
+		case 'file-list':
+			defaultValue = fileListDefaultValue.value
+			break
+		case 'json_object':
+			defaultValue = parseJsonValue(dialogForm.defaultValueText, {})
+			break
+		default:
+			defaultValue = `${dialogForm.default_value ?? ''}`
+			break
+	}
+
+	return normalizeVariable({
+		name: dialogForm.name,
+		label: dialogForm.label,
+		max_length: dialogForm.max_length,
+		default_value: defaultValue,
+		json: dialogForm.formType === 'json_object' ? parseJsonValue(dialogForm.jsonText, {}) : {},
+		is_require: dialogForm.is_require,
+		is_hide: dialogForm.is_hide,
+		formType,
+		options:
+			formType === 'select'
+				? dialogForm?.options?.map((item) => `${item}`.trim()).filter(Boolean)
+				: [],
+		file_types: supportsFileConfig(formType) ? dialogForm.file_types || [] : [],
+		file_extensions: supportsFileConfig(formType) ? dialogForm.file_extensions || [] : []
+	})
+})
+
+const handleSaveVariable = async () => {
+	if (!dialogFormRef.value) return
+
+	try {
+		await dialogFormRef.value.validate()
+	} catch {
+		return
+	}
+
+	const nextVariable = normalizedDialogVariable.value
+
+	if (editingIndex.value === -1) {
+		formData.value.variables.push(nextVariable)
+	} else if (formData.value.variables?.[editingIndex.value]) {
+		formData.value.variables[editingIndex.value] = nextVariable
+	}
+
+	ElMessage.success(editingIndex.value === -1 ? '变量已添加' : '变量已更新')
+	handleCloseDialog()
+}
 </script>
 
-<style scoped></style>
+<style scoped lang="less">
+.start-setter {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.empty-state {
+	display: flex;
+	flex-direction: column;
+	align-items: flex-start;
+	gap: 12px;
+	padding: 18px 16px;
+	border: 1px dashed #d0d5dd;
+	border-radius: 12px;
+	background: #f8fafc;
+}
+
+.empty-desc {
+	font-size: 12px;
+	color: #667085;
+}
+
+.variable-list {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.variable-card {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+	padding: 14px 16px;
+	border: 1px solid #eaecf0;
+	border-radius: 14px;
+	background: #fff;
+	box-shadow: 0 6px 18px rgba(16, 24, 40, 0.06);
+}
+
+.variable-left {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	flex: 1;
+	min-width: 0;
+}
+
+.variable-right {
+	display: flex;
+	align-items: center;
+	gap: 2px;
+	flex-shrink: 0;
+}
+
+.drag-icon {
+	color: #98a2b3;
+	flex-shrink: 0;
+	cursor: move;
+}
+
+.field-icon {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 32px;
+	height: 32px;
+	border-radius: 10px;
+	background: #eef4ff;
+	color: #296dff;
+	flex-shrink: 0;
+}
+
+.field-icon--large {
+	width: 40px;
+	height: 40px;
+}
+
+.variable-title {
+	display: flex;
+	align-items: center;
+	gap: 2px;
+	min-width: 0;
+	font-size: 14px;
+	font-weight: 600;
+	color: #344054;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;
+}
+
+.title-separator {
+	color: #98a2b3;
+}
+
+.variable-subtitle {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 10px;
+	margin-top: 4px;
+	font-size: 12px;
+	color: #667085;
+}
+
+.required-tag,
+.type-tag,
+.legacy-badge {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	padding: 4px 8px;
+	border-radius: 999px;
+	font-size: 12px;
+	line-height: 1;
+}
+
+.required-tag {
+	color: #475467;
+	background: #f2f4f7;
+}
+
+.type-tag {
+	color: #344054;
+	background: #f8fafc;
+	border: 1px solid #d0d5dd;
+}
+
+.legacy-badge {
+	color: #296dff;
+	background: #eef4ff;
+	border: 1px solid #b2ccff;
+}
+
+.type-picker {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+	padding: 12px 14px;
+	border-radius: 12px;
+	background: #f8fafc;
+}
+
+.type-preview {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+}
+
+.type-preview__title {
+	font-size: 14px;
+	font-weight: 600;
+	color: #344054;
+}
+
+.type-preview__desc {
+	margin-top: 2px;
+	font-size: 12px;
+	color: #667085;
+	line-height: 1;
+}
+
+.switch-line,
+.check-line {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	margin-top: 4px;
+	color: #344054;
+}
+
+.dialog-footer {
+	display: flex;
+	justify-content: flex-end;
+	gap: 8px;
+}
+
+:deep(.el-input-number.w-full) {
+	width: 100%;
+}
+</style>

+ 0 - 10
apps/web/src/views/Editor.vue

@@ -68,16 +68,6 @@
 						@dragleave="onDragLeave"
 						@create:connection:cancelled="onConnectionOpenNodeLibary"
 						@click:connection:add="handleClickConectionAdd"
-						@loop:child:click:node="handleSelectNode"
-						@loop:child:dblclick:node="handleNodeClick"
-						@loop:child:create:connection:end="onCreateConnection"
-						@loop:child:run:node="handleRunNode"
-						@loop:child:update:nodes:position="handleUpdateNodesPosition"
-						@loop:child:update:node:attrs="handleUpdateNodeProps"
-						@loop:child:delete:node="handleDeleteNode"
-						@loop:child:delete:connection="handleDeleteEdge"
-						@loop:child:create:connection:cancelled="onConnectionOpenNodeLibary"
-						@loop:child:click:connection:add="handleClickConectionAdd"
 						class="bg-#f5f5f5"
 					>
 						<Toolbar

+ 13 - 37
packages/workflow/src/components/Canvas.vue

@@ -86,28 +86,8 @@ const emit = defineEmits<{
 	/**
 	 * 连线节点添加
 	 */
-		'click:connection:add': [connection: Connection]
-		'loop:child:click:node': [id: string, position: XYPosition]
-		'loop:child:dblclick:node': [id: string, position: XYPosition]
-		'loop:child:click:node:add': [
-			payload: { nodeId: string; handle: string; position: XYPosition; event?: MouseEvent }
-		]
-		'loop:child:create:connection:end': [connection: Connection, event?: MouseEvent]
-		'loop:child:update:node:attrs': [id: string, attrs: Record<string, unknown>]
-		'loop:child:update:nodes:position': [events: CanvasNodeMoveEvent[]]
-		'loop:child:create:connection:cancelled': [
-			payload: {
-				handle: ConnectStartEvent
-				position: XYPosition
-				event?: MouseEvent
-				parentId?: string
-			}
-		]
-		'loop:child:run:node': [id: string]
-		'loop:child:delete:node': [id: string]
-		'loop:child:delete:connection': [connection: Connection]
-		'loop:child:click:connection:add': [connection: Connection]
-	}>()
+	'click:connection:add': [connection: Connection]
+}>()
 
 const props = withDefaults(
 	defineProps<{
@@ -508,25 +488,21 @@ defineExpose({
 					@delete:connection="onDeleteConnection"
 					@update:nodes:position="onUpdateNodesPosition"
 					@edge:add:click="onClickConnectionAdd"
-					@loop:child:click:node="(id, position) => emit('loop:child:click:node', id, position)"
-					@loop:child:dblclick:node="
-						(id, position) => emit('loop:child:dblclick:node', id, position)
-					"
-					@loop:child:click:node:add="emit('loop:child:click:node:add', $event)"
+					@loop:child:click:node="(id, position) => emit('click:node', id, position)"
+					@loop:child:dblclick:node="(id, position) => emit('dblclick:node', id, position)"
+					@loop:child:click:node:add="emit('click:node:add', $event)"
 					@loop:child:create:connection:end="
-						(connection, event) => emit('loop:child:create:connection:end', connection, event)
+						(connection, event) => emit('create:connection:end', connection, event)
 					"
-					@loop:child:update:node:attrs="
-						(id, attrs) => emit('loop:child:update:node:attrs', id, attrs)
-					"
-					@loop:child:update:nodes:position="emit('loop:child:update:nodes:position', $event)"
+					@loop:child:update:node:attrs="(id, attrs) => emit('update:node:attrs', id, attrs)"
+					@loop:child:update:nodes:position="emit('update:nodes:position', $event)"
 					@loop:child:create:connection:cancelled="
-						(p) => emit('loop:child:create:connection:cancelled', { ...p, parentId: nodeProps.id })
+						(p) => emit('create:connection:cancelled', { ...p, parentId: nodeProps.id })
 					"
-					@loop:child:run:node="emit('loop:child:run:node', $event)"
-					@loop:child:delete:node="emit('loop:child:delete:node', $event)"
-					@loop:child:delete:connection="emit('loop:child:delete:connection', $event)"
-					@loop:child:click:connection:add="emit('loop:child:click:connection:add', $event)"
+					@loop:child:run:node="emit('run:node', $event)"
+					@loop:child:delete:node="emit('delete:node', $event)"
+					@loop:child:delete:connection="emit('delete:connection', $event)"
+					@loop:child:click:connection:add="emit('click:connection:add', $event)"
 				/>
 			</slot>
 		</template>

+ 2 - 3
packages/workflow/src/components/elements/nodes/render-types/NodeLoop.vue

@@ -32,7 +32,7 @@ const childrenEdges = computed(() =>
 	)
 )
 const emit = defineEmits<{
-	update: [id: string, attrs: Record<string, unknown>]
+	update: [attrs: Record<string, unknown>]
 	move: [position: XYPosition]
 	'add-inner-node': [parentId: string]
 	'loop:child:click:node': [id: string, position: XYPosition]
@@ -89,7 +89,7 @@ function onResize(event: OnResize) {
 		x: event.params.x,
 		y: event.params.y
 	})
-	emit('update', node.props.value.id, {
+	emit('update', {
 		...(event.params.width != null ? { width: event.params.width } : {}),
 		...(event.params.height != null ? { height: event.params.height } : {})
 	})
@@ -100,7 +100,6 @@ function onAddNode() {
 		emit('add-inner-node', node.props.value.id)
 	}
 }
-
 </script>
 
 <template>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 9461 - 7460
pnpm-lock.yaml