发布 v1.10 #5
@@ -116,6 +116,7 @@ public class Model extends ModelBase {
|
|||||||
}
|
}
|
||||||
ollamaChatConfig.setModel(checkAndGetModelName());
|
ollamaChatConfig.setModel(checkAndGetModelName());
|
||||||
ollamaChatConfig.setProvider(getModelProvider().getProviderName());
|
ollamaChatConfig.setProvider(getModelProvider().getProviderName());
|
||||||
|
ollamaChatConfig.setSupportImage(getSupportImage());
|
||||||
ollamaChatConfig.setSupportImageBase64Only(getSupportImageB64Only());
|
ollamaChatConfig.setSupportImageBase64Only(getSupportImageB64Only());
|
||||||
return new OllamaChatModel(ollamaChatConfig);
|
return new OllamaChatModel(ollamaChatConfig);
|
||||||
case "deepseek":
|
case "deepseek":
|
||||||
@@ -128,6 +129,7 @@ public class Model extends ModelBase {
|
|||||||
deepseekConfig.setSupportThinking(Boolean.TRUE);
|
deepseekConfig.setSupportThinking(Boolean.TRUE);
|
||||||
deepseekConfig.setThinkingProtocol("deepseek");
|
deepseekConfig.setThinkingProtocol("deepseek");
|
||||||
deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE);
|
deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE);
|
||||||
|
deepseekConfig.setSupportImage(getSupportImage());
|
||||||
deepseekConfig.setSupportImageBase64Only(getSupportImageB64Only());
|
deepseekConfig.setSupportImageBase64Only(getSupportImageB64Only());
|
||||||
if (getSupportTool() != null) {
|
if (getSupportTool() != null) {
|
||||||
deepseekConfig.setSupportToolMessage(getSupportTool());
|
deepseekConfig.setSupportToolMessage(getSupportTool());
|
||||||
@@ -140,6 +142,7 @@ public class Model extends ModelBase {
|
|||||||
openAIChatConfig.setApiKey(checkAndGetApiKey());
|
openAIChatConfig.setApiKey(checkAndGetApiKey());
|
||||||
openAIChatConfig.setModel(checkAndGetModelName());
|
openAIChatConfig.setModel(checkAndGetModelName());
|
||||||
openAIChatConfig.setRequestPath(checkAndGetRequestPath());
|
openAIChatConfig.setRequestPath(checkAndGetRequestPath());
|
||||||
|
openAIChatConfig.setSupportImage(getSupportImage());
|
||||||
openAIChatConfig.setSupportImageBase64Only(getSupportImageB64Only());
|
openAIChatConfig.setSupportImageBase64Only(getSupportImageB64Only());
|
||||||
if (getSupportTool() != null) {
|
if (getSupportTool() != null) {
|
||||||
openAIChatConfig.setSupportToolMessage(getSupportTool());
|
openAIChatConfig.setSupportToolMessage(getSupportTool());
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
||||||
|
|
||||||
@@ -18,6 +18,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
resourceType: {
|
||||||
|
type: Number,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['choose']);
|
const emit = defineEmits(['choose']);
|
||||||
@@ -26,6 +30,12 @@ const pageDataRef = ref();
|
|||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
const chooseResources = ref([]);
|
const chooseResources = ref([]);
|
||||||
const currentChoose = ref<any>({});
|
const currentChoose = ref<any>({});
|
||||||
|
const pageUrl = computed(() => {
|
||||||
|
const baseUrl = '/api/v1/resource/page';
|
||||||
|
return props.resourceType === undefined
|
||||||
|
? baseUrl
|
||||||
|
: `${baseUrl}?resourceType=${props.resourceType}`;
|
||||||
|
});
|
||||||
function openDialog() {
|
function openDialog() {
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -54,7 +64,7 @@ watch(
|
|||||||
>
|
>
|
||||||
<PageData
|
<PageData
|
||||||
ref="pageDataRef"
|
ref="pageDataRef"
|
||||||
page-url="/api/v1/resource/page"
|
:page-url="pageUrl"
|
||||||
:page-size="8"
|
:page-size="8"
|
||||||
:page-sizes="[8, 12, 16, 20]"
|
:page-sizes="[8, 12, 16, 20]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
let isStartNodeInputParam = $derived.by(() => {
|
let isStartNodeInputParam = $derived.by(() => {
|
||||||
return node?.current?.type === START_NODE_TYPE && param.refType === 'input';
|
return node?.current?.type === START_NODE_TYPE && param.refType === 'input';
|
||||||
});
|
});
|
||||||
|
let isImageStartParam = $derived.by(() => {
|
||||||
|
return isStartNodeInputParam && param.contentType === 'image';
|
||||||
|
});
|
||||||
let availableFormTypes = $derived.by(() => {
|
let availableFormTypes = $derived.by(() => {
|
||||||
if (isSystemStartParam) {
|
if (isSystemStartParam) {
|
||||||
return startFormTypes.filter((item) => item.value === 'input' || item.value === 'textarea');
|
return startFormTypes.filter((item) => item.value === 'input' || item.value === 'textarea');
|
||||||
@@ -117,7 +120,15 @@
|
|||||||
} else if (key === 'required') {
|
} else if (key === 'required') {
|
||||||
patch = { required: Boolean(value) };
|
patch = { required: Boolean(value) };
|
||||||
} else if (key === 'formType') {
|
} else if (key === 'formType') {
|
||||||
patch = { type: toStartFormFieldType(value) };
|
const type = toStartFormFieldType(value);
|
||||||
|
patch = {
|
||||||
|
type,
|
||||||
|
...(type === 'file'
|
||||||
|
? { contentType: 'file' }
|
||||||
|
: param.contentType === 'file'
|
||||||
|
? { contentType: 'text' }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
} else if (key === 'formLabel') {
|
} else if (key === 'formLabel') {
|
||||||
patch = { label: value };
|
patch = { label: value };
|
||||||
} else if (key === 'formDescription') {
|
} else if (key === 'formDescription') {
|
||||||
@@ -127,7 +138,16 @@
|
|||||||
} else if (key === 'enums') {
|
} else if (key === 'enums') {
|
||||||
patch = { options: Array.isArray(value) ? value : [] };
|
patch = { options: Array.isArray(value) ? value : [] };
|
||||||
} else if (key === 'contentType') {
|
} else if (key === 'contentType') {
|
||||||
patch = { type: value === 'file' ? 'file' : 'text' };
|
patch = {
|
||||||
|
contentType: value,
|
||||||
|
...(value === 'file'
|
||||||
|
? { type: 'file' }
|
||||||
|
: value === 'image'
|
||||||
|
? { type: 'text' }
|
||||||
|
: param.contentType === 'file' || param.formType === 'file'
|
||||||
|
? { type: 'text' }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (Object.keys(patch).length === 0) {
|
if (Object.keys(patch).length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -249,17 +269,19 @@
|
|||||||
数据内容:
|
数据内容:
|
||||||
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
||||||
value={param.contentType ? [param.contentType] : []}
|
value={param.contentType ? [param.contentType] : []}
|
||||||
disabled={param.systemReserved === true || isStartNodeInputParam}
|
disabled={param.systemReserved === true}
|
||||||
onSelect={updateContentType}
|
onSelect={updateContentType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-more-item">
|
{#if !isImageStartParam}
|
||||||
输入方式:
|
<div class="input-more-item">
|
||||||
<Select items={availableFormTypes} style="width: 100%" defaultValue={["input"]}
|
输入方式:
|
||||||
value={displayFormTypeValue}
|
<Select items={availableFormTypes} style="width: 100%" defaultValue={["input"]}
|
||||||
onSelect={updateFormType}
|
value={displayFormTypeValue}
|
||||||
/>
|
onSelect={updateFormType}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if param.formType === "radio" || param.formType === "checkbox" || param.formType === "select" }
|
{#if param.formType === "radio" || param.formType === "checkbox" || param.formType === "select" }
|
||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
showContentType = false,
|
showContentType = false,
|
||||||
fixedNumberMin,
|
fixedNumberMin,
|
||||||
fixedNumberMax,
|
fixedNumberMax,
|
||||||
|
acceptedContentTypes = [],
|
||||||
loopOutputAggregation = false
|
loopOutputAggregation = false
|
||||||
}: {
|
}: {
|
||||||
parameter: Parameter,
|
parameter: Parameter,
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
showContentType?: boolean,
|
showContentType?: boolean,
|
||||||
fixedNumberMin?: number,
|
fixedNumberMin?: number,
|
||||||
fixedNumberMax?: number,
|
fixedNumberMax?: number,
|
||||||
|
acceptedContentTypes?: string[],
|
||||||
loopOutputAggregation?: boolean,
|
loopOutputAggregation?: boolean,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -158,6 +160,7 @@
|
|||||||
};
|
};
|
||||||
let selectItems = useRefOptions(
|
let selectItems = useRefOptions(
|
||||||
() => useChildrenOnly === true,
|
() => useChildrenOnly === true,
|
||||||
|
() => acceptedContentTypes,
|
||||||
() => param.ref || ''
|
() => param.ref || ''
|
||||||
);
|
);
|
||||||
let sourceDataType = $derived.by(() => {
|
let sourceDataType = $derived.by(() => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
showContentType = false,
|
showContentType = false,
|
||||||
fixedNumberMin,
|
fixedNumberMin,
|
||||||
fixedNumberMax,
|
fixedNumberMax,
|
||||||
|
acceptedContentTypes = [],
|
||||||
loopOutputAggregation = false
|
loopOutputAggregation = false
|
||||||
}: {
|
}: {
|
||||||
noneParameterText?: string;
|
noneParameterText?: string;
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
showContentType?: boolean,
|
showContentType?: boolean,
|
||||||
fixedNumberMin?: number,
|
fixedNumberMin?: number,
|
||||||
fixedNumberMax?: number,
|
fixedNumberMax?: number,
|
||||||
|
acceptedContentTypes?: string[],
|
||||||
loopOutputAggregation?: boolean,
|
loopOutputAggregation?: boolean,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
{showContentType}
|
{showContentType}
|
||||||
{fixedNumberMin}
|
{fixedNumberMin}
|
||||||
{fixedNumberMax}
|
{fixedNumberMax}
|
||||||
|
{acceptedContentTypes}
|
||||||
{loopOutputAggregation}
|
{loopOutputAggregation}
|
||||||
/>
|
/>
|
||||||
{:else }
|
{:else }
|
||||||
|
|||||||
@@ -1,6 +1,53 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { filterRefOptionsByDataType } from './refOptionFilter';
|
import {
|
||||||
|
filterRefOptionsByContentType,
|
||||||
|
filterRefOptionsByDataType,
|
||||||
|
} from './refOptionFilter';
|
||||||
|
|
||||||
|
describe('filterRefOptionsByContentType', () => {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
label: '开始节点',
|
||||||
|
selectable: false,
|
||||||
|
value: 'start',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: '图片',
|
||||||
|
selectable: true,
|
||||||
|
value: 'start.image',
|
||||||
|
contentType: 'image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '问题',
|
||||||
|
selectable: true,
|
||||||
|
value: 'start.user_input',
|
||||||
|
contentType: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('图片输入仅展示图片类型引用', () => {
|
||||||
|
const filtered = filterRefOptionsByContentType(options, ['image']);
|
||||||
|
|
||||||
|
expect(filtered[0]?.children).toHaveLength(1);
|
||||||
|
expect(filtered[0]?.children[0]?.value).toBe('start.image');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('保留当前已选择的旧版非图片引用', () => {
|
||||||
|
const filtered = filterRefOptionsByContentType(
|
||||||
|
options,
|
||||||
|
['image'],
|
||||||
|
'start.user_input',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(filtered[0]?.children.map((item: any) => item.value)).toEqual([
|
||||||
|
'start.image',
|
||||||
|
'start.user_input',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('filterRefOptionsByDataType', () => {
|
describe('filterRefOptionsByDataType', () => {
|
||||||
const options = [
|
const options = [
|
||||||
|
|||||||
@@ -1,3 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 按内容类型过滤引用选项,并保留当前已选择的旧引用。
|
||||||
|
*/
|
||||||
|
export function filterRefOptionsByContentType(
|
||||||
|
options: any[],
|
||||||
|
acceptedContentTypes: string[],
|
||||||
|
currentRef = '',
|
||||||
|
): any[] {
|
||||||
|
if (!acceptedContentTypes.length) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
const accepted = new Set(acceptedContentTypes);
|
||||||
|
return options
|
||||||
|
.map((option) => {
|
||||||
|
const children = Array.isArray(option.children)
|
||||||
|
? filterRefOptionsByContentType(
|
||||||
|
option.children,
|
||||||
|
acceptedContentTypes,
|
||||||
|
currentRef,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const selectable =
|
||||||
|
option.selectable === true &&
|
||||||
|
(accepted.has(String(option.contentType || '')) ||
|
||||||
|
String(option.value || '') === currentRef);
|
||||||
|
if (!selectable && children.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
selectable,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
function isAcceptedDataType(dataType: string, acceptedDataTypes: string[]) {
|
function isAcceptedDataType(dataType: string, acceptedDataTypes: string[]) {
|
||||||
const normalized = dataType.trim().toLowerCase();
|
const normalized = dataType.trim().toLowerCase();
|
||||||
return acceptedDataTypes.some((acceptedDataType) => {
|
return acceptedDataTypes.some((acceptedDataType) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
isArrayDataType,
|
isArrayDataType,
|
||||||
projectParameterDataType,
|
projectParameterDataType,
|
||||||
} from '../../utils/loopScope';
|
} from '../../utils/loopScope';
|
||||||
|
import { filterRefOptionsByContentType } from './refOptionFilter';
|
||||||
|
|
||||||
const fillRefNodeIds = (
|
const fillRefNodeIds = (
|
||||||
refNodeIds: string[],
|
refNodeIds: string[],
|
||||||
@@ -53,6 +54,7 @@ const getChildren = (
|
|||||||
return {
|
return {
|
||||||
label: pathLabel,
|
label: pathLabel,
|
||||||
dataType,
|
dataType,
|
||||||
|
contentType: param.contentType,
|
||||||
value: parentId + '.' + param.name,
|
value: parentId + '.' + param.name,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
nodeType: nodeType,
|
nodeType: nodeType,
|
||||||
@@ -102,6 +104,7 @@ const nodeToOptions = (
|
|||||||
children.push({
|
children.push({
|
||||||
label,
|
label,
|
||||||
dataType: projectParameterDataType(parameter),
|
dataType: projectParameterDataType(parameter),
|
||||||
|
contentType: parameter.contentType,
|
||||||
value: node.id + '.' + parameter.name,
|
value: node.id + '.' + parameter.name,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
nodeType: nodeType,
|
nodeType: nodeType,
|
||||||
@@ -153,6 +156,7 @@ const nodeToOptions = (
|
|||||||
|
|
||||||
export const useRefOptions: any = (
|
export const useRefOptions: any = (
|
||||||
useChildrenOnly: boolean | (() => boolean) = false,
|
useChildrenOnly: boolean | (() => boolean) = false,
|
||||||
|
acceptedContentTypes: string[] | (() => string[]) = [],
|
||||||
currentRef: string | (() => string) = '',
|
currentRef: string | (() => string) = '',
|
||||||
) => {
|
) => {
|
||||||
const currentNodeId = getCurrentNodeId();
|
const currentNodeId = getCurrentNodeId();
|
||||||
@@ -162,6 +166,10 @@ export const useRefOptions: any = (
|
|||||||
typeof useChildrenOnly === 'function'
|
typeof useChildrenOnly === 'function'
|
||||||
? useChildrenOnly()
|
? useChildrenOnly()
|
||||||
: useChildrenOnly;
|
: useChildrenOnly;
|
||||||
|
const getAcceptedContentTypes = () =>
|
||||||
|
typeof acceptedContentTypes === 'function'
|
||||||
|
? acceptedContentTypes()
|
||||||
|
: acceptedContentTypes;
|
||||||
const getCurrentRef = () =>
|
const getCurrentRef = () =>
|
||||||
typeof currentRef === 'function' ? currentRef() : currentRef;
|
typeof currentRef === 'function' ? currentRef() : currentRef;
|
||||||
|
|
||||||
@@ -207,7 +215,11 @@ export const useRefOptions: any = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = resultOptions;
|
const items = filterRefOptionsByContentType(
|
||||||
|
resultOptions,
|
||||||
|
getAcceptedContentTypes(),
|
||||||
|
getCurrentRef(),
|
||||||
|
);
|
||||||
const stack = [...items];
|
const stack = [...items];
|
||||||
let selected;
|
let selected;
|
||||||
const currentValue = getCurrentRef();
|
const currentValue = getCurrentRef();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type SelectItem = {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
nodeType?: string;
|
nodeType?: string;
|
||||||
dataType?: string;
|
dataType?: string;
|
||||||
|
contentType?: string;
|
||||||
displayLabel?: string;
|
displayLabel?: string;
|
||||||
pathLabel?: string;
|
pathLabel?: string;
|
||||||
itemTypeLabel?: string;
|
itemTypeLabel?: string;
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ describe('workflow node fields', () => {
|
|||||||
key: 'file_field',
|
key: 'file_field',
|
||||||
label: '文件字段',
|
label: '文件字段',
|
||||||
type: 'file',
|
type: 'file',
|
||||||
|
contentType: 'file',
|
||||||
placeholder: '请选择文件',
|
placeholder: '请选择文件',
|
||||||
});
|
});
|
||||||
expect((updated.parameters as any[]).find((item) => item.name === 'file_field'))
|
expect((updated.parameters as any[]).find((item) => item.name === 'file_field'))
|
||||||
@@ -110,6 +111,7 @@ describe('workflow node fields', () => {
|
|||||||
expect(updated.startFormSchema?.find((item: any) => item.key === 'attachments'))
|
expect(updated.startFormSchema?.find((item: any) => item.key === 'attachments'))
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
type: 'file',
|
type: 'file',
|
||||||
|
contentType: 'file',
|
||||||
placeholder: '请选择文件',
|
placeholder: '请选择文件',
|
||||||
});
|
});
|
||||||
expect((updated.parameters as any[]).find((item) => item.name === 'attachments'))
|
expect((updated.parameters as any[]).find((item) => item.name === 'attachments'))
|
||||||
@@ -149,6 +151,99 @@ describe('workflow node fields', () => {
|
|||||||
expect(nextParameter?.id).toBe(previousParameter?.id);
|
expect(nextParameter?.id).toBe(previousParameter?.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves resource content type independently from input type', () => {
|
||||||
|
for (const contentType of ['image', 'video', 'audio', 'other'] as const) {
|
||||||
|
const key = `preview_${contentType}`;
|
||||||
|
const initial = createInitialWorkflowData();
|
||||||
|
const appended = appendStartFormField(
|
||||||
|
initial.nodes[0]?.data as Record<string, any>,
|
||||||
|
{
|
||||||
|
key,
|
||||||
|
label: `资源-${contentType}`,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = updateStartFormField(appended, key, {
|
||||||
|
contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
updated.startFormSchema?.find((item: any) => item.key === key),
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'text',
|
||||||
|
contentType,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(updated.parameters as any[]).find((item) => item.name === key),
|
||||||
|
).toMatchObject({
|
||||||
|
dataType: contentType === 'image' ? 'Object' : 'String',
|
||||||
|
contentType,
|
||||||
|
formType: 'input',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconciles a legacy text schema with its image parameter', () => {
|
||||||
|
const initial = createInitialWorkflowData();
|
||||||
|
const startData = initial.nodes[0]?.data as Record<string, any>;
|
||||||
|
const appended = appendStartFormField(startData, {
|
||||||
|
key: 'legacy_image',
|
||||||
|
label: '图片',
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
const imageParameterData = {
|
||||||
|
...appended,
|
||||||
|
parameters: (appended.parameters as any[]).map((parameter) =>
|
||||||
|
parameter.name === 'legacy_image'
|
||||||
|
? {
|
||||||
|
...parameter,
|
||||||
|
contentType: 'image',
|
||||||
|
dataType: 'Object',
|
||||||
|
}
|
||||||
|
: parameter,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = normalizeStartNodeData(imageParameterData);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalized.startFormSchema?.find(
|
||||||
|
(field: any) => field.key === 'legacy_image',
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
contentType: 'image',
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(normalized.parameters as any[]).find(
|
||||||
|
(parameter) => parameter.name === 'legacy_image',
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
contentType: 'image',
|
||||||
|
dataType: 'Object',
|
||||||
|
});
|
||||||
|
|
||||||
|
const changedToText = updateStartFormField(normalized, 'legacy_image', {
|
||||||
|
contentType: 'text',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
changedToText.startFormSchema?.find(
|
||||||
|
(field: any) => field.key === 'legacy_image',
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
contentType: 'text',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(changedToText.parameters as any[]).find(
|
||||||
|
(parameter) => parameter.name === 'legacy_image',
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
contentType: 'text',
|
||||||
|
dataType: 'String',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps start fields unchanged when renaming to an existing key', () => {
|
it('keeps start fields unchanged when renaming to an existing key', () => {
|
||||||
const initial = createInitialWorkflowData();
|
const initial = createInitialWorkflowData();
|
||||||
const withTopic = appendStartFormField(
|
const withTopic = appendStartFormField(
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export type StartFormFieldType =
|
|||||||
| 'select'
|
| 'select'
|
||||||
| 'file';
|
| 'file';
|
||||||
|
|
||||||
|
export type StartFormContentType =
|
||||||
|
| 'text'
|
||||||
|
| 'image'
|
||||||
|
| 'video'
|
||||||
|
| 'audio'
|
||||||
|
| 'file'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
export type StartFormMeta = {
|
export type StartFormMeta = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -54,6 +62,7 @@ export type StartFormFieldSchema = {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: StartFormFieldType;
|
type: StartFormFieldType;
|
||||||
|
contentType: StartFormContentType;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -70,6 +79,14 @@ const START_FORM_FIELD_TYPE_SET = new Set<StartFormFieldType>([
|
|||||||
'select',
|
'select',
|
||||||
'file',
|
'file',
|
||||||
]);
|
]);
|
||||||
|
const START_FORM_CONTENT_TYPE_SET = new Set<StartFormContentType>([
|
||||||
|
'text',
|
||||||
|
'image',
|
||||||
|
'video',
|
||||||
|
'audio',
|
||||||
|
'file',
|
||||||
|
'other',
|
||||||
|
]);
|
||||||
const OPTION_FIELD_TYPE_SET = new Set<StartFormFieldType>([
|
const OPTION_FIELD_TYPE_SET = new Set<StartFormFieldType>([
|
||||||
'radio',
|
'radio',
|
||||||
'checkbox',
|
'checkbox',
|
||||||
@@ -126,6 +143,14 @@ function isOptionFieldType(value: unknown): value is StartFormFieldType {
|
|||||||
return OPTION_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
|
return OPTION_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStartFormContentType(
|
||||||
|
value: unknown,
|
||||||
|
fallback: StartFormContentType = 'text',
|
||||||
|
): StartFormContentType {
|
||||||
|
const normalized = trimString(value) as StartFormContentType;
|
||||||
|
return START_FORM_CONTENT_TYPE_SET.has(normalized) ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function cloneParameter(parameter: Parameter): Parameter {
|
function cloneParameter(parameter: Parameter): Parameter {
|
||||||
return {
|
return {
|
||||||
...parameter,
|
...parameter,
|
||||||
@@ -511,20 +536,22 @@ function parameterTypeToStartFormFieldType(parameter?: Parameter | null): StartF
|
|||||||
return 'text';
|
return 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldTypeToDataType(type: StartFormFieldType) {
|
function fieldTypeToDataType(
|
||||||
|
type: StartFormFieldType,
|
||||||
|
contentType: StartFormContentType,
|
||||||
|
) {
|
||||||
if (type === 'checkbox') {
|
if (type === 'checkbox') {
|
||||||
return 'Array';
|
return 'Array';
|
||||||
}
|
}
|
||||||
if (type === 'file') {
|
if (type === 'file') {
|
||||||
return 'File';
|
return 'File';
|
||||||
}
|
}
|
||||||
|
if (contentType === 'image') {
|
||||||
|
return 'Object';
|
||||||
|
}
|
||||||
return 'String';
|
return 'String';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldTypeToContentType(type: StartFormFieldType) {
|
|
||||||
return type === 'file' ? 'file' : 'text';
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldTypeToFormType(type: StartFormFieldType) {
|
function fieldTypeToFormType(type: StartFormFieldType) {
|
||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
return 'input';
|
return 'input';
|
||||||
@@ -550,11 +577,31 @@ function normalizeStartFormField(
|
|||||||
: fallbackType;
|
: fallbackType;
|
||||||
const isSystemField =
|
const isSystemField =
|
||||||
field?.systemReserved === true || key === SYSTEM_START_PARAM_NAME;
|
field?.systemReserved === true || key === SYSTEM_START_PARAM_NAME;
|
||||||
|
const parameterContentType = normalizeStartFormContentType(
|
||||||
|
existingParameter?.contentType,
|
||||||
|
requestedType === 'file' ? 'file' : 'text',
|
||||||
|
);
|
||||||
|
const fieldContentType = normalizeStartFormContentType(
|
||||||
|
field?.contentType ?? existingParameter?.contentType,
|
||||||
|
requestedType === 'file' ? 'file' : 'text',
|
||||||
|
);
|
||||||
|
// 兼容旧工作流:图片参数已更新,但同名表单 Schema 仍保留为 text。
|
||||||
|
const requestedContentType =
|
||||||
|
parameterContentType === 'image' && fieldContentType === 'text'
|
||||||
|
? 'image'
|
||||||
|
: fieldContentType;
|
||||||
|
const contentType = isSystemField
|
||||||
|
? 'text'
|
||||||
|
: requestedType === 'file' || requestedContentType === 'file'
|
||||||
|
? 'file'
|
||||||
|
: requestedContentType;
|
||||||
const type = isSystemField
|
const type = isSystemField
|
||||||
? requestedType === 'text'
|
? requestedType === 'text'
|
||||||
? 'text'
|
? 'text'
|
||||||
: 'textarea'
|
: 'textarea'
|
||||||
: requestedType;
|
: contentType === 'file'
|
||||||
|
? 'file'
|
||||||
|
: requestedType;
|
||||||
const options = isOptionFieldType(type)
|
const options = isOptionFieldType(type)
|
||||||
? ensureStringArray(field?.options || existingParameter?.enums)
|
? ensureStringArray(field?.options || existingParameter?.enums)
|
||||||
: [];
|
: [];
|
||||||
@@ -572,6 +619,7 @@ function normalizeStartFormField(
|
|||||||
trimString(existingParameter?.displayName) ||
|
trimString(existingParameter?.displayName) ||
|
||||||
key,
|
key,
|
||||||
type,
|
type,
|
||||||
|
contentType,
|
||||||
required: isSystemField ? true : Boolean(field?.required ?? existingParameter?.required),
|
required: isSystemField ? true : Boolean(field?.required ?? existingParameter?.required),
|
||||||
placeholder:
|
placeholder:
|
||||||
trimString(field?.placeholder) || trimString(existingParameter?.formPlaceholder),
|
trimString(field?.placeholder) || trimString(existingParameter?.formPlaceholder),
|
||||||
@@ -590,6 +638,7 @@ function parameterToStartFormField(parameter?: Parameter | null) {
|
|||||||
key: trimString(parameter?.name),
|
key: trimString(parameter?.name),
|
||||||
label: trimString(parameter?.formLabel),
|
label: trimString(parameter?.formLabel),
|
||||||
type: parameterTypeToStartFormFieldType(parameter),
|
type: parameterTypeToStartFormFieldType(parameter),
|
||||||
|
contentType: normalizeStartFormContentType(parameter?.contentType),
|
||||||
required: Boolean(parameter?.required),
|
required: Boolean(parameter?.required),
|
||||||
placeholder: trimString(parameter?.formPlaceholder),
|
placeholder: trimString(parameter?.formPlaceholder),
|
||||||
description: trimString(parameter?.formDescription),
|
description: trimString(parameter?.formDescription),
|
||||||
@@ -624,10 +673,10 @@ function startFormFieldToParameter(
|
|||||||
...cloneParameter(existingParameter || {}),
|
...cloneParameter(existingParameter || {}),
|
||||||
id: trimString(existingParameter?.id) || trimString(field.id),
|
id: trimString(existingParameter?.id) || trimString(field.id),
|
||||||
name: field.key,
|
name: field.key,
|
||||||
dataType: fieldTypeToDataType(field.type),
|
dataType: fieldTypeToDataType(field.type, field.contentType),
|
||||||
refType: 'input',
|
refType: 'input',
|
||||||
required: Boolean(field.required),
|
required: Boolean(field.required),
|
||||||
contentType: fieldTypeToContentType(field.type),
|
contentType: field.contentType,
|
||||||
formType: fieldTypeToFormType(field.type),
|
formType: fieldTypeToFormType(field.type),
|
||||||
formLabel,
|
formLabel,
|
||||||
formDescription: asString(field.description),
|
formDescription: asString(field.description),
|
||||||
@@ -705,6 +754,7 @@ export function normalizeStartFormSchema(
|
|||||||
normalizeSystemStartFormType(systemParameter?.formType) === 'input'
|
normalizeSystemStartFormType(systemParameter?.formType) === 'input'
|
||||||
? 'text'
|
? 'text'
|
||||||
: 'textarea',
|
: 'textarea',
|
||||||
|
contentType: 'text',
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: trimString(systemParameter?.formPlaceholder),
|
placeholder: trimString(systemParameter?.formPlaceholder),
|
||||||
description: trimString(systemParameter?.formDescription),
|
description: trimString(systemParameter?.formDescription),
|
||||||
@@ -754,13 +804,19 @@ export function createCustomStartFormField(
|
|||||||
field?: Partial<StartFormFieldSchema> | null,
|
field?: Partial<StartFormFieldSchema> | null,
|
||||||
existingKeys: string[] = [],
|
existingKeys: string[] = [],
|
||||||
): StartFormFieldSchema {
|
): StartFormFieldSchema {
|
||||||
const type = normalizeStartFormFieldUiType(field?.type, 'text');
|
const requestedType = normalizeStartFormFieldUiType(field?.type, 'text');
|
||||||
|
const contentType = normalizeStartFormContentType(
|
||||||
|
field?.contentType,
|
||||||
|
requestedType === 'file' ? 'file' : 'text',
|
||||||
|
);
|
||||||
|
const type = contentType === 'file' ? 'file' : requestedType;
|
||||||
const key = trimString(field?.key) || buildUniqueStartFormFieldKey(type, existingKeys);
|
const key = trimString(field?.key) || buildUniqueStartFormFieldKey(type, existingKeys);
|
||||||
return {
|
return {
|
||||||
id: ensureStartFormFieldId(field?.id),
|
id: ensureStartFormFieldId(field?.id),
|
||||||
key,
|
key,
|
||||||
label: trimString(field?.label) || getDefaultStartFormFieldLabel(type),
|
label: trimString(field?.label) || getDefaultStartFormFieldLabel(type),
|
||||||
type,
|
type,
|
||||||
|
contentType,
|
||||||
required: Boolean(field?.required),
|
required: Boolean(field?.required),
|
||||||
placeholder:
|
placeholder:
|
||||||
trimString(field?.placeholder) ||
|
trimString(field?.placeholder) ||
|
||||||
@@ -841,8 +897,24 @@ export function updateStartFormField(
|
|||||||
type: nextType,
|
type: nextType,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const nextParameters = currentParameters.map((parameter) => {
|
||||||
|
const parameterId = trimString(parameter.id);
|
||||||
|
const parameterName = trimString(parameter.name);
|
||||||
|
const matchedField = nextSchema.find(
|
||||||
|
(field) =>
|
||||||
|
(parameterId && trimString(field.id) === parameterId) ||
|
||||||
|
field.key === parameterName,
|
||||||
|
);
|
||||||
|
return matchedField
|
||||||
|
? {
|
||||||
|
...parameter,
|
||||||
|
contentType: matchedField.contentType,
|
||||||
|
}
|
||||||
|
: parameter;
|
||||||
|
});
|
||||||
return normalizeStartNodeData({
|
return normalizeStartNodeData({
|
||||||
...currentData,
|
...currentData,
|
||||||
|
parameters: nextParameters,
|
||||||
startFormSchema: nextSchema,
|
startFormSchema: nextSchema,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { ElButton, ElDialog } from 'element-plus';
|
import { ElButton, ElDialog } from 'element-plus';
|
||||||
|
|
||||||
@@ -16,6 +16,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
resourceType: {
|
||||||
|
type: Number,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['choose']);
|
const emit = defineEmits(['choose']);
|
||||||
@@ -24,6 +28,12 @@ const pageDataRef = ref();
|
|||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
const chooseResources = ref([]);
|
const chooseResources = ref([]);
|
||||||
const currentChoose = ref<any>({});
|
const currentChoose = ref<any>({});
|
||||||
|
const pageUrl = computed(() => {
|
||||||
|
const baseUrl = '/userCenter/resource/page';
|
||||||
|
return props.resourceType === undefined
|
||||||
|
? baseUrl
|
||||||
|
: `${baseUrl}?resourceType=${props.resourceType}`;
|
||||||
|
});
|
||||||
function openDialog() {
|
function openDialog() {
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -55,7 +65,7 @@ watch(
|
|||||||
>
|
>
|
||||||
<PageData
|
<PageData
|
||||||
ref="pageDataRef"
|
ref="pageDataRef"
|
||||||
page-url="/userCenter/resource/page"
|
:page-url="pageUrl"
|
||||||
:page-size="8"
|
:page-size="8"
|
||||||
:page-sizes="[8, 12, 16, 20]"
|
:page-sizes="[8, 12, 16, 20]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ElButton, ElForm, ElFormItem } from 'element-plus';
|
|||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||||
|
import { resolveWorkflowFormParameters } from '#/views/ai/workflow/components/workflowFormParameters';
|
||||||
|
|
||||||
export type WorkflowFormProps = {
|
export type WorkflowFormProps = {
|
||||||
onAsyncExecute?: (values: any) => void;
|
onAsyncExecute?: (values: any) => void;
|
||||||
@@ -48,30 +49,9 @@ const startFormMeta = computed(() => {
|
|||||||
submitText: String(meta.submitText || '').trim() || $t('button.run'),
|
submitText: String(meta.submitText || '').trim() || $t('button.run'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const parameters = computed(() => {
|
const parameters = computed(() =>
|
||||||
const schema = Array.isArray(props.workflowParams?.startFormSchema)
|
resolveWorkflowFormParameters(props.workflowParams),
|
||||||
? 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(
|
watch(
|
||||||
parameters,
|
parameters,
|
||||||
(items) => {
|
(items) => {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||||
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.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({
|
const props = defineProps({
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -37,7 +40,7 @@ function getContentType(item: any) {
|
|||||||
return 'text';
|
return 'text';
|
||||||
}
|
}
|
||||||
function isResource(contentType: any) {
|
function isResource(contentType: any) {
|
||||||
return ['audio', 'image', 'video'].includes(contentType);
|
return ['audio', 'video'].includes(contentType);
|
||||||
}
|
}
|
||||||
function isFileContentType(contentType: any) {
|
function isFileContentType(contentType: any) {
|
||||||
return contentType === 'file';
|
return contentType === 'file';
|
||||||
@@ -61,6 +64,14 @@ function buildRules(item: any) {
|
|||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
validator: (_rule: any, value: any, callback: any) => {
|
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)) {
|
if (Array.isArray(value)) {
|
||||||
callback(value.length > 0 ? undefined : new Error($t('message.required')));
|
callback(value.length > 0 ? undefined : new Error($t('message.required')));
|
||||||
return;
|
return;
|
||||||
@@ -144,6 +155,12 @@ function choose(data: any, propName: string) {
|
|||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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))">
|
<template v-if="isResource(getContentType(item))">
|
||||||
<ElInput
|
<ElInput
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
支持 PNG、JPEG、WebP、GIF、BMP,单张不超过 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>
|
||||||
@@ -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 图片');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user