feat: 完善用户确认节点选项与输出流转

- 重构确认节点单选多选配置及输出参数契约

- 统一管理端、用户中心、匿名分享和公共接口恢复流程

- 增加保存校验、错误契约及交互测试
This commit is contained in:
2026-09-04 14:55:55 +08:00
parent b99e275371
commit c7cac61ce8
51 changed files with 2465 additions and 1591 deletions

View File

@@ -1,210 +0,0 @@
<script setup lang="ts">
import { Download } from '@element-plus/icons-vue';
import { ElIcon, ElText } from 'element-plus';
import confirmFile from '#/assets/ai/workflow/confirm-file.png';
// 导入你的图片资源
import confirmOther from '#/assets/ai/workflow/confirm-other.png';
// 定义 Props
const props = defineProps({
// v-model 绑定值
modelValue: {
type: [String, Number, Object],
default: null,
},
// 数据类型: text, image, video, audio, other, file
selectionDataType: {
type: String,
default: 'text',
},
// 数据列表
selectionData: {
type: Array as () => any[],
default: () => [],
},
});
// 定义 Emits
const emit = defineEmits(['update:modelValue', 'change']);
// 判断是否选中
const isSelected = (item: any) => {
return props.modelValue === item;
};
// 切换选中状态
const changeValue = (item: any) => {
if (props.modelValue === item) {
// 如果点击已选中的,则取消选中
emit('update:modelValue', null);
emit('change', null); // 触发 Element Plus 表单验证
} else {
emit('update:modelValue', item);
emit('change', item); // 触发 Element Plus 表单验证
}
};
// 获取图标
const getIcon = (type: string) => {
return type === 'other' ? confirmOther : confirmFile;
};
// 下载处理
const handleDownload = (url: string) => {
window.open(url, '_blank');
};
</script>
<template>
<div class="custom-radio-group">
<template v-for="(item, index) in selectionData" :key="index">
<!-- 类型: Text -->
<div
v-if="selectionDataType === 'text'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
{{ item }}
</div>
<!-- 类型: Image -->
<div
v-else-if="selectionDataType === 'image'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="padding: 0"
@click="changeValue(item)"
>
<img
:src="item"
alt=""
style="width: 80px; height: 80px; border-radius: 8px; display: block"
/>
</div>
<!-- 类型: Video -->
<div
v-else-if="selectionDataType === 'video'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
@click="changeValue(item)"
>
<video controls :src="item" style="width: 162px; height: 141px"></video>
</div>
<!-- 类型: Audio -->
<div
v-else-if="selectionDataType === 'audio'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
<audio
controls
:src="item"
style="width: 100%; height: 44px; margin-top: 8px"
></audio>
</div>
<!-- 类型: File Other -->
<div
v-else-if="
selectionDataType === 'other' || selectionDataType === 'file'
"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
>
<div style="width: 92%; display: flex; align-items: center">
<img
style="width: 20px; height: 20px; margin-right: 8px"
alt=""
:src="getIcon(selectionDataType)"
/>
<!-- 使用 Element Plus 的 Text 组件处理省略号,如果没有安装 Element Plus可以用普通的 span + css -->
<ElText truncated>
{{ item }}
</ElText>
</div>
<div class="download-icon-btn" @click.stop="handleDownload(item)">
<ElIcon><Download /></ElIcon>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.custom-radio-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.custom-radio-option {
background-color: var(--el-bg-color);
padding: 8px;
border-radius: 8px;
cursor: pointer;
position: relative;
box-shadow: 0 0 0 1px var(--el-border-color);
transition: all 0.2s;
box-sizing: border-box; /* 确保 padding 不会撑大宽度 */
}
.custom-radio-option:hover {
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
.custom-radio-option.selected {
box-shadow: 0 0 0 1px var(--el-color-primary-light-3);
padding: 8px;
background: var(--el-color-primary-light-9);
}
.custom-radio-option.selected::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
background-color: var(--el-color-primary);
border-radius: 6px 2px;
box-sizing: border-box;
}
.custom-radio-option.selected::before {
content: '';
position: absolute;
right: 3px;
bottom: 7px;
width: 9px;
height: 4px;
border-left: 1px solid white;
border-bottom: 1px solid white;
transform: rotate(-45deg);
z-index: 1;
}
.download-icon-btn {
font-size: 18px;
cursor: pointer;
margin-right: 10px;
display: flex; /* 为了对齐图标 */
align-items: center;
}
</style>

