feat: 完成工作流开始节点开场表单

- 增加开始节点 startFormMeta/startFormSchema 配置与运行参数解析

- 统一 Admin/UserCenter 开场表单渲染与文件集合输入

- 补充开始表单校验、引用迁移和前端工具测试
This commit is contained in:
2026-04-19 13:57:57 +08:00
parent 8546d927bc
commit 9feb889637
26 changed files with 3391 additions and 172 deletions

View File

@@ -14,6 +14,10 @@ const props = defineProps({
type: String,
required: true,
},
multiple: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['choose']);
@@ -29,7 +33,7 @@ function closeDialog() {
dialogVisible.value = false;
}
function confirm() {
emit('choose', currentChoose.value, props.attrName);
emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName);
closeDialog();
}
watch(
@@ -55,7 +59,11 @@ watch(
:page-sizes="[8, 12, 16, 20]"
>
<template #default="{ pageList }">
<ResourceCardList v-model="chooseResources" :data="pageList" />
<ResourceCardList
v-model="chooseResources"
:data="pageList"
:multiple="props.multiple"
/>
</template>
</PageData>
<template #footer>

View File

@@ -376,7 +376,7 @@ async function getWorkflowInfo(workflowId: any) {
: {};
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
? createInitialWorkflowData()
: parsedContent;
: normalizeWorkflowStartNodes(parsedContent);
syncNavTitle(workflowInfo.value?.title || '');
});
}

View File

