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,
|
||||
|
||||
@@ -106,7 +106,12 @@ export class Tinyflow {
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
return flow.toObject();
|
||||
this.store.flushPendingEdits();
|
||||
return {
|
||||
...flow.toObject(),
|
||||
nodes: this.store.getNodes(),
|
||||
edges: this.store.getEdges(),
|
||||
};
|
||||
}
|
||||
|
||||
updateData(data: TinyflowData, options?: { preserveViewport?: boolean }) {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
variant = 'default',
|
||||
showSelectedType = true,
|
||||
selectedType,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
...rest
|
||||
}: {
|
||||
items: SelectItem[],
|
||||
@@ -26,46 +28,55 @@
|
||||
variant?: 'default' | 'reference' | 'model'
|
||||
showSelectedType?: boolean
|
||||
selectedType?: string
|
||||
disabled?: boolean
|
||||
disabledReason?: string
|
||||
[key: string]: any
|
||||
} = $props();
|
||||
|
||||
let activeItemsState = $derived.by(() => {
|
||||
const resultItems: SelectItem[] = [];
|
||||
const fillResult = (items: SelectItem[]) => {
|
||||
for (let item of items) {
|
||||
if (value.length > 0) {
|
||||
if (value.includes(item.value)) {
|
||||
resultItems.push(item);
|
||||
}
|
||||
} else {
|
||||
if (defaultValue.includes(item.value)) {
|
||||
resultItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const flattenedItems: SelectItem[] = [];
|
||||
const flatten = (sourceItems: SelectItem[]) => {
|
||||
for (const item of sourceItems) {
|
||||
flattenedItems.push(item);
|
||||
if (item.children && item.children.length > 0) {
|
||||
fillResult(item.children);
|
||||
flatten(item.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
fillResult(items);
|
||||
return resultItems;
|
||||
flatten(items);
|
||||
const selectedValues = value.length > 0 ? value : defaultValue;
|
||||
return selectedValues.flatMap((selectedValue) => {
|
||||
const item = flattenedItems.find((candidate) => candidate.value === selectedValue);
|
||||
return item ? [item] : [];
|
||||
});
|
||||
});
|
||||
|
||||
let triggerObject: any = $state();
|
||||
let triggerButton: HTMLButtonElement | undefined = $state();
|
||||
let hoveredItem: SelectItem | null = $state(null);
|
||||
let isOpen = $state(false);
|
||||
|
||||
function closeMenu() {
|
||||
triggerObject?.hide();
|
||||
isOpen = false;
|
||||
hoveredItem = null;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !isOpen) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
triggerButton?.focus();
|
||||
}
|
||||
|
||||
function handlerOnSelect(item: SelectItem) {
|
||||
if (item.selectable !== false) {
|
||||
onSelect?.(item);
|
||||
closeMenu();
|
||||
if (!multiple) {
|
||||
closeMenu();
|
||||
}
|
||||
} else {
|
||||
if (variant === 'reference') {
|
||||
hoveredItem = item;
|
||||
@@ -89,8 +100,23 @@
|
||||
|
||||
{#snippet renderDefaultItems(items: SelectItem[], depth = 0)}
|
||||
{#each items as item}
|
||||
<button class="tf-select-default-item" style="padding-left: {10 + depth * 14}px" onclick={(e) => { e.stopPropagation(); handlerOnSelect(item); }}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
class="tf-select-default-item {value.includes(item.value) ? 'active' : ''} {item.selectable === false ? 'disabled' : ''}"
|
||||
style="padding-left: {10 + depth * 14}px"
|
||||
aria-selected={value.includes(item.value)}
|
||||
aria-disabled={item.selectable === false}
|
||||
aria-label={item.disabledReason
|
||||
? `${String(item.displayLabel || item.label)}:${item.disabledReason}`
|
||||
: undefined}
|
||||
title={item.disabledReason || String(item.displayLabel || item.label)}
|
||||
onclick={(e) => { e.stopPropagation(); handlerOnSelect(item); }}
|
||||
>
|
||||
<span class="tf-select-default-item-label">{item.label}</span>
|
||||
{#if value.includes(item.value)}
|
||||
<svg class="tf-select-default-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{#if item.children && item.children.length > 0}
|
||||
<div class="tf-select-default-children">
|
||||
@@ -205,7 +231,7 @@
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<div {...rest} class="tf-select {rest['class']}">
|
||||
<div {...rest} class="tf-select {rest['class']}" onkeydown={handleKeydown}>
|
||||
<FloatingTrigger
|
||||
bind:this={triggerObject}
|
||||
onShow={() => isOpen = true}
|
||||
@@ -213,8 +239,26 @@
|
||||
syncWidth={true}
|
||||
syncWidthMode={variant === 'default' ? 'equal' : 'min'}
|
||||
>
|
||||
<button class="tf-select-input nopan nodrag {isOpen ? 'active' : ''}" {...rest}>
|
||||
<button
|
||||
bind:this={triggerButton}
|
||||
type="button"
|
||||
class="tf-select-input nopan nodrag {isOpen ? 'active' : ''} {disabled ? 'disabled' : ''}"
|
||||
{...rest}
|
||||
{disabled}
|
||||
title={disabled ? disabledReason : undefined}
|
||||
aria-haspopup={variant === 'default' ? 'listbox' : undefined}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div class="tf-select-input-value">
|
||||
{#if multiple && activeItemsState.length > 0}
|
||||
{@const item = activeItemsState[0]}
|
||||
<div class="tf-parameter-label-input">
|
||||
<span class="tf-parameter-name" title={String(item.displayLabel || item.label)}>{item.displayLabel || item.label}</span>
|
||||
{#if activeItemsState.length > 1}
|
||||
<span class="tf-select-count">+{activeItemsState.length - 1}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#each activeItemsState as item, index (`${index}_${item.value}`)}
|
||||
{#if !multiple}
|
||||
{#if index === 0}
|
||||
@@ -238,35 +282,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="tf-parameter-label-input">
|
||||
{#if variant === 'reference' && item.nodeType && nodeIcons[item.nodeType]}
|
||||
<span class="tf-select-item-icon-input">
|
||||
{@html nodeIcons[item.nodeType]}
|
||||
</span>
|
||||
{:else if variant === 'model' && item.icon}
|
||||
<span class="tf-select-item-icon-input-model">
|
||||
{#if isMarkupIcon(item.icon)}
|
||||
{@html item.icon}
|
||||
{:else}
|
||||
<img src={item.icon} alt="" />
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tf-parameter-name" title={String(item.displayLabel || item.label)}>{item.displayLabel || item.label}</span>
|
||||
{#if variant === 'reference' && showSelectedType && (selectedType ?? item.dataType)}
|
||||
<span class="tf-parameter-type" title={selectedType ?? item.dataType}>{selectedType ?? item.dataType}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if index < activeItemsState.length - 1}
|
||||
<span style="margin-right: 4px;">,</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="tf-select-input-placeholder">
|
||||
{placeholder}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tf-select-input-arrow">
|
||||
{#if variant === 'reference'}
|
||||
@@ -284,7 +306,11 @@
|
||||
|
||||
{#snippet floating()}
|
||||
{#if variant === 'default'}
|
||||
<div class="tf-select-default-wrapper nopan nodrag nowheel">
|
||||
<div
|
||||
class="tf-select-default-wrapper nopan nodrag nowheel"
|
||||
role="listbox"
|
||||
aria-multiselectable={multiple}
|
||||
>
|
||||
{@render renderDefaultItems(items)}
|
||||
</div>
|
||||
{:else if variant === 'model'}
|
||||
@@ -379,6 +405,46 @@
|
||||
&:hover {
|
||||
background: var(--tf-bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--tf-primary-soft-bg);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--tf-text-muted);
|
||||
cursor: help;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
&.disabled:hover {
|
||||
background: var(--tf-bg-surface);
|
||||
}
|
||||
}
|
||||
|
||||
.tf-select-default-item-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tf-select-default-check {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
color: var(--tf-primary-color);
|
||||
}
|
||||
|
||||
.tf-select-input.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.tf-select-count {
|
||||
flex-shrink: 0;
|
||||
color: var(--tf-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tf-select-default-children {
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {Input, MenuButton, Textarea} from '../base';
|
||||
import {Button, FloatingTrigger, Select} from '../base/index.js';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {useRefOptions} from '../utils/useRefOptions.svelte';
|
||||
import type {Parameter} from '#types';
|
||||
import {confirmFormTypes, contentTypes} from '#consts';
|
||||
|
||||
|
||||
const { parameter, index, dataKeyName, useChildrenOnly }: {
|
||||
parameter: Parameter,
|
||||
index: number,
|
||||
dataKeyName: string,
|
||||
useChildrenOnly?: boolean,
|
||||
} = $props();
|
||||
|
||||
|
||||
let currentNodeId = getCurrentNodeId();
|
||||
let node = useNodesData(currentNodeId);
|
||||
let param = $derived.by(() => {
|
||||
return {
|
||||
...parameter,
|
||||
...(node?.current?.data?.[dataKeyName] as Array<Parameter>)[index]
|
||||
};
|
||||
});
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
|
||||
|
||||
const updateParam = (key: string, value: any) => {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
|
||||
parameters[index] = {
|
||||
...parameters[index],
|
||||
[key]: value
|
||||
};
|
||||
return {
|
||||
[dataKeyName]: parameters
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const updateParamByEvent = (name: string, event: Event) => {
|
||||
const newValue = (event.target as any).value;
|
||||
updateParam(name, newValue);
|
||||
};
|
||||
|
||||
const updateRef = (item: any) => {
|
||||
const newValue = item.value;
|
||||
updateParam('ref', newValue);
|
||||
};
|
||||
|
||||
const updateFormType = (item: any) => {
|
||||
const newValue = item.value;
|
||||
updateParam('formType', newValue);
|
||||
};
|
||||
|
||||
const updateContentType = (item: any) => {
|
||||
const newValue = item.value;
|
||||
updateParam('contentType', newValue);
|
||||
};
|
||||
|
||||
// const updateRequired = (item: any) => {
|
||||
// const newValue = item.target.checked;
|
||||
// updateParam('required', newValue);
|
||||
// };
|
||||
|
||||
|
||||
let triggerObject: any;
|
||||
const handleDelete = () => {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
|
||||
parameters.splice(index, 1);
|
||||
return {
|
||||
[dataKeyName]: [...parameters]
|
||||
};
|
||||
});
|
||||
triggerObject?.hide();
|
||||
};
|
||||
let selectItems = useRefOptions(() => useChildrenOnly === true);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<div class="input-item">
|
||||
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
|
||||
disabled={param.nameDisabled === true}
|
||||
oninput={(event)=>updateParamByEvent('name', event)} />
|
||||
</div>
|
||||
<div class="input-item">
|
||||
{#if param.refType === 'fixed'}
|
||||
<Input value={param.value} placeholder="请输入参数值" oninput={(event)=>updateParamByEvent('value', event)} />
|
||||
{:else if (param.refType !== 'input')}
|
||||
<Select items={selectItems.current} style="width: 100%" defaultValue={["ref"]} value={[param.ref]} variant="reference"
|
||||
expandAll
|
||||
onSelect={updateRef} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
|
||||
<MenuButton />
|
||||
{#snippet floating()}
|
||||
<div class="input-more-setting">
|
||||
<div class="input-more-item">
|
||||
数据内容:
|
||||
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
||||
value={param.contentType ? [param.contentType] : []}
|
||||
onSelect={updateContentType}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-more-item">
|
||||
确认方式:
|
||||
<Select items={confirmFormTypes} style="width: 100%" defaultValue={["single"]}
|
||||
value={param.formType ? [param.formType] : []}
|
||||
onSelect={updateFormType}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-more-item">
|
||||
数据标题:
|
||||
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formLabel', event)
|
||||
}} value={param.formLabel} />
|
||||
</div>
|
||||
<div class="input-more-item">
|
||||
数据描述:
|
||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formDescription', event)
|
||||
}} value={param.formDescription} />
|
||||
</div>
|
||||
|
||||
<!-- <label class="input-item-inline">-->
|
||||
<!-- <span>是否必填:</span>-->
|
||||
<!-- <input type="checkbox" checked={false} onchange={updateRequired} />-->
|
||||
<!-- </label>-->
|
||||
|
||||
|
||||
<div class="input-more-item">
|
||||
<Button onclick={handleDelete}>删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FloatingTrigger>
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="less">
|
||||
|
||||
.input-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-more-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: var(--tf-bg-surface);
|
||||
border: 1px solid var(--tf-border-color-strong);
|
||||
border-radius: 5px;
|
||||
width: 200px;
|
||||
box-shadow: var(--tf-shadow-medium);
|
||||
|
||||
.input-more-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--tf-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {useNodesData} from '@xyflow/svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import ConfirmParameterItem from './ConfirmParameterItem.svelte';
|
||||
|
||||
const {
|
||||
noneParameterText = '无确认数据',
|
||||
dataKeyName = 'parameters',
|
||||
useChildrenOnly,
|
||||
}: {
|
||||
noneParameterText?: string;
|
||||
dataKeyName?: string;
|
||||
useChildrenOnly?: boolean,
|
||||
} = $props();
|
||||
|
||||
let currentNodeId = getCurrentNodeId();
|
||||
let node = useNodesData(currentNodeId);
|
||||
|
||||
let parameters = $derived.by(() => {
|
||||
return [...node?.current?.data?.[dataKeyName] as Array<any> || []];
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<div class="input-container">
|
||||
{#if (parameters.length !== 0)}
|
||||
<div class="input-header">参数名称</div>
|
||||
<div class="input-header">参数值</div>
|
||||
<div class="input-header"></div>
|
||||
{/if}
|
||||
{#each parameters as param, index (param.id)}
|
||||
<ConfirmParameterItem parameter={param} index={index} {dataKeyName} {useChildrenOnly}/>
|
||||
{:else }
|
||||
<div class="none-params">{noneParameterText}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="less">
|
||||
.input-container {
|
||||
display: grid;
|
||||
grid-template-columns: 40% 50% 10%;
|
||||
row-gap: 5px;
|
||||
column-gap: 3px;
|
||||
|
||||
.none-params {
|
||||
font-size: 12px;
|
||||
background: var(--tf-bg-muted);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 5px;
|
||||
width: calc(100% - 5px);
|
||||
grid-column: 1 / -1; /* 从第一列开始到最后一列结束 */
|
||||
}
|
||||
|
||||
.input-header {
|
||||
font-size: 12px;
|
||||
color: var(--tf-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
allowCopy = true,
|
||||
allowDelete = true,
|
||||
allowSetting = true,
|
||||
allowAsyncSetting = true,
|
||||
allowSettingOfCondition = true,
|
||||
showSourceHandle = true,
|
||||
showTargetHandle = true,
|
||||
@@ -49,6 +50,7 @@
|
||||
allowCopy?: boolean,
|
||||
allowDelete?: boolean,
|
||||
allowSetting?: boolean,
|
||||
allowAsyncSetting?: boolean,
|
||||
allowSettingOfCondition?: boolean,
|
||||
showSourceHandle?: boolean,
|
||||
showTargetHandle?: boolean,
|
||||
@@ -268,15 +270,17 @@
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<label class="input-item-inline">
|
||||
<span>异步执行:</span>
|
||||
<input type="checkbox" checked={!!data.async} onchange={(event)=>{
|
||||
const value = (event.target as any).checked;
|
||||
updateNodeData(currentNodeId,{
|
||||
async: value
|
||||
})
|
||||
}} />
|
||||
</label>
|
||||
{#if allowAsyncSetting}
|
||||
<label class="input-item-inline">
|
||||
<span>异步执行:</span>
|
||||
<input type="checkbox" checked={!!data.async} onchange={(event)=>{
|
||||
const value = (event.target as any).checked;
|
||||
updateNodeData(currentNodeId,{
|
||||
async: value
|
||||
})
|
||||
}} />
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="input-item-inline">
|
||||
<span>循环执行:</span>
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
position,
|
||||
dataKeyName,
|
||||
placeholder = '请输入参数值',
|
||||
readOnly = false,
|
||||
onParametersChange,
|
||||
}: {
|
||||
parameter: Parameter,
|
||||
position: number[],
|
||||
dataKeyName: string,
|
||||
placeholder?: string,
|
||||
readOnly?: boolean,
|
||||
onParametersChange?: ParameterChangeHandler,
|
||||
} = $props();
|
||||
|
||||
@@ -99,7 +101,7 @@
|
||||
};
|
||||
|
||||
|
||||
let triggerObject: any;
|
||||
let triggerObject: any = $state();
|
||||
const handleDelete = () => {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
const previousParameters = deepClone(
|
||||
@@ -180,16 +182,26 @@
|
||||
{#if position.length > 1}
|
||||
<span class="output-branch-marker"></span>
|
||||
{/if}
|
||||
<Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
|
||||
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
|
||||
{#if readOnly}
|
||||
<span class="readonly-value" title={displayParameterName}>{displayParameterName || '--'}</span>
|
||||
{:else}
|
||||
<Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
|
||||
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]}
|
||||
value={currentParameter.dataType ? [currentParameter.dataType]:[]}
|
||||
disabled={currentParameter.dataTypeDisabled === true}
|
||||
onSelect={updateDataType} />
|
||||
{#if (currentParameter.dataType === "Object" || currentParameter.dataType === "Array") && currentParameter.addChildDisabled !== true}
|
||||
{#if readOnly}
|
||||
<span class="readonly-value readonly-value--type" title={currentParameter.dataType || 'String'}>
|
||||
{currentParameter.dataType || 'String'}
|
||||
</span>
|
||||
{:else}
|
||||
<Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]}
|
||||
value={currentParameter.dataType ? [currentParameter.dataType]:[]}
|
||||
disabled={currentParameter.dataTypeDisabled === true}
|
||||
onSelect={updateDataType} />
|
||||
{/if}
|
||||
{#if !readOnly && (currentParameter.dataType === "Object" || currentParameter.dataType === "Array") && currentParameter.addChildDisabled !== true}
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={handleAddChildParameter}>
|
||||
<svg style="transform: scaleY(-1)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
fill="currentColor">
|
||||
@@ -199,37 +211,41 @@
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
|
||||
<MenuButton />
|
||||
{#snippet floating()}
|
||||
<div class="input-more-setting">
|
||||
<div class="input-more-item">
|
||||
默认值:
|
||||
<Textarea rows={1} style="width: 100%;"
|
||||
value={currentParameter.defaultValue||''}
|
||||
onchange={(event)=>{
|
||||
updateByEvent( 'defaultValue', event)
|
||||
}} />
|
||||
</div>
|
||||
<div class="input-more-item">
|
||||
参数描述:
|
||||
<Textarea rows={3} style="width: 100%;"
|
||||
value={currentParameter.description||''}
|
||||
onchange={(event)=>{
|
||||
updateByEvent( 'description', event)
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{#if currentParameter.deleteDisabled !== true}
|
||||
{#if !readOnly}
|
||||
<div class="input-item">
|
||||
{#if currentParameter.settingsDisabled !== true}
|
||||
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
|
||||
<MenuButton />
|
||||
{#snippet floating()}
|
||||
<div class="input-more-setting">
|
||||
<div class="input-more-item">
|
||||
<Button onclick={handleDelete}>删除</Button>
|
||||
默认值:
|
||||
<Textarea rows={1} style="width: 100%;"
|
||||
value={currentParameter.defaultValue||''}
|
||||
onchange={(event)=>{
|
||||
updateByEvent( 'defaultValue', event)
|
||||
}} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</FloatingTrigger>
|
||||
</div>
|
||||
<div class="input-more-item">
|
||||
参数描述:
|
||||
<Textarea rows={3} style="width: 100%;"
|
||||
value={currentParameter.description||''}
|
||||
onchange={(event)=>{
|
||||
updateByEvent( 'description', event)
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{#if currentParameter.deleteDisabled !== true}
|
||||
<div class="input-more-item">
|
||||
<Button onclick={handleDelete}>删除</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</FloatingTrigger>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<style lang="less">
|
||||
@@ -238,6 +254,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.output-name-shell {
|
||||
@@ -261,6 +278,22 @@
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.readonly-value {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: var(--tf-text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&--type {
|
||||
color: var(--tf-text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.input-more-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
noneParameterText = '无输出参数',
|
||||
dataKeyName = 'outputDefs',
|
||||
placeholder = '请输入参数名称',
|
||||
readOnly = false,
|
||||
onParametersChange,
|
||||
}: {
|
||||
noneParameterText?: string;
|
||||
dataKeyName?: string;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
onParametersChange?: ParameterChangeHandler;
|
||||
} = $props();
|
||||
|
||||
@@ -31,6 +33,7 @@
|
||||
position={[...position, index]}
|
||||
{dataKeyName}
|
||||
{placeholder}
|
||||
{readOnly}
|
||||
{onParametersChange}
|
||||
/>
|
||||
{#if param.children}
|
||||
@@ -44,11 +47,13 @@
|
||||
{/snippet}
|
||||
|
||||
|
||||
<div class="input-container">
|
||||
<div class="input-container" class:input-container--readonly={readOnly}>
|
||||
{#if (parameters.length !== 0)}
|
||||
<div class="input-header">参数名称</div>
|
||||
<div class="input-header">参数类型</div>
|
||||
<div class="input-header"></div>
|
||||
{#if !readOnly}
|
||||
<div class="input-header"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
{@render parameterList(parameters || [], [])}
|
||||
</div>
|
||||
@@ -65,6 +70,11 @@
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
&--readonly {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.none-params {
|
||||
font-size: 12px;
|
||||
background: var(--tf-bg-muted);
|
||||
@@ -82,6 +92,10 @@
|
||||
font-size: 12px;
|
||||
color: var(--tf-text-secondary);
|
||||
min-width: 0;
|
||||
|
||||
&:nth-child(2) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,132 +1,461 @@
|
||||
<svelte:options customElement={{ props: {} }} />
|
||||
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, Heading} from '../base';
|
||||
import {Textarea} from '../base/index.js';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import {useSvelteFlow} from '@xyflow/svelte';
|
||||
import {onMount, untrack} from 'svelte';
|
||||
|
||||
import type {TinyflowNodeData} from '#types';
|
||||
import {Heading, Input, Textarea} from '../base';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import ConfirmParameterList from '../core/ConfirmParameterList.svelte';
|
||||
import type {Parameter, TinyflowNodeData} from '#types';
|
||||
import {deepEqual} from '#components/utils/deepEqual';
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {
|
||||
createConfirmOption,
|
||||
MAX_CONFIRM_OPTIONS,
|
||||
normalizeConfirmNodeData,
|
||||
validateConfirmOptions,
|
||||
} from '../utils/confirmNode';
|
||||
import {deepEqual} from '../utils/deepEqual';
|
||||
import {getCurrentNodeId} from '../utils/NodeUtils';
|
||||
import {useTinyflowStore} from '../../store/stores.svelte';
|
||||
|
||||
const {data, ...rest}: {
|
||||
data: TinyflowNodeData;
|
||||
[key: string]: any;
|
||||
} = $props();
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: TinyflowNodeData,
|
||||
[key: string]: any
|
||||
} = $props();
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
const {updateNodeData} = useSvelteFlow();
|
||||
const store = useTinyflowStore();
|
||||
const INPUT_COMMIT_DELAY_MS = 200;
|
||||
|
||||
const multiple = $derived(data.multiple === true);
|
||||
let messageDraft = $state(untrack(() => String(data.message || '')));
|
||||
let optionDrafts = $state<string[]>(
|
||||
untrack(() => normalizeOptions(data.options)),
|
||||
);
|
||||
let messageDirty = false;
|
||||
let optionsDirty = false;
|
||||
let inputCommitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const options = $derived(optionDrafts);
|
||||
const validations = $derived(validateConfirmOptions(options));
|
||||
let draggedOptionIndex = $state<number | null>(null);
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
const { addParameter } = useAddParameter();
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
function normalizeOptions(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.map((option) => typeof option === 'string' ? option : '')
|
||||
: [];
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (data.confirms) {
|
||||
const outputDefs = data.confirms.map((confirm: Parameter) => {
|
||||
return {
|
||||
// id?: string;
|
||||
// name?: string;
|
||||
// nameDisabled?: boolean;
|
||||
// dataType?: string;
|
||||
// dataTypeDisabled?: boolean;
|
||||
// ref?: string;
|
||||
// refType?: string;
|
||||
// value?: string;
|
||||
// description?: string;
|
||||
// required?: boolean;
|
||||
// defaultValue?: string;
|
||||
// deleteDisabled?: boolean;
|
||||
// addChildDisabled?: boolean;
|
||||
// children?: Parameter[];
|
||||
...confirm,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
dataType: confirm.formType === 'checkbox' || confirm.formType === 'select' ? 'Array' : 'String',
|
||||
addChildDisabled: true
|
||||
} as Parameter;
|
||||
});
|
||||
function sameOptions(left: string[], right: string[]) {
|
||||
return left.length === right.length
|
||||
&& left.every((option, index) => option === right[index]);
|
||||
}
|
||||
|
||||
// 判断 outputDefs 与 data.outputDefs 是否完全一致
|
||||
// 如果不判断,则会造成死循环更新
|
||||
if (!deepEqual(outputDefs, data.outputDefs)) {
|
||||
updateNodeData(currentNodeId, () => {
|
||||
return {
|
||||
outputDefs
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
const nextMessage = String(data.message || '');
|
||||
const nextOptions = normalizeOptions(data.options);
|
||||
untrack(() => {
|
||||
if (!messageDirty && messageDraft !== nextMessage) {
|
||||
messageDraft = nextMessage;
|
||||
}
|
||||
if (!optionsDirty && !sameOptions(optionDrafts, nextOptions)) {
|
||||
optionDrafts = nextOptions;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const normalizedData = normalizeConfirmNodeData(data);
|
||||
if (!deepEqual(normalizedData, data)) {
|
||||
updateNodeData(currentNodeId, normalizedData, {replace: true});
|
||||
}
|
||||
});
|
||||
|
||||
function flushInputDraft() {
|
||||
if (inputCommitTimer) {
|
||||
clearTimeout(inputCommitTimer);
|
||||
inputCommitTimer = undefined;
|
||||
}
|
||||
if (!messageDirty && !optionsDirty) return;
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (messageDirty) patch.message = messageDraft;
|
||||
if (optionsDirty) patch.options = [...optionDrafts];
|
||||
messageDirty = false;
|
||||
optionsDirty = false;
|
||||
store.updateNodeData(currentNodeId, patch);
|
||||
}
|
||||
|
||||
function scheduleInputCommit() {
|
||||
if (inputCommitTimer) clearTimeout(inputCommitTimer);
|
||||
inputCommitTimer = setTimeout(flushInputDraft, INPUT_COMMIT_DELAY_MS);
|
||||
}
|
||||
|
||||
function updateMessage(content: string) {
|
||||
messageDraft = content;
|
||||
messageDirty = true;
|
||||
scheduleInputCommit();
|
||||
}
|
||||
|
||||
function replaceOptions(nextOptions: string[], immediate = false) {
|
||||
optionDrafts = nextOptions;
|
||||
optionsDirty = true;
|
||||
if (immediate) {
|
||||
flushInputDraft();
|
||||
return;
|
||||
}
|
||||
scheduleInputCommit();
|
||||
}
|
||||
|
||||
function updateOption(index: number, content: string) {
|
||||
replaceOptions(options.map((option, optionIndex) =>
|
||||
optionIndex === index ? content : option,
|
||||
));
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
if (options.length >= MAX_CONFIRM_OPTIONS) return;
|
||||
replaceOptions([...options, createConfirmOption(options)], true);
|
||||
}
|
||||
|
||||
function deleteOption(index: number) {
|
||||
if (options.length <= 1) return;
|
||||
replaceOptions(
|
||||
options.filter((_, optionIndex) => optionIndex !== index),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function moveOption(from: number, to: number) {
|
||||
if (from === to || from < 0 || to < 0 || to >= options.length) return;
|
||||
const nextOptions = [...options];
|
||||
const [moved] = nextOptions.splice(from, 1);
|
||||
nextOptions.splice(to, 0, moved);
|
||||
replaceOptions(nextOptions, true);
|
||||
}
|
||||
|
||||
function onGripKeydown(event: KeyboardEvent, index: number) {
|
||||
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
|
||||
event.preventDefault();
|
||||
moveOption(index, index + (event.key === 'ArrowUp' ? -1 : 1));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unregister = store.registerPendingEditFlusher(flushInputDraft);
|
||||
return () => {
|
||||
flushInputDraft();
|
||||
unregister();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<NodeWrapper
|
||||
{data}
|
||||
{...rest}
|
||||
allowAsyncSetting={false}
|
||||
wrapperClass="tf-node-wrapper--confirm"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z"></path>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<NodeWrapper {data} {...rest}>
|
||||
<div class="confirm-card">
|
||||
<section>
|
||||
<Heading level={3} mb="8px">固定信息</Heading>
|
||||
<div class="setting-title">提示内容</div>
|
||||
<Textarea
|
||||
class="confirm-message"
|
||||
rows={3}
|
||||
maxHeight="120px"
|
||||
maxlength={2000}
|
||||
placeholder="请输入用户需要确认的提示内容"
|
||||
style="width: 100%"
|
||||
value={messageDraft}
|
||||
oninput={(event: Event) => updateMessage(
|
||||
(event.target as HTMLTextAreaElement).value,
|
||||
)}
|
||||
onblur={flushInputDraft}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{#snippet icon()}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<section>
|
||||
<Heading level={3} mb="8px">交互选项</Heading>
|
||||
<fieldset class="choice-mode">
|
||||
<legend>选择方式</legend>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="confirm-mode-{currentNodeId}"
|
||||
checked={!multiple}
|
||||
onchange={() => updateNodeData(currentNodeId, {multiple: false})}
|
||||
/>
|
||||
<span>单选</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="confirm-mode-{currentNodeId}"
|
||||
checked={multiple}
|
||||
onchange={() => updateNodeData(currentNodeId, {multiple: true})}
|
||||
/>
|
||||
<span>多选</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="heading">
|
||||
<Heading level={3}>确认数据</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId, 'confirms')
|
||||
}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<ConfirmParameterList dataKeyName="confirms" noneParameterText="无确认数据" />
|
||||
|
||||
<Heading level={3} mt="10px">确认消息</Heading>
|
||||
<div class="setting-title">消息内容</div>
|
||||
<div class="setting-item">
|
||||
<Textarea rows={5} placeholder="请输入用户需要确认的消息内容"
|
||||
style="width: 100%" onchange={(e:any)=>{
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
message: e.target.value
|
||||
<div class="option-list nowheel">
|
||||
{#each options as option, optionIndex (optionIndex)}
|
||||
<div class="option-item">
|
||||
<div
|
||||
class="option-row"
|
||||
role="group"
|
||||
aria-label={`选项 ${optionIndex + 1}`}
|
||||
ondragover={(event: DragEvent) => event.preventDefault()}
|
||||
ondrop={(event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
if (draggedOptionIndex !== null) {
|
||||
moveOption(draggedOptionIndex, optionIndex);
|
||||
}
|
||||
})
|
||||
}} value={String(data.message || '')} />
|
||||
</div>
|
||||
draggedOptionIndex = null;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="grip nodrag nopan"
|
||||
draggable="true"
|
||||
aria-label={`调整选项 ${optionIndex + 1} 顺序,方向键也可移动`}
|
||||
ondragstart={() => draggedOptionIndex = optionIndex}
|
||||
ondragend={() => draggedOptionIndex = null}
|
||||
onkeydown={(event: KeyboardEvent) => onGripKeydown(event, optionIndex)}
|
||||
>⠿</button>
|
||||
<Input
|
||||
value={option}
|
||||
maxlength={200}
|
||||
aria-label={`选项 ${optionIndex + 1} 内容`}
|
||||
aria-invalid={Boolean(validations[optionIndex])}
|
||||
placeholder="请输入选项内容"
|
||||
oninput={(event: Event) => updateOption(
|
||||
optionIndex,
|
||||
(event.target as HTMLInputElement).value,
|
||||
)}
|
||||
onblur={flushInputDraft}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-action nodrag nopan"
|
||||
aria-label={`删除选项 ${option || optionIndex + 1}`}
|
||||
disabled={options.length <= 1}
|
||||
onclick={() => deleteOption(optionIndex)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if validations[optionIndex]}
|
||||
<div class="validation-message" role="alert">{validations[optionIndex]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="text-action nodrag nopan"
|
||||
disabled={options.length >= MAX_CONFIRM_OPTIONS}
|
||||
onclick={addOption}
|
||||
><span aria-hidden="true">+</span> 添加选项</button>
|
||||
</section>
|
||||
|
||||
<div class="heading">
|
||||
<Heading level={3} mt="10px">输出参数</Heading>
|
||||
</div>
|
||||
<OutputDefList placeholder="" />
|
||||
|
||||
<section>
|
||||
<Heading level={3} mb="10px">输出参数</Heading>
|
||||
<OutputDefList />
|
||||
</section>
|
||||
</div>
|
||||
</NodeWrapper>
|
||||
|
||||
<style>
|
||||
.heading {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
<style lang="less">
|
||||
.confirm-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-title {
|
||||
font-size: 12px;
|
||||
color: var(--tf-text-muted);
|
||||
margin-bottom: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
.setting-title,
|
||||
.choice-mode {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--tf-text-secondary);
|
||||
}
|
||||
|
||||
.setting-title {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
:global(.confirm-message) {
|
||||
box-sizing: border-box;
|
||||
min-height: 72px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.choice-mode {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.choice-mode legend {
|
||||
float: left;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.choice-mode label {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
color: var(--tf-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.choice-mode input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--tf-primary-color);
|
||||
}
|
||||
|
||||
.choice-mode input:focus-visible,
|
||||
.grip:focus-visible,
|
||||
.icon-action:focus-visible,
|
||||
.text-action:focus-visible {
|
||||
outline: 0;
|
||||
border-radius: 5px;
|
||||
box-shadow: var(--tf-focus-shadow);
|
||||
}
|
||||
|
||||
.option-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
padding-right: 2px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 28px;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.option-row :global(.tf-input) {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.grip,
|
||||
.icon-action,
|
||||
.text-action {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.grip,
|
||||
.icon-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--tf-text-muted);
|
||||
}
|
||||
|
||||
.grip {
|
||||
width: 18px;
|
||||
height: 28px;
|
||||
font-size: 16px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.icon-action {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
opacity: .56;
|
||||
}
|
||||
|
||||
.icon-action svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
overflow: visible;
|
||||
stroke: currentcolor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.option-row:hover .icon-action,
|
||||
.option-row:focus-within .icon-action {
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.icon-action:hover:not(:disabled) {
|
||||
color: var(--tf-danger-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.icon-action:disabled,
|
||||
.text-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .35;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
padding-left: 24px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--tf-danger-color);
|
||||
}
|
||||
|
||||
.text-action {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--tf-primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-action:hover:not(:disabled) {
|
||||
color: var(--tf-primary-color-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildConfirmOutputDefs,
|
||||
createConfirmOption,
|
||||
normalizeConfirmNodeData,
|
||||
validateConfirmOptions,
|
||||
} from './confirmNode';
|
||||
|
||||
describe('confirm node contract', () => {
|
||||
it('maps the selection mode to one output with a default name', () => {
|
||||
expect(buildConfirmOutputDefs(false)).toMatchObject([
|
||||
{ name: 'selection', dataType: 'String' },
|
||||
]);
|
||||
expect(buildConfirmOutputDefs(true)).toMatchObject([
|
||||
{ name: 'selection', dataType: 'Array<String>' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the output name editable and locks its inferred type', () => {
|
||||
expect(buildConfirmOutputDefs(false)[0]).toMatchObject({
|
||||
addChildDisabled: true,
|
||||
autoManaged: true,
|
||||
dataTypeItems: [{ label: 'String', value: 'String' }],
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
settingsDisabled: true,
|
||||
});
|
||||
expect(buildConfirmOutputDefs(false)[0]).not.toHaveProperty('nameDisabled');
|
||||
expect(buildConfirmOutputDefs(true)[0]).toMatchObject({
|
||||
dataType: 'Array<String>',
|
||||
dataTypeItems: [
|
||||
{ label: 'Array<String>', value: 'Array<String>' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a configured output name when the selection mode changes', () => {
|
||||
expect(buildConfirmOutputDefs(true, 'templateChoice')).toMatchObject([
|
||||
{ name: 'templateChoice', dataType: 'Array<String>' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('materializes the visible single-select default in node data', () => {
|
||||
expect(normalizeConfirmNodeData({
|
||||
message: '请选择会议纪要模板',
|
||||
options: ['确认', '取消'],
|
||||
})).toMatchObject({
|
||||
multiple: false,
|
||||
outputDefs: [{ name: 'selection', dataType: 'String' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('removes data that is not part of the final confirm contract', () => {
|
||||
const normalized = normalizeConfirmNodeData({
|
||||
async: true,
|
||||
confirms: [{ name: 'legacy' }],
|
||||
fields: [{ key: 'legacy' }],
|
||||
message: '请选择会议纪要模板',
|
||||
multiple: false,
|
||||
options: ['确认', '取消'],
|
||||
outputDefs: [{ name: 'templateType', dataType: 'String' }],
|
||||
parameters: [{ name: 'unused' }],
|
||||
schemaVersion: 1,
|
||||
unknownLegacySetting: true,
|
||||
title: '用户确认',
|
||||
});
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
message: '请选择会议纪要模板',
|
||||
multiple: false,
|
||||
options: ['确认', '取消'],
|
||||
outputDefs: [{ name: 'templateType', dataType: 'String' }],
|
||||
title: '用户确认',
|
||||
});
|
||||
expect(normalized).not.toHaveProperty('confirms');
|
||||
expect(normalized).not.toHaveProperty('async');
|
||||
expect(normalized).not.toHaveProperty('fields');
|
||||
expect(normalized).not.toHaveProperty('parameters');
|
||||
expect(normalized).not.toHaveProperty('schemaVersion');
|
||||
expect(normalized).not.toHaveProperty('unknownLegacySetting');
|
||||
});
|
||||
|
||||
it('preserves common node settings that are effective at runtime', () => {
|
||||
const normalized = normalizeConfirmNodeData({
|
||||
condition: 'true',
|
||||
description: '确认继续或选择内容',
|
||||
expand: true,
|
||||
joinMode: 'all',
|
||||
loopEnable: true,
|
||||
loopIntervalMs: 1000,
|
||||
maxLoopCount: 2,
|
||||
message: '请选择会议纪要模板',
|
||||
multiple: false,
|
||||
options: ['确认', '取消'],
|
||||
outputDefs: [{ name: 'selection', dataType: 'String' }],
|
||||
retryEnable: true,
|
||||
retryIntervalMs: 1000,
|
||||
maxRetryCount: 3,
|
||||
title: '用户确认',
|
||||
});
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
condition: 'true',
|
||||
expand: true,
|
||||
joinMode: 'all',
|
||||
loopEnable: true,
|
||||
retryEnable: true,
|
||||
title: '用户确认',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps invalid explicit modes visible to backend validation', () => {
|
||||
expect(normalizeConfirmNodeData({
|
||||
multiple: 'false',
|
||||
outputDefs: [{ name: 'templateType', dataType: 'String' }],
|
||||
}).multiple).toBe('false');
|
||||
});
|
||||
|
||||
it('creates a unique option content after an option was removed', () => {
|
||||
expect(createConfirmOption(['选项 2', '选项 3'])).toBe('选项 4');
|
||||
});
|
||||
|
||||
it('reports empty and duplicate option contents', () => {
|
||||
expect(validateConfirmOptions(['', '审议类', ' 审议类 '])).toEqual([
|
||||
'请输入选项内容',
|
||||
'选项内容不能重复',
|
||||
'选项内容不能重复',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Parameter } from '#types';
|
||||
|
||||
export const MAX_CONFIRM_OPTIONS = 100;
|
||||
export const DEFAULT_CONFIRM_OUTPUT_NAME = 'selection';
|
||||
export const CONFIRM_NODE_DATA_KEYS = new Set([
|
||||
'condition',
|
||||
'description',
|
||||
'expand',
|
||||
'joinMode',
|
||||
'loopBreakCondition',
|
||||
'loopEnable',
|
||||
'loopIntervalMs',
|
||||
'maxLoopCount',
|
||||
'maxRetryCount',
|
||||
'message',
|
||||
'multiple',
|
||||
'options',
|
||||
'outputDefs',
|
||||
'resetRetryCountAfterNormal',
|
||||
'retryEnable',
|
||||
'retryIntervalMs',
|
||||
'title',
|
||||
]);
|
||||
|
||||
export function createConfirmOption(existing: string[]) {
|
||||
let sequence = existing.length + 1;
|
||||
let option = `选项 ${sequence}`;
|
||||
while (existing.includes(option)) {
|
||||
sequence += 1;
|
||||
option = `选项 ${sequence}`;
|
||||
}
|
||||
return option;
|
||||
}
|
||||
|
||||
export function buildConfirmOutputDefs(
|
||||
multiple: boolean,
|
||||
outputName = DEFAULT_CONFIRM_OUTPUT_NAME,
|
||||
): Parameter[] {
|
||||
const dataType = multiple ? 'Array<String>' : 'String';
|
||||
return [
|
||||
{
|
||||
id: 'confirm-selection',
|
||||
name: outputName,
|
||||
dataType,
|
||||
dataTypeItems: [{ label: dataType, value: dataType }],
|
||||
dataTypeDisabled: true,
|
||||
addChildDisabled: true,
|
||||
deleteDisabled: true,
|
||||
settingsDisabled: true,
|
||||
autoManaged: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function normalizeConfirmNodeData(data: Record<string, any>) {
|
||||
let nextData = data;
|
||||
const mutableData = () => {
|
||||
if (nextData === data) {
|
||||
nextData = { ...data };
|
||||
}
|
||||
return nextData;
|
||||
};
|
||||
|
||||
for (const key of Object.keys(nextData)) {
|
||||
if (!CONFIRM_NODE_DATA_KEYS.has(key)) {
|
||||
delete mutableData()[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (nextData.multiple == null) {
|
||||
mutableData().multiple = false;
|
||||
}
|
||||
|
||||
const configuredOutputName = Array.isArray(nextData.outputDefs)
|
||||
&& typeof nextData.outputDefs[0]?.name === 'string'
|
||||
? nextData.outputDefs[0].name
|
||||
: DEFAULT_CONFIRM_OUTPUT_NAME;
|
||||
const outputDefs = buildConfirmOutputDefs(
|
||||
nextData.multiple === true,
|
||||
configuredOutputName,
|
||||
);
|
||||
if (JSON.stringify(nextData.outputDefs) !== JSON.stringify(outputDefs)) {
|
||||
mutableData().outputDefs = outputDefs;
|
||||
}
|
||||
|
||||
return nextData;
|
||||
}
|
||||
|
||||
export function validateConfirmOptions(options: string[]) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const option of options) {
|
||||
const normalized = option.trim();
|
||||
if (normalized) counts.set(normalized, (counts.get(normalized) || 0) + 1);
|
||||
}
|
||||
|
||||
return options.map((option) => {
|
||||
const normalized = option.trim();
|
||||
if (!normalized) return '请输入选项内容';
|
||||
if (counts.get(normalized)! > 1) return '选项内容不能重复';
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Node } from '@xyflow/svelte';
|
||||
import type { TinyflowOptions } from '#types';
|
||||
|
||||
import { DEFAULT_CODE_NODE_JAVASCRIPT } from './codeNodeScaffold';
|
||||
import { buildConfirmOutputDefs } from './confirmNode';
|
||||
|
||||
export type NodePaletteItem = {
|
||||
icon?: string;
|
||||
@@ -101,6 +102,12 @@ const BUILT_IN_NODES: NodePaletteItem[] = [
|
||||
sortNo: 900,
|
||||
description: '确认继续或选择内容',
|
||||
category: '输入输出',
|
||||
extra: {
|
||||
message: '请确认以下内容',
|
||||
multiple: false,
|
||||
options: ['选项一', '选项二'],
|
||||
outputDefs: buildConfirmOutputDefs(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5.1438V16.0002H18.3391L6 5.1438ZM4 2.932C4 2.07155 5.01456 1.61285 5.66056 2.18123L21.6501 16.2494C22.3423 16.8584 21.9116 18.0002 20.9896 18.0002H6V22H4V2.932Z"></path></svg>',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const createStore = () => {
|
||||
let edgesInternal = $state.raw([] as Edge[]);
|
||||
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
||||
let normalizeNode: TinyflowNodeNormalizer = (node) => node;
|
||||
const pendingEditFlushers = new Set<() => void>();
|
||||
|
||||
const normalizeNodes = (nodes: Node[]) => nodes.map(normalizeNode);
|
||||
|
||||
@@ -39,6 +40,13 @@ export const createStore = () => {
|
||||
setViewport: (v: Viewport) => {
|
||||
viewport = v;
|
||||
},
|
||||
registerPendingEditFlusher: (flusher: () => void) => {
|
||||
pendingEditFlushers.add(flusher);
|
||||
return () => pendingEditFlushers.delete(flusher);
|
||||
},
|
||||
flushPendingEdits: () => {
|
||||
[...pendingEditFlushers].forEach((flusher) => flusher());
|
||||
},
|
||||
|
||||
getNode: (id: string) => nodesInternal.find((node) => node.id === id),
|
||||
addNode: (node: Node) => {
|
||||
|
||||
@@ -120,6 +120,12 @@
|
||||
min-width: 296px;
|
||||
max-width: 296px;
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
width: 360px;
|
||||
min-width: 360px;
|
||||
max-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.svelte-flow__attribution a {
|
||||
|
||||
@@ -85,4 +85,35 @@ describe('tinyflow store isolation', () => {
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
});
|
||||
|
||||
it('flushes pending node edits before exporting data', async () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const tinyflow = new Tinyflow({
|
||||
element: container,
|
||||
data: {
|
||||
nodes: [
|
||||
{ id: 'confirm', position: { x: 0, y: 0 }, data: { message: '旧值' } },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
});
|
||||
|
||||
await waitForRender();
|
||||
|
||||
const store = (tinyflow as unknown as {
|
||||
store: {
|
||||
registerPendingEditFlusher: (flusher: () => void) => () => boolean;
|
||||
updateNodeData: (id: string, data: Record<string, unknown>) => void;
|
||||
};
|
||||
}).store;
|
||||
const unregister = store.registerPendingEditFlusher(() => {
|
||||
store.updateNodeData('confirm', { message: '最新值' });
|
||||
});
|
||||
|
||||
expect(tinyflow.getData()?.nodes[0]?.data.message).toBe('最新值');
|
||||
|
||||
unregister();
|
||||
tinyflow.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ export type SelectItem = {
|
||||
itemTypeLabel?: string;
|
||||
isCollection?: boolean;
|
||||
tags?: string[];
|
||||
disabledReason?: string;
|
||||
children?: SelectItem[];
|
||||
};
|
||||
|
||||
@@ -157,9 +158,11 @@ export type Parameter = {
|
||||
required?: boolean;
|
||||
defaultValue?: string;
|
||||
deleteDisabled?: boolean;
|
||||
settingsDisabled?: boolean;
|
||||
addChildDisabled?: boolean;
|
||||
children?: Parameter[];
|
||||
enums?: string[];
|
||||
options?: ParameterOption[];
|
||||
formType?: string;
|
||||
formLabel?: string;
|
||||
formDescription?: string;
|
||||
@@ -174,6 +177,11 @@ export type Parameter = {
|
||||
flattenAggregation?: boolean;
|
||||
};
|
||||
|
||||
export type ParameterOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ParameterChangeHandler = (
|
||||
previousParameters: Parameter[],
|
||||
nextParameters: Parameter[],
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
FIELD_BINDING_META_KEY,
|
||||
isStartFormFieldKeyAvailable,
|
||||
normalizeStartNodeData,
|
||||
normalizeWorkflowStartNodes,
|
||||
normalizeWorkflowNodes,
|
||||
renameStartFieldReferencesInNodes,
|
||||
removeStartFormField,
|
||||
syncManagedParametersForFields,
|
||||
@@ -1395,7 +1395,7 @@ describe('workflow node fields', () => {
|
||||
});
|
||||
|
||||
it('normalizes only start nodes that already contain fixed user_input', () => {
|
||||
const normalizedWorkflow = normalizeWorkflowStartNodes({
|
||||
const normalizedWorkflow = normalizeWorkflowNodes({
|
||||
nodes: [
|
||||
{
|
||||
id: 'start_new',
|
||||
@@ -1430,4 +1430,63 @@ describe('workflow node fields', () => {
|
||||
).toBe('user_input');
|
||||
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes retired confirm data without changing the current contract', () => {
|
||||
const normalizedWorkflow = normalizeWorkflowNodes({
|
||||
nodes: [
|
||||
{
|
||||
id: 'confirm_1',
|
||||
type: 'confirmNode',
|
||||
data: {
|
||||
title: '用户确认',
|
||||
message: '请选择会议纪要模板',
|
||||
multiple: false,
|
||||
options: ['确认', '取消'],
|
||||
outputDefs: [{ name: 'selection', dataType: 'String' }],
|
||||
confirms: [],
|
||||
fields: [],
|
||||
parameters: [{ name: 'unused' }],
|
||||
schemaVersion: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(normalizedWorkflow.nodes[0]?.data).toMatchObject({
|
||||
title: '用户确认',
|
||||
message: '请选择会议纪要模板',
|
||||
multiple: false,
|
||||
options: ['确认', '取消'],
|
||||
outputDefs: [{ name: 'selection', dataType: 'String' }],
|
||||
});
|
||||
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty('confirms');
|
||||
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty('fields');
|
||||
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty('parameters');
|
||||
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty(
|
||||
'schemaVersion',
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the visible single-select default into confirm node data', () => {
|
||||
const normalizedWorkflow = normalizeWorkflowNodes({
|
||||
nodes: [
|
||||
{
|
||||
id: 'confirm_1',
|
||||
type: 'confirmNode',
|
||||
data: {
|
||||
message: '请选择会议纪要模板',
|
||||
options: ['确认', '取消'],
|
||||
outputDefs: [{ name: 'templateType', dataType: 'String' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(normalizedWorkflow.nodes[0]?.data).toMatchObject({
|
||||
multiple: false,
|
||||
outputDefs: [{ name: 'templateType', dataType: 'String' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
buildLoopReferenceParameters,
|
||||
buildLoopScopeParameters,
|
||||
} from './loopScope';
|
||||
import { normalizeConfirmNodeData } from '../components/utils/confirmNode';
|
||||
|
||||
export const START_NODE_TYPE = 'startNode';
|
||||
export const CONFIRM_NODE_TYPE = 'confirmNode';
|
||||
export const LLM_NODE_TYPE = 'llmNode';
|
||||
export const KNOWLEDGE_NODE_TYPE = 'knowledgeNode';
|
||||
export const SYSTEM_START_PARAM_NAME = 'user_input';
|
||||
@@ -1147,7 +1149,7 @@ export function createInitialWorkflowData() {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorkflowStartNodes<T extends Record<string, any>>(
|
||||
export function normalizeWorkflowNodes<T extends Record<string, any>>(
|
||||
data: T,
|
||||
): T {
|
||||
if (!data || typeof data !== 'object' || !Array.isArray(data.nodes)) {
|
||||
@@ -1156,10 +1158,24 @@ export function normalizeWorkflowStartNodes<T extends Record<string, any>>(
|
||||
|
||||
let changed = false;
|
||||
const nextNodes = data.nodes.map((node) => {
|
||||
if (node?.type !== START_NODE_TYPE) {
|
||||
if (!node?.data || typeof node.data !== 'object') {
|
||||
return node;
|
||||
}
|
||||
const currentData = (node.data || {}) as Record<string, any>;
|
||||
if (node.type === CONFIRM_NODE_TYPE) {
|
||||
const nextData = normalizeConfirmNodeData(currentData);
|
||||
if (nextData === currentData) {
|
||||
return node;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
data: nextData,
|
||||
};
|
||||
}
|
||||
if (node.type !== START_NODE_TYPE) {
|
||||
return node;
|
||||
}
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
|
||||
Reference in New Issue
Block a user