View File

@@ -1,216 +0,0 @@
<script setup lang="ts">
import { Download } from '@element-plus/icons-vue';
import { ElIcon, ElText } from 'element-plus';
import confirmFile from '#/assets/ai/workflow/confirm-file.png';
// 导入你的图片资源
import confirmOther from '#/assets/ai/workflow/confirm-other.png';
// 定义 Props
const props = defineProps({
// v-model 绑定值,多选版本这里是数组
modelValue: {
type: Array as () => any[],
default: () => [],
},
// 数据类型: text, image, video, audio, other, file
selectionDataType: {
type: String,
default: 'text',
},
// 数据列表
selectionData: {
type: Array as () => any[],
default: () => [],
},
});
// 定义 Emits
const emit = defineEmits(['update:modelValue', 'change']);
// 判断是否选中
const isSelected = (item: any) => {
return props.modelValue && props.modelValue.includes(item);
};
// 切换选中状态 (多选逻辑)
const changeValue = (item: any) => {
// 复制一份当前数组,避免直接修改 prop
const currentValues = props.modelValue ? [...props.modelValue] : [];
const index = currentValues.indexOf(item);
if (index === -1) {
// 如果不存在,则添加
currentValues.push(item);
} else {
// 如果已存在,则移除
currentValues.splice(index, 1);
}
// 更新 v-model
emit('update:modelValue', currentValues);
// 触发 Element Plus 表单验证
emit('change', currentValues);
};
// 获取图标
const getIcon = (type: string) => {
return type === 'other' ? confirmOther : confirmFile;
};
// 下载处理
const handleDownload = (url: string) => {
window.open(url, '_blank');
};
</script>
<template>
<div class="custom-radio-group">
<template v-for="(item, index) in selectionData" :key="index">
<!-- 类型: Text -->
<div
v-if="selectionDataType === 'text'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
{{ item }}
</div>
<!-- 类型: Image -->
<div
v-else-if="selectionDataType === 'image'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="padding: 0"
@click="changeValue(item)"
>
<img
:src="item"
alt=""
style="width: 80px; height: 80px; border-radius: 8px; display: block"
/>
</div>
<!-- 类型: Video -->
<div
v-else-if="selectionDataType === 'video'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
@click="changeValue(item)"
>
<video controls :src="item" style="width: 162px; height: 141px"></video>
</div>
<!-- 类型: Audio -->
<div
v-else-if="selectionDataType === 'audio'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 300px; flex-shrink: 0"
@click="changeValue(item)"
>
<audio controls :src="item" style="width: 100%; height: 40px"></audio>
</div>
<!-- 类型: File Other -->
<div
v-else-if="
selectionDataType === 'other' || selectionDataType === 'file'
"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
>
<div style="width: 92%; display: flex; align-items: center">
<img
style="width: 20px; height: 20px; margin-right: 8px"
alt=""
:src="getIcon(selectionDataType)"
/>
<!-- 使用 Element Plus 的 Text 组件处理省略号 -->
<ElText truncated>
{{ item }}
</ElText>
</div>
<div class="download-icon-btn" @click.stop="handleDownload(item)">
<ElIcon><Download /></ElIcon>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* 这里复用之前的 CSS样式完全一致 */
.custom-radio-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.custom-radio-option {
background-color: var(--el-bg-color);
padding: 8px;
border-radius: 8px;
cursor: pointer;
position: relative;
box-shadow: 0 0 0 1px var(--el-border-color);
transition: all 0.2s;
box-sizing: border-box;
}
.custom-radio-option:hover {
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
.custom-radio-option.selected {
box-shadow: 0 0 0 1px var(--el-color-primary-light-3);
padding: 8px;
background: var(--el-color-primary-light-9);
}
.custom-radio-option.selected::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
background-color: var(--el-color-primary);
border-radius: 6px 2px;
box-sizing: border-box;
}
.custom-radio-option.selected::before {
content: '';
position: absolute;
right: 3px;
bottom: 7px;
width: 9px;
height: 5px;
border-left: 1px solid white;
border-bottom: 1px solid white;
transform: rotate(-45deg);
z-index: 1;
}
.download-icon-btn {
font-size: 18px;
cursor: pointer;
margin-right: 10px;
display: flex;
align-items: center;
}
</style>