@@ -8,15 +8,19 @@ import { $t } from '#/locales';
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import {
appendWorkflowFileValues,
buildWorkflowFileValueFromResource,
buildWorkflowFileValueFromUpload,
formatWorkflowFileSize,
isWorkflowFileValue,
normalizeWorkflowFileValues,
validateWorkflowFileSelection,
validateWorkflowFileValues,
WORKFLOW_FILE_LIMITS,
} from './workflowFileValue';
const props = defineProps({
modelValue: {
type: Object as () => Record<string, any> | undefined,
type: [Array, Object],
default: undefined,
},
});
@@ -26,9 +30,7 @@ const emit = defineEmits(['update:modelValue']);
const uploadLoading = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const currentFile = computed(() =>
isWorkflowFileValue(props.modelValue) ? props.modelValue : undefined,
);
const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue));
function triggerSelectFile() {
if (uploadLoading.value) {
@@ -39,17 +41,24 @@ function triggerSelectFile() {
async function handleNativeFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
const files = Array.from(input.files || []);
if (files.length === 0) {
return;
}
uploadLoading.value = true;
try {
const res = await api.upload('/api/v1/commons/upload', { file }, {});
const fileValue = buildWorkflowFileValueFromUpload(file, res?.data?.path);
emit('update:modelValue', fileValue);
} catch (error) {
ElMessage.error('文件上传失败');
validateWorkflowFileSelection(currentFiles.value, files);
const uploadedFiles = [];
for (const file of files) {
const res = await api.upload('/api/v1/commons/upload', { file }, {});
uploadedFiles.push(buildWorkflowFileValueFromUpload(file, res?.data?.path));
}
const nextFiles = appendWorkflowFileValues(currentFiles.value, uploadedFiles);
validateWorkflowFileValues(nextFiles);
emit('update:modelValue', nextFiles);
} catch (error: any) {
ElMessage.error(error?.message || '文件上传失败');
console.error('工作流文件上传失败', error);
} finally {
uploadLoading.value = false;
@@ -57,17 +66,27 @@ async function handleNativeFileChange(event: Event) {
}
}
function handleChooseResource(resource: any) {
function handleChooseResource(resources: any) {
try {
const fileValue = buildWorkflowFileValueFromResource(resource || {});
emit('update:modelValue', fileValue);
const resourceList = Array.isArray(resources) ? resources : [resources];
const fileValues = resourceList
.map((resource) => buildWorkflowFileValueFromResource(resource || {}))
.filter(Boolean);
const nextFiles = appendWorkflowFileValues(currentFiles.value, fileValues);
validateWorkflowFileValues(nextFiles);
emit('update:modelValue', nextFiles);
} catch (error: any) {
ElMessage.error(error?.message || '素材文件选择失败');
}
}
function clearFile() {
emit('update:modelValue', undefined);
function removeFile(filePath: string) {
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath);
emit('update:modelValue', nextFiles);
}
function clearFiles() {
emit('update:modelValue', []);
}
</script>
@@ -77,23 +96,41 @@ function clearFile() {
ref="fileInputRef"
class="workflow-file-input__native"
type="file"
multiple
@change="handleNativeFileChange"
/>
<div v-if="currentFile" class="workflow-file-input__summary">
<div class="workflow-file-input__name">
{{ currentFile.fileName }}
</div>
<div class="workflow-file-input__meta">
<span>{{ formatWorkflowFileSize(currentFile.size) }}</span>
<ElLink
v-if="currentFile.url || currentFile.filePath"
:href="currentFile.url || currentFile.filePath"
target="_blank"
type="primary"
>
{{ $t('button.view') }}
</ElLink>
<div class="workflow-file-input__hint">
最多 {{ WORKFLOW_FILE_LIMITS.maxCount }} 个文件单个不超过
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize) }}总计不超过
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize) }}
</div>
<div v-if="currentFiles.length > 0" class="workflow-file-input__list">
<div
v-for="item in currentFiles"
:key="item.filePath"
class="workflow-file-input__summary"
>
<div class="workflow-file-input__content">
<div class="workflow-file-input__name">
{{ item.fileName }}
</div>
<div class="workflow-file-input__meta">
<span>{{ formatWorkflowFileSize(item.size) }}</span>
<ElLink
v-if="item.url || item.filePath"
:href="item.url || item.filePath"
target="_blank"
type="primary"
>
{{ $t('button.view') }}
</ElLink>
</div>
</div>
<ElButton text type="danger" @click="removeFile(item.filePath)">
{{ $t('button.delete') }}
</ElButton>
</div>
</div>
@@ -104,16 +141,16 @@ function clearFile() {
:loading="uploadLoading"
@click="triggerSelectFile"
>
{{ currentFile ? $t('button.replace') : $t('button.upload') }}
{{ currentFiles.length > 0 ? '继续上传' : $t('button.upload') }}
</ElButton>
<ChooseResource attr-name="file" @choose="handleChooseResource" />
<ChooseResource attr-name="file" multiple @choose="handleChooseResource" />
<ElButton
v-if="currentFile"
v-if="currentFiles.length > 0"
text
type="danger"
@click="clearFile"
@click="clearFiles"
>
{{ $t('button.delete') }}
清空
</ElButton>
</div>
</div>
@@ -130,13 +167,34 @@ function clearFile() {
display: none;
}
.workflow-file-input__hint {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.workflow-file-input__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.workflow-file-input__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--el-border-color-light);
border-radius: 10px;
background: var(--el-fill-color-blank);
}
.workflow-file-input__content {
min-width: 0;
flex: 1;
}
.workflow-file-input__name {
font-size: 13px;
font-weight: 600;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { computed, onUnmounted, ref } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { Position } from '@element-plus/icons-vue';
import { ElButton, ElForm, ElFormItem } from 'element-plus';
@@ -35,9 +35,74 @@ defineExpose({
const runForm = ref<FormInstance>();
const runParams = ref<any>({});
const submitLoading = ref(false);
const parameters = computed(() => {
return props.workflowParams.parameters;
const startFormMeta = computed(() => {
const meta = props.workflowParams?.startFormMeta || {};
return {
title: String(meta.title || '').trim() || props.workflowParams?.title || '',
description:
String(meta.description || '').trim() ||
props.workflowParams?.description ||
'',
submitText: String(meta.submitText || '').trim() || $t('button.run'),
};
});
const parameters = computed(() => {
const schema = Array.isArray(props.workflowParams?.startFormSchema)
? props.workflowParams.startFormSchema
: [];
if (schema.length === 0) {
return props.workflowParams.parameters || [];
}
return schema.map((field: any) => {
const type = String(field.type || '').trim() || 'text';
return {
name: field.key,
formLabel: field.label || field.key,
formDescription: field.description || '',
formPlaceholder: field.placeholder || '',
required: Boolean(field.required),
defaultValue: field.defaultValue,
enums: Array.isArray(field.options) ? field.options : [],
contentType: type === 'file' ? 'file' : 'text',
formType: type === 'text' ? 'input' : type === 'file' ? 'input' : type,
dataType:
type === 'checkbox' ? 'Array' : type === 'file' ? 'File' : 'String',
};
});
});
watch(
parameters,
(items) => {
const nextRunParams = { ...runParams.value };
let changed = false;
for (const item of items || []) {
if (nextRunParams[item.name] !== undefined) {
continue;
}
if (item.defaultValue !== undefined && item.defaultValue !== '') {
nextRunParams[item.name] = item.defaultValue;
changed = true;
continue;
}
if (item.formType === 'checkbox') {
nextRunParams[item.name] = [];
changed = true;
continue;
}
if (item.contentType === 'file') {
nextRunParams[item.name] = [];
changed = true;
}
}
if (changed) {
runParams.value = nextRunParams;
}
},
{
immediate: true,
deep: true,
},
);
const executeId = ref('');
function resume(data: any) {
data.executeId = executeId.value;
@@ -110,6 +175,20 @@ onUnmounted(() => {
<template>
<div>
<ElForm label-position="top" ref="runForm" :model="runParams">
<div
v-if="startFormMeta.title || startFormMeta.description"
class="workflow-form__header"
>
<div v-if="startFormMeta.title" class="workflow-form__title">
{{ startFormMeta.title }}
</div>
<div
v-if="startFormMeta.description"
class="workflow-form__description"
>
{{ startFormMeta.description }}
</div>
</div>
<WorkflowFormItem
v-model:run-params="runParams"
:parameters="parameters"
@@ -121,11 +200,28 @@ onUnmounted(() => {
:loading="submitLoading"
:icon="Position"
>
{{ $t('button.run') }}
{{ startFormMeta.submitText }}
</ElButton>
</ElFormItem>
</ElForm>
</div>
</template>
<style scoped></style>
<style scoped>
.workflow-form__header {
margin-bottom: 16px;
}
.workflow-form__title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.workflow-form__description {
margin-top: 6px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -53,6 +53,31 @@ function getCheckboxOptions(item: any) {
}
return [];
}
function buildRules(item: any) {
if (!item.required) {
return [];
}
return [
{
required: true,
validator: (_rule: any, value: any, callback: any) => {
if (Array.isArray(value)) {
callback(value.length > 0 ? undefined : new Error($t('message.required')));
return;
}
if (value && typeof value === 'object') {
callback(
Object.keys(value).length > 0
? undefined
: new Error($t('message.required')),
);
return;
}
callback(value ? undefined : new Error($t('message.required')));
},
},
];
}
function updateParam(name: string, value: any) {
const newValue = { ...props.runParams, [name]: value };
emit('update:runParams', newValue);
@@ -68,9 +93,7 @@ function choose(data: any, propName: string) {
:prop="`${propPrefix}${item.name}`"
:key="idx"
:label="item.formLabel || item.name"
:rules="
item.required ? [{ required: true, message: $t('message.required') }] : []
"
:rules="buildRules(item)"
>
<template v-if="getContentType(item) === 'text'">
<ElInput

View File

@@ -1,10 +1,14 @@
import { describe, expect, it } from 'vitest';
import {
appendWorkflowFileValues,
buildWorkflowFileValueFromResource,
buildWorkflowFileValueFromUpload,
formatWorkflowFileSize,
isWorkflowFileValue,
normalizeWorkflowFileValues,
validateWorkflowFileSelection,
validateWorkflowFileValues,
} from '../workflowFileValue';
describe('workflowFileValue', () => {
@@ -49,4 +53,99 @@ describe('workflowFileValue', () => {
expect(isWorkflowFileValue({ fileName: 'demo.pdf' })).toBe(false);
expect(formatWorkflowFileSize(2048)).toBe('2.0 KB');
});
it('归一化并去重多文件值', () => {
const result = normalizeWorkflowFileValues([
{
fileName: 'a.pdf',
filePath: 'https://example.com/a.pdf',
},
{
fileName: 'a-copy.pdf',
filePath: 'https://example.com/a.pdf',
},
{
fileName: 'b.pdf',
filePath: 'https://example.com/b.pdf',
},
]);
expect(result).toHaveLength(2);
expect(result[0]?.fileName).toBe('a.pdf');
expect(result[1]?.fileName).toBe('b.pdf');
});
it('追加文件时按 filePath 去重', () => {
const result = appendWorkflowFileValues(
[
{
fileName: 'a.pdf',
filePath: 'https://example.com/a.pdf',
},
],
[
{
fileName: 'a-duplicate.pdf',
filePath: 'https://example.com/a.pdf',
},
{
fileName: 'b.pdf',
filePath: 'https://example.com/b.pdf',
},
],
);
expect(result).toHaveLength(2);
expect(result[1]?.fileName).toBe('b.pdf');
});
it('超出文件限制时抛出错误', () => {
expect(() =>
validateWorkflowFileValues(
new Array(11).fill(null).map((_, index) => ({
fileName: `file-${index}.pdf`,
filePath: `https://example.com/${index}.pdf`,
size: 1024,
})),
),
).toThrow('最多上传 10 个文件');
});
it('上传前校验文件数量限制', () => {
const currentFiles = new Array(9).fill(null).map((_, index) => ({
fileName: `file-${index}.pdf`,
filePath: `https://example.com/${index}.pdf`,
size: 1024,
}));
expect(() =>
validateWorkflowFileSelection(currentFiles, [
new File(['a'], 'a.pdf'),
new File(['b'], 'b.pdf'),
]),
).toThrow('最多上传 10 个文件');
});
it('上传前校验单文件大小限制', () => {
const oversizedFile = new File([new Uint8Array(6 * 1024 * 1024)], 'large.pdf');
expect(() =>
validateWorkflowFileSelection([], [oversizedFile]),
).toThrow('单个文件不能超过 5.0 MB');
});
it('上传前校验总大小限制', () => {
const currentFiles = [
{
fileName: 'existing.pdf',
filePath: 'https://example.com/existing.pdf',
size: 49 * 1024 * 1024,
},
];
const incomingFile = new File([new Uint8Array(2 * 1024 * 1024)], 'new.pdf');
expect(() =>
validateWorkflowFileSelection(currentFiles, [incomingFile]),
).toThrow('文件总大小不能超过 50.0 MB');
});
});

View File

@@ -19,6 +19,12 @@ export interface WorkflowResourceLike {
suffix?: string;
}
export const WORKFLOW_FILE_LIMITS = {
maxCount: 10,
maxSingleSize: 5 * 1024 * 1024,
maxTotalSize: 50 * 1024 * 1024,
} as const;
/**
* 基于上传结果构建工作流文件值。
*/
@@ -78,6 +84,87 @@ export function isWorkflowFileValue(value: unknown): value is WorkflowFileValue
);
}
/**
* 归一化单文件或多文件值。
*/
export function normalizeWorkflowFileValues(value: unknown): WorkflowFileValue[] {
const candidates = Array.isArray(value) ? value : [value];
const result: WorkflowFileValue[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
if (!isWorkflowFileValue(candidate)) {
continue;
}
const key = String(candidate.filePath).trim();
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
result.push(candidate);
}
return result;
}
/**
* 追加文件并按 filePath 去重。
*/
export function appendWorkflowFileValues(
currentValue: unknown,
incomingValues: WorkflowFileValue[],
): WorkflowFileValue[] {
return normalizeWorkflowFileValues([
...normalizeWorkflowFileValues(currentValue),
...incomingValues,
]);
}
/**
* 在上传前基于原生 File 列表校验文件限制,避免无效文件先落库。
*/
export function validateWorkflowFileSelection(
currentValue: unknown,
incomingFiles: File[],
) {
const currentFiles = normalizeWorkflowFileValues(currentValue);
const totalCount = currentFiles.length + incomingFiles.length;
if (totalCount > WORKFLOW_FILE_LIMITS.maxCount) {
throw new Error(`最多上传 ${WORKFLOW_FILE_LIMITS.maxCount} 个文件`);
}
let totalSize = currentFiles.reduce((sum, item) => sum + Number(item.size || 0), 0);
for (const file of incomingFiles) {
if (file.size > WORKFLOW_FILE_LIMITS.maxSingleSize) {
throw new Error(`单个文件不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize)}`);
}
totalSize += file.size;
}
if (totalSize > WORKFLOW_FILE_LIMITS.maxTotalSize) {
throw new Error(`文件总大小不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize)}`);
}
}
/**
* 校验最终文件列表是否满足工作流限制。
*/
export function validateWorkflowFileValues(values: WorkflowFileValue[]) {
const normalized = normalizeWorkflowFileValues(values);
if (normalized.length > WORKFLOW_FILE_LIMITS.maxCount) {
throw new Error(`最多上传 ${WORKFLOW_FILE_LIMITS.maxCount} 个文件`);
}
let totalSize = 0;
for (const item of normalized) {
const size = Number(item.size || 0);
if (size > WORKFLOW_FILE_LIMITS.maxSingleSize) {
throw new Error(`单个文件不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize)}`);
}
totalSize += size;
}
if (totalSize > WORKFLOW_FILE_LIMITS.maxTotalSize) {
throw new Error(`文件总大小不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize)}`);
}
}
/**
* 友好格式化文件大小。
*/