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

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

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

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

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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 | 缺少接口权限或工作流调用权限 |`);

View File

@@ -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>

View File

@@ -1,216 +0,0 @@
<script setup lang="ts">
import { Download } from '@element-plus/icons-vue';
import { ElIcon, ElText } from 'element-plus';
import confirmFile from '#/assets/ai/workflow/confirm-file.png';
// 导入你的图片资源
import confirmOther from '#/assets/ai/workflow/confirm-other.png';
// 定义 Props
const props = defineProps({
// v-model 绑定值,多选版本这里是数组
modelValue: {
type: Array as () => any[],
default: () => [],
},
// 数据类型: text, image, video, audio, other, file
selectionDataType: {
type: String,
default: 'text',
},
// 数据列表
selectionData: {
type: Array as () => any[],
default: () => [],
},
});
// 定义 Emits
const emit = defineEmits(['update:modelValue', 'change']);
// 判断是否选中
const isSelected = (item: any) => {
return props.modelValue && props.modelValue.includes(item);
};
// 切换选中状态 (多选逻辑)
const changeValue = (item: any) => {
// 复制一份当前数组,避免直接修改 prop
const currentValues = props.modelValue ? [...props.modelValue] : [];
const index = currentValues.indexOf(item);
if (index === -1) {
// 如果不存在,则添加
currentValues.push(item);
} else {
// 如果已存在,则移除
currentValues.splice(index, 1);
}
// 更新 v-model
emit('update:modelValue', currentValues);
// 触发 Element Plus 表单验证
emit('change', currentValues);
};
// 获取图标
const getIcon = (type: string) => {
return type === 'other' ? confirmOther : confirmFile;
};
// 下载处理
const handleDownload = (url: string) => {
window.open(url, '_blank');
};
</script>
<template>
<div class="custom-radio-group">
<template v-for="(item, index) in selectionData" :key="index">
<!-- 类型: Text -->
<div
v-if="selectionDataType === 'text'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="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>

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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) => ({

View File

@@ -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">&nbsp;</div>
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
</div>
<div
class="description-container"
v-if="ops.formType !== 'confirm'"
<ElButton
:disabled="confirmBtnLoading"
:loading="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ ops.formDescription }}
</div>
<ElFormItem
v-if="ops.formType !== 'confirm'"
:prop="ops.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ConfirmItem
v-if="getSelectMode(ops) === 'radio'"
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
<ConfirmItemMulti
v-else
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
</ElFormItem>
</template>
<ElFormItem v-if="node.suspendForParameters?.length > 0">
<div class="flex justify-end">
<ElButton
:disabled="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ $t('button.confirm') }}
</ElButton>
</div>
</ElFormItem>
继续
</ElButton>
</div>
</ElForm>
</div>
<div v-else>
@@ -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>

View File

@@ -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);
});
});

View File

@@ -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: '审议类' },
]);
});
});

View File

@@ -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,

View File

@@ -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 }) {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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([
'请输入选项内容',
'选项内容不能重复',
'选项内容不能重复',
]);
});
});

View File

@@ -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;
});
}

View File

@@ -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>',

View File

@@ -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) => {

View File

@@ -120,6 +120,12 @@
min-width: 296px;
max-width: 296px;
}
&--confirm {
width: 360px;
min-width: 360px;
max-width: 360px;
}
}
.svelte-flow__attribution a {

View File

@@ -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();
});
});

View File

@@ -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[],

View File

@@ -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' }],
});
});
});

View File

@@ -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[])
: [];