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

@@ -12,6 +12,10 @@ const props = defineProps({
type: String,
required: true,
},
multiple: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['choose']);
@@ -27,7 +31,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(
@@ -56,7 +60,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

@@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ElButton, ElLink, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import {
appendWorkflowFileValues,
buildWorkflowFileValueFromResource,
buildWorkflowFileValueFromUpload,
formatWorkflowFileSize,
normalizeWorkflowFileValues,
validateWorkflowFileSelection,
validateWorkflowFileValues,
WORKFLOW_FILE_LIMITS,
} from './workflowFileValue';
const props = defineProps({
modelValue: {
type: [Array, Object],
default: undefined,
},
});
const emit = defineEmits(['update:modelValue']);
const uploadLoading = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue));
function triggerSelectFile() {
if (uploadLoading.value) {
return;
}
fileInputRef.value?.click();
}
async function handleNativeFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
if (files.length === 0) {
return;
}
uploadLoading.value = true;
try {
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;
input.value = '';
}
}
function handleChooseResource(resources: any) {
try {
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 removeFile(filePath: string) {
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath);
emit('update:modelValue', nextFiles);
}
function clearFiles() {
emit('update:modelValue', []);
}
</script>
<template>
<div class="workflow-file-input">
<input
ref="fileInputRef"
class="workflow-file-input__native"
type="file"
multiple
@change="handleNativeFileChange"
/>
<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>
<div class="workflow-file-input__actions">
<ElButton
type="primary"
plain
:loading="uploadLoading"
@click="triggerSelectFile"
>
{{ currentFiles.length > 0 ? '继续上传' : $t('button.upload') }}
</ElButton>
<ChooseResource attr-name="file" multiple @choose="handleChooseResource" />
<ElButton
v-if="currentFiles.length > 0"
text
type="danger"
@click="clearFiles"
>
清空
</ElButton>
</div>
</div>
</template>
<style scoped>
.workflow-file-input {
display: flex;
flex-direction: column;
gap: 10px;
}
.workflow-file-input__native {
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;
color: var(--el-text-color-primary);
word-break: break-word;
}
.workflow-file-input__meta {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.workflow-file-input__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

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';
@@ -34,9 +34,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;
@@ -109,6 +174,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"
@@ -120,11 +199,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

@@ -10,6 +10,7 @@ import {
import { $t } from '#/locales';
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
const props = defineProps({
parameters: {
@@ -27,10 +28,19 @@ const props = defineProps({
});
const emit = defineEmits(['update:runParams']);
function getContentType(item: any) {
return item.contentType || 'text';
if (item.contentType) {
return item.contentType;
}
if (String(item.dataType || '').toLowerCase() === 'file') {
return 'file';
}
return 'text';
}
function isResource(contentType: any) {
return ['audio', 'file', 'image', 'video'].includes(contentType);
return ['audio', 'image', 'video'].includes(contentType);
}
function isFileContentType(contentType: any) {
return contentType === 'file';
}
function getCheckboxOptions(item: any) {
if (item.enums) {
@@ -43,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);
@@ -58,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
@@ -105,6 +138,12 @@ function choose(data: any, propName: string) {
:placeholder="item.formPlaceholder"
/>
</template>
<template v-if="isFileContentType(getContentType(item))">
<WorkflowFileInput
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
/>
</template>
<template v-if="isResource(getContentType(item))">
<ElInput
:model-value="runParams[item.name]"

View File

@@ -0,0 +1,196 @@
/**
* 工作流运行态单文件值。
*/
export interface WorkflowFileValue {
fileName: string;
filePath: string;
contentType?: string;
size?: number;
url?: string;
}
/**
* 素材对象最小字段约束。
*/
export interface WorkflowResourceLike {
fileSize?: number | string;
resourceName?: string;
resourceUrl?: string;
suffix?: string;
}
export const WORKFLOW_FILE_LIMITS = {
maxCount: 10,
maxSingleSize: 5 * 1024 * 1024,
maxTotalSize: 50 * 1024 * 1024,
} as const;
/**
* 基于上传结果构建工作流文件值。
*/
export function buildWorkflowFileValueFromUpload(
file: File,
path: string,
): WorkflowFileValue {
const resolvedPath = String(path || '').trim();
if (!resolvedPath) {
throw new Error('上传结果缺少文件路径');
}
return {
fileName: file.name,
filePath: resolvedPath,
contentType: file.type || '',
size: file.size,
url: resolvedPath,
};
}
/**
* 基于素材对象构建工作流文件值。
*/
export function buildWorkflowFileValueFromResource(
resource: WorkflowResourceLike,
): WorkflowFileValue {
const resourceUrl = String(resource?.resourceUrl || '').trim();
if (!resourceUrl) {
throw new Error('素材缺少 resourceUrl');
}
const resourceName = String(resource?.resourceName || '').trim();
const suffix = String(resource?.suffix || '').trim();
const fallbackFileName = resourceUrl.split('/').pop()?.split('?')[0] || '';
return {
fileName:
resourceName && suffix
? `${resourceName}.${suffix}`
: resourceName || fallbackFileName,
filePath: resourceUrl,
contentType: '',
size: toNumber(resource?.fileSize),
url: resourceUrl,
};
}
/**
* 判断是否为有效的工作流文件值。
*/
export function isWorkflowFileValue(value: unknown): value is WorkflowFileValue {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<WorkflowFileValue>;
return Boolean(
String(candidate.fileName || '').trim() &&
String(candidate.filePath || '').trim(),
);
}
/**
* 归一化单文件或多文件值。
*/
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)}`);
}
}
/**
* 友好格式化文件大小。
*/
export function formatWorkflowFileSize(size?: number): string {
if (!size || Number.isNaN(size) || size <= 0) {
return '-';
}
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
if (size < 1024 * 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function toNumber(value?: number | string) {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}