fix: 完善工作流图片参数流转

- 图片参数使用 Object 类型并过滤上游图片引用

- 试运行支持上传、URL 和素材库图片输入

- 将模型图片能力映射到运行配置
This commit is contained in:
2026-07-31 14:51:07 +08:00
parent fb08424cef
commit 4a0efe8879
18 changed files with 924 additions and 50 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { ElButton, ElDialog } from 'element-plus';
@@ -16,6 +16,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
resourceType: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['choose']);
@@ -24,6 +28,12 @@ const pageDataRef = ref();
const dialogVisible = ref(false);
const chooseResources = ref([]);
const currentChoose = ref<any>({});
const pageUrl = computed(() => {
const baseUrl = '/userCenter/resource/page';
return props.resourceType === undefined
? baseUrl
: `${baseUrl}?resourceType=${props.resourceType}`;
});
function openDialog() {
dialogVisible.value = true;
}
@@ -55,7 +65,7 @@ watch(
>
<PageData
ref="pageDataRef"
page-url="/userCenter/resource/page"
:page-url="pageUrl"
:page-size="8"
:page-sizes="[8, 12, 16, 20]"
>

View File

@@ -9,6 +9,7 @@ import { ElButton, ElForm, ElFormItem } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
import { resolveWorkflowFormParameters } from '#/views/ai/workflow/components/workflowFormParameters';
export type WorkflowFormProps = {
onAsyncExecute?: (values: any) => void;
@@ -48,30 +49,9 @@ const startFormMeta = computed(() => {
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',
};
});
});
const parameters = computed(() =>
resolveWorkflowFormParameters(props.workflowParams),
);
watch(
parameters,
(items) => {

View File

@@ -11,6 +11,9 @@ import {
import { $t } from '#/locales';
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
import WorkflowImageInput from '#/views/ai/workflow/components/WorkflowImageInput.vue';
import { hasWorkflowImageValue } from './workflowImageValue';
const props = defineProps({
parameters: {
@@ -37,7 +40,7 @@ function getContentType(item: any) {
return 'text';
}
function isResource(contentType: any) {
return ['audio', 'image', 'video'].includes(contentType);
return ['audio', 'video'].includes(contentType);
}
function isFileContentType(contentType: any) {
return contentType === 'file';
@@ -61,6 +64,14 @@ function buildRules(item: any) {
{
required: true,
validator: (_rule: any, value: any, callback: any) => {
if (getContentType(item) === 'image') {
callback(
hasWorkflowImageValue(value)
? undefined
: new Error($t('message.required')),
);
return;
}
if (Array.isArray(value)) {
callback(value.length > 0 ? undefined : new Error($t('message.required')));
return;
@@ -144,6 +155,12 @@ function choose(data: any, propName: string) {
@update:model-value="(val) => updateParam(item.name, val)"
/>
</template>
<template v-if="getContentType(item) === 'image'">
<WorkflowImageInput
: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,231 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { ElButton, ElImage, ElInput, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import {
buildWorkflowImageValueFromResource,
buildWorkflowImageValueFromUpload,
buildWorkflowImageValueFromUrl,
formatWorkflowImageSize,
getWorkflowImagePreviewUrl,
normalizeWorkflowImageValue,
validateWorkflowImageFile,
WORKFLOW_IMAGE_LIMITS,
} from './workflowImageValue';
const props = defineProps({
modelValue: {
type: [String, Object],
default: undefined,
},
});
const emit = defineEmits(['update:modelValue']);
const uploadLoading = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const urlInput = ref('');
const currentImage = computed(() =>
normalizeWorkflowImageValue(props.modelValue),
);
const previewUrl = computed(() =>
getWorkflowImagePreviewUrl(props.modelValue),
);
watch(
() => props.modelValue,
(value) => {
const image = normalizeWorkflowImageValue(value);
urlInput.value = image?.sourceType === 'url' ? image.url : '';
},
{ immediate: true },
);
function applyUrl() {
try {
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
} catch (error: any) {
ElMessage.error(error?.message || '图片 URL 无效');
}
}
function triggerSelectFile() {
if (!uploadLoading.value) {
fileInputRef.value?.click();
}
}
async function handleNativeFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
uploadLoading.value = true;
try {
validateWorkflowImageFile(file);
const response = await api.upload('/api/v1/commons/upload', { file }, {});
emit(
'update:modelValue',
buildWorkflowImageValueFromUpload(file, response?.data?.path),
);
} catch (error: any) {
ElMessage.error(error?.message || '图片上传失败');
console.error('工作流图片上传失败', error);
} finally {
uploadLoading.value = false;
input.value = '';
}
}
function handleChooseResource(resource: any) {
try {
emit(
'update:modelValue',
buildWorkflowImageValueFromResource(resource || {}),
);
} catch (error: any) {
ElMessage.error(error?.message || '图片素材选择失败');
}
}
function clearImage() {
urlInput.value = '';
emit('update:modelValue', undefined);
}
</script>
<template>
<div class="workflow-image-input">
<input
ref="fileInputRef"
class="workflow-image-input__native"
type="file"
:accept="WORKFLOW_IMAGE_LIMITS.accept"
@change="handleNativeFileChange"
/>
<div class="workflow-image-input__hint">
支持 PNGJPEGWebPGIFBMP单张不超过 10 MiB
</div>
<div v-if="currentImage" class="workflow-image-input__preview">
<ElImage
class="workflow-image-input__thumbnail"
:src="previewUrl"
fit="cover"
:preview-src-list="previewUrl ? [previewUrl] : []"
preview-teleported
/>
<div class="workflow-image-input__summary">
<div class="workflow-image-input__name">
{{
currentImage.sourceType === 'url'
? currentImage.url
: currentImage.fileName
}}
</div>
<div class="workflow-image-input__meta">
{{
currentImage.sourceType === 'url'
? '图片 URL'
: formatWorkflowImageSize(currentImage.size) || '图片文件'
}}
</div>
</div>
</div>
<ElInput
v-model="urlInput"
clearable
placeholder="输入 HTTP/HTTPS 图片 URL"
@keyup.enter="applyUrl"
>
<template #append>
<ElButton @click="applyUrl">使用 URL</ElButton>
</template>
</ElInput>
<div class="workflow-image-input__actions">
<ElButton
type="primary"
plain
:loading="uploadLoading"
@click="triggerSelectFile"
>
{{ currentImage ? '替换图片' : $t('button.upload') }}
</ElButton>
<ChooseResource
attr-name="image"
:resource-type="0"
@choose="handleChooseResource"
/>
<ElButton v-if="currentImage" text type="danger" @click="clearImage">
清空
</ElButton>
</div>
</div>
</template>
<style scoped>
.workflow-image-input {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.workflow-image-input__native {
display: none;
}
.workflow-image-input__hint,
.workflow-image-input__meta {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.workflow-image-input__preview {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid var(--el-border-color-light);
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-blank);
}
.workflow-image-input__thumbnail {
width: 96px;
height: 72px;
flex: none;
border-radius: var(--el-border-radius-small);
background: var(--el-fill-color-light);
}
.workflow-image-input__summary {
min-width: 0;
flex: 1;
}
.workflow-image-input__name {
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-image-input__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
buildWorkflowImageValueFromResource,
buildWorkflowImageValueFromUpload,
buildWorkflowImageValueFromUrl,
hasWorkflowImageValue,
normalizeWorkflowImageValue,
validateWorkflowImageFile,
} from '../workflowImageValue';
describe('workflowImageValue', () => {
it('支持 URL、上传和素材图片值', () => {
expect(buildWorkflowImageValueFromUrl('https://example.com/a.png')).toEqual({
sourceType: 'url',
url: 'https://example.com/a.png',
});
expect(
buildWorkflowImageValueFromUpload(
new File(['image'], 'a.png', { type: 'image/png' }),
'/files/a.png',
),
).toMatchObject({ sourceType: 'upload', fileName: 'a.png' });
expect(
buildWorkflowImageValueFromResource({
resourceName: 'asset',
resourceType: 0,
resourceUrl: '/files/asset.webp',
suffix: 'webp',
}),
).toMatchObject({ sourceType: 'resource', fileName: 'asset.webp' });
});
it('兼容旧值并校验必填', () => {
expect(normalizeWorkflowImageValue('https://example.com/a.png')).toEqual({
sourceType: 'url',
url: 'https://example.com/a.png',
});
expect(
hasWorkflowImageValue({
fileName: 'legacy.jpg',
filePath: '/files/legacy.jpg',
}),
).toBe(true);
expect(hasWorkflowImageValue(undefined)).toBe(false);
});
it('拒绝超限和非图片文件', () => {
expect(() =>
validateWorkflowImageFile(
new File(
[new Uint8Array(10 * 1024 * 1024 + 1)],
'oversized.png',
{ type: 'image/png' },
),
),
).toThrow('单张图片不能超过 10 MiB');
expect(() =>
validateWorkflowImageFile(
new File(['text'], 'note.txt', { type: 'text/plain' }),
),
).toThrow('仅支持 PNG、JPEG、WebP、GIF、BMP 图片');
});
});

View File

@@ -0,0 +1,54 @@
const SUPPORTED_CONTENT_TYPES = ['text', 'image', 'video', 'audio', 'other'];
export function resolveWorkflowFormParameters(workflowParams: any) {
const schema = Array.isArray(workflowParams?.startFormSchema)
? workflowParams.startFormSchema
: [];
if (schema.length === 0) {
return workflowParams?.parameters || [];
}
const parameterMap = new Map(
(Array.isArray(workflowParams?.parameters)
? workflowParams.parameters
: []
).map((parameter: any) => [
String(parameter?.name || '').trim(),
parameter,
]),
);
return schema.map((field: any) => {
const type = String(field.type || '').trim() || 'text';
const rawContentType = String(field.contentType || '').trim();
const parameter = parameterMap.get(String(field.key || '').trim()) as
| any
| undefined;
const parameterContentType = String(parameter?.contentType || '').trim();
const contentType =
type === 'file'
? 'file'
: parameterContentType === 'image'
? 'image'
: SUPPORTED_CONTENT_TYPES.includes(rawContentType)
? rawContentType
: '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,
formType: type === 'text' || contentType === 'file' ? 'input' : type,
dataType:
type === 'checkbox'
? 'Array'
: contentType === 'file'
? 'File'
: contentType === 'image'
? 'Object'
: 'String',
};
});
}

View File

@@ -0,0 +1,213 @@
export type WorkflowImageSourceType = 'resource' | 'upload' | 'url';
export type WorkflowImageValue =
| {
sourceType: 'url';
url: string;
}
| {
sourceType: 'resource' | 'upload';
fileName: string;
filePath: string;
contentType?: string;
size?: number;
url?: string;
};
export interface WorkflowImageResourceLike {
fileSize?: number | string;
resourceName?: string;
resourceType?: number | string;
resourceUrl?: string;
suffix?: string;
}
export const WORKFLOW_IMAGE_LIMITS = {
maxSize: 10 * 1024 * 1024,
accept: '.png,.jpg,.jpeg,.webp,.gif,.bmp',
} as const;
const ALLOWED_MIME_TYPES = new Set([
'image/bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
]);
const ALLOWED_EXTENSIONS = new Set(['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']);
export function buildWorkflowImageValueFromUpload(
file: File,
path: string,
): WorkflowImageValue {
validateWorkflowImageFile(file);
const filePath = String(path || '').trim();
if (!filePath) {
throw new Error('上传结果缺少图片路径');
}
return {
sourceType: 'upload',
fileName: file.name,
filePath,
contentType: file.type || undefined,
size: file.size,
url: filePath,
};
}
export function buildWorkflowImageValueFromResource(
resource: WorkflowImageResourceLike,
): WorkflowImageValue {
const filePath = String(resource?.resourceUrl || '').trim();
if (!filePath) {
throw new Error('图片素材缺少 resourceUrl');
}
if (
resource.resourceType !== undefined &&
Number(resource.resourceType) !== 0
) {
throw new Error('请选择图片素材');
}
const suffix = String(resource?.suffix || '').trim().toLowerCase();
if (suffix && !ALLOWED_EXTENSIONS.has(suffix)) {
throw new Error('仅支持 PNG、JPEG、WebP、GIF、BMP 图片');
}
const size = toNumber(resource?.fileSize);
validateWorkflowImageSize(size);
const resourceName = String(resource?.resourceName || '').trim();
const fallbackName = filePath.split('/').pop()?.split('?')[0] || 'image';
return {
sourceType: 'resource',
fileName:
resourceName && suffix
? `${resourceName}.${suffix}`
: resourceName || fallbackName,
filePath,
contentType: suffixToMimeType(suffix),
size,
url: filePath,
};
}
export function buildWorkflowImageValueFromUrl(url: string): WorkflowImageValue {
const normalized = String(url || '').trim();
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error('请输入有效的图片 URL');
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('图片 URL 仅支持 HTTP/HTTPS');
}
return {
sourceType: 'url',
url: normalized,
};
}
export function normalizeWorkflowImageValue(
value: unknown,
): WorkflowImageValue | undefined {
if (typeof value === 'string' && value.trim()) {
try {
return buildWorkflowImageValueFromUrl(value);
} catch {
return undefined;
}
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const candidate = value as Record<string, unknown>;
const filePath = String(candidate.filePath || '').trim();
const sourceType = String(
candidate.sourceType || (filePath ? 'upload' : 'url'),
) as WorkflowImageSourceType;
if (sourceType === 'url') {
try {
return buildWorkflowImageValueFromUrl(String(candidate.url || ''));
} catch {
return undefined;
}
}
if (!['resource', 'upload'].includes(sourceType)) {
return undefined;
}
const fileName = String(candidate.fileName || '').trim();
if (!fileName || !filePath) {
return undefined;
}
const size = toNumber(candidate.size as number | string | undefined);
try {
validateWorkflowImageSize(size);
} catch {
return undefined;
}
return {
sourceType,
fileName,
filePath,
contentType: String(candidate.contentType || '').trim() || undefined,
size,
url: String(candidate.url || '').trim() || undefined,
};
}
export function hasWorkflowImageValue(value: unknown): boolean {
return normalizeWorkflowImageValue(value) !== undefined;
}
export function getWorkflowImagePreviewUrl(value: unknown): string {
const image = normalizeWorkflowImageValue(value);
return image?.sourceType === 'url'
? image.url
: image?.url || image?.filePath || '';
}
export function validateWorkflowImageFile(file: File) {
const extension = file.name.split('.').pop()?.toLowerCase() || '';
if (
!ALLOWED_MIME_TYPES.has(file.type.toLowerCase()) &&
!ALLOWED_EXTENSIONS.has(extension)
) {
throw new Error('仅支持 PNG、JPEG、WebP、GIF、BMP 图片');
}
validateWorkflowImageSize(file.size);
}
export function formatWorkflowImageSize(size?: number): string {
if (!size || size <= 0 || Number.isNaN(size)) {
return '';
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function validateWorkflowImageSize(size?: number) {
if (size !== undefined && size > WORKFLOW_IMAGE_LIMITS.maxSize) {
throw new Error('单张图片不能超过 10 MiB');
}
}
function suffixToMimeType(suffix: string): string | undefined {
if (!suffix) {
return undefined;
}
return suffix === 'jpg' || suffix === 'jpeg'
? 'image/jpeg'
: `image/${suffix}`;
}
function toNumber(value?: number | string): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}