View File

@@ -86,14 +86,22 @@ watch(
},
);
const executeId = ref('');
function resume(data: any) {
async function resume(data: any) {
data.executeId = executeId.value;
submitLoading.value = true;
api.post('/userCenter/workflow/resume', data).then((res) => {
let accepted = false;
try {
const res = await api.post('/userCenter/workflow/resume', data);
if (res.errorCode === 0) {
accepted = true;
startPolling(executeId.value);
}
});
return accepted;
} finally {
if (!accepted) {
submitLoading.value = false;
}
}
}
function submitV2() {
runForm.value?.validate((valid) => {
@@ -131,7 +139,7 @@ function startPolling(executeId: any) {
if (pollingActive) return;
pollingActive = true;
pollingGeneration += 1;
schedulePolling(executeId, pollingGeneration);
void executePolling(executeId, pollingGeneration);
}
function schedulePolling(executeId: any, generation: number) {
timer.value = setTimeout(() => {

View File

@@ -46,6 +46,12 @@ function isFileContentType(contentType: any) {
return contentType === 'file';
}
function getCheckboxOptions(item: any) {
if (Array.isArray(item.options)) {
return item.options.map((option: any) => ({
label: option.label,
value: option.value,
}));
}
if (item.enums) {
return (
item.enums?.map((option: any) => ({
@@ -73,7 +79,9 @@ function buildRules(item: any) {
return;
}
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;
}
if (value && typeof value === 'object') {

View File

@@ -15,14 +15,11 @@ import {
ElCollapse,
ElCollapseItem,
ElForm,
ElFormItem,
ElIcon,
} from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
import { $t } from '#/locales';
import ConfirmItem from '#/views/ai/workflow/components/ConfirmItem.vue';
import ConfirmItemMulti from '#/views/ai/workflow/components/ConfirmItemMulti.vue';
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
export interface WorkflowStepsProps {
workflowId: any;
@@ -31,7 +28,9 @@ export interface WorkflowStepsProps {
pollingData?: any;
}
const props = defineProps<WorkflowStepsProps>();
const emit = defineEmits(['resume']);
const emit = defineEmits<{
resume: [payload: any, onSettled: (accepted: boolean) => void];
}>();
const nodes = ref<any[]>([]);
const nodeStatusMap = ref<Record<string, any>>({});
const isChainError = ref(false);
@@ -43,13 +42,18 @@ watch(
isChainError.value = true;
chainErrMsg.value = newVal.message;
}
if (![20, 21].includes(newVal.status)) {
if (Number(newVal.status) !== 5) {
confirmBtnLoading.value = false;
}
for (const nodeId in nodes) {
const previousStatus = nodeStatusMap.value[nodeId]?.status;
nodeStatusMap.value[nodeId] = nodes[nodeId];
if (nodes[nodeId].status === 5) {
activeName.value = nodeId;
if (Number(previousStatus) !== 5) {
initializeConfirmParams(nodes[nodeId].suspendForParameters);
confirmBtnLoading.value = false;
}
}
}
},
@@ -59,6 +63,7 @@ watch(
() => props.initSignal,
() => {
nodeStatusMap.value = {};
confirmParams.value = {};
isChainError.value = false;
confirmBtnLoading.value = false;
chainErrMsg.value = '';
@@ -91,8 +96,13 @@ const setFormRef = (el: any, key: string) => {
};
const confirmBtnLoading = ref(false);
const chainErrMsg = ref('');
function getSelectMode(ops: any) {
return ops.formType || 'radio';
function initializeConfirmParams(parameters: any) {
const values: Record<string, any> = {};
for (const parameter of Array.isArray(parameters) ? parameters : []) {
values[parameter.name] =
parameter.formType === 'checkbox' ? [] : (parameter.defaultValue ?? '');
}
confirmParams.value = values;
}
function handleConfirm(node: any) {
const nodeKey = node.key;
@@ -103,17 +113,27 @@ function handleConfirm(node: any) {
console.warn(`Form instance for ${nodeKey} not found`);
return;
}
const confirmKey = node.suspendForParameters[0].name;
form.validate((valid) => {
if (valid) {
const value = {
confirmParams: {
[confirmKey]: 'yes',
...confirmParams.value,
},
confirmParams: { ...confirmParams.value },
};
confirmBtnLoading.value = true;
emit('resume', value);
emit('resume', value, (accepted) => {
confirmBtnLoading.value = false;
if (!accepted) {
return;
}
confirmParams.value = {};
const currentState = nodeStatusMap.value[nodeKey];
if (currentState) {
nodeStatusMap.value[nodeKey] = {
...currentState,
status: 1,
suspendForParameters: [],
};
}
});
}
});
}
@@ -165,7 +185,12 @@ function handleConfirm(node: any) {
</div>
</div>
</template>
<div v-if="node.original.type === 'confirmNode'" class="p-2.5">
<div
v-if="
node.original.type === 'confirmNode' && Number(node.status) === 5
"
class="p-2.5"
>
<div class="mb-2 text-[16px] font-bold">
{{ node.original.data.message }}
</div>
@@ -174,50 +199,24 @@ function handleConfirm(node: any) {
label-position="top"
:model="confirmParams"
>
<template
v-for="(ops, idx) in node.suspendForParameters"
:key="idx"
<WorkflowFormItem
:parameters="node.suspendForParameters || []"
:run-params="confirmParams"
@update:run-params="confirmParams = $event"
/>
<div
v-if="node.suspendForParameters?.length > 0"
class="flex justify-end"
>
<div class="header-container" v-if="ops.formType !== 'confirm'">
<div class="blue-bar">&nbsp;</div>
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
</div>
<div
class="description-container"
v-if="ops.formType !== 'confirm'"
<ElButton
:disabled="confirmBtnLoading"
:loading="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ ops.formDescription }}
</div>
<ElFormItem
v-if="ops.formType !== 'confirm'"
:prop="ops.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ConfirmItem
v-if="getSelectMode(ops) === 'radio'"
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
<ConfirmItemMulti
v-else
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
</ElFormItem>
</template>
<ElFormItem v-if="node.suspendForParameters?.length > 0">
<div class="flex justify-end">
<ElButton
:disabled="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ $t('button.confirm') }}
</ElButton>
</div>
</ElFormItem>
继续
</ElButton>
</div>
</ElForm>
</div>
<div v-else>
@@ -248,26 +247,4 @@ function handleConfirm(node: any) {
transform: rotate(360deg);
}
}
.header-container {
display: flex;
align-items: center;
font-weight: bold;
word-break: break-all;
}
.blue-bar {
display: inline-block;
width: 2px;
height: 16px;
margin-right: 16px;
background-color: var(--el-color-primary);
border-radius: 1px;
}
.description-container {
margin-bottom: 16px;
color: #969799;
word-break: break-all;
}
</style>