hookehuyr

feat: initialize logicflow editor migration base

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