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