feat: 完善用户确认节点选项与输出流转
- 重构确认节点单选多选配置及输出参数契约 - 统一管理端、用户中心、匿名分享和公共接口恢复流程 - 增加保存校验、错误契约及交互测试
This commit is contained in:
@@ -291,23 +291,29 @@ async function executePolling(nextExecuteId: string, generation: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function resumeChain(payload: any) {
|
||||
async function resumeChain(
|
||||
payload: any,
|
||||
onSettled: (accepted: boolean) => void,
|
||||
) {
|
||||
if (!executeId.value) {
|
||||
onSettled(false);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.post('/api/v1/pluginItem/testResume', {
|
||||
try {
|
||||
const res = await api.post('/api/v1/pluginItem/testResume', {
|
||||
executeId: executeId.value,
|
||||
confirmParams: payload?.confirmParams || {},
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
startPolling(executeId.value);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
runResultResponse.value = buildErrorResult(error);
|
||||
});
|
||||
if (res.errorCode === 0) {
|
||||
startPolling(executeId.value);
|
||||
onSettled(true);
|
||||
return;
|
||||
}
|
||||
onSettled(false);
|
||||
} catch (error) {
|
||||
onSettled(false);
|
||||
runResultResponse.value = buildErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
function showWorkflowSteps() {
|
||||
|
||||
@@ -50,7 +50,7 @@ import nodeNames from './customNode/nodeNames';
|
||||
import {
|
||||
createInitialWorkflowData,
|
||||
isWorkflowDataEmpty,
|
||||
normalizeWorkflowStartNodes,
|
||||
normalizeWorkflowNodes,
|
||||
} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||
import '@tinyflow-ai/vue/dist/index.css';
|
||||
|
||||
@@ -444,7 +444,7 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
||||
}
|
||||
saveLoading.value = true;
|
||||
try {
|
||||
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
||||
const content = normalizeWorkflowNodes(tinyflowRef.value?.getData());
|
||||
const savedContentSignature = createWorkflowContentSignature(content);
|
||||
const res = await api.post('/api/v1/workflow/update', {
|
||||
id: workflowId.value,
|
||||
@@ -475,11 +475,11 @@ async function getWorkflowInfo(workflowId: any, syncFlowData: boolean = true) {
|
||||
: {};
|
||||
const serverContent = isWorkflowDataEmpty(parsedContent)
|
||||
? createInitialWorkflowData()
|
||||
: normalizeWorkflowStartNodes(parsedContent);
|
||||
: normalizeWorkflowNodes(parsedContent);
|
||||
serverContentSignature = createWorkflowContentSignature(serverContent);
|
||||
const draft = readWorkflowDraft(workflowId, serverContent);
|
||||
tinyFlowData.value = draft
|
||||
? normalizeWorkflowStartNodes(draft.content as Record<string, any>)
|
||||
? normalizeWorkflowNodes(draft.content as Record<string, any>)
|
||||
: serverContent;
|
||||
lastObservedWorkflowContent = tinyFlowData.value;
|
||||
}
|
||||
@@ -492,7 +492,7 @@ function persistPendingWorkflowDraft() {
|
||||
draftWriteTimer = undefined;
|
||||
return;
|
||||
}
|
||||
const content = normalizeWorkflowStartNodes(pendingDraftContent);
|
||||
const content = normalizeWorkflowNodes(pendingDraftContent);
|
||||
lastObservedWorkflowContent = content;
|
||||
pendingDraftContent = null;
|
||||
draftWriteTimer = undefined;
|
||||
@@ -531,7 +531,7 @@ function flushWorkflowDraft() {
|
||||
function captureCurrentWorkflowDraft() {
|
||||
const content = tinyflowRef.value?.getData();
|
||||
if (content) {
|
||||
lastObservedWorkflowContent = normalizeWorkflowStartNodes(content);
|
||||
lastObservedWorkflowContent = normalizeWorkflowNodes(content);
|
||||
pendingDraftContent = lastObservedWorkflowContent;
|
||||
}
|
||||
flushWorkflowDraft();
|
||||
@@ -554,7 +554,7 @@ function reconcileWorkflowDraftAfterSave(savedContentSignature: string) {
|
||||
clearPendingWorkflowDraft();
|
||||
return;
|
||||
}
|
||||
const normalizedContent = normalizeWorkflowStartNodes(currentContent);
|
||||
const normalizedContent = normalizeWorkflowNodes(currentContent);
|
||||
lastObservedWorkflowContent = normalizedContent;
|
||||
if (
|
||||
createWorkflowContentSignature(normalizedContent) === savedContentSignature
|
||||
@@ -594,7 +594,7 @@ async function runCheck(
|
||||
stage: WorkflowCheckStage,
|
||||
silentPass: boolean = false,
|
||||
) {
|
||||
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
||||
const content = normalizeWorkflowNodes(tinyflowRef.value?.getData());
|
||||
if (!content) {
|
||||
ElMessage.error($t('aiWorkflow.checkContentEmpty'));
|
||||
return false;
|
||||
@@ -756,8 +756,13 @@ async function runIndependently(node: any) {
|
||||
singleNode.value = node;
|
||||
singleRunVisible.value = true;
|
||||
}
|
||||
function resumeChain(data: any) {
|
||||
workflowForm.value?.resume(data);
|
||||
async function resumeChain(data: any, onSettled: (accepted: boolean) => void) {
|
||||
try {
|
||||
const accepted = await workflowForm.value?.resume(data);
|
||||
onSettled(accepted === true);
|
||||
} catch {
|
||||
onSettled(false);
|
||||
}
|
||||
}
|
||||
function handleChoose(nodeName: string, value: any) {
|
||||
if (nodeName === nodeNames.workflowNode) {
|
||||
|
||||
@@ -682,7 +682,7 @@ function buildResumeRequestExample() {
|
||||
{
|
||||
executeId: '执行ID',
|
||||
confirmParams: {
|
||||
confirm: true,
|
||||
'selection__confirm-node-1': '审议类',
|
||||
},
|
||||
},
|
||||
null,
|
||||
@@ -936,6 +936,10 @@ const apiDocMarkdown = computed(() => {
|
||||
lines.push(buildResumeRequestExample());
|
||||
lines.push('```');
|
||||
lines.push(``);
|
||||
lines.push(
|
||||
'`confirmParams` 的键请使用暂停节点 `suspendForParameters[].name` 返回的运行参数名;确认节点固定为 `selection__<节点 ID>`。单选传一个 `options[].value` 字符串,多选传由这些值组成的字符串数组;确认节点的 `label` 与 `value` 都是配置的选项内容。',
|
||||
);
|
||||
lines.push(``);
|
||||
lines.push(`### 响应`);
|
||||
lines.push(``);
|
||||
lines.push('```json');
|
||||
@@ -971,6 +975,9 @@ const apiDocMarkdown = computed(() => {
|
||||
lines.push(
|
||||
`| 400 | 40017 | 其他工作流运行参数不合法,例如文件 URL 无法识别文件名或扩展名 |`,
|
||||
);
|
||||
lines.push(
|
||||
`| 400 | 40031 | 确认节点恢复参数缺失、类型错误或包含未配置选项 |`,
|
||||
);
|
||||
lines.push(`| 401 | 40101 | 缺少 ApiKey 请求头 |`);
|
||||
lines.push(`| 401 | 40102 / 40103 | ApiKey 无效、禁用或过期 |`);
|
||||
lines.push(`| 403 | 40301 / 40302 | 缺少接口权限或工作流调用权限 |`);
|
||||
|
||||
@@ -1,211 +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';
|
||||
// 导入你的图片资源
|
||||
// 请确保路径正确,或者将图片放在 public 目录下引用
|
||||
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="flex-shrink: 0; width: 100%"
|
||||
@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="display: block; width: 80px; height: 80px; border-radius: 8px"
|
||||
/>
|
||||
</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="flex-shrink: 0; width: 100%"
|
||||
@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="flex-shrink: 0; width: 100%"
|
||||
@click="changeValue(item)"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
"
|
||||
>
|
||||
<div style="display: flex; align-items: center; width: 92%">
|
||||
<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;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.custom-radio-option {
|
||||
position: relative;
|
||||
box-sizing: border-box; /* 确保 padding 不会撑大宽度 */
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px var(--el-border-color);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.custom-radio-option:hover {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.custom-radio-option.selected {
|
||||
padding: 8px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-3);
|
||||
}
|
||||
|
||||
.custom-radio-option.selected::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
box-sizing: border-box;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
content: '';
|
||||
background-color: var(--el-color-primary);
|
||||
border-radius: 6px 2px;
|
||||
}
|
||||
|
||||
.custom-radio-option.selected::before {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 7px;
|
||||
z-index: 1;
|
||||
width: 9px;
|
||||
height: 4px;
|
||||
content: '';
|
||||
border-bottom: 1px solid white;
|
||||
border-left: 1px solid white;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.download-icon-btn {
|
||||
display: flex; /* 为了对齐图标 */
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -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="flex-shrink: 0; width: 100%"
|
||||
@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="display: block; width: 80px; height: 80px; border-radius: 8px"
|
||||
/>
|
||||
</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="flex-shrink: 0; width: 300px"
|
||||
@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="flex-shrink: 0; width: 100%"
|
||||
@click="changeValue(item)"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
"
|
||||
>
|
||||
<div style="display: flex; align-items: center; width: 92%">
|
||||
<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;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.custom-radio-option {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px var(--el-border-color);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.custom-radio-option:hover {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.custom-radio-option.selected {
|
||||
padding: 8px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-3);
|
||||
}
|
||||
|
||||
.custom-radio-option.selected::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
box-sizing: border-box;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
content: '';
|
||||
background-color: var(--el-color-primary);
|
||||
border-radius: 6px 2px;
|
||||
}
|
||||
|
||||
.custom-radio-option.selected::before {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 7px;
|
||||
z-index: 1;
|
||||
width: 9px;
|
||||
height: 5px;
|
||||
content: '';
|
||||
border-bottom: 1px solid white;
|
||||
border-left: 1px solid white;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.download-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -161,7 +161,7 @@ const extraFormRef = ref<FormInstance>();
|
||||
const waitingConfirmation = ref<Record<string, any>>();
|
||||
const confirmValues = ref<Record<string, any>>({});
|
||||
const confirmFormRef = ref<FormInstance>();
|
||||
const confirmSubmittingAction = ref<'' | 'confirm' | 'reject'>('');
|
||||
const confirmSubmittingAction = ref<'' | 'continue'>('');
|
||||
const confirmError = ref('');
|
||||
const detailVisible = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
@@ -252,21 +252,9 @@ const hiddenParameterSummaryCount = computed(() =>
|
||||
),
|
||||
);
|
||||
const confirmParameters = computed(() => {
|
||||
const parameters = Array.isArray(waitingConfirmation.value?.parameters)
|
||||
return Array.isArray(waitingConfirmation.value?.parameters)
|
||||
? waitingConfirmation.value?.parameters
|
||||
: [];
|
||||
return parameters.filter(
|
||||
(parameter: any) => parameter.formType !== 'confirm',
|
||||
);
|
||||
});
|
||||
const confirmKey = computed(() => {
|
||||
const parameters = Array.isArray(waitingConfirmation.value?.parameters)
|
||||
? waitingConfirmation.value?.parameters
|
||||
: [];
|
||||
return (
|
||||
parameters.find((parameter: any) => parameter.formType === 'confirm')
|
||||
?.name || ''
|
||||
);
|
||||
});
|
||||
const composerDisabled = computed(
|
||||
() =>
|
||||
@@ -1012,33 +1000,25 @@ function finalizeLiveExecutionSteps(
|
||||
function initializeConfirmValues(parameters: unknown) {
|
||||
const values: Record<string, any> = {};
|
||||
for (const parameter of Array.isArray(parameters) ? parameters : []) {
|
||||
if (parameter.formType === 'confirm') {
|
||||
continue;
|
||||
}
|
||||
values[parameter.name] = parameter.defaultValue ?? '';
|
||||
values[parameter.name] =
|
||||
parameter.formType === 'checkbox' ? [] : (parameter.defaultValue ?? '');
|
||||
}
|
||||
confirmValues.value = values;
|
||||
}
|
||||
|
||||
async function resumeExecution(confirmed: boolean) {
|
||||
if (!executeId.value || !confirmKey.value || confirmSubmittingAction.value) {
|
||||
async function resumeExecution() {
|
||||
if (!executeId.value || confirmSubmittingAction.value) {
|
||||
return;
|
||||
}
|
||||
confirmSubmittingAction.value = confirmed ? 'confirm' : 'reject';
|
||||
confirmSubmittingAction.value = 'continue';
|
||||
confirmError.value = '';
|
||||
try {
|
||||
if (
|
||||
confirmed &&
|
||||
!(await confirmFormRef.value?.validate().catch(() => false))
|
||||
) {
|
||||
if (!(await confirmFormRef.value?.validate().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
await api.post(workflowChatEndpoint('resume'), {
|
||||
executeId: executeId.value,
|
||||
confirmParams: {
|
||||
[confirmKey.value]: confirmed ? 'yes' : 'no',
|
||||
...(confirmed ? confirmValues.value : {}),
|
||||
},
|
||||
confirmParams: { ...confirmValues.value },
|
||||
});
|
||||
waitingConfirmation.value = undefined;
|
||||
executionState.value = 'running';
|
||||
@@ -1523,20 +1503,13 @@ function executionTraceText(
|
||||
{{ confirmError }}
|
||||
</p>
|
||||
<div class="workflow-chat__form-actions">
|
||||
<ElButton
|
||||
:loading="confirmSubmittingAction === 'reject'"
|
||||
:disabled="Boolean(confirmSubmittingAction)"
|
||||
@click="resumeExecution(false)"
|
||||
>
|
||||
拒绝
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="confirmSubmittingAction === 'confirm'"
|
||||
:loading="confirmSubmittingAction === 'continue'"
|
||||
:disabled="Boolean(confirmSubmittingAction)"
|
||||
@click="resumeExecution(true)"
|
||||
@click="resumeExecution"
|
||||
>
|
||||
确认
|
||||
继续
|
||||
</ElButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -87,14 +87,22 @@ watch(
|
||||
},
|
||||
);
|
||||
const executeId = ref('');
|
||||
function resume(data: any) {
|
||||
async function resume(data: any) {
|
||||
data.executeId = executeId.value;
|
||||
submitLoading.value = true;
|
||||
api.post('/api/v1/workflow/resume', data).then((res) => {
|
||||
let accepted = false;
|
||||
try {
|
||||
const res = await api.post('/api/v1/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) => {
|
||||
@@ -132,7 +140,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(() => {
|
||||
|
||||
@@ -59,6 +59,12 @@ function isWideItem(item: any) {
|
||||
return item.formType === 'textarea' || contentType === 'image';
|
||||
}
|
||||
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) => ({
|
||||
|
||||
@@ -14,14 +14,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 {
|
||||
expandAll?: boolean;
|
||||
}
|
||||
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 activeNames = ref<string[]>([]);
|
||||
@@ -90,7 +89,7 @@ watch(
|
||||
}
|
||||
const currentNodes = newVal.nodes || {};
|
||||
chainErrMsg.value = newVal.status === 21 ? newVal.message : '';
|
||||
if (![20, 21].includes(newVal.status)) {
|
||||
if (Number(newVal.status) !== 5) {
|
||||
confirmBtnLoading.value = false;
|
||||
}
|
||||
let autoExpandNodeId: string | undefined;
|
||||
@@ -106,6 +105,10 @@ watch(
|
||||
continue;
|
||||
}
|
||||
nodeStatusMap.value[nodeId] = currentNodeState;
|
||||
if (Number(currentStatus) === 5 && Number(previousStatus) !== 5) {
|
||||
initializeConfirmParams(currentNodeState?.suspendForParameters);
|
||||
confirmBtnLoading.value = false;
|
||||
}
|
||||
if (
|
||||
!userControlledExpansion.value &&
|
||||
!props.expandAll &&
|
||||
@@ -128,6 +131,7 @@ watch(
|
||||
() => props.initSignal,
|
||||
() => {
|
||||
nodeStatusMap.value = {};
|
||||
confirmParams.value = {};
|
||||
confirmBtnLoading.value = false;
|
||||
chainErrMsg.value = '';
|
||||
userControlledExpansion.value = false;
|
||||
@@ -164,8 +168,13 @@ const setFormRef = (el: any, key: string) => {
|
||||
formRefs.value[key] = el as FormInstance;
|
||||
}
|
||||
};
|
||||
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;
|
||||
@@ -176,17 +185,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: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -234,7 +253,12 @@ function handleConfirm(node: any) {
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="isExpandedNode(node.key)">
|
||||
<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>
|
||||
@@ -243,50 +267,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"> </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>
|
||||
@@ -318,26 +316,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>
|
||||
|
||||
@@ -25,6 +25,30 @@ function createWorkflowNodes() {
|
||||
];
|
||||
}
|
||||
|
||||
function createConfirmNode() {
|
||||
return [
|
||||
{
|
||||
key: 'confirm-a',
|
||||
label: '用户确认',
|
||||
original: {
|
||||
data: { message: '请选择模板' },
|
||||
type: 'confirmNode',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createConfirmNodes() {
|
||||
return ['confirm-a', 'confirm-b'].map((key) => ({
|
||||
key,
|
||||
label: `用户确认 ${key}`,
|
||||
original: {
|
||||
data: { message: `请选择 ${key}` },
|
||||
type: 'confirmNode',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function mountWorkflowSteps() {
|
||||
return mount(WorkflowSteps, {
|
||||
props: {
|
||||
@@ -35,9 +59,8 @@ function mountWorkflowSteps() {
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ConfirmItem: true,
|
||||
ConfirmItemMulti: true,
|
||||
ShowJson: true,
|
||||
WorkflowFormItem: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -159,4 +182,145 @@ describe('workflowSteps', () => {
|
||||
'JavaScript 语法错误(第 2 行,第 3 列):Unexpected token',
|
||||
);
|
||||
});
|
||||
|
||||
it('恢复请求未被受理时允许用户修正后重试', async () => {
|
||||
const wrapper = mount(WorkflowSteps, {
|
||||
props: {
|
||||
initSignal: false,
|
||||
nodeJson: createConfirmNode(),
|
||||
pollingData: undefined,
|
||||
workflowId: 'workflow-1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ShowJson: true,
|
||||
WorkflowFormItem: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
await wrapper.setProps({
|
||||
pollingData: {
|
||||
nodes: {
|
||||
'confirm-a': {
|
||||
status: 5,
|
||||
suspendForParameters: [
|
||||
{
|
||||
formType: 'radio',
|
||||
name: 'selection__confirm-a',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const button = wrapper.get('button.el-button');
|
||||
await button.trigger('click');
|
||||
expect(button.attributes('disabled')).toBeDefined();
|
||||
|
||||
const settle = wrapper.emitted('resume')?.[0]?.[1] as
|
||||
| ((accepted: boolean) => void)
|
||||
| undefined;
|
||||
expect(settle).toBeTypeOf('function');
|
||||
settle?.(false);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(button.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('恢复成功后同一节点再次暂停会清空旧值并允许再次确认', async () => {
|
||||
const wrapper = mount(WorkflowSteps, {
|
||||
props: {
|
||||
initSignal: false,
|
||||
nodeJson: createConfirmNode(),
|
||||
pollingData: undefined,
|
||||
workflowId: 'workflow-1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ShowJson: true,
|
||||
WorkflowFormItem: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const suspendedState = {
|
||||
nodes: {
|
||||
'confirm-a': {
|
||||
status: 5,
|
||||
suspendForParameters: [
|
||||
{
|
||||
formType: 'radio',
|
||||
name: 'selection__confirm-a',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 5,
|
||||
};
|
||||
await wrapper.setProps({ pollingData: suspendedState });
|
||||
|
||||
await wrapper.get('button.el-button').trigger('click');
|
||||
const settle = wrapper.emitted('resume')?.[0]?.[1] as
|
||||
| ((accepted: boolean) => void)
|
||||
| undefined;
|
||||
settle?.(true);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('button.el-button').exists()).toBe(false);
|
||||
|
||||
await wrapper.setProps({
|
||||
pollingData: {
|
||||
...suspendedState,
|
||||
nodes: {
|
||||
'confirm-a': {
|
||||
...suspendedState.nodes['confirm-a'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const nextButton = wrapper.get('button.el-button');
|
||||
expect(nextButton.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('多个确认节点只在当前暂停节点展示确认表单', async () => {
|
||||
const wrapper = mount(WorkflowSteps, {
|
||||
props: {
|
||||
expandAll: true,
|
||||
initSignal: false,
|
||||
nodeJson: createConfirmNodes(),
|
||||
pollingData: undefined,
|
||||
workflowId: 'workflow-1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ShowJson: true,
|
||||
WorkflowFormItem: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const suspendForParameters = [
|
||||
{
|
||||
formType: 'radio',
|
||||
name: 'selection__confirm-a',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
await wrapper.setProps({
|
||||
pollingData: {
|
||||
nodes: {
|
||||
'confirm-a': { status: 5, suspendForParameters },
|
||||
'confirm-b': { status: 0, suspendForParameters },
|
||||
},
|
||||
status: 5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.findAll('workflow-form-item-stub')).toHaveLength(1);
|
||||
expect(wrapper.findAll('button.el-button')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,4 +46,33 @@ describe('workflow public form item', () => {
|
||||
expect(input.props('allowResourcePicker')).toBe(false);
|
||||
expect(input.props('uploadUrl')).toBe('/api/v1/workflowChat/public/upload');
|
||||
});
|
||||
|
||||
it('renders confirmation option content as both label and output value', () => {
|
||||
const wrapper = mount(WorkflowFormItem, {
|
||||
props: {
|
||||
parameters: [
|
||||
{
|
||||
contentType: 'text',
|
||||
formLabel: '会议纪要模板',
|
||||
formType: 'radio',
|
||||
name: 'selection__confirm',
|
||||
options: [
|
||||
{ label: '第一议题', value: '第一议题' },
|
||||
{ label: '审议类', value: '审议类' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
runParams: {},
|
||||
},
|
||||
});
|
||||
|
||||
const group = wrapper.findComponent({ name: 'ElRadioGroup' });
|
||||
const formItem = wrapper.findComponent({ name: 'ElFormItem' });
|
||||
expect(formItem.props('label')).toBe('会议纪要模板');
|
||||
expect(group.props('options')).toEqual([
|
||||
{ label: '第一议题', value: '第一议题' },
|
||||
{ label: '审议类', value: '审议类' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,10 +64,16 @@ function replaceDefaultParameterLabel(label: unknown, name: unknown) {
|
||||
*/
|
||||
export function resolveWorkflowParameterLabel(parameter: any) {
|
||||
const name = String(parameter?.name || '').trim();
|
||||
const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
|
||||
const hasStructuredOptions =
|
||||
['checkbox', 'radio'].includes(String(parameter?.formType || '')) &&
|
||||
Array.isArray(parameter?.options);
|
||||
if (hasStructuredOptions && formLabel) {
|
||||
return formLabel;
|
||||
}
|
||||
if (!isSystemParameter(parameter, name) && name) {
|
||||
return configuredParameterName(name);
|
||||
}
|
||||
const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
|
||||
const displayName = replaceDefaultParameterLabel(
|
||||
parameter?.displayName,
|
||||
name,
|
||||
|
||||
Reference in New Issue
Block a user