Showing
36 changed files
with
1427 additions
and
0 deletions
.gitignore
0 → 100644
AGENTS.md
0 → 100644
| 1 | +# 仓库协作规则 | ||
| 2 | + | ||
| 3 | +## 文档规则 | ||
| 4 | + | ||
| 5 | +- 本仓库中的需求文档、设计文档、计划文档、进度文档、说明文档统一使用中文编写。 | ||
| 6 | +- 遇到路径、命令、库名、API 名称、类型名等技术标识时,可保留原文,不强制翻译。 | ||
| 7 | +- 新增文档时,标题、章节说明、结论、风险、验收标准等面向人的内容必须使用中文。 | ||
| 8 | + | ||
| 9 | +## 代码注释规则 | ||
| 10 | + | ||
| 11 | +- 页面主要逻辑上的注释统一使用中文。 | ||
| 12 | +- 组件页面中与业务流程、交互状态、核心分支、数据转换相关的关键注释必须使用中文。 | ||
| 13 | +- 注释应简洁明确,优先解释“为什么这样做”或“这一段主要负责什么”,避免无意义的逐行翻译式注释。 | ||
| 14 | + | ||
| 15 | +## 执行要求 | ||
| 16 | + | ||
| 17 | +- 修改已有文档时,优先检查是否存在英文标题或英文说明,若有则同步转成中文。 | ||
| 18 | +- 新增页面或重构页面逻辑时,需要检查主要逻辑处是否补充了必要的中文注释。 |
apps/demo/index.html
0 → 100644
| 1 | +<!doctype html> | ||
| 2 | +<html lang="zh-CN"> | ||
| 3 | + <head> | ||
| 4 | + <meta charset="UTF-8" /> | ||
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| 6 | + <title>流程编辑器迁移基线页</title> | ||
| 7 | + </head> | ||
| 8 | + <body> | ||
| 9 | + <div id="app"></div> | ||
| 10 | + <script type="module" src="/src/main.ts"></script> | ||
| 11 | + </body> | ||
| 12 | +</html> | ||
| 13 | + |
apps/demo/src/App.vue
0 → 100644
| 1 | +<script setup lang="ts"> | ||
| 2 | +import { computed, onMounted, ref } from 'vue' | ||
| 3 | +import { ElMessage } from 'element-plus' | ||
| 4 | +import { FlowEditor, normalizeGraphData } from '@flow-editor' | ||
| 5 | +import type { | ||
| 6 | + FlowEditorRef, | ||
| 7 | + FlowEditorInputGraphData, | ||
| 8 | + ToolbarButtonDefinition, | ||
| 9 | +} from '@flow-editor' | ||
| 10 | + | ||
| 11 | +const editorRef = ref<FlowEditorRef | null>(null) | ||
| 12 | +const currentVersionLabel = ref('基线版本') | ||
| 13 | +const latestAction = ref('等待初始化') | ||
| 14 | +const flowData = ref<FlowEditorInputGraphData>({ | ||
| 15 | + nodes: [], | ||
| 16 | + edges: [], | ||
| 17 | +}) | ||
| 18 | + | ||
| 19 | +const toolbarButtonHandler = (buttons: ToolbarButtonDefinition[]) => { | ||
| 20 | + const hiddenKeys = new Set(['grid', 'miniMap', 'delete', 'undo', 'redo']) | ||
| 21 | + return buttons.filter((button) => !hiddenKeys.has(button.key)) | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +const hasLoadedRemoteBootstrap = computed(() => { | ||
| 25 | + const normalizedGraph = normalizeGraphData(flowData.value) | ||
| 26 | + return normalizedGraph.nodes.length > 0 || normalizedGraph.edges.length > 0 | ||
| 27 | +}) | ||
| 28 | + | ||
| 29 | +function onRef(instance: FlowEditorRef) { | ||
| 30 | + editorRef.value = instance | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +function centerCanvas() { | ||
| 34 | + editorRef.value?.commander.fitView() | ||
| 35 | + latestAction.value = '已执行居中/适应画布' | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +function saveFlowDraft() { | ||
| 39 | + latestAction.value = '当前为迁移阶段演示版,已模拟保存成功' | ||
| 40 | + ElMessage.success('当前为迁移阶段演示版,已模拟保存成功') | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +function openPreviewPanel() { | ||
| 44 | + editorRef.value?.openPreview() | ||
| 45 | + latestAction.value = '已打开流程预览' | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +async function loadBootstrapData() { | ||
| 49 | + // 这里保留旧项目的接口语义,方便把 Playwright 拦截能力平移到新项目。 | ||
| 50 | + const requestJson = async <T,>(url: string, fallback: T): Promise<T> => { | ||
| 51 | + try { | ||
| 52 | + const response = await fetch(url) | ||
| 53 | + if (!response.ok) { | ||
| 54 | + return fallback | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + const payload = (await response.json()) as { data?: T } | ||
| 58 | + return payload.data ?? fallback | ||
| 59 | + } catch { | ||
| 60 | + return fallback | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + const [versionList, graph] = await Promise.all([ | ||
| 65 | + requestJson<Array<{ note?: string }>>('/admin/index.php?m=mod&a=flow_version', [ | ||
| 66 | + { note: '基线版本' }, | ||
| 67 | + ]), | ||
| 68 | + requestJson<FlowEditorInputGraphData>('/admin/index.php?m=srv&a=flow_nodes', { | ||
| 69 | + nodes: [], | ||
| 70 | + edges: [], | ||
| 71 | + }), | ||
| 72 | + ]) | ||
| 73 | + | ||
| 74 | + currentVersionLabel.value = versionList[0]?.note || '基线版本' | ||
| 75 | + flowData.value = graph | ||
| 76 | + editorRef.value?.read(graph) | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +function bindTestContract() { | ||
| 80 | + window.__FLOW_EDITOR_TEST_API__ = { | ||
| 81 | + getEditorContract: () => { | ||
| 82 | + const instance = editorRef.value | ||
| 83 | + | ||
| 84 | + return { | ||
| 85 | + hasEditor: Boolean(instance), | ||
| 86 | + hasCommander: Boolean(instance?.commander), | ||
| 87 | + hasDelete: typeof instance?.commander?.delete === 'function', | ||
| 88 | + hasUndo: typeof instance?.commander?.undo === 'function', | ||
| 89 | + hasRedo: typeof instance?.commander?.redo === 'function', | ||
| 90 | + hasOpenModel: typeof instance?.openModel === 'function', | ||
| 91 | + hasCloseModel: typeof instance?.closeModel === 'function', | ||
| 92 | + hasAddNode: typeof instance?.addNode === 'function', | ||
| 93 | + hasUpdateModel: typeof instance?.updateModel === 'function', | ||
| 94 | + hasGraphRead: typeof instance?.read === 'function', | ||
| 95 | + } | ||
| 96 | + }, | ||
| 97 | + } | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +onMounted(async () => { | ||
| 101 | + bindTestContract() | ||
| 102 | + await loadBootstrapData() | ||
| 103 | + bindTestContract() | ||
| 104 | +}) | ||
| 105 | +</script> | ||
| 106 | + | ||
| 107 | +<template> | ||
| 108 | + <div class="app"> | ||
| 109 | + <header class="page-header"> | ||
| 110 | + <div> | ||
| 111 | + <p class="page-eyebrow">流程编辑器重构迁移</p> | ||
| 112 | + <h1>新项目基线页</h1> | ||
| 113 | + </div> | ||
| 114 | + <div class="version-card"> | ||
| 115 | + <span>当前启用版本</span> | ||
| 116 | + <strong>{{ currentVersionLabel }}</strong> | ||
| 117 | + </div> | ||
| 118 | + </header> | ||
| 119 | + | ||
| 120 | + <main class="page-main"> | ||
| 121 | + <FlowEditor | ||
| 122 | + :data="flowData" | ||
| 123 | + :grid="false" | ||
| 124 | + :mini-map="false" | ||
| 125 | + :on-ref="onRef" | ||
| 126 | + :toolbar-button-handler="toolbarButtonHandler" | ||
| 127 | + > | ||
| 128 | + <template #toolbar-extra> | ||
| 129 | + <button | ||
| 130 | + type="button" | ||
| 131 | + class="toolbar-button toolbar-button--accent" | ||
| 132 | + @click="centerCanvas" | ||
| 133 | + > | ||
| 134 | + 居中 | ||
| 135 | + </button> | ||
| 136 | + <button | ||
| 137 | + type="button" | ||
| 138 | + class="toolbar-button toolbar-button--accent" | ||
| 139 | + @click="saveFlowDraft" | ||
| 140 | + > | ||
| 141 | + 保存 | ||
| 142 | + </button> | ||
| 143 | + <button | ||
| 144 | + type="button" | ||
| 145 | + class="toolbar-button toolbar-button--accent" | ||
| 146 | + data-testid="open-preview-panel" | ||
| 147 | + @click="openPreviewPanel" | ||
| 148 | + > | ||
| 149 | + 预览测试 | ||
| 150 | + </button> | ||
| 151 | + </template> | ||
| 152 | + </FlowEditor> | ||
| 153 | + | ||
| 154 | + <aside class="status-panel"> | ||
| 155 | + <h2>迁移状态</h2> | ||
| 156 | + <p>当前目标:先建立新项目最小壳层与测试护栏,再逐步补齐 LogicFlow 实现。</p> | ||
| 157 | + <p>接口基线:{{ hasLoadedRemoteBootstrap ? '已接入初始化 mock 语义' : '使用本地兜底空图数据' }}</p> | ||
| 158 | + <p>最近动作:{{ latestAction }}</p> | ||
| 159 | + </aside> | ||
| 160 | + </main> | ||
| 161 | + </div> | ||
| 162 | +</template> | ||
| 163 | + | ||
| 164 | +<style scoped> | ||
| 165 | +.app { | ||
| 166 | + min-height: 100vh; | ||
| 167 | + padding: 24px; | ||
| 168 | + background: | ||
| 169 | + radial-gradient(circle at top left, rgba(43, 104, 168, 0.14), transparent 32%), | ||
| 170 | + linear-gradient(180deg, #f5f7fb 0%, #eef2f8 100%); | ||
| 171 | + color: #18314f; | ||
| 172 | + font-family: "PingFang SC", "Microsoft YaHei", sans-serif; | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +.page-header { | ||
| 176 | + display: flex; | ||
| 177 | + align-items: flex-start; | ||
| 178 | + justify-content: space-between; | ||
| 179 | + gap: 16px; | ||
| 180 | + margin-bottom: 20px; | ||
| 181 | +} | ||
| 182 | + | ||
| 183 | +.page-header h1 { | ||
| 184 | + margin: 6px 0 0; | ||
| 185 | + font-size: 28px; | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +.page-eyebrow { | ||
| 189 | + margin: 0; | ||
| 190 | + font-size: 12px; | ||
| 191 | + letter-spacing: 0.14em; | ||
| 192 | + color: #52749b; | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +.version-card { | ||
| 196 | + min-width: 180px; | ||
| 197 | + padding: 14px 16px; | ||
| 198 | + border: 1px solid rgba(24, 49, 79, 0.1); | ||
| 199 | + border-radius: 16px; | ||
| 200 | + background: rgba(255, 255, 255, 0.9); | ||
| 201 | + box-shadow: 0 18px 40px rgba(34, 61, 96, 0.08); | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +.version-card span { | ||
| 205 | + display: block; | ||
| 206 | + margin-bottom: 6px; | ||
| 207 | + font-size: 12px; | ||
| 208 | + color: #52749b; | ||
| 209 | +} | ||
| 210 | + | ||
| 211 | +.version-card strong { | ||
| 212 | + font-size: 18px; | ||
| 213 | +} | ||
| 214 | + | ||
| 215 | +.page-main { | ||
| 216 | + display: grid; | ||
| 217 | + grid-template-columns: minmax(0, 1fr) 320px; | ||
| 218 | + gap: 20px; | ||
| 219 | +} | ||
| 220 | + | ||
| 221 | +.status-panel { | ||
| 222 | + padding: 18px; | ||
| 223 | + border: 1px solid rgba(24, 49, 79, 0.08); | ||
| 224 | + border-radius: 20px; | ||
| 225 | + background: rgba(255, 255, 255, 0.88); | ||
| 226 | + box-shadow: 0 18px 32px rgba(34, 61, 96, 0.08); | ||
| 227 | +} | ||
| 228 | + | ||
| 229 | +.status-panel h2 { | ||
| 230 | + margin: 0 0 12px; | ||
| 231 | + font-size: 18px; | ||
| 232 | +} | ||
| 233 | + | ||
| 234 | +.status-panel p { | ||
| 235 | + margin: 0 0 10px; | ||
| 236 | + line-height: 1.6; | ||
| 237 | +} | ||
| 238 | + | ||
| 239 | +.toolbar-button { | ||
| 240 | + border: none; | ||
| 241 | + border-radius: 999px; | ||
| 242 | + background: #ffffff; | ||
| 243 | + color: #18314f; | ||
| 244 | +} | ||
| 245 | + | ||
| 246 | +.toolbar-button--accent { | ||
| 247 | + background: linear-gradient(135deg, #2b68a8 0%, #3f8ec6 100%); | ||
| 248 | + color: #ffffff; | ||
| 249 | +} | ||
| 250 | + | ||
| 251 | +@media (max-width: 960px) { | ||
| 252 | + .page-main { | ||
| 253 | + grid-template-columns: 1fr; | ||
| 254 | + } | ||
| 255 | + | ||
| 256 | + .page-header { | ||
| 257 | + flex-direction: column; | ||
| 258 | + } | ||
| 259 | +} | ||
| 260 | +</style> |
apps/demo/src/main.ts
0 → 100644
| 1 | +import { createApp } from 'vue' | ||
| 2 | +import ElementPlus from 'element-plus' | ||
| 3 | +import 'element-plus/dist/index.css' | ||
| 4 | +import '@logicflow/core/lib/style/index.css' | ||
| 5 | +import '@logicflow/extension/lib/style/index.css' | ||
| 6 | +import App from './App.vue' | ||
| 7 | + | ||
| 8 | +createApp(App).use(ElementPlus).mount('#app') |
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
env.d.ts
0 → 100644
| 1 | +/// <reference types="vite/client" /> | ||
| 2 | + | ||
| 3 | +declare module '*.vue' { | ||
| 4 | + import type { DefineComponent } from 'vue' | ||
| 5 | + | ||
| 6 | + const component: DefineComponent<Record<string, never>, Record<string, never>, unknown> | ||
| 7 | + export default component | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +declare global { | ||
| 11 | + interface Window { | ||
| 12 | + __FLOW_EDITOR_TEST_API__?: { | ||
| 13 | + getEditorContract: () => { | ||
| 14 | + hasEditor: boolean | ||
| 15 | + hasCommander: boolean | ||
| 16 | + hasDelete: boolean | ||
| 17 | + hasUndo: boolean | ||
| 18 | + hasRedo: boolean | ||
| 19 | + hasOpenModel: boolean | ||
| 20 | + hasCloseModel: boolean | ||
| 21 | + hasAddNode: boolean | ||
| 22 | + hasUpdateModel: boolean | ||
| 23 | + hasGraphRead: boolean | ||
| 24 | + } | ||
| 25 | + } | ||
| 26 | + } | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +export {} | ||
| 30 | + |
findings.md
0 → 100644
This diff is collapsed. Click to expand it.
package.json
0 → 100644
| 1 | +{ | ||
| 2 | + "name": "vue-flow-editor2", | ||
| 3 | + "version": "0.1.0", | ||
| 4 | + "private": true, | ||
| 5 | + "type": "module", | ||
| 6 | + "packageManager": "pnpm@10.9.0", | ||
| 7 | + "scripts": { | ||
| 8 | + "dev": "vite --host 127.0.0.1 --port 4173", | ||
| 9 | + "build": "vite build", | ||
| 10 | + "preview": "vite preview --host 127.0.0.1 --port 4173", | ||
| 11 | + "test:unit": "vitest run", | ||
| 12 | + "test:unit:watch": "vitest", | ||
| 13 | + "test:e2e": "node ./scripts/run-e2e.mjs" | ||
| 14 | + }, | ||
| 15 | + "dependencies": { | ||
| 16 | + "@logicflow/core": "^2.1.11", | ||
| 17 | + "@logicflow/extension": "^2.1.15", | ||
| 18 | + "element-plus": "^2.13.6", | ||
| 19 | + "vue": "^3.5.31" | ||
| 20 | + }, | ||
| 21 | + "devDependencies": { | ||
| 22 | + "@playwright/test": "^1.58.2", | ||
| 23 | + "@types/node": "^25.5.0", | ||
| 24 | + "@vitejs/plugin-vue": "^6.0.5", | ||
| 25 | + "@vue/test-utils": "^2.4.6", | ||
| 26 | + "jsdom": "^29.0.1", | ||
| 27 | + "typescript": "^6.0.2", | ||
| 28 | + "vite": "^8.0.3", | ||
| 29 | + "vitest": "^4.1.2", | ||
| 30 | + "vue-tsc": "^3.2.6" | ||
| 31 | + } | ||
| 32 | +} |
This diff is collapsed. Click to expand it.
| 1 | +import type { FlowEdgeData, FlowGraphData, FlowNodeData } from '../../types' | ||
| 2 | +import type { | ||
| 3 | + FlowEditorInputEdge, | ||
| 4 | + FlowEditorInputGraphData, | ||
| 5 | + FlowEditorInputNode, | ||
| 6 | + LogicFlowGraphData, | ||
| 7 | +} from '../schema/types' | ||
| 8 | + | ||
| 9 | +function isLegacyEdge(edge: FlowEditorInputEdge): edge is { | ||
| 10 | + source?: string | ||
| 11 | + target?: string | ||
| 12 | + sourceAnchor?: number | ||
| 13 | + targetAnchor?: number | ||
| 14 | + [key: string]: unknown | ||
| 15 | +} { | ||
| 16 | + return 'source' in edge || 'target' in edge | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +function isLegacyNode(node: FlowEditorInputNode): node is { | ||
| 20 | + text?: string | ||
| 21 | + shape?: string | ||
| 22 | + activity?: string | ||
| 23 | + control?: string | ||
| 24 | + [key: string]: unknown | ||
| 25 | +} { | ||
| 26 | + return 'shape' in node || 'text' in node || 'activity' in node || 'control' in node | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +function inferNodeType(node: FlowEditorInputNode): string { | ||
| 30 | + if ('type' in node && typeof node.type === 'string' && node.type) { | ||
| 31 | + return node.type | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + if ('id' in node && node.id === 'start-node') { | ||
| 35 | + return 'start' | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + if ('id' in node && node.id === 'end-node') { | ||
| 39 | + return 'end' | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + if (isLegacyNode(node)) { | ||
| 43 | + if (node.activity) { | ||
| 44 | + return 'activity' | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + if (node.control) { | ||
| 48 | + return 'control' | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + if (node.shape === 'start' || node.shape === 'start-node') { | ||
| 52 | + return 'start' | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + if (node.shape === 'end' || node.shape === 'end-node') { | ||
| 56 | + return 'end' | ||
| 57 | + } | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + return 'custom' | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +function inferLogicFlowNodeType(node: FlowNodeData): string { | ||
| 64 | + if (node.type === 'start' || node.type === 'end') { | ||
| 65 | + return 'circle' | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + return 'rect' | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +function normalizeNode(node: FlowEditorInputNode): FlowNodeData { | ||
| 72 | + const label = 'label' in node && typeof node.label === 'string' && node.label | ||
| 73 | + ? node.label | ||
| 74 | + : isLegacyNode(node) && typeof node.text === 'string' && node.text | ||
| 75 | + ? node.text | ||
| 76 | + : node.id | ||
| 77 | + | ||
| 78 | + return { | ||
| 79 | + ...node, | ||
| 80 | + id: node.id, | ||
| 81 | + label, | ||
| 82 | + type: inferNodeType(node), | ||
| 83 | + x: typeof node.x === 'number' ? node.x : 120, | ||
| 84 | + y: typeof node.y === 'number' ? node.y : 120, | ||
| 85 | + } | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +function normalizeEdge(edge: FlowEditorInputEdge, index: number): FlowEdgeData { | ||
| 89 | + if (!isLegacyEdge(edge)) { | ||
| 90 | + return { | ||
| 91 | + ...edge, | ||
| 92 | + id: edge.id || `edge-${edge.sourceNodeId}-${edge.targetNodeId}-${index}`, | ||
| 93 | + label: edge.label ?? '', | ||
| 94 | + } | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + const sourceNodeId = typeof edge.source === 'string' ? edge.source : '' | ||
| 98 | + const targetNodeId = typeof edge.target === 'string' ? edge.target : '' | ||
| 99 | + const label = typeof edge.label === 'string' && edge.label | ||
| 100 | + ? edge.label | ||
| 101 | + : typeof edge.text === 'string' | ||
| 102 | + ? edge.text | ||
| 103 | + : '' | ||
| 104 | + | ||
| 105 | + return { | ||
| 106 | + ...edge, | ||
| 107 | + id: typeof edge.id === 'string' && edge.id ? edge.id : `edge-${sourceNodeId}-${targetNodeId}-${index}`, | ||
| 108 | + sourceNodeId, | ||
| 109 | + targetNodeId, | ||
| 110 | + label, | ||
| 111 | + } | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +export function normalizeGraphData(graphData: FlowEditorInputGraphData): FlowGraphData { | ||
| 115 | + return { | ||
| 116 | + nodes: graphData.nodes.map((node) => normalizeNode(node)), | ||
| 117 | + edges: graphData.edges.map((edge, index) => normalizeEdge(edge, index)), | ||
| 118 | + } | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +export function cloneNormalizedGraphData(graphData: FlowGraphData): FlowGraphData { | ||
| 122 | + return { | ||
| 123 | + nodes: graphData.nodes.map((node) => ({ ...node })), | ||
| 124 | + edges: graphData.edges.map((edge) => ({ ...edge })), | ||
| 125 | + } | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +export function toLogicFlowGraphData(graphData: FlowEditorInputGraphData): LogicFlowGraphData { | ||
| 129 | + const normalizedGraph = normalizeGraphData(graphData) | ||
| 130 | + | ||
| 131 | + return { | ||
| 132 | + nodes: normalizedGraph.nodes.map((node) => ({ | ||
| 133 | + id: node.id, | ||
| 134 | + type: inferLogicFlowNodeType(node), | ||
| 135 | + x: node.x ?? 120, | ||
| 136 | + y: node.y ?? 120, | ||
| 137 | + text: node.label, | ||
| 138 | + properties: { | ||
| 139 | + ...node, | ||
| 140 | + }, | ||
| 141 | + })), | ||
| 142 | + edges: normalizedGraph.edges.map((edge) => ({ | ||
| 143 | + id: edge.id, | ||
| 144 | + type: 'polyline', | ||
| 145 | + sourceNodeId: edge.sourceNodeId, | ||
| 146 | + targetNodeId: edge.targetNodeId, | ||
| 147 | + text: edge.label, | ||
| 148 | + properties: { | ||
| 149 | + ...edge, | ||
| 150 | + }, | ||
| 151 | + })), | ||
| 152 | + } | ||
| 153 | +} | ||
| 154 | + |
| 1 | +import type { FlowEdgeData, FlowGraphData, FlowNodeData } from '../../types' | ||
| 2 | + | ||
| 3 | +export interface LegacyFlowNode { | ||
| 4 | + id: string | ||
| 5 | + x?: number | ||
| 6 | + y?: number | ||
| 7 | + text?: string | ||
| 8 | + label?: string | ||
| 9 | + shape?: string | ||
| 10 | + type?: string | ||
| 11 | + desc?: string | ||
| 12 | + activity?: string | ||
| 13 | + control?: string | ||
| 14 | + data?: Record<string, unknown> | ||
| 15 | + [key: string]: unknown | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +export interface LegacyFlowEdge { | ||
| 19 | + id?: string | ||
| 20 | + shape?: string | ||
| 21 | + source?: string | ||
| 22 | + target?: string | ||
| 23 | + sourceAnchor?: number | ||
| 24 | + targetAnchor?: number | ||
| 25 | + text?: string | ||
| 26 | + label?: string | ||
| 27 | + [key: string]: unknown | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +export interface LegacyFlowGraphData { | ||
| 31 | + nodes: LegacyFlowNode[] | ||
| 32 | + edges: LegacyFlowEdge[] | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +export interface LogicFlowNodeData { | ||
| 36 | + id: string | ||
| 37 | + type: string | ||
| 38 | + x: number | ||
| 39 | + y: number | ||
| 40 | + text: string | ||
| 41 | + properties: Record<string, unknown> | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +export interface LogicFlowEdgeData { | ||
| 45 | + id: string | ||
| 46 | + type: string | ||
| 47 | + sourceNodeId: string | ||
| 48 | + targetNodeId: string | ||
| 49 | + text?: string | ||
| 50 | + properties: Record<string, unknown> | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +export interface LogicFlowGraphData { | ||
| 54 | + nodes: LogicFlowNodeData[] | ||
| 55 | + edges: LogicFlowEdgeData[] | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +export type FlowEditorInputNode = FlowNodeData | LegacyFlowNode | ||
| 59 | +export type FlowEditorInputEdge = FlowEdgeData | LegacyFlowEdge | ||
| 60 | +export type FlowEditorInputGraphData = FlowGraphData | LegacyFlowGraphData | ||
| 61 | + |
packages/flow-editor/src/index.ts
0 → 100644
| 1 | +export { default as FlowEditor } from './components/FlowEditor.vue' | ||
| 2 | +export { | ||
| 3 | + cloneNormalizedGraphData, | ||
| 4 | + normalizeGraphData, | ||
| 5 | + toLogicFlowGraphData, | ||
| 6 | +} from './core/adapter/g6-to-logicflow' | ||
| 7 | +export type { | ||
| 8 | + FlowEditorCommander, | ||
| 9 | + FlowEditorRef, | ||
| 10 | + FlowEditorState, | ||
| 11 | + FlowEdgeData, | ||
| 12 | + FlowGraphData, | ||
| 13 | + FlowNodeData, | ||
| 14 | + ToolbarButtonDefinition, | ||
| 15 | +} from './types' | ||
| 16 | +export type { | ||
| 17 | + FlowEditorInputGraphData, | ||
| 18 | + LegacyFlowEdge, | ||
| 19 | + LegacyFlowGraphData, | ||
| 20 | + LegacyFlowNode, | ||
| 21 | + LogicFlowEdgeData, | ||
| 22 | + LogicFlowGraphData, | ||
| 23 | + LogicFlowNodeData, | ||
| 24 | +} from './core/schema/types' |
packages/flow-editor/src/types.ts
0 → 100644
| 1 | +export interface FlowNodeData { | ||
| 2 | + id: string | ||
| 3 | + label: string | ||
| 4 | + type?: string | ||
| 5 | + x?: number | ||
| 6 | + y?: number | ||
| 7 | + [key: string]: unknown | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +export interface FlowEdgeData { | ||
| 11 | + id: string | ||
| 12 | + sourceNodeId: string | ||
| 13 | + targetNodeId: string | ||
| 14 | + label?: string | ||
| 15 | + [key: string]: unknown | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +export interface FlowGraphData { | ||
| 19 | + nodes: FlowNodeData[] | ||
| 20 | + edges: FlowEdgeData[] | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +export interface ToolbarButtonDefinition { | ||
| 24 | + key: string | ||
| 25 | + label: string | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +export interface FlowEditorState { | ||
| 29 | + canvasProps: { | ||
| 30 | + grid: boolean | ||
| 31 | + miniMap: boolean | ||
| 32 | + zoom: number | ||
| 33 | + } | ||
| 34 | + graphData: FlowGraphData | ||
| 35 | + selectedNodeIds: string[] | ||
| 36 | + isModelOpen: boolean | ||
| 37 | + isPreviewOpen: boolean | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +export interface FlowEditorCommander { | ||
| 41 | + switchGrid: () => void | ||
| 42 | + switchMiniMap: () => void | ||
| 43 | + fitView: () => void | ||
| 44 | + actualView: () => void | ||
| 45 | + zoomIn: () => void | ||
| 46 | + zoomOut: () => void | ||
| 47 | + delete: () => void | ||
| 48 | + undo: () => void | ||
| 49 | + redo: () => void | ||
| 50 | + selectAll: () => void | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +export interface FlowEditorRef { | ||
| 54 | + editorState: FlowEditorState | ||
| 55 | + commander: FlowEditorCommander | ||
| 56 | + openModel: () => void | ||
| 57 | + closeModel: () => void | ||
| 58 | + addNode: (node?: Partial<FlowNodeData>) => FlowNodeData | ||
| 59 | + updateModel: (patch?: Record<string, unknown>) => void | ||
| 60 | + openPreview: () => void | ||
| 61 | + read: (graphData: FlowGraphData) => void | ||
| 62 | + clearStates: () => void | ||
| 63 | +} | ||
| 64 | + |
playwright.config.ts
0 → 100644
pnpm-lock.yaml
0 → 100644
This diff is collapsed. Click to expand it.
pnpm-workspace.yaml
0 → 100644
progress.md
0 → 100644
This diff is collapsed. Click to expand it.
scripts/run-e2e.mjs
0 → 100644
| 1 | +import { spawn } from 'node:child_process' | ||
| 2 | + | ||
| 3 | +const VITE_READY_PATTERN = /127\.0\.0\.1:4174/ | ||
| 4 | +const START_TIMEOUT = 20_000 | ||
| 5 | + | ||
| 6 | +function waitForServer(serverProcess) { | ||
| 7 | + return new Promise((resolve, reject) => { | ||
| 8 | + const timeout = setTimeout(() => { | ||
| 9 | + reject(new Error('等待 Vite 测试服务启动超时')) | ||
| 10 | + }, START_TIMEOUT) | ||
| 11 | + | ||
| 12 | + const handleOutput = (chunk) => { | ||
| 13 | + const text = chunk.toString() | ||
| 14 | + process.stdout.write(text) | ||
| 15 | + | ||
| 16 | + if (VITE_READY_PATTERN.test(text)) { | ||
| 17 | + clearTimeout(timeout) | ||
| 18 | + serverProcess.stdout.off('data', handleOutput) | ||
| 19 | + serverProcess.stderr.off('data', handleOutput) | ||
| 20 | + resolve() | ||
| 21 | + } | ||
| 22 | + } | ||
| 23 | + | ||
| 24 | + serverProcess.stdout.on('data', handleOutput) | ||
| 25 | + serverProcess.stderr.on('data', handleOutput) | ||
| 26 | + serverProcess.once('exit', (code) => { | ||
| 27 | + clearTimeout(timeout) | ||
| 28 | + reject(new Error(`Vite 测试服务启动失败,退出码:${code ?? 'unknown'}`)) | ||
| 29 | + }) | ||
| 30 | + }) | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +function killProcessTree(serverProcess) { | ||
| 34 | + return new Promise((resolve) => { | ||
| 35 | + if (serverProcess.killed) { | ||
| 36 | + resolve() | ||
| 37 | + return | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + serverProcess.once('exit', () => resolve()) | ||
| 41 | + serverProcess.kill('SIGTERM') | ||
| 42 | + | ||
| 43 | + setTimeout(() => { | ||
| 44 | + if (!serverProcess.killed) { | ||
| 45 | + serverProcess.kill('SIGKILL') | ||
| 46 | + } | ||
| 47 | + }, 3_000) | ||
| 48 | + }) | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +async function main() { | ||
| 52 | + const playwrightArgs = process.argv.slice(2) | ||
| 53 | + const serverProcess = spawn( | ||
| 54 | + 'npx', | ||
| 55 | + ['vite', '--host', '127.0.0.1', '--port', '4174', '--strictPort'], | ||
| 56 | + { | ||
| 57 | + stdio: ['ignore', 'pipe', 'pipe'], | ||
| 58 | + env: process.env, | ||
| 59 | + }, | ||
| 60 | + ) | ||
| 61 | + | ||
| 62 | + try { | ||
| 63 | + await waitForServer(serverProcess) | ||
| 64 | + | ||
| 65 | + const testProcess = spawn('npx', ['playwright', 'test', ...playwrightArgs], { | ||
| 66 | + stdio: 'inherit', | ||
| 67 | + env: process.env, | ||
| 68 | + }) | ||
| 69 | + | ||
| 70 | + const exitCode = await new Promise((resolve, reject) => { | ||
| 71 | + testProcess.once('exit', (code) => resolve(code ?? 1)) | ||
| 72 | + testProcess.once('error', reject) | ||
| 73 | + }) | ||
| 74 | + | ||
| 75 | + process.exitCode = exitCode | ||
| 76 | + } finally { | ||
| 77 | + await killProcessTree(serverProcess) | ||
| 78 | + } | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +main().catch((error) => { | ||
| 82 | + console.error(error) | ||
| 83 | + process.exitCode = 1 | ||
| 84 | +}) |
task_plan.md
0 → 100644
| 1 | +# 任务计划:流程编辑器重构调研 | ||
| 2 | + | ||
| 3 | +## 目标 | ||
| 4 | + | ||
| 5 | +输出一份完整的需求与迁移设计方案,用于将 `vue-flow-editor` 重构为基于 Vue 3、Element Plus、LogicFlow 2.0、Vite 的新项目,并采用“测试优先、分阶段迁移”的安全升级策略。 | ||
| 6 | + | ||
| 7 | +## 当前阶段 | ||
| 8 | + | ||
| 9 | +阶段 10 | ||
| 10 | + | ||
| 11 | +## 阶段拆分 | ||
| 12 | + | ||
| 13 | +### 阶段 1:需求与旧项目调研 | ||
| 14 | +- [x] 理解用户诉求 | ||
| 15 | +- [x] 确认新项目当前起点 | ||
| 16 | +- [x] 审计旧项目架构、行为能力和对外 API | ||
| 17 | +- [x] 记录迁移约束与测试风险 | ||
| 18 | +- **状态:** 已完成 | ||
| 19 | + | ||
| 20 | +### 阶段 2:迁移策略与安全方案设计 | ||
| 21 | +- [x] 定义分阶段迁移路径 | ||
| 22 | +- [x] 定义重构前的基线保护策略 | ||
| 23 | +- [x] 定义兼容口径与验收清单 | ||
| 24 | +- **状态:** 已完成 | ||
| 25 | + | ||
| 26 | +### 阶段 3:文档编写 | ||
| 27 | +- [x] 编写需求与设计文档 | ||
| 28 | +- [x] 编写分阶段迁移方案 | ||
| 29 | +- [x] 编写测试迁移与验证方案 | ||
| 30 | +- **状态:** 已完成 | ||
| 31 | + | ||
| 32 | +### 阶段 4:复核与收敛 | ||
| 33 | +- [x] 基于旧项目再次核对关键假设 | ||
| 34 | +- [x] 收紧风险、开放问题和优先级 | ||
| 35 | +- [x] 形成可直接开工的初始方案 | ||
| 36 | +- **状态:** 已完成 | ||
| 37 | + | ||
| 38 | +### 阶段 5:实施计划细化 | ||
| 39 | +- [x] 将迁移方案细化成可执行任务清单 | ||
| 40 | +- [x] 补充双仓目录规划和阶段性产物 | ||
| 41 | +- [x] 输出下一步的明确实施顺序 | ||
| 42 | +- **状态:** 已完成 | ||
| 43 | + | ||
| 44 | +### 阶段 6:旧项目基线测试落地 | ||
| 45 | +- [x] 固定旧项目基线回归页说明 | ||
| 46 | +- [x] 接入 Playwright 最小测试基础设施 | ||
| 47 | +- [x] 跑通第一条旧项目冒烟测试 | ||
| 48 | +- **状态:** 已完成 | ||
| 49 | + | ||
| 50 | +### 阶段 7:新项目骨架与测试底座初始化 | ||
| 51 | +- [x] 初始化 Vue 3 + Vite + TypeScript + pnpm workspace 工程 | ||
| 52 | +- [x] 接入 Element Plus 与 LogicFlow 2.x 基础依赖 | ||
| 53 | +- [x] 建立 `apps/demo`、`packages/flow-editor`、`tests` 目录结构 | ||
| 54 | +- [x] 迁入首批基线测试语义(冒烟、工具栏、实例契约) | ||
| 55 | +- [x] 跑通新项目 `build`、`test:unit`、`test:e2e` | ||
| 56 | +- **状态:** 已完成 | ||
| 57 | + | ||
| 58 | +### 阶段 8:新项目数据适配层 | ||
| 59 | +- [x] 梳理旧项目图数据与当前规范数据的差异 | ||
| 60 | +- [x] 实现 `g6-to-logicflow` 兼容适配层 | ||
| 61 | +- [x] 让 demo 与编辑器内部统一走适配器 | ||
| 62 | +- [x] 补齐单节点、线性流、分支流三类夹具与单元测试 | ||
| 63 | +- [x] 跑通适配层相关验证 | ||
| 64 | +- **状态:** 已完成 | ||
| 65 | + | ||
| 66 | +### 阶段 9:新项目基础编辑闭环 | ||
| 67 | +- [x] 接通工具栏主要按钮的真实点击行为 | ||
| 68 | +- [x] 支持新增节点、选中节点、删除节点 | ||
| 69 | +- [x] 支持打开预览面板 | ||
| 70 | +- [x] 为基础交互补齐 E2E 回归 | ||
| 71 | +- **状态:** 已完成 | ||
| 72 | + | ||
| 73 | +### 阶段 10:新项目命令闭环增强 | ||
| 74 | +- [x] 移除遮挡画布的过渡提示层 | ||
| 75 | +- [x] 支持节点名称更新 | ||
| 76 | +- [x] 支持基于当前选中节点创建连线 | ||
| 77 | +- [x] 支持图数据撤销/重做 | ||
| 78 | +- [x] 为命令闭环补齐 E2E 回归 | ||
| 79 | +- **状态:** 已完成 | ||
| 80 | + | ||
| 81 | +## 关键问题 | ||
| 82 | +1. 旧项目里哪些行为必须被视为严格兼容项,哪些属于可接受的现代化调整? | ||
| 83 | +2. 如何在迁移编辑器内核之前,先建立可靠的自动化回归护栏? | ||
| 84 | +3. 应该优先迁移哪些能力,才能保证每一阶段都可验证、可回退? | ||
| 85 | + | ||
| 86 | +## 已做决策 | ||
| 87 | +| 决策 | 原因 | | ||
| 88 | +|------|------| | ||
| 89 | +| 先写文档和迁移设计,再启动代码搭建 | 新仓库当前为空,且用户明确希望优先保证升级安全性 | | ||
| 90 | +| 采用“行为优先”的迁移方式,而不是“依赖升级优先” | 旧项目存在混合技术栈,直接升级依赖风险高 | | ||
| 91 | +| 将测试视为本次迁移的一等产物 | 用户无法在重构后进行大量人工点击回归 | | ||
| 92 | +| 先把旧项目 `doc/index.vue` 作为第一份基线回归样板 | 该页面已覆盖 hooks、事件、插槽和复杂业务接入 | | ||
| 93 | +| 在新项目大规模开发前先迁移基线测试 | 这是保住现有业务行为最稳妥的方式 | | ||
| 94 | +| 继续补一份“实施级迁移计划”文档 | 这样后续可以按任务直接开工,而不是重复讨论阶段划分 | | ||
| 95 | +| 旧项目基线测试先采用“Node 16 + 接口拦截”的运行方式 | 这是当前成本最低、稳定性最高的自动化起步方案 | | ||
| 96 | +| 新项目先用“兼容壳层 + 最小 LogicFlow 2.x 实例”承接测试语义 | 这样可以在不提前实现全部交互的前提下,尽快建立新仓回归护栏 | | ||
| 97 | +| 新项目 E2E 改为脚本自管 Vite 生命周期 | 当前环境中 Playwright `webServer` 预检查会被本机代理/旧服务误导,脚本启动更稳 | | ||
| 98 | +| 新项目内部图数据统一先归一化,再映射到 LogicFlow 2.x | 这样后续命令系统、事件兼容层和保存链路都只面对一套稳定结构 | | ||
| 99 | +| 先做“可操作的基础编辑闭环”,再补复杂命令和业务面板 | 这样用户可以尽早看到真实可交互页面,而不是一直停留在只读状态 | | ||
| 100 | +| 连线创建和撤销/重做先基于兼容层图数据历史实现 | 先把用户操作闭环跑通,再逐步下沉到更完整的 LogicFlow 命令栈 | | ||
| 101 | + | ||
| 102 | +## 遇到的问题 | ||
| 103 | +| 问题 | 次数 | 处理方式 | | ||
| 104 | +|------|------|----------| | ||
| 105 | +| `rg --files` 在新仓库中没有返回文件 | 1 | 通过 `ls -la` 确认新仓库确实为空仓库,因此先从规划与文档开始 | | ||
| 106 | +| `corepack pnpm install` 出现签名 `keyid` 校验失败 | 1 | 改为使用 `npx pnpm@10.9.0 install` 绕过本机 corepack 问题 | | ||
| 107 | +| Playwright `webServer` 会误判本地端口已被占用 | 1 | 移除 `webServer` 配置,改为仓库脚本显式启动 Vite 并回收进程 | | ||
| 108 | + | ||
| 109 | +## 备注 | ||
| 110 | +- 在做重大决策前,重新回看本计划文件 | ||
| 111 | +- 继续将旧项目调研结论记录到 `findings.md` | ||
| 112 | +- 最终设计文档路径为 `docs/plans/2026-03-31-flow-editor-rebuild-design.md` | ||
| 113 | +- 实施计划文档路径为 `docs/plans/2026-03-31-flow-editor-migration-implementation-plan.md` |
tests/e2e/contract.spec.ts
0 → 100644
| 1 | +import { expect, test } from '@playwright/test' | ||
| 2 | +import { mockFlowBootstrap } from '../helpers/mock-flow-api' | ||
| 3 | + | ||
| 4 | +test('新项目基线页应暴露稳定的编辑器实例契约', async ({ page }) => { | ||
| 5 | + await mockFlowBootstrap(page) | ||
| 6 | + | ||
| 7 | + await page.goto('/') | ||
| 8 | + await expect(page.locator('.vue-flow-editor')).toBeVisible() | ||
| 9 | + | ||
| 10 | + const contract = await page.evaluate(() => { | ||
| 11 | + return window.__FLOW_EDITOR_TEST_API__?.getEditorContract() | ||
| 12 | + }) | ||
| 13 | + | ||
| 14 | + expect(contract?.hasEditor).toBe(true) | ||
| 15 | + expect(contract?.hasCommander).toBe(true) | ||
| 16 | + expect(contract?.hasDelete).toBe(true) | ||
| 17 | + expect(contract?.hasUndo).toBe(true) | ||
| 18 | + expect(contract?.hasRedo).toBe(true) | ||
| 19 | + expect(contract?.hasOpenModel).toBe(true) | ||
| 20 | + expect(contract?.hasCloseModel).toBe(true) | ||
| 21 | + expect(contract?.hasAddNode).toBe(true) | ||
| 22 | + expect(contract?.hasUpdateModel).toBe(true) | ||
| 23 | + expect(contract?.hasGraphRead).toBe(true) | ||
| 24 | +}) | ||
| 25 | + |
tests/e2e/interaction.spec.ts
0 → 100644
| 1 | +import { expect, test } from '@playwright/test' | ||
| 2 | +import { mockFlowBootstrap } from '../helpers/mock-flow-api' | ||
| 3 | + | ||
| 4 | +test('新项目页面应支持新增节点并删除当前选中节点', async ({ page }) => { | ||
| 5 | + await mockFlowBootstrap(page) | ||
| 6 | + | ||
| 7 | + await page.goto('/') | ||
| 8 | + | ||
| 9 | + await expect(page.getByTestId('node-count-value')).toHaveText('2') | ||
| 10 | + | ||
| 11 | + await page.getByTestId('add-approval-node').click() | ||
| 12 | + | ||
| 13 | + await expect(page.getByTestId('node-count-value')).toHaveText('3') | ||
| 14 | + await expect(page.getByTestId('selected-node-label')).toContainText('审批节点') | ||
| 15 | + | ||
| 16 | + await page.getByTestId('delete-selected-node').click() | ||
| 17 | + | ||
| 18 | + await expect(page.getByTestId('node-count-value')).toHaveText('2') | ||
| 19 | + await expect(page.getByTestId('selected-node-label')).toHaveText('未选中节点') | ||
| 20 | +}) | ||
| 21 | + | ||
| 22 | +test('新项目页面应支持缩放操作并打开预览面板', async ({ page }) => { | ||
| 23 | + await mockFlowBootstrap(page) | ||
| 24 | + | ||
| 25 | + await page.goto('/') | ||
| 26 | + | ||
| 27 | + await expect(page.getByTestId('zoom-value')).toHaveText('1.0') | ||
| 28 | + | ||
| 29 | + await page.getByTestId('toolbar-zoomIn').click() | ||
| 30 | + await expect(page.getByTestId('zoom-value')).toHaveText('1.1') | ||
| 31 | + | ||
| 32 | + await page.getByTestId('toolbar-actualView').click() | ||
| 33 | + await expect(page.getByTestId('zoom-value')).toHaveText('1.0') | ||
| 34 | + | ||
| 35 | + await page.getByTestId('open-preview-panel').click() | ||
| 36 | + await expect(page.getByTestId('preview-panel')).toBeVisible() | ||
| 37 | + await expect(page.getByTestId('preview-panel')).toContainText('审批节点') | ||
| 38 | +}) | ||
| 39 | + | ||
| 40 | +test('新项目页面应支持更新节点名称并创建连线', async ({ page }) => { | ||
| 41 | + await mockFlowBootstrap(page) | ||
| 42 | + | ||
| 43 | + await page.goto('/') | ||
| 44 | + | ||
| 45 | + await page.getByTestId('node-chip-start').click() | ||
| 46 | + await expect(page.getByTestId('selected-node-label')).toContainText('开始节点') | ||
| 47 | + | ||
| 48 | + await page.getByTestId('selected-node-name-input').fill('发起流程') | ||
| 49 | + await page.getByTestId('save-node-name').click() | ||
| 50 | + await expect(page.getByTestId('selected-node-label')).toContainText('发起流程') | ||
| 51 | + | ||
| 52 | + await page.getByTestId('add-copy-node').click() | ||
| 53 | + await expect(page.getByTestId('node-count-value')).toHaveText('3') | ||
| 54 | + | ||
| 55 | + await page.getByTestId('node-chip-approve').click() | ||
| 56 | + await page.getByTestId('create-edge-from-selection').click() | ||
| 57 | + | ||
| 58 | + await expect(page.getByTestId('edge-count-value')).toHaveText('2') | ||
| 59 | + await expect(page.getByTestId('selected-count-value')).toHaveText('2') | ||
| 60 | +}) | ||
| 61 | + | ||
| 62 | +test('新项目页面应支持撤销与重做图数据改动', async ({ page }) => { | ||
| 63 | + await mockFlowBootstrap(page) | ||
| 64 | + | ||
| 65 | + await page.goto('/') | ||
| 66 | + | ||
| 67 | + await expect(page.getByTestId('node-count-value')).toHaveText('2') | ||
| 68 | + | ||
| 69 | + await page.getByTestId('add-copy-node').click() | ||
| 70 | + await expect(page.getByTestId('node-count-value')).toHaveText('3') | ||
| 71 | + | ||
| 72 | + await page.getByTestId('command-undo').click() | ||
| 73 | + await expect(page.getByTestId('node-count-value')).toHaveText('2') | ||
| 74 | + | ||
| 75 | + await page.getByTestId('command-redo').click() | ||
| 76 | + await expect(page.getByTestId('node-count-value')).toHaveText('3') | ||
| 77 | +}) |
tests/e2e/smoke.spec.ts
0 → 100644
| 1 | +import { expect, test } from '@playwright/test' | ||
| 2 | +import { EditorPage } from '../helpers/editor-page' | ||
| 3 | +import { mockFlowBootstrap } from '../helpers/mock-flow-api' | ||
| 4 | + | ||
| 5 | +test('新项目基线页可以正常打开并渲染编辑器骨架', async ({ page }) => { | ||
| 6 | + await mockFlowBootstrap(page) | ||
| 7 | + | ||
| 8 | + const editorPage = new EditorPage(page) | ||
| 9 | + await editorPage.gotoHome() | ||
| 10 | + | ||
| 11 | + await expect(editorPage.app).toBeVisible() | ||
| 12 | + await expect(editorPage.editor).toBeVisible() | ||
| 13 | + await expect(editorPage.toolbar).toBeVisible() | ||
| 14 | + await expect(editorPage.canvas).toBeVisible() | ||
| 15 | +}) | ||
| 16 | + |
tests/e2e/toolbar.spec.ts
0 → 100644
| 1 | +import { expect, test } from '@playwright/test' | ||
| 2 | +import { mockFlowBootstrap } from '../helpers/mock-flow-api' | ||
| 3 | + | ||
| 4 | +test('新项目基线页应先对齐核心工具栏展示语义', async ({ page }) => { | ||
| 5 | + await mockFlowBootstrap(page) | ||
| 6 | + | ||
| 7 | + await page.goto('/') | ||
| 8 | + | ||
| 9 | + const toolbar = page.locator('.vue-flow-editor-toolbar') | ||
| 10 | + | ||
| 11 | + await expect(toolbar).toBeVisible() | ||
| 12 | + await expect(toolbar.getByText('适应画布', { exact: true })).toBeVisible() | ||
| 13 | + await expect(toolbar.getByText('实际尺寸', { exact: true })).toBeVisible() | ||
| 14 | + await expect(toolbar.getByText('放大', { exact: true })).toBeVisible() | ||
| 15 | + await expect(toolbar.getByText('缩小', { exact: true })).toBeVisible() | ||
| 16 | + await expect(toolbar.getByText('预览', { exact: true })).toBeVisible() | ||
| 17 | + await expect(toolbar.getByText('居中', { exact: true })).toBeVisible() | ||
| 18 | + await expect(toolbar.getByText('保存', { exact: true })).toBeVisible() | ||
| 19 | + await expect(toolbar.getByText('预览测试', { exact: true })).toBeVisible() | ||
| 20 | + await expect(toolbar.getByText('网格')).toHaveCount(0) | ||
| 21 | + await expect(toolbar.getByText('缩略图')).toHaveCount(0) | ||
| 22 | + await expect(toolbar.getByText('删除')).toHaveCount(0) | ||
| 23 | + await expect(toolbar.getByText('撤销')).toHaveCount(0) | ||
| 24 | + await expect(toolbar.getByText('重做')).toHaveCount(0) | ||
| 25 | +}) | ||
| 26 | + |
tests/fixtures/graphs/base-single-node.json
0 → 100644
tests/fixtures/graphs/branching-flow.json
0 → 100644
| 1 | +{ | ||
| 2 | + "nodes": [ | ||
| 3 | + { | ||
| 4 | + "id": "start-node", | ||
| 5 | + "text": "开始节点", | ||
| 6 | + "shape": "start", | ||
| 7 | + "x": 120, | ||
| 8 | + "y": 200 | ||
| 9 | + }, | ||
| 10 | + { | ||
| 11 | + "id": "review-node", | ||
| 12 | + "text": "审批节点", | ||
| 13 | + "activity": "approval", | ||
| 14 | + "shape": "activity", | ||
| 15 | + "x": 320, | ||
| 16 | + "y": 200 | ||
| 17 | + }, | ||
| 18 | + { | ||
| 19 | + "id": "approve-node", | ||
| 20 | + "text": "通过分支", | ||
| 21 | + "control": "branch-yes", | ||
| 22 | + "shape": "control", | ||
| 23 | + "x": 520, | ||
| 24 | + "y": 140 | ||
| 25 | + }, | ||
| 26 | + { | ||
| 27 | + "id": "reject-node", | ||
| 28 | + "text": "驳回分支", | ||
| 29 | + "control": "branch-no", | ||
| 30 | + "shape": "control", | ||
| 31 | + "x": 520, | ||
| 32 | + "y": 260 | ||
| 33 | + }, | ||
| 34 | + { | ||
| 35 | + "id": "end-node", | ||
| 36 | + "text": "结束节点", | ||
| 37 | + "shape": "end", | ||
| 38 | + "x": 740, | ||
| 39 | + "y": 200 | ||
| 40 | + } | ||
| 41 | + ], | ||
| 42 | + "edges": [ | ||
| 43 | + { | ||
| 44 | + "source": "start-node", | ||
| 45 | + "target": "review-node", | ||
| 46 | + "shape": "flow-polyline-round", | ||
| 47 | + "text": "提交审批" | ||
| 48 | + }, | ||
| 49 | + { | ||
| 50 | + "source": "review-node", | ||
| 51 | + "target": "approve-node", | ||
| 52 | + "shape": "flow-polyline-round", | ||
| 53 | + "text": "同意" | ||
| 54 | + }, | ||
| 55 | + { | ||
| 56 | + "source": "review-node", | ||
| 57 | + "target": "reject-node", | ||
| 58 | + "shape": "flow-polyline-round", | ||
| 59 | + "text": "驳回" | ||
| 60 | + }, | ||
| 61 | + { | ||
| 62 | + "source": "approve-node", | ||
| 63 | + "target": "end-node", | ||
| 64 | + "shape": "flow-polyline-round", | ||
| 65 | + "text": "完成" | ||
| 66 | + }, | ||
| 67 | + { | ||
| 68 | + "source": "reject-node", | ||
| 69 | + "target": "end-node", | ||
| 70 | + "shape": "flow-polyline-round", | ||
| 71 | + "text": "结束" | ||
| 72 | + } | ||
| 73 | + ] | ||
| 74 | +} | ||
| 75 | + |
| 1 | +{ | ||
| 2 | + "nodes": [ | ||
| 3 | + { | ||
| 4 | + "id": "start", | ||
| 5 | + "label": "开始节点", | ||
| 6 | + "type": "start", | ||
| 7 | + "x": 120, | ||
| 8 | + "y": 180 | ||
| 9 | + }, | ||
| 10 | + { | ||
| 11 | + "id": "approve", | ||
| 12 | + "label": "审批节点", | ||
| 13 | + "type": "approval", | ||
| 14 | + "x": 320, | ||
| 15 | + "y": 180 | ||
| 16 | + }, | ||
| 17 | + { | ||
| 18 | + "id": "finish", | ||
| 19 | + "label": "结束节点", | ||
| 20 | + "type": "end", | ||
| 21 | + "x": 520, | ||
| 22 | + "y": 180 | ||
| 23 | + } | ||
| 24 | + ], | ||
| 25 | + "edges": [ | ||
| 26 | + { | ||
| 27 | + "id": "edge-start-approve", | ||
| 28 | + "sourceNodeId": "start", | ||
| 29 | + "targetNodeId": "approve", | ||
| 30 | + "label": "提交审批" | ||
| 31 | + }, | ||
| 32 | + { | ||
| 33 | + "id": "edge-approve-finish", | ||
| 34 | + "sourceNodeId": "approve", | ||
| 35 | + "targetNodeId": "finish", | ||
| 36 | + "label": "审批通过" | ||
| 37 | + } | ||
| 38 | + ] | ||
| 39 | +} | ||
| 40 | + |
tests/fixtures/operations/README.md
0 → 100644
tests/helpers/editor-page.ts
0 → 100644
| 1 | +import type { Locator, Page } from '@playwright/test' | ||
| 2 | + | ||
| 3 | +export class EditorPage { | ||
| 4 | + readonly page: Page | ||
| 5 | + readonly app: Locator | ||
| 6 | + readonly editor: Locator | ||
| 7 | + readonly toolbar: Locator | ||
| 8 | + readonly canvas: Locator | ||
| 9 | + | ||
| 10 | + constructor(page: Page) { | ||
| 11 | + this.page = page | ||
| 12 | + this.app = page.locator('.app') | ||
| 13 | + this.editor = page.locator('.vue-flow-editor') | ||
| 14 | + this.toolbar = page.locator('.vue-flow-editor-toolbar') | ||
| 15 | + this.canvas = page.locator('.vue-flow-editor-canvas-target') | ||
| 16 | + } | ||
| 17 | + | ||
| 18 | + async gotoHome() { | ||
| 19 | + await this.page.goto('/') | ||
| 20 | + } | ||
| 21 | +} | ||
| 22 | + |
tests/helpers/mock-flow-api.ts
0 → 100644
| 1 | +import type { Page } from '@playwright/test' | ||
| 2 | + | ||
| 3 | +export async function mockFlowBootstrap(page: Page) { | ||
| 4 | + await page.route('**/admin/**', async (route) => { | ||
| 5 | + const url = route.request().url() | ||
| 6 | + | ||
| 7 | + if (url.includes('a=flow_version') && url.includes('m=mod')) { | ||
| 8 | + await route.fulfill({ | ||
| 9 | + status: 200, | ||
| 10 | + contentType: 'application/json', | ||
| 11 | + body: JSON.stringify({ | ||
| 12 | + code: 1, | ||
| 13 | + data: [ | ||
| 14 | + { | ||
| 15 | + id: 1, | ||
| 16 | + code: 1, | ||
| 17 | + status: '1', | ||
| 18 | + note: '基线版本', | ||
| 19 | + }, | ||
| 20 | + ], | ||
| 21 | + }), | ||
| 22 | + }) | ||
| 23 | + return | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + if (url.includes('a=flow_nodes') && url.includes('m=srv')) { | ||
| 27 | + await route.fulfill({ | ||
| 28 | + status: 200, | ||
| 29 | + contentType: 'application/json', | ||
| 30 | + body: JSON.stringify({ | ||
| 31 | + code: 1, | ||
| 32 | + data: { | ||
| 33 | + nodes: [ | ||
| 34 | + { | ||
| 35 | + id: 'start', | ||
| 36 | + text: '开始节点', | ||
| 37 | + shape: 'start', | ||
| 38 | + x: 120, | ||
| 39 | + y: 180, | ||
| 40 | + }, | ||
| 41 | + { | ||
| 42 | + id: 'approve', | ||
| 43 | + text: '审批节点', | ||
| 44 | + activity: 'approval', | ||
| 45 | + shape: 'activity', | ||
| 46 | + x: 320, | ||
| 47 | + y: 180, | ||
| 48 | + }, | ||
| 49 | + ], | ||
| 50 | + edges: [ | ||
| 51 | + { | ||
| 52 | + source: 'start', | ||
| 53 | + target: 'approve', | ||
| 54 | + shape: 'flow-polyline-round', | ||
| 55 | + text: '提交审批', | ||
| 56 | + }, | ||
| 57 | + ], | ||
| 58 | + }, | ||
| 59 | + }), | ||
| 60 | + }) | ||
| 61 | + return | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + await route.fulfill({ | ||
| 65 | + status: 200, | ||
| 66 | + contentType: 'application/json', | ||
| 67 | + body: JSON.stringify({ | ||
| 68 | + code: 1, | ||
| 69 | + data: {}, | ||
| 70 | + }), | ||
| 71 | + }) | ||
| 72 | + }) | ||
| 73 | +} |
tests/unit/compat-contract.spec.ts
0 → 100644
| 1 | +import { mount } from '@vue/test-utils' | ||
| 2 | +import { describe, expect, it } from 'vitest' | ||
| 3 | +import type { FlowEditorRef } from '@flow-editor' | ||
| 4 | +import FlowEditor from '../../packages/flow-editor/src/components/FlowEditor.vue' | ||
| 5 | + | ||
| 6 | +describe('FlowEditor 兼容层', () => { | ||
| 7 | + it('应通过 onRef 暴露最小实例契约', () => { | ||
| 8 | + let editorInstance: FlowEditorRef | null = null | ||
| 9 | + | ||
| 10 | + mount(FlowEditor, { | ||
| 11 | + props: { | ||
| 12 | + enableEngine: false, | ||
| 13 | + onRef: (instance: FlowEditorRef) => { | ||
| 14 | + editorInstance = instance | ||
| 15 | + }, | ||
| 16 | + }, | ||
| 17 | + }) | ||
| 18 | + | ||
| 19 | + expect(editorInstance).not.toBeNull() | ||
| 20 | + expect(typeof editorInstance?.commander.delete).toBe('function') | ||
| 21 | + expect(typeof editorInstance?.commander.undo).toBe('function') | ||
| 22 | + expect(typeof editorInstance?.commander.redo).toBe('function') | ||
| 23 | + expect(typeof editorInstance?.openModel).toBe('function') | ||
| 24 | + expect(typeof editorInstance?.closeModel).toBe('function') | ||
| 25 | + expect(typeof editorInstance?.addNode).toBe('function') | ||
| 26 | + expect(typeof editorInstance?.updateModel).toBe('function') | ||
| 27 | + expect(typeof editorInstance?.read).toBe('function') | ||
| 28 | + }) | ||
| 29 | +}) |
tests/unit/g6-to-logicflow.spec.ts
0 → 100644
| 1 | +import { describe, expect, it } from 'vitest' | ||
| 2 | +import { | ||
| 3 | + normalizeGraphData, | ||
| 4 | + toLogicFlowGraphData, | ||
| 5 | +} from '../../packages/flow-editor/src/core/adapter/g6-to-logicflow' | ||
| 6 | +import baseSingleNode from '../fixtures/graphs/base-single-node.json' | ||
| 7 | +import branchingFlow from '../fixtures/graphs/branching-flow.json' | ||
| 8 | +import linearApprovalFlow from '../fixtures/graphs/linear-approval-flow.json' | ||
| 9 | + | ||
| 10 | +describe('g6-to-logicflow 适配器', () => { | ||
| 11 | + it('应能把旧项目单节点数据归一化为兼容结构', () => { | ||
| 12 | + const normalizedGraph = normalizeGraphData(baseSingleNode) | ||
| 13 | + | ||
| 14 | + expect(normalizedGraph.nodes).toHaveLength(1) | ||
| 15 | + expect(normalizedGraph.nodes[0]).toMatchObject({ | ||
| 16 | + id: 'start-node', | ||
| 17 | + label: '开始节点', | ||
| 18 | + type: 'start', | ||
| 19 | + x: 160, | ||
| 20 | + y: 180, | ||
| 21 | + }) | ||
| 22 | + expect(normalizedGraph.edges).toHaveLength(0) | ||
| 23 | + }) | ||
| 24 | + | ||
| 25 | + it('应兼容已经整理过的新结构图数据', () => { | ||
| 26 | + const normalizedGraph = normalizeGraphData(linearApprovalFlow) | ||
| 27 | + | ||
| 28 | + expect(normalizedGraph.nodes).toHaveLength(3) | ||
| 29 | + expect(normalizedGraph.edges).toHaveLength(2) | ||
| 30 | + expect(normalizedGraph.nodes[1]).toMatchObject({ | ||
| 31 | + id: 'approve', | ||
| 32 | + label: '审批节点', | ||
| 33 | + type: 'approval', | ||
| 34 | + }) | ||
| 35 | + expect(normalizedGraph.edges[0]).toMatchObject({ | ||
| 36 | + sourceNodeId: 'start', | ||
| 37 | + targetNodeId: 'approve', | ||
| 38 | + label: '提交审批', | ||
| 39 | + }) | ||
| 40 | + }) | ||
| 41 | + | ||
| 42 | + it('应能把旧项目分支流程映射为 LogicFlow 2.x 可消费结构', () => { | ||
| 43 | + const logicFlowGraph = toLogicFlowGraphData(branchingFlow) | ||
| 44 | + | ||
| 45 | + expect(logicFlowGraph.nodes).toHaveLength(5) | ||
| 46 | + expect(logicFlowGraph.edges).toHaveLength(5) | ||
| 47 | + expect(logicFlowGraph.nodes[0]).toMatchObject({ | ||
| 48 | + id: 'start-node', | ||
| 49 | + type: 'circle', | ||
| 50 | + text: '开始节点', | ||
| 51 | + }) | ||
| 52 | + expect(logicFlowGraph.nodes[2]).toMatchObject({ | ||
| 53 | + id: 'approve-node', | ||
| 54 | + type: 'rect', | ||
| 55 | + text: '通过分支', | ||
| 56 | + }) | ||
| 57 | + expect(logicFlowGraph.edges[0]).toMatchObject({ | ||
| 58 | + sourceNodeId: 'start-node', | ||
| 59 | + targetNodeId: 'review-node', | ||
| 60 | + type: 'polyline', | ||
| 61 | + text: '提交审批', | ||
| 62 | + }) | ||
| 63 | + expect(logicFlowGraph.edges[0].id).toContain('edge-start-node-review-node') | ||
| 64 | + }) | ||
| 65 | +}) |
tsconfig.json
0 → 100644
| 1 | +{ | ||
| 2 | + "compilerOptions": { | ||
| 3 | + "target": "ES2022", | ||
| 4 | + "useDefineForClassFields": true, | ||
| 5 | + "module": "ESNext", | ||
| 6 | + "moduleResolution": "Bundler", | ||
| 7 | + "strict": true, | ||
| 8 | + "jsx": "preserve", | ||
| 9 | + "resolveJsonModule": true, | ||
| 10 | + "isolatedModules": true, | ||
| 11 | + "esModuleInterop": true, | ||
| 12 | + "lib": ["ES2022", "DOM", "DOM.Iterable"], | ||
| 13 | + "skipLibCheck": true, | ||
| 14 | + "types": ["node", "vitest/globals"], | ||
| 15 | + "baseUrl": ".", | ||
| 16 | + "paths": { | ||
| 17 | + "@flow-editor": ["packages/flow-editor/src/index.ts"], | ||
| 18 | + "@flow-editor/*": ["packages/flow-editor/src/*"] | ||
| 19 | + } | ||
| 20 | + }, | ||
| 21 | + "include": [ | ||
| 22 | + "env.d.ts", | ||
| 23 | + "vite.config.ts", | ||
| 24 | + "vitest.config.ts", | ||
| 25 | + "playwright.config.ts", | ||
| 26 | + "apps/**/*.ts", | ||
| 27 | + "apps/**/*.vue", | ||
| 28 | + "packages/**/*.ts", | ||
| 29 | + "packages/**/*.vue", | ||
| 30 | + "tests/**/*.ts" | ||
| 31 | + ] | ||
| 32 | +} | ||
| 33 | + |
vite.config.ts
0 → 100644
| 1 | +import { resolve } from 'node:path' | ||
| 2 | +import { defineConfig } from 'vite' | ||
| 3 | +import vue from '@vitejs/plugin-vue' | ||
| 4 | + | ||
| 5 | +export default defineConfig({ | ||
| 6 | + root: resolve(__dirname, 'apps/demo'), | ||
| 7 | + plugins: [vue()], | ||
| 8 | + resolve: { | ||
| 9 | + alias: { | ||
| 10 | + '@flow-editor': resolve(__dirname, 'packages/flow-editor/src/index.ts'), | ||
| 11 | + '@flow-editor/components': resolve(__dirname, 'packages/flow-editor/src/components'), | ||
| 12 | + '@flow-editor-src': resolve(__dirname, 'packages/flow-editor/src'), | ||
| 13 | + }, | ||
| 14 | + }, | ||
| 15 | + server: { | ||
| 16 | + host: '127.0.0.1', | ||
| 17 | + port: 4173, | ||
| 18 | + }, | ||
| 19 | +}) | ||
| 20 | + |
vitest.config.ts
0 → 100644
| 1 | +import { resolve } from 'node:path' | ||
| 2 | +import { defineConfig } from 'vitest/config' | ||
| 3 | +import vue from '@vitejs/plugin-vue' | ||
| 4 | + | ||
| 5 | +export default defineConfig({ | ||
| 6 | + plugins: [vue()], | ||
| 7 | + resolve: { | ||
| 8 | + alias: { | ||
| 9 | + '@flow-editor': resolve(__dirname, 'packages/flow-editor/src/index.ts'), | ||
| 10 | + '@flow-editor/components': resolve(__dirname, 'packages/flow-editor/src/components'), | ||
| 11 | + '@flow-editor-src': resolve(__dirname, 'packages/flow-editor/src'), | ||
| 12 | + }, | ||
| 13 | + }, | ||
| 14 | + test: { | ||
| 15 | + environment: 'jsdom', | ||
| 16 | + include: ['tests/unit/**/*.spec.ts'], | ||
| 17 | + }, | ||
| 18 | +}) | ||
| 19 | + |
-
Please register or login to post a comment