feat: 增加工作流编排临时草稿缓存
- 按当前标签页缓存并静默恢复未保存的工作流编排 - 优化试运行抽屉层级、窄屏宽度与长结果滚动 - 覆盖版本变化、缓存异常与保存竞态场景
This commit is contained in:
@@ -101,6 +101,7 @@
|
|||||||
"publishPendingHint": "There is already an approval in progress for this workflow.",
|
"publishPendingHint": "There is already an approval in progress for this workflow.",
|
||||||
"deletePendingHint": "There is already an approval in progress for this workflow.",
|
"deletePendingHint": "There is already an approval in progress for this workflow.",
|
||||||
"check": "Check",
|
"check": "Check",
|
||||||
|
"draftCacheFailed": "Unable to cache the temporary draft. Please save manually",
|
||||||
"checkPassed": "Workflow check passed",
|
"checkPassed": "Workflow check passed",
|
||||||
"checkFailed": "Workflow check failed. Please fix the issues first",
|
"checkFailed": "Workflow check failed. Please fix the issues first",
|
||||||
"checkContentEmpty": "Canvas content is empty, unable to check",
|
"checkContentEmpty": "Canvas content is empty, unable to check",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
|
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
|
||||||
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
|
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
|
||||||
"check": "检查",
|
"check": "检查",
|
||||||
|
"draftCacheFailed": "临时草稿保存失败,请及时手动保存",
|
||||||
"checkPassed": "工作流检查通过",
|
"checkPassed": "工作流检查通过",
|
||||||
"checkFailed": "工作流检查未通过,请先修复问题",
|
"checkFailed": "工作流检查未通过,请先修复问题",
|
||||||
"checkContentEmpty": "当前画布内容为空,无法检查",
|
"checkContentEmpty": "当前画布内容为空,无法检查",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<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 {useRoute} from 'vue-router';
|
||||||
|
|
||||||
import {usePreferences} from '@easyflow/preferences';
|
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 SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
|
||||||
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
|
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
|
||||||
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.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 {getCustomNode} from './customNode/index';
|
||||||
import nodeNames from './customNode/nodeNames';
|
import nodeNames from './customNode/nodeNames';
|
||||||
@@ -48,12 +61,18 @@ onMounted(async () => {
|
|||||||
]);
|
]);
|
||||||
showTinyFlow.value = true;
|
showTinyFlow.value = true;
|
||||||
});
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
captureCurrentWorkflowDraft();
|
||||||
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', handleKeydown);
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
if (focusPulseTimer) {
|
if (focusPulseTimer) {
|
||||||
clearTimeout(focusPulseTimer);
|
clearTimeout(focusPulseTimer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
onDeactivated(() => {
|
||||||
|
captureCurrentWorkflowDraft();
|
||||||
|
});
|
||||||
// variables
|
// variables
|
||||||
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
|
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
|
||||||
const workflowId = ref(route.query.id);
|
const workflowId = ref(route.query.id);
|
||||||
@@ -69,6 +88,12 @@ const codeEngineList = ref<any[]>([
|
|||||||
available: true,
|
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) {
|
function escapeHtmlAttr(value?: string) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
@@ -343,12 +368,16 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
|||||||
saveLoading.value = true;
|
saveLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
||||||
|
const savedContentSignature = createWorkflowContentSignature(content);
|
||||||
const res = await api.post('/api/v1/workflow/update', {
|
const res = await api.post('/api/v1/workflow/update', {
|
||||||
id: workflowId.value,
|
id: workflowId.value,
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
if (res.errorCode === 0 && showMsg) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(res.message);
|
reconcileWorkflowDraftAfterSave(savedContentSignature);
|
||||||
|
if (showMsg) {
|
||||||
|
ElMessage.success(res.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res.errorCode === 0;
|
return res.errorCode === 0;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -364,13 +393,101 @@ async function getWorkflowInfo(workflowId: any, syncFlowData: boolean = true) {
|
|||||||
const parsedContent = workflowInfo.value.content
|
const parsedContent = workflowInfo.value.content
|
||||||
? JSON.parse(workflowInfo.value.content)
|
? JSON.parse(workflowInfo.value.content)
|
||||||
: {};
|
: {};
|
||||||
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
|
const serverContent = isWorkflowDataEmpty(parsedContent)
|
||||||
? createInitialWorkflowData()
|
? createInitialWorkflowData()
|
||||||
: normalizeWorkflowStartNodes(parsedContent);
|
: 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 || '');
|
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() {
|
async function getLlmList() {
|
||||||
return api.get('/api/v1/model/list').then((res) => {
|
return api.get('/api/v1/model/list').then((res) => {
|
||||||
llmList.value = res.data;
|
llmList.value = res.data;
|
||||||
@@ -652,12 +769,18 @@ function onAsyncExecute(info: any) {
|
|||||||
<ElDrawer
|
<ElDrawer
|
||||||
v-model="singleRunVisible"
|
v-model="singleRunVisible"
|
||||||
:title="singleNode?.data?.title"
|
:title="singleNode?.data?.title"
|
||||||
|
append-to-body
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
size="600px"
|
size="min(600px, 94vw)"
|
||||||
>
|
>
|
||||||
<SingleRun :node="singleNode" :workflow-id="workflowId" />
|
<SingleRun :node="singleNode" :workflow-id="workflowId" />
|
||||||
</ElDrawer>
|
</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>
|
<div class="mb-2.5 font-semibold">{{ $t('aiWorkflow.params') }}:</div>
|
||||||
<WorkflowForm
|
<WorkflowForm
|
||||||
ref="workflowForm"
|
ref="workflowForm"
|
||||||
@@ -733,6 +856,7 @@ function onAsyncExecute(info: any) {
|
|||||||
:theme="isDark ? 'dark' : 'light'"
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:custom-nodes="customNode"
|
:custom-nodes="customNode"
|
||||||
|
:on-data-change="scheduleWorkflowDraft"
|
||||||
:on-node-execute="runIndependently"
|
:on-node-execute="runIndependently"
|
||||||
:on-run-test="runWorkflow"
|
:on-run-test="runWorkflow"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearWorkflowDraft,
|
||||||
|
createWorkflowContentSignature,
|
||||||
|
readWorkflowDraft,
|
||||||
|
writeWorkflowDraft,
|
||||||
|
} from '../workflowDraftCache';
|
||||||
|
|
||||||
|
const workflowId = 'workflow-1';
|
||||||
|
const serverContent = {
|
||||||
|
edges: [],
|
||||||
|
nodes: [{ id: 'start' }],
|
||||||
|
};
|
||||||
|
const draftContent = {
|
||||||
|
edges: [],
|
||||||
|
nodes: [{ id: 'start' }, { id: 'llm' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('workflowDraftCache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按工作流保存并恢复当前浏览器标签页中的草稿', () => {
|
||||||
|
expect(
|
||||||
|
writeWorkflowDraft({
|
||||||
|
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||||
|
content: draftContent,
|
||||||
|
workflowId,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(readWorkflowDraft(workflowId, serverContent)?.content).toEqual(
|
||||||
|
draftContent,
|
||||||
|
);
|
||||||
|
expect(readWorkflowDraft('workflow-2', serverContent)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('内容回到服务端版本时自动移除草稿', () => {
|
||||||
|
writeWorkflowDraft({
|
||||||
|
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||||
|
content: draftContent,
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
writeWorkflowDraft({
|
||||||
|
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||||
|
content: serverContent,
|
||||||
|
workflowId,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(readWorkflowDraft(workflowId, serverContent)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('服务端内容已更新时丢弃旧草稿,避免覆盖新版本', () => {
|
||||||
|
writeWorkflowDraft({
|
||||||
|
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||||
|
content: draftContent,
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedServerContent = {
|
||||||
|
edges: [{ id: 'edge-1' }],
|
||||||
|
nodes: [{ id: 'start' }, { id: 'end' }],
|
||||||
|
};
|
||||||
|
expect(readWorkflowDraft(workflowId, updatedServerContent)).toBeNull();
|
||||||
|
expect(readWorkflowDraft(workflowId, serverContent)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('保存期间产生的新编辑可基于已保存版本继续缓存', () => {
|
||||||
|
const newerDraftContent = {
|
||||||
|
edges: [{ id: 'edge-1' }],
|
||||||
|
nodes: [...draftContent.nodes, { id: 'end' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
writeWorkflowDraft({
|
||||||
|
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||||
|
content: draftContent,
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
writeWorkflowDraft({
|
||||||
|
baseContentSignature: createWorkflowContentSignature(draftContent),
|
||||||
|
content: newerDraftContent,
|
||||||
|
workflowId,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(readWorkflowDraft(workflowId, draftContent)?.content).toEqual(
|
||||||
|
newerDraftContent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缓存不可写时返回失败且不抛出异常', () => {
|
||||||
|
const storage = {
|
||||||
|
getItem: vi.fn(() => null),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
setItem: vi.fn(() => {
|
||||||
|
throw new Error('quota exceeded');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
writeWorkflowDraft(
|
||||||
|
{
|
||||||
|
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||||
|
content: draftContent,
|
||||||
|
workflowId,
|
||||||
|
},
|
||||||
|
storage,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('忽略结构异常的缓存内容', () => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
`easyflow:workflow-draft:${workflowId}`,
|
||||||
|
JSON.stringify({
|
||||||
|
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||||
|
content: 'invalid',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
version: 1,
|
||||||
|
workflowId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readWorkflowDraft(workflowId, serverContent)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('可显式清理已保存草稿', () => {
|
||||||
|
writeWorkflowDraft({
|
||||||
|
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||||
|
content: draftContent,
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearWorkflowDraft(workflowId);
|
||||||
|
|
||||||
|
expect(readWorkflowDraft(workflowId, serverContent)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -93,8 +93,13 @@ function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="single-run">
|
||||||
<ElForm label-position="top" ref="singleRunForm" :model="runParams">
|
<ElForm
|
||||||
|
class="single-run__form"
|
||||||
|
label-position="top"
|
||||||
|
ref="singleRunForm"
|
||||||
|
:model="runParams"
|
||||||
|
>
|
||||||
<template v-if="isFieldMode">
|
<template v-if="isFieldMode">
|
||||||
<div class="single-run-section">
|
<div class="single-run-section">
|
||||||
<div class="single-run-section__title">字段值</div>
|
<div class="single-run-section__title">字段值</div>
|
||||||
@@ -176,12 +181,45 @@ function submit() {
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
<div class="mb-2.5 mt-2.5 font-semibold">{{ $t('workflow.result') }}:</div>
|
<section class="single-run-result">
|
||||||
<ShowJson :value="result" />
|
<div class="single-run-result__title">{{ $t('workflow.result') }}:</div>
|
||||||
|
<ShowJson class="single-run-result__viewer" :value="result" />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.single-run {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-run__form {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-run-result {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-run-result__title {
|
||||||
|
margin: 10px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-run-result__viewer {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: min(60vh, 720px);
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.single-run-section + .single-run-section {
|
.single-run-section + .single-run-section {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
type WorkflowDraftStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>;
|
||||||
|
|
||||||
|
interface WorkflowDraftSnapshot {
|
||||||
|
baseContentSignature: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
updatedAt: number;
|
||||||
|
version: 1;
|
||||||
|
workflowId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WriteWorkflowDraftOptions {
|
||||||
|
baseContentSignature: string;
|
||||||
|
content: unknown;
|
||||||
|
workflowId: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKFLOW_DRAFT_PREFIX = 'easyflow:workflow-draft';
|
||||||
|
const WORKFLOW_DRAFT_VERSION = 1;
|
||||||
|
|
||||||
|
function isWorkflowContent(
|
||||||
|
content: unknown,
|
||||||
|
): content is Record<string, unknown> {
|
||||||
|
return (
|
||||||
|
typeof content === 'object' && content !== null && !Array.isArray(content)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSessionStorage(): null | WorkflowDraftStorage {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorageKey(workflowId: unknown) {
|
||||||
|
const normalizedId = String(workflowId ?? '').trim();
|
||||||
|
return normalizedId ? `${WORKFLOW_DRAFT_PREFIX}:${normalizedId}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStoredDraft(
|
||||||
|
storage: null | WorkflowDraftStorage,
|
||||||
|
storageKey: string,
|
||||||
|
) {
|
||||||
|
if (!storage || !storageKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
storage.removeItem(storageKey);
|
||||||
|
} catch {
|
||||||
|
// 临时缓存清理失败不应阻断工作流编辑。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkflowContentSignature(content: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(content ?? null);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWorkflowDraft(
|
||||||
|
workflowId: unknown,
|
||||||
|
serverContent: unknown,
|
||||||
|
storage: null | WorkflowDraftStorage = resolveSessionStorage(),
|
||||||
|
) {
|
||||||
|
const storageKey = getStorageKey(workflowId);
|
||||||
|
if (!storage || !storageKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = storage.getItem(storageKey);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const snapshot = JSON.parse(raw) as Partial<WorkflowDraftSnapshot>;
|
||||||
|
const serverContentSignature =
|
||||||
|
createWorkflowContentSignature(serverContent);
|
||||||
|
const draftContentSignature = createWorkflowContentSignature(
|
||||||
|
snapshot.content,
|
||||||
|
);
|
||||||
|
const isValid =
|
||||||
|
snapshot.version === WORKFLOW_DRAFT_VERSION &&
|
||||||
|
snapshot.workflowId === String(workflowId) &&
|
||||||
|
snapshot.baseContentSignature === serverContentSignature &&
|
||||||
|
isWorkflowContent(snapshot.content) &&
|
||||||
|
Boolean(draftContentSignature);
|
||||||
|
|
||||||
|
if (!isValid || draftContentSignature === serverContentSignature) {
|
||||||
|
removeStoredDraft(storage, storageKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot as WorkflowDraftSnapshot;
|
||||||
|
} catch {
|
||||||
|
removeStoredDraft(storage, storageKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeWorkflowDraft(
|
||||||
|
options: WriteWorkflowDraftOptions,
|
||||||
|
storage: null | WorkflowDraftStorage = resolveSessionStorage(),
|
||||||
|
) {
|
||||||
|
const storageKey = getStorageKey(options.workflowId);
|
||||||
|
const contentSignature = createWorkflowContentSignature(options.content);
|
||||||
|
if (
|
||||||
|
!storage ||
|
||||||
|
!storageKey ||
|
||||||
|
!contentSignature ||
|
||||||
|
!options.baseContentSignature ||
|
||||||
|
!isWorkflowContent(options.content)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentSignature === options.baseContentSignature) {
|
||||||
|
removeStoredDraft(storage, storageKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot: WorkflowDraftSnapshot = {
|
||||||
|
baseContentSignature: options.baseContentSignature,
|
||||||
|
content: options.content,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
version: WORKFLOW_DRAFT_VERSION,
|
||||||
|
workflowId: String(options.workflowId),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
storage.setItem(storageKey, JSON.stringify(snapshot));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearWorkflowDraft(
|
||||||
|
workflowId: unknown,
|
||||||
|
storage: null | WorkflowDraftStorage = resolveSessionStorage(),
|
||||||
|
) {
|
||||||
|
removeStoredDraft(storage, getStorageKey(workflowId));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user