feat: 增加工作流编排临时草稿缓存

- 按当前标签页缓存并静默恢复未保存的工作流编排

- 优化试运行抽屉层级、窄屏宽度与长结果滚动

- 覆盖版本变化、缓存异常与保存竞态场景
This commit is contained in:
2026-07-17 18:58:47 +08:00
parent 5a91323a2e
commit 62d763199f
6 changed files with 464 additions and 10 deletions

View File

@@ -1,5 +1,12 @@
<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref} from 'vue';
import {
computed,
onBeforeUnmount,
onDeactivated,
onMounted,
onUnmounted,
ref,
} from 'vue';
import {useRoute} from 'vue-router';
import {usePreferences} from '@easyflow/preferences';
@@ -23,6 +30,12 @@ import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
import {
clearWorkflowDraft,
createWorkflowContentSignature,
readWorkflowDraft,
writeWorkflowDraft,
} from '#/views/ai/workflow/workflowDraftCache';
import {getCustomNode} from './customNode/index';
import nodeNames from './customNode/nodeNames';
@@ -48,12 +61,18 @@ onMounted(async () => {
]);
showTinyFlow.value = true;
});
onBeforeUnmount(() => {
captureCurrentWorkflowDraft();
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
if (focusPulseTimer) {
clearTimeout(focusPulseTimer);
}
});
onDeactivated(() => {
captureCurrentWorkflowDraft();
});
// variables
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
const workflowId = ref(route.query.id);
@@ -69,6 +88,12 @@ const codeEngineList = ref<any[]>([
available: true,
},
]);
const WORKFLOW_DRAFT_WRITE_DELAY = 320;
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
let pendingDraftContent: any = null;
let lastObservedWorkflowContent: any = null;
let serverContentSignature = '';
let draftCacheWarningShown = false;
function escapeHtmlAttr(value?: string) {
return String(value || '')
@@ -343,12 +368,16 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
saveLoading.value = true;
try {
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
const savedContentSignature = createWorkflowContentSignature(content);
const res = await api.post('/api/v1/workflow/update', {
id: workflowId.value,
content,
});
if (res.errorCode === 0 && showMsg) {
ElMessage.success(res.message);
if (res.errorCode === 0) {
reconcileWorkflowDraftAfterSave(savedContentSignature);
if (showMsg) {
ElMessage.success(res.message);
}
}
return res.errorCode === 0;
} catch {
@@ -364,13 +393,101 @@ async function getWorkflowInfo(workflowId: any, syncFlowData: boolean = true) {
const parsedContent = workflowInfo.value.content
? JSON.parse(workflowInfo.value.content)
: {};
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
const serverContent = isWorkflowDataEmpty(parsedContent)
? createInitialWorkflowData()
: normalizeWorkflowStartNodes(parsedContent);
serverContentSignature = createWorkflowContentSignature(serverContent);
const draft = readWorkflowDraft(workflowId, serverContent);
tinyFlowData.value = draft
? normalizeWorkflowStartNodes(draft.content as Record<string, any>)
: serverContent;
lastObservedWorkflowContent = tinyFlowData.value;
}
syncNavTitle(workflowInfo.value?.title || '');
});
}
function persistPendingWorkflowDraft() {
if (!pendingDraftContent || !workflowId.value || !serverContentSignature) {
draftWriteTimer = undefined;
return;
}
const content = pendingDraftContent;
pendingDraftContent = null;
draftWriteTimer = undefined;
const persisted = writeWorkflowDraft({
baseContentSignature: serverContentSignature,
content,
workflowId: workflowId.value,
});
if (!persisted && !draftCacheWarningShown) {
draftCacheWarningShown = true;
ElMessage.warning($t('aiWorkflow.draftCacheFailed'));
}
}
function scheduleWorkflowDraft(content: any) {
lastObservedWorkflowContent = normalizeWorkflowStartNodes(content);
pendingDraftContent = lastObservedWorkflowContent;
if (draftWriteTimer) {
clearTimeout(draftWriteTimer);
}
draftWriteTimer = setTimeout(
persistPendingWorkflowDraft,
WORKFLOW_DRAFT_WRITE_DELAY,
);
}
function flushWorkflowDraft() {
if (draftWriteTimer) {
clearTimeout(draftWriteTimer);
}
if (pendingDraftContent) {
persistPendingWorkflowDraft();
}
}
function captureCurrentWorkflowDraft() {
const content = tinyflowRef.value?.getData();
if (content) {
lastObservedWorkflowContent = normalizeWorkflowStartNodes(content);
pendingDraftContent = lastObservedWorkflowContent;
}
flushWorkflowDraft();
}
function clearPendingWorkflowDraft() {
if (draftWriteTimer) {
clearTimeout(draftWriteTimer);
}
draftWriteTimer = undefined;
pendingDraftContent = null;
clearWorkflowDraft(workflowId.value);
}
function reconcileWorkflowDraftAfterSave(savedContentSignature: string) {
serverContentSignature = savedContentSignature;
const currentContent =
tinyflowRef.value?.getData() || lastObservedWorkflowContent;
if (!currentContent) {
clearPendingWorkflowDraft();
return;
}
const normalizedContent = normalizeWorkflowStartNodes(currentContent);
lastObservedWorkflowContent = normalizedContent;
if (
createWorkflowContentSignature(normalizedContent) === savedContentSignature
) {
clearPendingWorkflowDraft();
return;
}
if (draftWriteTimer) {
clearTimeout(draftWriteTimer);
}
draftWriteTimer = undefined;
pendingDraftContent = normalizedContent;
persistPendingWorkflowDraft();
}
async function getLlmList() {
return api.get('/api/v1/model/list').then((res) => {
llmList.value = res.data;
@@ -652,12 +769,18 @@ function onAsyncExecute(info: any) {
<ElDrawer
v-model="singleRunVisible"
:title="singleNode?.data?.title"
append-to-body
destroy-on-close
size="600px"
size="min(600px, 94vw)"
>
<SingleRun :node="singleNode" :workflow-id="workflowId" />
</ElDrawer>
<ElDrawer v-model="drawerVisible" :title="$t('button.run')" size="600px">
<ElDrawer
v-model="drawerVisible"
:title="$t('button.run')"
append-to-body
size="min(600px, 94vw)"
>
<div class="mb-2.5 font-semibold">{{ $t('aiWorkflow.params') }}</div>
<WorkflowForm
ref="workflowForm"
@@ -733,6 +856,7 @@ function onAsyncExecute(info: any) {
:theme="isDark ? 'dark' : 'light'"
:provider="provider"
:custom-nodes="customNode"
:on-data-change="scheduleWorkflowDraft"
:on-node-execute="runIndependently"
:on-run-test="runWorkflow"
/>