feat: 优化工作流运行输入交互
- 合并运行参数与问题输入并锁定首轮参数 - 优化文件上传、参数摘要与十二小时草稿恢复 - 补充输入表单国际化与相关测试
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"visibilityScopePublic": "Public",
|
"visibilityScopePublic": "Public",
|
||||||
"visibilityScopePublicDesc": "Available to internal users matched by category",
|
"visibilityScopePublicDesc": "Available to internal users matched by category",
|
||||||
"params": "Params",
|
"params": "Params",
|
||||||
|
"runInputForm": "Input form",
|
||||||
"steps": "Steps",
|
"steps": "Steps",
|
||||||
"result": "Result",
|
"result": "Result",
|
||||||
"confirm": "For contents to be confirmed, please confirm first!",
|
"confirm": "For contents to be confirmed, please confirm first!",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"visibilityScopePublic": "公开",
|
"visibilityScopePublic": "公开",
|
||||||
"visibilityScopePublicDesc": "分类命中的内部用户可访问",
|
"visibilityScopePublicDesc": "分类命中的内部用户可访问",
|
||||||
"params": "执行参数",
|
"params": "执行参数",
|
||||||
|
"runInputForm": "输入表单",
|
||||||
"steps": "执行步骤",
|
"steps": "执行步骤",
|
||||||
"result": "执行结果",
|
"result": "执行结果",
|
||||||
"confirm": "有待确认的内容,请先确认!",
|
"confirm": "有待确认的内容,请先确认!",
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
multiple: {
|
multiple: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -37,13 +41,20 @@ const pageUrl = computed(() => {
|
|||||||
: `${baseUrl}?resourceType=${props.resourceType}`;
|
: `${baseUrl}?resourceType=${props.resourceType}`;
|
||||||
});
|
});
|
||||||
function openDialog() {
|
function openDialog() {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
}
|
}
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
}
|
}
|
||||||
function confirm() {
|
function confirm() {
|
||||||
emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName);
|
emit(
|
||||||
|
'choose',
|
||||||
|
props.multiple ? chooseResources.value : currentChoose.value,
|
||||||
|
props.attrName,
|
||||||
|
);
|
||||||
closeDialog();
|
closeDialog();
|
||||||
}
|
}
|
||||||
watch(
|
watch(
|
||||||
@@ -85,7 +96,7 @@ watch(
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</EasyFlowPanelModal>
|
</EasyFlowPanelModal>
|
||||||
<ElButton @click="openDialog()">
|
<ElButton :disabled="disabled" @click="openDialog()">
|
||||||
{{ $t('button.choose') }}
|
{{ $t('button.choose') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { ElButton, ElLink, ElMessage } from 'element-plus';
|
import {
|
||||||
|
CircleCheck,
|
||||||
|
Delete,
|
||||||
|
Document,
|
||||||
|
UploadFilled,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
import { ElButton, ElIcon, ElLink, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
appendWorkflowFileValues,
|
appendWorkflowFileValues,
|
||||||
buildWorkflowFileValueFromResource,
|
|
||||||
buildWorkflowFileValueFromUpload,
|
buildWorkflowFileValueFromUpload,
|
||||||
formatWorkflowFileSize,
|
formatWorkflowFileSize,
|
||||||
normalizeWorkflowFileValues,
|
normalizeWorkflowFileValues,
|
||||||
@@ -19,6 +23,10 @@ import {
|
|||||||
} from './workflowFileValue';
|
} from './workflowFileValue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: [Array, Object],
|
type: [Array, Object],
|
||||||
default: undefined,
|
default: undefined,
|
||||||
@@ -28,21 +36,25 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
const uploadLoading = ref(false);
|
const uploadLoading = ref(false);
|
||||||
|
const dragActive = ref(false);
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue));
|
const currentFiles = computed(() =>
|
||||||
|
normalizeWorkflowFileValues(props.modelValue),
|
||||||
|
);
|
||||||
|
const maxSingleFileSizeText = formatWorkflowFileSize(
|
||||||
|
WORKFLOW_FILE_LIMITS.maxSingleSize,
|
||||||
|
).replace('.0 ', ' ');
|
||||||
|
|
||||||
function triggerSelectFile() {
|
function triggerSelectFile() {
|
||||||
if (uploadLoading.value) {
|
if (props.disabled || uploadLoading.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileInputRef.value?.click();
|
fileInputRef.value?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNativeFileChange(event: Event) {
|
async function uploadFiles(files: File[]) {
|
||||||
const input = event.target as HTMLInputElement;
|
if (props.disabled || files.length === 0) {
|
||||||
const files = Array.from(input.files || []);
|
|
||||||
if (files.length === 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +64,17 @@ async function handleNativeFileChange(event: Event) {
|
|||||||
const uploadedFiles = [];
|
const uploadedFiles = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||||
uploadedFiles.push(buildWorkflowFileValueFromUpload(file, res?.data?.path));
|
uploadedFiles.push(
|
||||||
|
buildWorkflowFileValueFromUpload(file, res?.data?.path),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const nextFiles = appendWorkflowFileValues(currentFiles.value, uploadedFiles);
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextFiles = appendWorkflowFileValues(
|
||||||
|
currentFiles.value,
|
||||||
|
uploadedFiles,
|
||||||
|
);
|
||||||
validateWorkflowFileValues(nextFiles);
|
validateWorkflowFileValues(nextFiles);
|
||||||
emit('update:modelValue', nextFiles);
|
emit('update:modelValue', nextFiles);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -62,32 +82,38 @@ async function handleNativeFileChange(event: Event) {
|
|||||||
console.error('工作流文件上传失败', error);
|
console.error('工作流文件上传失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
uploadLoading.value = false;
|
uploadLoading.value = false;
|
||||||
input.value = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChooseResource(resources: any) {
|
async function handleNativeFileChange(event: Event) {
|
||||||
try {
|
const input = event.target as HTMLInputElement;
|
||||||
const resourceList = Array.isArray(resources) ? resources : [resources];
|
await uploadFiles([...(input.files || [])]);
|
||||||
const fileValues = resourceList
|
input.value = '';
|
||||||
.map((resource) => buildWorkflowFileValueFromResource(resource || {}))
|
}
|
||||||
.filter(Boolean);
|
|
||||||
const nextFiles = appendWorkflowFileValues(currentFiles.value, fileValues);
|
function setDragActive(active: boolean) {
|
||||||
validateWorkflowFileValues(nextFiles);
|
if (!props.disabled) {
|
||||||
emit('update:modelValue', nextFiles);
|
dragActive.value = active;
|
||||||
} catch (error: any) {
|
|
||||||
ElMessage.error(error?.message || '素材文件选择失败');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDrop(event: DragEvent) {
|
||||||
|
dragActive.value = false;
|
||||||
|
if (props.disabled || uploadLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await uploadFiles([...(event.dataTransfer?.files || [])]);
|
||||||
|
}
|
||||||
|
|
||||||
function removeFile(filePath: string) {
|
function removeFile(filePath: string) {
|
||||||
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath);
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextFiles = currentFiles.value.filter(
|
||||||
|
(item) => item.filePath !== filePath,
|
||||||
|
);
|
||||||
emit('update:modelValue', nextFiles);
|
emit('update:modelValue', nextFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFiles() {
|
|
||||||
emit('update:modelValue', []);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,14 +122,42 @@ function clearFiles() {
|
|||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
class="workflow-file-input__native"
|
class="workflow-file-input__native"
|
||||||
type="file"
|
type="file"
|
||||||
|
:disabled="disabled"
|
||||||
multiple
|
multiple
|
||||||
@change="handleNativeFileChange"
|
@change="handleNativeFileChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="workflow-file-input__hint">
|
<div
|
||||||
最多 {{ WORKFLOW_FILE_LIMITS.maxCount }} 个文件,单个不超过
|
v-if="currentFiles.length === 0"
|
||||||
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize) }},总计不超过
|
class="workflow-file-input__dropzone"
|
||||||
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize) }}
|
:class="{ 'is-disabled': disabled, 'is-dragging': dragActive }"
|
||||||
|
@dragenter.prevent="setDragActive(true)"
|
||||||
|
@dragover.prevent="setDragActive(true)"
|
||||||
|
@dragleave.prevent="setDragActive(false)"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="workflow-file-input__upload-trigger"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled || uploadLoading"
|
||||||
|
@click="triggerSelectFile"
|
||||||
|
>
|
||||||
|
<ElIcon class="workflow-file-input__upload-icon">
|
||||||
|
<UploadFilled />
|
||||||
|
</ElIcon>
|
||||||
|
<span class="workflow-file-input__dropzone-copy">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
disabled
|
||||||
|
? '未上传文件'
|
||||||
|
: uploadLoading
|
||||||
|
? '正在上传…'
|
||||||
|
: '拖入文件或点击上传'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<small>单个文件不超过 {{ maxSingleFileSizeText }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentFiles.length > 0" class="workflow-file-input__list">
|
<div v-if="currentFiles.length > 0" class="workflow-file-input__list">
|
||||||
@@ -112,47 +166,39 @@ function clearFiles() {
|
|||||||
:key="item.filePath"
|
:key="item.filePath"
|
||||||
class="workflow-file-input__summary"
|
class="workflow-file-input__summary"
|
||||||
>
|
>
|
||||||
|
<ElIcon class="workflow-file-input__file-icon">
|
||||||
|
<Document />
|
||||||
|
</ElIcon>
|
||||||
<div class="workflow-file-input__content">
|
<div class="workflow-file-input__content">
|
||||||
<div class="workflow-file-input__name">
|
<div class="workflow-file-input__name">
|
||||||
{{ item.fileName }}
|
{{ item.fileName }}
|
||||||
</div>
|
</div>
|
||||||
<div class="workflow-file-input__meta">
|
<div class="workflow-file-input__meta">
|
||||||
<span>{{ formatWorkflowFileSize(item.size) }}</span>
|
<span>{{ formatWorkflowFileSize(item.size) }}</span>
|
||||||
<ElLink
|
<span class="workflow-file-input__ready">
|
||||||
v-if="item.url || item.filePath"
|
<ElIcon><CircleCheck /></ElIcon>
|
||||||
:href="item.url || item.filePath"
|
已上传
|
||||||
target="_blank"
|
</span>
|
||||||
type="primary"
|
|
||||||
>
|
|
||||||
{{ $t('button.view') }}
|
|
||||||
</ElLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ElButton text type="danger" @click="removeFile(item.filePath)">
|
<ElLink
|
||||||
{{ $t('button.delete') }}
|
v-if="item.url || item.filePath"
|
||||||
</ElButton>
|
:href="item.url || item.filePath"
|
||||||
|
target="_blank"
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
{{ $t('button.view') }}
|
||||||
|
</ElLink>
|
||||||
|
<ElButton
|
||||||
|
v-if="!disabled"
|
||||||
|
:icon="Delete"
|
||||||
|
text
|
||||||
|
circle
|
||||||
|
aria-label="删除文件"
|
||||||
|
@click="removeFile(item.filePath)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -161,16 +207,83 @@ function clearFiles() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__native {
|
.workflow-file-input__native {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__hint {
|
.workflow-file-input__dropzone {
|
||||||
font-size: 12px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
line-height: 1.5;
|
background: hsl(var(--surface-subtle));
|
||||||
|
border: 1px dashed var(--el-border-color);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
transition:
|
||||||
|
color var(--motion-duration-base) var(--motion-ease-standard),
|
||||||
|
background-color var(--motion-duration-base) var(--motion-ease-standard),
|
||||||
|
border-color var(--motion-duration-base) var(--motion-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__dropzone:hover,
|
||||||
|
.workflow-file-input__dropzone.is-dragging {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 6%);
|
||||||
|
border-color: hsl(var(--primary) / 48%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__dropzone.is-disabled,
|
||||||
|
.workflow-file-input__dropzone.is-disabled:hover {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-color: var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__upload-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__upload-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--primary) / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__upload-trigger:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__upload-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__dropzone-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
gap: var(--space-1) var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__dropzone-copy small {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__list {
|
.workflow-file-input__list {
|
||||||
@@ -181,39 +294,46 @@ function clearFiles() {
|
|||||||
|
|
||||||
.workflow-file-input__summary {
|
.workflow-file-input__summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
padding: var(--space-3);
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--el-border-color-light);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--el-fill-color-blank);
|
background: var(--el-fill-color-blank);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__file-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 18px;
|
||||||
|
color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__content {
|
.workflow-file-input__content {
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__name {
|
.workflow-file-input__name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
word-break: break-word;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__meta {
|
.workflow-file-input__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__actions {
|
.workflow-file-input__ready {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-wrap: wrap;
|
gap: var(--space-1);
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
|
color: var(--el-color-success);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { resolveWorkflowParameterLabel } from './workflowFormParameters';
|
|||||||
import { hasWorkflowImageValue } from './workflowImageValue';
|
import { hasWorkflowImageValue } from './workflowImageValue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
type: Array<any>,
|
type: Array<any>,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -46,6 +50,10 @@ function isResource(contentType: any) {
|
|||||||
function isFileContentType(contentType: any) {
|
function isFileContentType(contentType: any) {
|
||||||
return contentType === 'file';
|
return contentType === 'file';
|
||||||
}
|
}
|
||||||
|
function isWideItem(item: any) {
|
||||||
|
const contentType = getContentType(item);
|
||||||
|
return item.formType === 'textarea' || contentType === 'image';
|
||||||
|
}
|
||||||
function getCheckboxOptions(item: any) {
|
function getCheckboxOptions(item: any) {
|
||||||
if (item.enums) {
|
if (item.enums) {
|
||||||
return (
|
return (
|
||||||
@@ -74,7 +82,9 @@ function buildRules(item: any) {
|
|||||||
return;
|
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;
|
||||||
}
|
}
|
||||||
if (value && typeof value === 'object') {
|
if (value && typeof value === 'object') {
|
||||||
@@ -91,10 +101,16 @@ function buildRules(item: any) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
function updateParam(name: string, value: any) {
|
function updateParam(name: string, value: any) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newValue = { ...props.runParams, [name]: value };
|
const newValue = { ...props.runParams, [name]: value };
|
||||||
emit('update:runParams', newValue);
|
emit('update:runParams', newValue);
|
||||||
}
|
}
|
||||||
function choose(data: any, propName: string) {
|
function choose(data: any, propName: string) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateParam(propName, data.resourceUrl);
|
updateParam(propName, data.resourceUrl);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -102,6 +118,8 @@ function choose(data: any, propName: string) {
|
|||||||
<template>
|
<template>
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
v-for="(item, idx) in parameters"
|
v-for="(item, idx) in parameters"
|
||||||
|
class="workflow-form-item"
|
||||||
|
:class="{ 'is-wide': isWideItem(item) }"
|
||||||
:prop="`${propPrefix}${item.name}`"
|
:prop="`${propPrefix}${item.name}`"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
:label="resolveWorkflowParameterLabel(item)"
|
:label="resolveWorkflowParameterLabel(item)"
|
||||||
@@ -110,12 +128,14 @@ function choose(data: any, propName: string) {
|
|||||||
<template v-if="getContentType(item) === 'text'">
|
<template v-if="getContentType(item) === 'text'">
|
||||||
<ElInput
|
<ElInput
|
||||||
v-if="item.formType === 'input' || !item.formType"
|
v-if="item.formType === 'input' || !item.formType"
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
/>
|
/>
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-if="item.formType === 'select'"
|
v-if="item.formType === 'select'"
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
@@ -124,6 +144,7 @@ function choose(data: any, propName: string) {
|
|||||||
/>
|
/>
|
||||||
<ElInput
|
<ElInput
|
||||||
v-if="item.formType === 'textarea'"
|
v-if="item.formType === 'textarea'"
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
@@ -132,12 +153,14 @@ function choose(data: any, propName: string) {
|
|||||||
/>
|
/>
|
||||||
<ElRadioGroup
|
<ElRadioGroup
|
||||||
v-if="item.formType === 'radio'"
|
v-if="item.formType === 'radio'"
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:options="getCheckboxOptions(item)"
|
:options="getCheckboxOptions(item)"
|
||||||
/>
|
/>
|
||||||
<ElCheckboxGroup
|
<ElCheckboxGroup
|
||||||
v-if="item.formType === 'checkbox'"
|
v-if="item.formType === 'checkbox'"
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:options="getCheckboxOptions(item)"
|
:options="getCheckboxOptions(item)"
|
||||||
@@ -145,6 +168,7 @@ function choose(data: any, propName: string) {
|
|||||||
</template>
|
</template>
|
||||||
<template v-if="getContentType(item) === 'other'">
|
<template v-if="getContentType(item) === 'other'">
|
||||||
<ElInput
|
<ElInput
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
@@ -152,23 +176,30 @@ function choose(data: any, propName: string) {
|
|||||||
</template>
|
</template>
|
||||||
<template v-if="isFileContentType(getContentType(item))">
|
<template v-if="isFileContentType(getContentType(item))">
|
||||||
<WorkflowFileInput
|
<WorkflowFileInput
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="getContentType(item) === 'image'">
|
<template v-if="getContentType(item) === 'image'">
|
||||||
<WorkflowImageInput
|
<WorkflowImageInput
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="isResource(getContentType(item))">
|
<template v-if="isResource(getContentType(item))">
|
||||||
<ElInput
|
<ElInput
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
/>
|
/>
|
||||||
<ChooseResource :attr-name="item.name" @choose="choose" />
|
<ChooseResource
|
||||||
|
:attr-name="item.name"
|
||||||
|
:disabled="disabled"
|
||||||
|
@choose="choose"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<ElAlert v-if="item.formDescription" type="info" style="margin-top: 5px">
|
<ElAlert v-if="item.formDescription" type="info" style="margin-top: 5px">
|
||||||
{{ item.formDescription }}
|
{{ item.formDescription }}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
} from './workflowImageValue';
|
} from './workflowImageValue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: [String, Object],
|
type: [String, Object],
|
||||||
default: undefined,
|
default: undefined,
|
||||||
@@ -33,9 +37,7 @@ const urlInput = ref('');
|
|||||||
const currentImage = computed(() =>
|
const currentImage = computed(() =>
|
||||||
normalizeWorkflowImageValue(props.modelValue),
|
normalizeWorkflowImageValue(props.modelValue),
|
||||||
);
|
);
|
||||||
const previewUrl = computed(() =>
|
const previewUrl = computed(() => getWorkflowImagePreviewUrl(props.modelValue));
|
||||||
getWorkflowImagePreviewUrl(props.modelValue),
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
@@ -47,6 +49,9 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function applyUrl() {
|
function applyUrl() {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
|
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -55,7 +60,7 @@ function applyUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerSelectFile() {
|
function triggerSelectFile() {
|
||||||
if (!uploadLoading.value) {
|
if (!props.disabled && !uploadLoading.value) {
|
||||||
fileInputRef.value?.click();
|
fileInputRef.value?.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,13 +68,16 @@ function triggerSelectFile() {
|
|||||||
async function handleNativeFileChange(event: Event) {
|
async function handleNativeFileChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) {
|
if (props.disabled || !file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadLoading.value = true;
|
uploadLoading.value = true;
|
||||||
try {
|
try {
|
||||||
validateWorkflowImageFile(file);
|
validateWorkflowImageFile(file);
|
||||||
const response = await api.upload('/api/v1/commons/upload', { file }, {});
|
const response = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit(
|
emit(
|
||||||
'update:modelValue',
|
'update:modelValue',
|
||||||
buildWorkflowImageValueFromUpload(file, response?.data?.path),
|
buildWorkflowImageValueFromUpload(file, response?.data?.path),
|
||||||
@@ -84,6 +92,9 @@ async function handleNativeFileChange(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleChooseResource(resource: any) {
|
function handleChooseResource(resource: any) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
emit(
|
emit(
|
||||||
'update:modelValue',
|
'update:modelValue',
|
||||||
@@ -95,6 +106,9 @@ function handleChooseResource(resource: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearImage() {
|
function clearImage() {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
urlInput.value = '';
|
urlInput.value = '';
|
||||||
emit('update:modelValue', undefined);
|
emit('update:modelValue', undefined);
|
||||||
}
|
}
|
||||||
@@ -107,6 +121,7 @@ function clearImage() {
|
|||||||
class="workflow-image-input__native"
|
class="workflow-image-input__native"
|
||||||
type="file"
|
type="file"
|
||||||
:accept="WORKFLOW_IMAGE_LIMITS.accept"
|
:accept="WORKFLOW_IMAGE_LIMITS.accept"
|
||||||
|
:disabled="disabled"
|
||||||
@change="handleNativeFileChange"
|
@change="handleNativeFileChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -143,11 +158,12 @@ function clearImage() {
|
|||||||
<ElInput
|
<ElInput
|
||||||
v-model="urlInput"
|
v-model="urlInput"
|
||||||
clearable
|
clearable
|
||||||
|
:disabled="disabled"
|
||||||
placeholder="输入 HTTP/HTTPS 图片 URL"
|
placeholder="输入 HTTP/HTTPS 图片 URL"
|
||||||
@keyup.enter="applyUrl"
|
@keyup.enter="applyUrl"
|
||||||
>
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
<ElButton @click="applyUrl">使用 URL</ElButton>
|
<ElButton :disabled="disabled" @click="applyUrl">使用 URL</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</ElInput>
|
</ElInput>
|
||||||
|
|
||||||
@@ -155,6 +171,7 @@ function clearImage() {
|
|||||||
<ElButton
|
<ElButton
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
|
:disabled="disabled"
|
||||||
:loading="uploadLoading"
|
:loading="uploadLoading"
|
||||||
@click="triggerSelectFile"
|
@click="triggerSelectFile"
|
||||||
>
|
>
|
||||||
@@ -162,10 +179,16 @@ function clearImage() {
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
<ChooseResource
|
<ChooseResource
|
||||||
attr-name="image"
|
attr-name="image"
|
||||||
|
:disabled="disabled"
|
||||||
:resource-type="0"
|
:resource-type="0"
|
||||||
@choose="handleChooseResource"
|
@choose="handleChooseResource"
|
||||||
/>
|
/>
|
||||||
<ElButton v-if="currentImage" text type="danger" @click="clearImage">
|
<ElButton
|
||||||
|
v-if="currentImage && !disabled"
|
||||||
|
text
|
||||||
|
type="danger"
|
||||||
|
@click="clearImage"
|
||||||
|
>
|
||||||
清空
|
清空
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { defineComponent, nextTick, ref } from 'vue';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import WorkflowFileInput from '../WorkflowFileInput.vue';
|
||||||
|
|
||||||
|
describe('workflow file input', () => {
|
||||||
|
it('shows the upload area again after the uploaded file is deleted', async () => {
|
||||||
|
const Host = defineComponent({
|
||||||
|
components: { WorkflowFileInput },
|
||||||
|
setup() {
|
||||||
|
const value = ref([
|
||||||
|
{
|
||||||
|
fileName: '需求说明.pdf',
|
||||||
|
filePath: '/files/requirements.pdf',
|
||||||
|
size: 1024,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return { value };
|
||||||
|
},
|
||||||
|
template: '<WorkflowFileInput v-model="value" />',
|
||||||
|
});
|
||||||
|
const wrapper = mount(Host);
|
||||||
|
|
||||||
|
expect(wrapper.find('.workflow-file-input__dropzone').exists()).toBe(false);
|
||||||
|
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(true);
|
||||||
|
|
||||||
|
await wrapper.get('button[aria-label="删除文件"]').trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(false);
|
||||||
|
expect(wrapper.find('.workflow-file-input__dropzone').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the delete action and disables upload when parameters are locked', async () => {
|
||||||
|
const wrapper = mount(WorkflowFileInput, {
|
||||||
|
props: {
|
||||||
|
disabled: true,
|
||||||
|
modelValue: [
|
||||||
|
{
|
||||||
|
fileName: '需求说明.pdf',
|
||||||
|
filePath: '/files/requirements.pdf',
|
||||||
|
size: 1024,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('button[aria-label="删除文件"]').exists()).toBe(false);
|
||||||
|
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(true);
|
||||||
|
|
||||||
|
await wrapper.setProps({ modelValue: [] });
|
||||||
|
expect(
|
||||||
|
wrapper.get('.workflow-file-input__upload-trigger').attributes(),
|
||||||
|
).toHaveProperty('disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -93,16 +93,30 @@ describe('resolveWorkflowFormParameters', () => {
|
|||||||
).toBe('开始节点 > customer_name');
|
).toBe('开始节点 > customer_name');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves an explicitly configured trial-run label', () => {
|
it('uses the configured parameter name instead of a type-derived label', () => {
|
||||||
const parameter = {
|
const parameter = {
|
||||||
name: 'customer_name',
|
name: 'start_1.file111',
|
||||||
formLabel: '客户名称',
|
formLabel: '文件',
|
||||||
displayName: '开始节点 > 客户名称',
|
displayName: '开始节点 > 文件',
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(resolveWorkflowParameterLabel(parameter)).toBe('客户名称');
|
expect(resolveWorkflowParameterLabel(parameter)).toBe('file111');
|
||||||
expect(resolveWorkflowParameterDisplayName(parameter)).toBe(
|
expect(resolveWorkflowParameterDisplayName(parameter)).toBe(
|
||||||
'开始节点 > 客户名称',
|
'开始节点 > file111',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the configured system question label', () => {
|
||||||
|
const parameter = {
|
||||||
|
name: 'user_input',
|
||||||
|
formLabel: '用户问题123',
|
||||||
|
displayName: '流程开始 > 用户问题123',
|
||||||
|
systemReserved: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveWorkflowParameterLabel(parameter)).toBe('用户问题123');
|
||||||
|
expect(resolveWorkflowParameterDisplayName(parameter)).toBe(
|
||||||
|
'流程开始 > 用户问题123',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildWorkflowFormParameterSummaries,
|
||||||
buildWorkflowFormSubmissionImages,
|
buildWorkflowFormSubmissionImages,
|
||||||
buildWorkflowFormSubmissionText,
|
buildWorkflowFormSubmissionText,
|
||||||
hasRequiredWorkflowFormParameters,
|
hasRequiredWorkflowFormParameters,
|
||||||
@@ -67,4 +68,43 @@ describe('workflowFormPresentation', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds compact summaries for configured workflow parameters', () => {
|
||||||
|
expect(
|
||||||
|
buildWorkflowFormParameterSummaries(
|
||||||
|
[
|
||||||
|
{ name: 'customer', formLabel: '客户名称', required: true },
|
||||||
|
{ name: 'scene', formLabel: '业务场景', required: false },
|
||||||
|
{ name: 'files', formLabel: '需求附件', required: true },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
customer: '华北分公司',
|
||||||
|
scene: '',
|
||||||
|
files: [{ fileName: '需求说明.pdf' }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
key: 'customer',
|
||||||
|
label: 'customer',
|
||||||
|
ready: true,
|
||||||
|
required: true,
|
||||||
|
value: '华北分公司',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scene',
|
||||||
|
label: 'scene',
|
||||||
|
ready: false,
|
||||||
|
required: false,
|
||||||
|
value: '待填写',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'files',
|
||||||
|
label: 'files',
|
||||||
|
ready: true,
|
||||||
|
required: true,
|
||||||
|
value: '需求说明.pdf',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWorkflowRunDraftKey,
|
||||||
|
hasWorkflowRunDraftContent,
|
||||||
|
readWorkflowRunDraft,
|
||||||
|
removeWorkflowRunDraft,
|
||||||
|
WORKFLOW_RUN_DRAFT_TTL_MS,
|
||||||
|
writeWorkflowRunDraft,
|
||||||
|
} from '../workflowRunDraft';
|
||||||
|
|
||||||
|
const parameters = [
|
||||||
|
{ contentType: 'text', formType: 'input', name: 'company' },
|
||||||
|
{ contentType: 'file', formType: 'input', name: 'attachment' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('workflowRunDraft', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates drafts by workflow, account and run mode', () => {
|
||||||
|
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false)).not.toBe(
|
||||||
|
buildWorkflowRunDraftKey('flow-1', 'tenant:user-2', false),
|
||||||
|
);
|
||||||
|
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false)).not.toBe(
|
||||||
|
buildWorkflowRunDraftKey('flow-2', 'tenant:user-1', false),
|
||||||
|
);
|
||||||
|
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', true)).not.toBe(
|
||||||
|
buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores current compatible fields within twelve hours', () => {
|
||||||
|
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
|
||||||
|
writeWorkflowRunDraft(
|
||||||
|
sessionStorage,
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
question: '分析合同',
|
||||||
|
values: {
|
||||||
|
attachment: [{ name: 'contract.pdf', url: '/contract.pdf' }],
|
||||||
|
company: '华北分公司',
|
||||||
|
removedField: '旧字段',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readWorkflowRunDraft(sessionStorage, key, parameters, 2000)).toEqual(
|
||||||
|
{
|
||||||
|
question: '分析合同',
|
||||||
|
values: {
|
||||||
|
attachment: [{ name: 'contract.pdf', url: '/contract.pdf' }],
|
||||||
|
company: '华北分公司',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops expired drafts', () => {
|
||||||
|
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
|
||||||
|
writeWorkflowRunDraft(
|
||||||
|
sessionStorage,
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
question: '过期内容',
|
||||||
|
values: { attachment: '错误文件值', company: ['错误文本值'] },
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
readWorkflowRunDraft(
|
||||||
|
sessionStorage,
|
||||||
|
key,
|
||||||
|
parameters,
|
||||||
|
1000 + WORKFLOW_RUN_DRAFT_TTL_MS,
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(sessionStorage.getItem(key)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only persists user changes and supports an explicit reset', () => {
|
||||||
|
const defaults = { attachment: [], company: '' };
|
||||||
|
expect(hasWorkflowRunDraftContent('', defaults, defaults)).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasWorkflowRunDraftContent(
|
||||||
|
'',
|
||||||
|
{ ...defaults, company: '华北' },
|
||||||
|
defaults,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(hasWorkflowRunDraftContent('待处理', defaults, defaults)).toBe(true);
|
||||||
|
|
||||||
|
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
|
||||||
|
writeWorkflowRunDraft(sessionStorage, key, {
|
||||||
|
question: '待处理',
|
||||||
|
values: defaults,
|
||||||
|
});
|
||||||
|
removeWorkflowRunDraft(sessionStorage, key);
|
||||||
|
expect(sessionStorage.getItem(key)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,22 @@ function configuredParameterName(name: unknown) {
|
|||||||
return nameParts[nameParts.length - 1] || normalizedName;
|
return nameParts[nameParts.length - 1] || normalizedName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSystemParameter(parameter: any, name: string) {
|
||||||
|
return parameter?.systemReserved === true || name === 'user_input';
|
||||||
|
}
|
||||||
|
|
||||||
|
function withConfiguredParameterName(label: unknown, name: unknown) {
|
||||||
|
const normalizedLabel = String(label || '').trim();
|
||||||
|
const parameterName = configuredParameterName(name);
|
||||||
|
if (!parameterName) {
|
||||||
|
return normalizedLabel;
|
||||||
|
}
|
||||||
|
const parts = normalizedLabel.split('>').map((part) => part.trim());
|
||||||
|
return parts.length > 1
|
||||||
|
? `${parts.slice(0, -1).join(' > ')} > ${parameterName}`
|
||||||
|
: parameterName;
|
||||||
|
}
|
||||||
|
|
||||||
function replaceDefaultParameterLabel(label: unknown, name: unknown) {
|
function replaceDefaultParameterLabel(label: unknown, name: unknown) {
|
||||||
const normalizedLabel = String(label || '').trim();
|
const normalizedLabel = String(label || '').trim();
|
||||||
const normalizedName = String(name || '').trim();
|
const normalizedName = String(name || '').trim();
|
||||||
@@ -48,6 +64,9 @@ function replaceDefaultParameterLabel(label: unknown, name: unknown) {
|
|||||||
*/
|
*/
|
||||||
export function resolveWorkflowParameterLabel(parameter: any) {
|
export function resolveWorkflowParameterLabel(parameter: any) {
|
||||||
const name = String(parameter?.name || '').trim();
|
const name = String(parameter?.name || '').trim();
|
||||||
|
if (!isSystemParameter(parameter, name) && name) {
|
||||||
|
return configuredParameterName(name);
|
||||||
|
}
|
||||||
const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
|
const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
|
||||||
const displayName = replaceDefaultParameterLabel(
|
const displayName = replaceDefaultParameterLabel(
|
||||||
parameter?.displayName,
|
parameter?.displayName,
|
||||||
@@ -64,6 +83,12 @@ export function resolveWorkflowParameterLabel(parameter: any) {
|
|||||||
*/
|
*/
|
||||||
export function resolveWorkflowParameterDisplayName(parameter: any) {
|
export function resolveWorkflowParameterDisplayName(parameter: any) {
|
||||||
const name = String(parameter?.name || '').trim();
|
const name = String(parameter?.name || '').trim();
|
||||||
|
if (!isSystemParameter(parameter, name) && name) {
|
||||||
|
return withConfiguredParameterName(
|
||||||
|
parameter?.displayName || parameter?.formLabel,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}
|
||||||
const displayName = replaceDefaultParameterLabel(
|
const displayName = replaceDefaultParameterLabel(
|
||||||
parameter?.displayName,
|
parameter?.displayName,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import { resolveWorkflowParameterLabel } from './workflowFormParameters';
|
||||||
import {
|
import {
|
||||||
getWorkflowImagePreviewUrl,
|
getWorkflowImagePreviewUrl,
|
||||||
normalizeWorkflowImageValue,
|
normalizeWorkflowImageValue,
|
||||||
} from './workflowImageValue';
|
} from './workflowImageValue';
|
||||||
|
|
||||||
|
export interface WorkflowFormParameterSummary {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
ready: boolean;
|
||||||
|
required: boolean;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断附加表单是否存在必填参数。
|
* 判断附加表单是否存在必填参数。
|
||||||
*
|
*
|
||||||
@@ -44,6 +53,29 @@ export function buildWorkflowFormSubmissionText(
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建运行参数的紧凑摘要。
|
||||||
|
*
|
||||||
|
* @param parameters 运行参数
|
||||||
|
* @param values 表单值
|
||||||
|
* @returns 可用于收起态展示的参数摘要
|
||||||
|
*/
|
||||||
|
export function buildWorkflowFormParameterSummaries(
|
||||||
|
parameters: any[],
|
||||||
|
values: Record<string, any>,
|
||||||
|
): WorkflowFormParameterSummary[] {
|
||||||
|
return parameters.map((parameter) => {
|
||||||
|
const value = formatWorkflowFormValue(values[parameter?.name]);
|
||||||
|
return {
|
||||||
|
key: String(parameter?.name || ''),
|
||||||
|
label: resolveWorkflowParameterLabel(parameter),
|
||||||
|
ready: Boolean(value),
|
||||||
|
required: parameter?.required === true,
|
||||||
|
value: value || '待填写',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将图片表单字段转换为聊天图片附件。
|
* 将图片表单字段转换为聊天图片附件。
|
||||||
*
|
*
|
||||||
@@ -86,7 +118,7 @@ export function buildWorkflowFormSubmissionImages(
|
|||||||
* @param value 表单字段值
|
* @param value 表单字段值
|
||||||
* @returns 用户可读文本;空值返回空字符串
|
* @returns 用户可读文本;空值返回空字符串
|
||||||
*/
|
*/
|
||||||
function formatWorkflowFormValue(value: any): string {
|
export function formatWorkflowFormValue(value: any): string {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
const WORKFLOW_RUN_DRAFT_PREFIX = 'easyflow:workflow-run-draft';
|
||||||
|
const WORKFLOW_RUN_DRAFT_VERSION = 1;
|
||||||
|
|
||||||
|
export const WORKFLOW_RUN_DRAFT_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
interface WorkflowRunDraftPayload {
|
||||||
|
question: string;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredWorkflowRunDraft extends WorkflowRunDraftPayload {
|
||||||
|
expiresAt: number;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>;
|
||||||
|
|
||||||
|
/** 获取可用的会话存储。 */
|
||||||
|
export function getWorkflowRunDraftStorage() {
|
||||||
|
try {
|
||||||
|
return globalThis.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成按工作流、账号和运行模式隔离的草稿键。 */
|
||||||
|
export function buildWorkflowRunDraftKey(
|
||||||
|
workflowId: string,
|
||||||
|
identity: string,
|
||||||
|
shareMode: boolean,
|
||||||
|
) {
|
||||||
|
const mode = shareMode ? 'share' : 'private';
|
||||||
|
const scope = shareMode ? 'public' : identity || 'anonymous';
|
||||||
|
return [
|
||||||
|
WORKFLOW_RUN_DRAFT_PREFIX,
|
||||||
|
`v${WORKFLOW_RUN_DRAFT_VERSION}`,
|
||||||
|
mode,
|
||||||
|
encodeURIComponent(scope),
|
||||||
|
encodeURIComponent(workflowId),
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取并按当前工作流参数定义过滤草稿。 */
|
||||||
|
export function readWorkflowRunDraft(
|
||||||
|
storage: DraftStorage | undefined,
|
||||||
|
key: string,
|
||||||
|
parameters: any[],
|
||||||
|
now = Date.now(),
|
||||||
|
): undefined | WorkflowRunDraftPayload {
|
||||||
|
if (!storage || !key) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = storage.getItem(key);
|
||||||
|
if (!raw) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const draft = JSON.parse(raw) as Partial<StoredWorkflowRunDraft>;
|
||||||
|
if (
|
||||||
|
draft.version !== WORKFLOW_RUN_DRAFT_VERSION ||
|
||||||
|
typeof draft.expiresAt !== 'number' ||
|
||||||
|
draft.expiresAt <= now ||
|
||||||
|
typeof draft.question !== 'string' ||
|
||||||
|
!draft.values ||
|
||||||
|
typeof draft.values !== 'object' ||
|
||||||
|
Array.isArray(draft.values)
|
||||||
|
) {
|
||||||
|
storage.removeItem(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const parameter of parameters) {
|
||||||
|
const name = String(parameter?.name || '').trim();
|
||||||
|
if (
|
||||||
|
name &&
|
||||||
|
Object.prototype.hasOwnProperty.call(draft.values, name) &&
|
||||||
|
isCompatibleDraftValue(parameter, draft.values[name])
|
||||||
|
) {
|
||||||
|
values[name] = draft.values[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { question: draft.question, values };
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
storage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时无需影响页面加载。
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存工作流运行草稿,并设置 12 小时过期时间。 */
|
||||||
|
export function writeWorkflowRunDraft(
|
||||||
|
storage: DraftStorage | undefined,
|
||||||
|
key: string,
|
||||||
|
draft: WorkflowRunDraftPayload,
|
||||||
|
now = Date.now(),
|
||||||
|
) {
|
||||||
|
if (!storage || !key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
storage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
...draft,
|
||||||
|
expiresAt: now + WORKFLOW_RUN_DRAFT_TTL_MS,
|
||||||
|
version: WORKFLOW_RUN_DRAFT_VERSION,
|
||||||
|
} satisfies StoredWorkflowRunDraft),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 存储不可用或空间不足时不阻断工作流输入。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除工作流运行草稿。 */
|
||||||
|
export function removeWorkflowRunDraft(
|
||||||
|
storage: DraftStorage | undefined,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
if (!storage || !key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
storage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时无需影响重置流程。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断当前输入是否包含需要持久化的用户修改。 */
|
||||||
|
export function hasWorkflowRunDraftContent(
|
||||||
|
question: string,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
defaults: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (question.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Object.keys(values).some(
|
||||||
|
(name) => !isSameDraftValue(values[name], defaults[name]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompatibleDraftValue(parameter: any, value: unknown) {
|
||||||
|
const contentType = String(parameter?.contentType || '').toLowerCase();
|
||||||
|
const formType = String(parameter?.formType || '').toLowerCase();
|
||||||
|
if (contentType === 'file' || formType === 'checkbox') {
|
||||||
|
return Array.isArray(value);
|
||||||
|
}
|
||||||
|
if (contentType === 'image') {
|
||||||
|
return Boolean(value) && typeof value === 'object';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
value === null ||
|
||||||
|
typeof value === 'boolean' ||
|
||||||
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDraftValue(left: unknown, right: unknown) {
|
||||||
|
if (left === right) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user