feat(流程图): 画流程图不用再拖框连线——点箭头上的 + 加一步,判断自动分出是 / 否,排版全自动

原来要从节点库拖框、对准连接点拉线、自己摆位置,还能画出断线和孤立的框;
画布上全是彩色圆角块,交上去的却是平行四边形和菱形。现在照画布稿 FlowEdB:
- 图存成一棵结构树(顺序 / 判断 / 循环),开始、结束固定在两头,排版是纯函数
- 每根箭头上一个 +,点了选输入 / 输出 / 处理 / 判断 / 循环;点框直接改字,右上角「删除」
- 形状照国标流程图,画布、缩略图、交给 AI 的 Mermaid 是同一套;缩略图配色跟着画布改
- 已保存时间、撤销、重做挪到工具栏,「清空画布」收进「⋯」;空框交不上去
- 去掉 vue-flow 三个包(编辑器打包后 16 KB),删掉没人进的 /flowchart 页

2026-10 之前交的图是 vue-flow 格式,只能看缩略图、载不回画布;旧草稿键名不同,不去碰。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 20:56:45 +08:00
1 parent ce26925e79
commit 09f8a56913
25 files changed
+1670 -2102

No files matched your search

@@ -1,270 +0,0 @@
<template>
<div
class="custom-node"
:class="{ 'is-hovered': isHovered, 'is-editing': isEditing }"
:data-node-type="nodeType"
:draggable="!isEditing"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dblclick="handleDoubleClick"
@dragstart="handleDragStart"
@mousedown="handleMouseDown"
>
<!-- 连线点 - 根据节点类型动态显示 -->
<NodeHandles :node-type="nodeType" :node-config="nodeConfig" />
<!-- 节点内容 -->
<div class="node-content">
<!-- 显示模式 -->
<span v-if="!isEditing" class="node-label">{{ displayLabel }}</span>
<!-- 编辑模式 -->
<input
v-if="isEditing"
ref="editInput"
v-model="editText"
class="node-input"
@blur="handleSaveEdit"
@keydown.enter="handleSaveEdit"
@keydown.escape="handleCancelEdit"
@click.stop
/>
<!-- 隐藏的文字用于保持尺寸 -->
<span v-if="isEditing" class="node-label-hidden" aria-hidden="true">
{{ displayLabel }}
</span>
</div>
<!-- 悬停时显示的操作按钮 -->
<NodeActions
v-if="isHovered"
@delete="handleDelete"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, onUnmounted, nextTick, computed } from "vue"
import { getNodeTypeConfig } from "./useNodeStyles"
import NodeHandles from "./NodeHandles.vue"
import NodeActions from "./NodeActions.vue"
interface NodeData {
label: string
color: string
originalType: string
customLabel?: string
}
interface Props {
id: string
type: string
data: NodeData
}
interface Emits {
delete: [nodeId: string]
update: [nodeId: string, newLabel: string]
}
// Props 和 Emits
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 响应式状态
const isHovered = ref(false)
const isEditing = ref(false)
const editText = ref("")
const editInput = useTemplateRef<HTMLInputElement>("editInput")
// 定时器和事件处理器
let hideTimeout: ReturnType<typeof setTimeout> | null = null
let globalClickHandler: ((event: MouseEvent) => void) | null = null
// 计算属性
const nodeType = computed(() => props.data.originalType || props.type)
const nodeConfig = computed(() => getNodeTypeConfig(nodeType.value))
const displayLabel = computed(() => props.data.customLabel || nodeConfig.value.label)
// 事件处理器
const handleDelete = () => emit("delete", props.id)
const handleMouseDown = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.closest(".vue-flow__handle")) {
event.preventDefault()
}
}
const handleDragStart = (event: DragEvent) => {
if (isEditing.value) return
const target = event.target as HTMLElement
if (target.closest(".vue-flow__handle")) {
event.preventDefault()
return
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move"
}
}
const handleDoubleClick = (event: MouseEvent) => {
event.stopPropagation()
if (!isEditing.value) {
isEditing.value = true
editText.value = displayLabel.value
nextTick(() => {
editInput.value?.focus()
editInput.value?.select()
})
addGlobalClickHandler()
}
}
const handleSaveEdit = () => {
if (isEditing.value) {
emit("update", props.id, editText.value.trim())
isEditing.value = false
removeGlobalClickHandler()
}
}
const handleCancelEdit = () => {
isEditing.value = false
editText.value = ""
removeGlobalClickHandler()
}
const handleMouseEnter = () => {
isHovered.value = true
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}
const handleMouseLeave = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
}
hideTimeout = setTimeout(() => {
isHovered.value = false
}, 300)
}
// 全局点击处理器
const addGlobalClickHandler = () => {
if (globalClickHandler) return
globalClickHandler = (event: MouseEvent) => {
if (isEditing.value && !(event.target as Element)?.closest(".custom-node")) {
handleSaveEdit()
}
}
document.addEventListener("click", globalClickHandler, { capture: true })
}
const removeGlobalClickHandler = () => {
if (globalClickHandler) {
document.removeEventListener("click", globalClickHandler, { capture: true })
globalClickHandler = null
}
}
// 清理函数
onUnmounted(() => {
if (hideTimeout) {
clearTimeout(hideTimeout)
}
removeGlobalClickHandler()
})
</script>
<style scoped>
/* 主容器 */
.custom-node {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: inherit;
transition: all 0.2s ease;
padding: 0 20px;
}
.custom-node.is-hovered {
z-index: 1;
}
.custom-node.is-hovered .node-content {
filter: brightness(1.1);
}
/* 节点内容区域 */
.node-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 100%;
border-radius: inherit;
transition: all 0.2s ease;
}
/* 节点标签 */
.node-label {
font-size: 16px;
font-weight: 500;
white-space: nowrap;
display: inline-block;
text-align: center;
line-height: 1.2;
}
/* 编辑输入框 */
.node-input {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
border: none;
outline: none;
font-size: 16px;
font-weight: 500;
color: inherit;
text-align: center;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
resize: none;
font-family: inherit;
line-height: 1.2;
}
/* 隐藏标签(用于保持尺寸) */
.node-label-hidden {
font-size: 16px;
font-weight: 500;
white-space: nowrap;
display: inline-block;
visibility: hidden;
pointer-events: none;
text-align: center;
line-height: 1.2;
}
</style>
@@ -1,52 +0,0 @@
<template>
<div class="node-actions" @mouseenter="$emit('mouseenter')" @mouseleave="$emit('mouseleave')">
<button class="action-btn delete-btn" @click.stop="$emit('delete')" title="删除节点">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
/>
</svg>
</button>
</div>
</template>
<script lang="ts" setup>
defineEmits<{
delete: []
mouseenter: []
mouseleave: []
}>()
</script>
<style scoped>
.node-actions {
position: absolute;
top: -40px;
right: -20px;
padding: 8px;
}
.action-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
font-size: 10px;
}
.delete-btn {
background: #ef4444;
color: white;
}
.delete-btn:hover {
background: #dc2626;
transform: scale(1.05);
}
</style>
@@ -1,238 +0,0 @@
<template>
<!-- 开始节点:只有输出 handle -->
<template v-if="nodeType === 'start'">
<Handle
type="source"
id="output"
:position="Position.Bottom"
:style="getHandleStyle('#10b981', { bottom: '-10px' })"
/>
</template>
<!-- 结束节点:只有输入 handle -->
<template v-else-if="nodeType === 'end'">
<Handle
type="target"
id="input"
:position="Position.Top"
:style="getHandleStyle('#ef4444', { top: '-10px' })"
/>
</template>
<!-- 选择判断节点:一个输入 + 两个输出(是/否) -->
<template v-else-if="nodeType === 'decision'">
<Handle
type="target"
id="input"
:position="Position.Top"
:style="getHandleStyle('#f59e0b', { top: '-16px' })"
/>
<Handle
type="source"
:position="Position.Left"
id="yes"
:style="{
background: '#10b981',
width: '12px',
height: '12px',
border: '2px solid white',
zIndex: 10,
left: '-10px',
top: '50%',
transform: 'translateY(-50%)',
}"
/>
<Handle
type="source"
:position="Position.Right"
id="no"
:style="{
background: '#ef4444',
width: '12px',
height: '12px',
border: '2px solid white',
zIndex: 10,
right: '-10px',
top: '50%',
transform: 'translateY(-50%)',
}"
/>
<!-- 是/否标签 -->
<div class="decision-labels">
<span class="decision-label decision-label-yes">是</span>
<span class="decision-label decision-label-no">否</span>
</div>
</template>
<!-- 循环判断节点:两个输入 + 两个输出(进入/循环体返回 + 继续/退出) -->
<template v-else-if="nodeType === 'loop'">
<!-- 进入循环的输入 -->
<Handle
type="target"
:position="Position.Top"
id="enter"
:style="getHandleStyle('#f59e0b', { top: '-16px' })"
/>
<!-- 循环体返回的输入 -->
<Handle
type="target"
:position="Position.Bottom"
id="return"
:style="
getHandleStyle('#8b5cf6', {
bottom: '-16px',
})
"
/>
<!-- 继续执行循环体 -->
<Handle
type="source"
:position="Position.Right"
id="continue"
:style="
getHandleStyle('#10b981', {
right: '-10px',
top: '50%',
transform: 'translateY(-50%)',
})
"
/>
<!-- 退出循环 -->
<Handle
type="source"
:position="Position.Left"
id="exit"
:style="
getHandleStyle('#ef4444', {
left: '-10px',
top: '50%',
transform: 'translateY(-50%)',
})
"
/>
<!-- 标签 -->
<div class="loop-labels">
<span class="loop-label loop-label-enter">进入</span>
<span class="loop-label loop-label-return">返回</span>
<span class="loop-label loop-label-continue">继续</span>
<span class="loop-label loop-label-exit">退出</span>
</div>
</template>
<!-- 上下两个 handle -->
<template v-else>
<Handle
type="target"
id="input"
:position="Position.Top"
:style="getHandleStyle(nodeConfig.color, { top: '-10px' })"
/>
<Handle
type="source"
id="output"
:position="Position.Bottom"
:style="getHandleStyle(nodeConfig.color, { bottom: '-10px' })"
/>
</template>
</template>
<script lang="ts" setup>
import { Handle, Position } from "@vue-flow/core"
interface Props {
nodeType: string
nodeConfig: {
color: string
label: string
}
}
defineProps<Props>()
// 获取 handle 样式
const getHandleStyle = (color: string, position: Record<string, string>) => ({
background: color,
width: "12px",
height: "12px",
border: "2px solid white",
zIndex: 10,
...position,
})
</script>
<style scoped>
/* 判断节点标签样式 */
.decision-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1000;
}
.decision-label {
position: absolute;
font-size: 16px;
font-weight: 600;
color: #000;
white-space: nowrap;
pointer-events: none;
user-select: none;
}
.decision-label-yes {
left: -25px;
top: -20px;
}
.decision-label-no {
right: -25px;
top: -20px;
}
/* 循环节点标签样式 */
.loop-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.loop-label {
position: absolute;
font-size: 16px;
font-weight: 600;
color: #000;
white-space: nowrap;
pointer-events: none;
user-select: none;
}
.loop-label-enter {
right: 20px;
top: -45px;
}
.loop-label-return {
right: 20px;
bottom: -45px;
}
.loop-label-continue {
right: -40px;
top: -16px;
transform: translateY(-50%);
}
.loop-label-exit {
left: -40px;
top: -16px;
transform: translateY(-50%);
}
</style>
@@ -1,400 +0,0 @@
<script setup lang="ts">
import { computed } from "vue"
import { parseTime } from "utils/functions"
import { getNodeTypeConfig } from "./useNodeStyles"
import { currentDragNodeType } from "./useDnD"
// 拖拽开始处理
const onDragStart = (event: DragEvent, type: string) => {
if (!event.dataTransfer || !type) return
event.dataTransfer.setData("application/vueflow", type)
event.dataTransfer.effectAllowed = "move"
currentDragNodeType.value = type
// 隐藏浏览器默认拖影,改用 canvas 跟随预览
const emptyImg = new Image(1, 1)
emptyImg.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
event.dataTransfer.setDragImage(emptyImg, 0, 0)
}
const onDragEnd = () => {
currentDragNodeType.value = null
}
// Props
const props = defineProps<{
canUndo?: boolean
canRedo?: boolean
isSaving?: boolean
lastSaved?: Date | null
hasUnsavedChanges?: boolean
}>()
// Emits
const emit = defineEmits<{
deleteNode: [nodeId: string]
undo: []
redo: []
clear: []
}>()
// 工具栏状态
// 节点类型定义 - 优化性能
const nodeTypes = computed(() =>
["start", "input", "default", "decision", "loop", "output", "end"].map((type) => {
const config = getNodeTypeConfig(type)
return {
type,
...config,
}
}),
)
const saveStatusTitle = computed(() => {
if (props.isSaving) {
return "正在保存..."
} else if (props.hasUnsavedChanges) {
return "有未保存的更改"
} else if (props.lastSaved) {
// 和站内其它时间同一口径(东八区),不用 toLocaleTimeString() 跟着浏览器走
return `已保存 - ${parseTime(props.lastSaved, "HH:mm:ss")}`
} else {
return "已保存"
}
})
</script>
<template>
<div class="toolbar">
<!-- 工具栏头部 -->
<div class="toolbar-header">
<div class="header-content">
<h3>节点库</h3>
<div
class="save-status-indicator"
:class="{
saving: props.isSaving,
unsaved: props.hasUnsavedChanges && !props.isSaving,
saved: !props.hasUnsavedChanges && !props.isSaving,
}"
:title="saveStatusTitle"
>
<span v-if="props.isSaving" class="spinner">⏳</span>
<span v-else-if="props.hasUnsavedChanges">●</span>
<span v-else>✔</span>
</div>
</div>
</div>
<!-- 节点列表 -->
<div class="nodes">
<div
v-for="nodeType in nodeTypes"
:key="nodeType.type"
class="node-item"
:draggable="true"
@dragstart="onDragStart($event, nodeType.type)"
@dragend="onDragEnd"
:style="{ borderColor: nodeType.color }"
:title="`${nodeType.label} - ${nodeType.description}`"
>
<div class="node-icon" :style="{ backgroundColor: nodeType.color }">
{{ nodeType.icon }}
</div>
<div class="node-info">
<div class="node-label">{{ nodeType.label }}</div>
<div class="node-description">{{ nodeType.description }}</div>
</div>
</div>
</div>
<!-- 工具栏操作 -->
<div class="toolbar-actions">
<div class="history-controls">
<button
class="action-btn history-btn"
:disabled="!canUndo"
@click="$emit('undo')"
title="撤销 (Ctrl+Z)"
>
<span class="btn-icon">↶</span>
<span class="btn-text">撤销</span>
</button>
<button
class="action-btn history-btn"
:disabled="!canRedo"
@click="$emit('redo')"
title="重做 (Ctrl+Y)"
>
<span class="btn-icon">↷</span>
<span class="btn-text">重做</span>
</button>
</div>
<button class="action-btn clear-btn" @click="$emit('clear')" title="清空画布">
<span class="btn-icon">🗑️</span>
<span class="btn-text">清空画布</span>
</button>
</div>
</div>
</template>
<style scoped>
.toolbar {
width: 140px;
height: auto;
max-height: calc(100vh - 40px);
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
padding: 16px;
overflow-y: auto;
transition: all 0.3s ease;
}
.toolbar-header {
margin-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 12px;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.toolbar-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
/* 保存状态指示器样式 */
.save-status-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 12px;
font-weight: bold;
transition: all 0.3s ease;
cursor: pointer;
}
.save-status-indicator.saving {
background: #fef3c7;
color: #d97706;
animation: pulse 1.5s ease-in-out infinite;
}
.save-status-indicator.unsaved {
background: #fef2f2;
color: #dc2626;
animation: pulse 1.5s ease-in-out infinite;
}
.save-status-indicator.saved {
background: #f0fdf4;
color: #16a34a;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* 节点列表样式 */
.nodes {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.node-item {
display: flex;
align-items: center;
padding: 10px;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: grab;
transition: all 0.2s ease;
background: white;
}
.node-item:hover {
border-color: #3b82f6;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
transform: translateY(-2px);
}
.node-item:active {
cursor: grabbing;
transform: translateY(0);
}
.node-icon {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: white;
margin-right: 10px;
flex-shrink: 0;
}
.node-info {
flex: 1;
min-width: 0;
}
.node-label {
font-weight: 500;
color: #1f2937;
margin-bottom: 2px;
font-size: 14px;
}
.node-description {
font-size: 12px;
color: #6b7280;
line-height: 1.3;
}
/* 工具栏操作按钮样式 */
.toolbar-actions {
display: flex;
flex-direction: column;
gap: 8px;
border-top: 1px solid #e5e7eb;
padding-top: 12px;
}
.history-controls {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #374151;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
}
.action-btn:hover {
background: #f9fafb;
border-color: #9ca3af;
transform: translateY(-1px);
}
.action-btn:active {
background: #f3f4f6;
transform: translateY(0);
}
.history-btn {
flex: 1;
font-size: 11px;
padding: 8px 10px;
}
.history-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f9fafb;
color: #9ca3af;
}
.history-btn:disabled:hover {
background: #f9fafb;
border-color: #d1d5db;
transform: none;
}
.clear-btn {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
.clear-btn:hover {
background: #fee2e2;
border-color: #fca5a5;
}
.btn-icon {
font-size: 14px;
}
.btn-text {
font-size: 12px;
}
/* 滚动条样式 */
.toolbar::-webkit-scrollbar {
width: 6px;
}
.toolbar::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.toolbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.toolbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 响应式设计 */
@media (max-width: 768px) {
.toolbar {
width: 180px;
top: 10px;
left: 10px;
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,294 @@
import type { Branch, FlowDoc, FlowStep, Slot, StepKind } from "./model"
/**
* 结构树 → 坐标。纯函数,每次改动整张重排(13 个框的图不到一毫秒)。
*
* 排法照教科书上的流程图:主线竖着往下;判断的「是」往左、「否」往右,从菱形的两个角
* 横着出去再往下,两路走完合回中线;循环的条件成立往下走循环体,走完从左边绕回菱形,
* 不成立从右边出去绕到下面。每一段竖线上留一个 +(插一步的地方)。
*
* 画布稿(画布「画流程图重设计」页 FlowEdB)是用同一套排法的 Python 版画的,两边的
* 常量对得上。
*/
export type NodeKind = StepKind | "start" | "end"
export interface LaidNode {
id: string
kind: NodeKind
text: string
/** 左上角 */
x: number
y: number
w: number
h: number
}
export interface LaidSlot {
key: string
slot: Slot
x: number
y: number
}
export interface LaidLabel {
x: number
y: number
text: string
anchor: "start" | "end"
}
export interface Layout {
nodes: LaidNode[]
/** 带箭头的线、不带箭头的线(合流那几段) */
arrows: string[]
lines: string[]
dots: { x: number; y: number }[]
labels: LaidLabel[]
slots: LaidSlot[]
width: number
height: number
}
const H = 32
const DH = 40
/** 两步之间的竖线长度,中间放 + */
const G = 30
/** 判断左右两支之间至少空这么多 */
const HG = 32
const PAD = 28
export const PLACEHOLDER: Record<StepKind, string> = {
input: "输入什么",
output: "输出什么",
process: "做什么",
if: "条件",
while: "条件",
}
type Measure = (text: string) => number
type Item = FlowStep | { kind: "start" | "end"; id: string; text: string }
interface Extent {
L: number
R: number
h: number
}
export function layoutDoc(doc: FlowDoc, measure: Measure): Layout {
const shown = (step: Item) =>
step.text.trim() || (step.kind in PLACEHOLDER ? PLACEHOLDER[step.kind as StepKind] : "")
const nodeW = (step: Item) => {
const t = measure(shown(step))
if (step.kind === "if" || step.kind === "while") return Math.max(t + 64, 104)
if (step.kind === "input" || step.kind === "output") return Math.max(t + 44, 100)
if (step.kind === "start" || step.kind === "end") return Math.max(t + 40, 80)
return Math.max(t + 32, 92)
}
const extentCache = new Map<Item, Extent>()
const measureBlock = (items: Item[]): Extent => {
let L = 0
let R = 0
let h = 0
items.forEach((item, i) => {
const e = measureItem(item)
L = Math.max(L, e.L)
R = Math.max(R, e.R)
h += e.h + (i ? G : 0)
})
return { L, R, h }
}
const ifGeom = (step: Extract<FlowStep, { kind: "if" }>) => {
const dw = nodeW(step)
const yes = measureBlock(step.yes)
const no = measureBlock(step.no)
const lx = step.yes.length ? Math.min(-dw / 2 - 16, -HG / 2 - yes.R) : -dw / 2 - 16
const rx = step.no.length ? Math.max(dw / 2 + 16, HG / 2 + no.L) : dw / 2 + 16
const bh = Math.max(yes.h, no.h, 24)
return { dw, yes, no, lx, rx, bh }
}
const measureItem = (item: Item): Extent => {
const cached = extentCache.get(item)
if (cached) return cached
let e: Extent
if (item.kind === "if") {
const { dw, yes, no, lx, rx, bh } = ifGeom(item)
e = {
L: Math.max(dw / 2, -lx + yes.L),
R: Math.max(dw / 2, rx + no.R),
h: DH + 24 + bh + 22,
}
} else if (item.kind === "while") {
const dw = nodeW(item)
const body = measureBlock(item.body)
e = {
L: Math.max(dw / 2, body.L) + 28,
R: Math.max(dw / 2, body.R) + 28,
h: DH + G + Math.max(body.h, 8) + 20 + 20,
}
} else {
const w = nodeW(item)
e = { L: w / 2, R: w / 2, h: H }
}
extentCache.set(item, e)
return e
}
const out: Layout = {
nodes: [],
arrows: [],
lines: [],
dots: [],
labels: [],
slots: [],
width: 0,
height: 0,
}
const pathOf = (pts: [number, number][]) =>
"M" + pts.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(" L")
const arrow = (pts: [number, number][]) => out.arrows.push(pathOf(pts))
const line = (pts: [number, number][]) => out.lines.push(pathOf(pts))
const slot = (parent: string | null, branch: Branch, index: number, x: number, y: number) =>
out.slots.push({
key: `${parent ?? "main"}:${branch}:${index}`,
slot: { parent, branch, index },
x,
y,
})
const place = (item: Item, cx: number, y: number) => {
const w = nodeW(item)
const h = item.kind === "if" || item.kind === "while" ? DH : H
out.nodes.push({ id: item.id, kind: item.kind, text: item.text, x: cx - w / 2, y, w, h })
}
/**
* 竖着排一串。main:两头是开始 / 结束,第 j 个缝对应 steps 的第 j-1 个位置;
* 分支:第 i 个缝就是第 i 个位置(头尾的 + 由判断 / 循环自己放)
*/
const drawBlock = (
items: Item[],
cx: number,
y: number,
parent: string | null,
branch: Branch,
): number => {
const offset = branch === "main" ? -1 : 0
items.forEach((item, i) => {
if (i) {
arrow([
[cx, y - G],
[cx, y - 1],
])
slot(parent, branch, i + offset, cx, y - G + 11)
}
drawItem(item, cx, y)
y += measureItem(item).h + (i < items.length - 1 ? G : 0)
})
return y
}
const drawItem = (item: Item, cx: number, y: number) => {
if (item.kind === "if") {
const { dw, lx, rx, bh } = ifGeom(item)
place(item, cx, y)
const my = y + DH / 2
const top = y + DH + 24
const merge = top + bh + 22
const sides = [
{ label: "是", bx: cx + lx, steps: item.yes, branch: "yes" as const, corner: cx - dw / 2 },
{ label: "否", bx: cx + rx, steps: item.no, branch: "no" as const, corner: cx + dw / 2 },
]
for (const side of sides) {
if (side.steps.length) {
arrow([
[side.corner, my],
[side.bx, my],
[side.bx, top - 1],
])
slot(item.id, side.branch, 0, side.bx, my + 22)
const end = drawBlock(side.steps, side.bx, top, item.id, side.branch)
slot(item.id, side.branch, side.steps.length, side.bx, end + 11)
line([
[side.bx, end],
[side.bx, merge],
[cx, merge],
])
} else {
line([
[side.corner, my],
[side.bx, my],
[side.bx, merge],
[cx, merge],
])
slot(item.id, side.branch, 0, side.bx, (my + merge) / 2)
}
out.labels.push({
x: side.label === "是" ? side.corner - 6 : side.corner + 6,
y: my - 7,
text: side.label,
anchor: side.label === "是" ? "end" : "start",
})
}
out.dots.push({ x: cx, y: merge })
return
}
if (item.kind === "while") {
const dw = nodeW(item)
const body = measureBlock(item.body)
place(item, cx, y)
const my = y + DH / 2
const top = y + DH + G
;(item.body.length ? arrow : line)([
[cx, y + DH],
[cx, item.body.length ? top - 1 : top + 8],
])
out.labels.push({ x: cx + 16, y: y + DH + 15, text: "是", anchor: "start" })
slot(item.id, "body", 0, cx, y + DH + 12)
let end = top + 8
if (item.body.length) {
end = drawBlock(item.body, cx, top, item.id, "body")
slot(item.id, "body", item.body.length, cx, end + 10)
}
const L = Math.max(dw / 2, body.L) + 28
const R = Math.max(dw / 2, body.R) + 28
const back = end + 20
arrow([
[cx, end],
[cx, back],
[cx - L, back],
[cx - L, my],
[cx - dw / 2 - 1, my],
])
const exit = back + 20
line([
[cx + dw / 2, my],
[cx + R, my],
[cx + R, exit],
[cx, exit],
])
out.labels.push({ x: cx + dw / 2 + 6, y: my - 7, text: "否", anchor: "start" })
return
}
place(item, cx, y)
}
const items: Item[] = [
{ kind: "start", id: "__start", text: "开始" },
...doc.steps,
{ kind: "end", id: "__end", text: "结束" },
]
const ext = measureBlock(items)
// 竖线和 + 的位置跟着 doc.steps 走:空图时开始和结束之间也要有一个 +
const cx = PAD + ext.L
drawBlock(items, cx, PAD, null, "main")
out.width = ext.L + ext.R + PAD * 2
out.height = ext.h + PAD * 2
return out
}
@@ -0,0 +1,248 @@
/**
* 流程图的数据:一棵结构树,不是「一堆框 + 一堆线」。
*
* 原来的编辑器(vue-flow)存的是自由摆放的节点和学生手拉的连线,能画出断线、孤立的框、
* 一个「是」连出去三根线 —— 学生大半时间花在拖框、对连接点、理线上。现在只有三种结构:
* 顺序、判断(是 / 否两路,走完合回来)、循环(当型,条件成立就走一遍循环体再回来判断),
* 「开始」「结束」固定在两头不进数据。画不出来的只有乱跳的线,中职教的三种结构都画得出。
*
* 存进数据库(flowchart_submission.flowchart_data,压缩过)和本地草稿的都是 FlowDoc。
* 2026-10 之前的提交存的是 vue-flow 的 nodes / edges,parseDoc 认不出来就是 null ——
* 那些只能看 Mermaid 渲染出来的图,载不回画布。
*/
export type BoxKind = "input" | "output" | "process"
export type StepKind = BoxKind | "if" | "while"
interface StepBase {
id: string
text: string
}
export type BoxStep = StepBase & { kind: BoxKind }
export type IfStep = StepBase & { kind: "if"; yes: FlowStep[]; no: FlowStep[] }
export type WhileStep = StepBase & { kind: "while"; body: FlowStep[] }
export type FlowStep = BoxStep | IfStep | WhileStep
export interface FlowDoc {
version: 2
steps: FlowStep[]
}
/** 一个步骤列表在树里的位置:主线,或者某个判断 / 循环的哪一支 */
export type Branch = "main" | "yes" | "no" | "body"
/** 可以插一步的地方:某个列表的第 index 个位置之前(index = 长度就是末尾) */
export interface Slot {
parent: string | null
branch: Branch
index: number
}
export const STEP_LABEL: Record<StepKind, string> = {
input: "输入",
output: "输出",
process: "处理",
if: "判断",
while: "循环",
}
export function emptyDoc(): FlowDoc {
return { version: 2, steps: [] }
}
let seq = 0
function newId() {
seq = (seq + 1) % 1_000_000
return `s${Date.now().toString(36)}${seq.toString(36)}`
}
export function newStep(kind: StepKind): FlowStep {
const base = { id: newId(), text: "" }
if (kind === "if") return { ...base, kind, yes: [], no: [] }
if (kind === "while") return { ...base, kind, body: [] }
return { ...base, kind }
}
export function cloneDoc(doc: FlowDoc): FlowDoc {
return JSON.parse(JSON.stringify(doc)) as FlowDoc
}
/** 按 id 找一步,顺带带出它所在的列表 */
export function findStep(
steps: FlowStep[],
id: string,
): { step: FlowStep; list: FlowStep[]; index: number } | null {
for (let index = 0; index < steps.length; index++) {
const step = steps[index]!
if (step.id === id) return { step, list: steps, index }
for (const child of children(step)) {
const found = findStep(child, id)
if (found) return found
}
}
return null
}
function children(step: FlowStep): FlowStep[][] {
if (step.kind === "if") return [step.yes, step.no]
if (step.kind === "while") return [step.body]
return []
}
function listAt(doc: FlowDoc, parent: string | null, branch: Branch): FlowStep[] | null {
if (parent === null) return branch === "main" ? doc.steps : null
const found = findStep(doc.steps, parent)
if (!found) return null
const step = found.step
if (step.kind === "if" && (branch === "yes" || branch === "no")) return step[branch]
if (step.kind === "while" && branch === "body") return step.body
return null
}
/** 在 slot 处插一步,原地改 doc,返回新那一步 */
export function insertStep(doc: FlowDoc, slot: Slot, kind: StepKind): FlowStep | null {
const list = listAt(doc, slot.parent, slot.branch)
if (!list) return null
const step = newStep(kind)
list.splice(Math.max(0, Math.min(slot.index, list.length)), 0, step)
return step
}
/** 删一步(判断 / 循环连同里面的一起删),返回一共删掉几个框 */
export function removeStep(doc: FlowDoc, id: string): number {
const found = findStep(doc.steps, id)
if (!found) return 0
found.list.splice(found.index, 1)
return countSteps([found.step])
}
export function countSteps(steps: FlowStep[]): number {
return steps.reduce(
(sum, step) => sum + 1 + children(step).reduce((n, c) => n + countSteps(c), 0),
0,
)
}
/** 还没写字的框有几个 */
export function countBlank(steps: FlowStep[]): number {
return steps.reduce(
(sum, step) =>
sum + (step.text.trim() ? 0 : 1) + children(step).reduce((n, c) => n + countBlank(c), 0),
0,
)
}
/**
* 一张图「长什么样」,不带 id:载回旧版、和交过的那一版比,只看结构和字。
*/
export function signature(doc: FlowDoc): string {
const strip = (steps: FlowStep[]): unknown[] =>
steps.map((step) => [step.kind, step.text.trim(), ...children(step).map(strip)])
return JSON.stringify(strip(doc.steps))
}
// ==================== 读回来的数据 ====================
const KINDS = new Set<string>(["input", "output", "process", "if", "while"])
function parseSteps(raw: unknown): FlowStep[] | null {
if (!Array.isArray(raw)) return null
const out: FlowStep[] = []
for (const item of raw) {
if (!item || typeof item !== "object") return null
const step = item as Record<string, unknown>
const kind = step.kind
if (typeof kind !== "string" || !KINDS.has(kind)) return null
const id = typeof step.id === "string" && step.id ? step.id : newId()
const text = typeof step.text === "string" ? step.text : ""
if (kind === "if") {
const yes = parseSteps(step.yes ?? [])
const no = parseSteps(step.no ?? [])
if (!yes || !no) return null
out.push({ id, text, kind, yes, no })
} else if (kind === "while") {
const body = parseSteps(step.body ?? [])
if (!body) return null
out.push({ id, text, kind, body })
} else {
out.push({ id, text, kind: kind as BoxKind })
}
}
return out
}
/** 本地草稿、交过的某一版:是新格式就读出来,旧的 vue-flow 格式或者坏数据给 null */
export function parseDoc(raw: unknown): FlowDoc | null {
if (!raw || typeof raw !== "object") return null
const data = raw as Record<string, unknown>
if (data.version !== 2) return null
const steps = parseSteps(data.steps)
return steps ? { version: 2, steps } : null
}
// ==================== Mermaid ====================
/**
* 交给 AI 的、结果页签和提交列表里渲染缩略图的,都是这份 Mermaid。
*
* 形状照国标流程图:起止是圆角跑道、输入输出是平行四边形、处理是矩形、判断是菱形。
* 原来起止画成圆、输入输出和画布上的样子对不上,AI 还会说「输出框应该用矩形」。
* class 行留着给 useMermaid 上色;classDef 不写了 —— 颜色是渲染那边的事,写进来
* AI 会把「用了颜色样式」也当成优点打分。
*
* 学生敲的字直接塞进 "...",一个双引号就会把语法撑破:坏掉的代码还会原样送去打分,
* 学生不知道扣分是因为一个引号。`#` 必须先转,否则本来就有的 `#quot;` 会被当成实体。
*/
function escapeLabel(raw: string) {
return raw.replace(/#/g, "#35;").replace(/"/g, "#quot;").replace(/\r?\n/g, "<br/>")
}
export function toMermaid(doc: FlowDoc): string {
const lines: string[] = ["flowchart TD"]
const classes: string[] = []
let n = 0
const node = (shape: (label: string) => string, text: string, cls: string) => {
const id = `n${n++}`
lines.push(` ${id}${shape(escapeLabel(text.trim() || " "))}`)
classes.push(` class ${id} ${cls}`)
return id
}
/** 还没接上下一步的出口:从哪个框出来、线上写什么 */
type Exit = { from: string; label?: string }
const link = (exits: Exit[], to: string) => {
for (const exit of exits) {
lines.push(
exit.label ? ` ${exit.from} -->|"${exit.label}"| ${to}` : ` ${exit.from} --> ${to}`,
)
}
}
const walk = (steps: FlowStep[], entries: Exit[]): Exit[] => {
let exits = entries
for (const step of steps) {
if (step.kind === "if") {
const id = node((l) => `{"${l}"}`, step.text, "decision")
link(exits, id)
exits = [
...walk(step.yes, [{ from: id, label: "是" }]),
...walk(step.no, [{ from: id, label: "否" }]),
]
} else if (step.kind === "while") {
const id = node((l) => `{"${l}"}`, step.text, "decision")
link(exits, id)
link(walk(step.body, [{ from: id, label: "是" }]), id)
exits = [{ from: id, label: "否" }]
} else {
const shape =
step.kind === "process" ? (l: string) => `["${l}"]` : (l: string) => `[/"${l}"/]`
const id = node(shape, step.text, step.kind)
link(exits, id)
exits = [{ from: id }]
}
}
return exits
}
const start = node((l) => `(["${l}"])`, "开始", "startNode")
const exits = walk(doc.steps, [{ from: start }])
const end = node((l) => `(["${l}"])`, "结束", "endNode")
link(exits, end)
return [...lines, "", ...classes].join("\n")
}
@@ -1,45 +0,0 @@
import type { Edge, Node } from "@vue-flow/core"
/**
* 把画布上的节点/连线裁成「可持久化」的形状。
*
* 画布里的 node 是 vue-flow 的 GraphNode:除了我们自己塞进去的字段,它还挂着
* `dimensions` / `computedPosition` / `handleBounds` / `selected` / `dragging` /
* `resizing` / `initialized` / `isParent` / `events` 一堆运行时内部状态(见
* vue-flow 的 parseNode)。这些东西会跟着一起:
*
* - 写进 localStorage(每次改动都写一次)
* - 进 20 份历史快照,每份都要 JSON 深拷贝一遍
* - 压缩后提交进数据库,长期存着
*
* 实测一个两节点的图就能撑到 600+ 字节,其中大半是 handleBounds。而重新挂载时
* 这些字段全都会被重新计算,存下来没有任何意义 —— 还会把存档格式和 vue-flow
* 的内部实现绑死,将来升级或迁移数据都要跟着动。
*
* `style` 保留:它是建节点时按类型算好的(见 useNodeStyles),丢了会让恢复出来
* 的图变样。
*/
export function toPortableNodes(nodes: Node[]) {
return nodes.map((node) => ({
id: node.id,
type: node.type,
position: {
x: node.position?.x ?? 0,
y: node.position?.y ?? 0,
},
data: node.data,
style: node.style,
})) as Node[]
}
export function toPortableEdges(edges: Edge[]) {
return edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle ?? null,
targetHandle: edge.targetHandle ?? null,
type: edge.type,
label: edge.label,
})) as Edge[]
}
@@ -1,109 +0,0 @@
import { ref, toValue, watch, type Ref, type MaybeRefOrGetter } from "vue"
import { useStorage, useDebounceFn } from "@vueuse/core"
import type { Node, Edge } from "@vue-flow/core"
import { toPortableEdges, toPortableNodes } from "./serialize"
/**
* 缓存管理 - 使用 @vueuse 的 useStorage
*/
export function useCache(
nodes: Ref<Node[]>,
edges: Ref<Edge[]>,
storageKey: MaybeRefOrGetter<string> = "flowchart-editor-data",
onReloaded?: () => void,
) {
const isSaving = ref(false)
const lastSaved = ref<Date | null>(null)
const hasUnsavedChanges = ref(false)
// 使用 useStorage 管理数据存储,支持响应式 key(题目 ID 异步加载时自动切换)
const storedData = useStorage<{
nodes: Node[]
edges: Edge[]
timestamp: string
}>(storageKey, {
nodes: [],
edges: [],
timestamp: "",
})
// 防抖保存:isSaving 在 watch 中置 true,保存完成后置 false,使 UI 能感知保存中状态
const debouncedSave = useDebounceFn(() => {
storedData.value.nodes = toPortableNodes(nodes.value)
storedData.value.edges = toPortableEdges(edges.value)
storedData.value.timestamp = new Date().toISOString()
lastSaved.value = new Date()
hasUnsavedChanges.value = false
isSaving.value = false
}, 500)
// 立即保存
const saveToCache = () => {
isSaving.value = true
storedData.value.nodes = toPortableNodes(nodes.value)
storedData.value.edges = toPortableEdges(edges.value)
storedData.value.timestamp = new Date().toISOString()
lastSaved.value = new Date()
hasUnsavedChanges.value = false
isSaving.value = false
}
// 从缓存加载数据
const loadFromCache = () => {
if (storedData.value.nodes?.length || storedData.value.edges?.length) {
nodes.value = storedData.value.nodes
edges.value = storedData.value.edges
lastSaved.value = storedData.value.timestamp ? new Date(storedData.value.timestamp) : null
hasUnsavedChanges.value = false
return true
}
return false
}
// 清除缓存数据
const clearCache = () => {
storedData.value = { nodes: [], edges: [], timestamp: "" }
lastSaved.value = null
hasUnsavedChanges.value = false
}
// 题目 ID 异步加载完成、或直接切到下一题时 storageKey 会变。
// useStorage 只把新 key 的内容读进 storedData,不会回填 nodes/edges:
// 不处理的话画布会继续显示上一题的图,学生一动就把上一题的内容写进这一题的
// key,把这道题原本存着的草稿覆盖掉。
// 这里依赖 useStorage 内部对 key 的 watch 先于本 watch 执行(两者都是 pre
// flush,且 useStorage 在上方先创建,pre 队列按创建顺序跑),
// 因此此刻 storedData 已经是新 key 的数据。
watch(
() => toValue(storageKey),
() => {
if (!loadFromCache()) {
nodes.value = []
edges.value = []
lastSaved.value = null
hasUnsavedChanges.value = false
}
onReloaded?.()
},
)
// 监听节点和边的变化,isSaving 在此置 true 以覆盖防抖等待窗口
watch(
[nodes, edges],
() => {
hasUnsavedChanges.value = true
isSaving.value = true
debouncedSave()
},
{ deep: true },
)
return {
isSaving,
lastSaved,
hasUnsavedChanges,
saveToCache,
loadFromCache,
clearCache,
}
}
@@ -1,80 +0,0 @@
import { ref } from "vue"
import { useVueFlow } from "@vue-flow/core"
import { getNodeTypeConfig, createNodeStyle, getNodeDimensions } from "./useNodeStyles"
import { getRandomId } from "utils/functions"
// 模块级共享:当前拖拽的节点类型(Toolbar 写入,canvas 读取)
export const currentDragNodeType = ref<string | null>(null)
/**
* 简化的拖拽处理
*/
export function useDnD() {
const { addNodes, screenToFlowCoordinate } = useVueFlow()
const isDragOver = ref(false)
const screenDragPos = ref<{ x: number; y: number } | null>(null)
// 拖拽悬停处理
const onDragOver = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = true
screenDragPos.value = { x: event.clientX, y: event.clientY }
}
// 拖拽离开处理
const onDragLeave = () => {
isDragOver.value = false
screenDragPos.value = null
}
// 拖拽放置处理
const onDrop = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
screenDragPos.value = null
currentDragNodeType.value = null
const type = event.dataTransfer?.getData("application/vueflow")
if (!type) return
// 获取鼠标在画布中的坐标
const position = screenToFlowCoordinate({
x: event.clientX,
y: event.clientY,
})
// 根据节点类型获取实际尺寸
const dimensions = getNodeDimensions(type)
// 调整位置,使节点中心点对齐到鼠标位置
const adjustedPosition = {
x: position.x - dimensions.width / 2,
y: position.y - dimensions.height / 2,
}
const nodeId = `node-${getRandomId()}`
const config = getNodeTypeConfig(type)
const newNode = {
id: nodeId,
type: "custom",
position: adjustedPosition,
data: {
label: config.label,
color: config.color,
originalType: type,
},
style: createNodeStyle(type),
}
addNodes([newNode])
return newNode
}
return {
isDragOver,
screenDragPos,
onDragOver,
onDragLeave,
onDrop,
}
}
@@ -1,139 +0,0 @@
import { nextTick } from "vue"
import type { Ref } from "vue"
import type { Node, Edge, Connection } from "@vue-flow/core"
import { useVueFlow } from "@vue-flow/core"
import { getRandomId } from "utils/functions"
export function useFlowOperations(
nodes: Ref<Node[]>,
edges: Ref<Edge[]>,
addEdges: (edges: Edge[]) => void,
removeNodes: (nodeIds: string[]) => void,
removeEdges: (edgeIds: string[]) => void,
saveState: (nodes: Node[], edges: Edge[]) => void,
) {
const { findNode, getSelectedNodes, getSelectedEdges } = useVueFlow()
const getAutoLabel = (
sourceNode: Node | undefined,
targetNode: Node | undefined,
sourceHandle: string | null | undefined,
targetHandle: string | null | undefined,
) => {
const sourceType = sourceNode?.data?.originalType || sourceNode?.type
const targetType = targetNode?.data?.originalType || targetNode?.type
// 如果是判断节点
if (sourceType === "decision") {
// 根据handle ID推断标签
if (sourceHandle === "yes") {
return "是"
} else if (sourceHandle === "no") {
return "否"
}
}
// 如果是循环节点
if (sourceType === "loop") {
// 根据handle ID推断标签
if (sourceHandle === "continue") {
return "继续"
} else if (sourceHandle === "exit") {
return "退出"
}
}
// 如果是循环体回到循环节点
if (targetType === "loop") {
if (targetHandle === "return") {
return "返回"
}
}
// 默认情况
return ""
}
const handleConnect = async (params: Connection) => {
const sourceNode = nodes.value.find((node) => node.id === params.source)
const targetNode = nodes.value.find((node) => node.id === params.target)
// 自动推断标签
const autoLabel = getAutoLabel(sourceNode, targetNode, params.sourceHandle, params.targetHandle)
const newEdge: Edge = {
id: `edge-${getRandomId()}`,
source: params.source,
target: params.target,
sourceHandle: params.sourceHandle,
targetHandle: params.targetHandle,
type: "default",
label: autoLabel,
}
addEdges([newEdge])
// vue-flow 的 store → v-model 回写走的是 watch(pre flush,异步),
// 紧接着读 nodes/edges 拿到的还是改动前的数组,存进历史就会错开一步。
// 画布上 handleDrop 早就这么等了,这几处一直漏了。
await nextTick()
saveState(nodes.value, edges.value)
}
const handleEdgeClick = async ({ edge }: { edge: Edge }) => {
removeEdges([edge.id])
await nextTick()
saveState(nodes.value, edges.value)
}
// 节点删除。removeNodes 的 removeConnectedEdges 默认就是 true,
// 相连的边不用自己再删一遍
const handleNodeDelete = async (nodeId: string) => {
removeNodes([nodeId])
await nextTick()
saveState(nodes.value, edges.value)
}
// 节点更新,空标签时清除自定义标签(恢复默认类型名称)
const handleNodeUpdate = (nodeId: string, newLabel: string) => {
const node = findNode(nodeId)
if (node) {
if (newLabel) {
node.data = { ...node.data, customLabel: newLabel }
} else {
const { customLabel: _, ...rest } = node.data
node.data = rest
}
saveState(nodes.value, edges.value)
}
}
// 清空画布
const clearCanvas = () => {
nodes.value = []
edges.value = []
saveState(nodes.value, edges.value)
}
// 删除选中的节点和边
const deleteSelected = async () => {
const selectedNodes = getSelectedNodes.value
const selectedEdges = getSelectedEdges.value
if (selectedNodes.length === 0 && selectedEdges.length === 0) return
if (selectedNodes.length > 0) {
removeNodes(selectedNodes.map((node) => node.id))
}
if (selectedEdges.length > 0) {
removeEdges(selectedEdges.map((edge) => edge.id))
}
await nextTick()
saveState(nodes.value, edges.value)
}
return {
handleConnect,
handleEdgeClick,
handleNodeDelete,
handleNodeUpdate,
clearCanvas,
deleteSelected,
}
}
@@ -1,86 +0,0 @@
import { shallowRef, computed } from "vue"
import type { Node, Edge } from "@vue-flow/core"
import { toPortableEdges, toPortableNodes } from "./serialize"
/**
* 简化的历史记录管理
*/
export function useHistory() {
const history = shallowRef<{ nodes: Node[]; edges: Edge[] }[]>([])
const historyIndex = ref(-1)
// 是否可以撤销
const canUndo = computed(() => historyIndex.value > 0)
// 是否可以重做
const canRedo = computed(() => historyIndex.value < history.value.length - 1)
const deepCopyState = (nodes: Node[], edges: Edge[]): { nodes: Node[]; edges: Edge[] } =>
// 先裁掉 vue-flow 的运行时内部字段再深拷贝:20 份快照 × 每个节点几百字节的
// handleBounds/dimensions,纯属白拷
JSON.parse(
JSON.stringify({
nodes: toPortableNodes(nodes),
edges: toPortableEdges(edges),
}),
) as {
nodes: Node[]
edges: Edge[]
}
// 保存状态到历史记录
const saveState = (nodes: Node[], edges: Edge[]) => {
const currentState = deepCopyState(nodes, edges)
// 如果当前不在历史记录的末尾,删除后面的记录
if (historyIndex.value < history.value.length - 1) {
history.value = history.value.slice(0, historyIndex.value + 1)
}
history.value = [...history.value, currentState]
historyIndex.value = history.value.length - 1
// 限制历史记录数量
if (history.value.length > 20) {
history.value = history.value.slice(1)
historyIndex.value--
}
}
// 用当前画布重建历史。挂载时和换题后都要调一次:
// 不播下这个初始快照的话 historyIndex 会从 -1 开始,canUndo 要求 index > 0,
// 第一步操作永远撤销不了;换题后不重建则一次撤销会把上一题的图还原到这一题里。
const resetHistory = (nodes: Node[], edges: Edge[]) => {
history.value = [deepCopyState(nodes, edges)]
historyIndex.value = 0
}
// 撤销
const undo = () => {
if (canUndo.value) {
historyIndex.value--
const state = history.value[historyIndex.value]
return deepCopyState(state.nodes, state.edges)
}
return null
}
// 重做
const redo = () => {
if (canRedo.value) {
historyIndex.value++
const state = history.value[historyIndex.value]
return deepCopyState(state.nodes, state.edges)
}
return null
}
return {
canUndo,
canRedo,
resetHistory,
saveState,
undo,
redo,
}
}
@@ -1,137 +0,0 @@
/**
* 节点样式管理
*/
/**
* 获取节点类型配置
*/
export function getNodeTypeConfig(type: string) {
const configs: Record<
string,
{ label: string; color: string; icon: string; description: string }
> = {
start: {
label: "开始",
color: "#10b981",
icon: "▶",
description: "流程开始",
},
input: {
label: "输入",
color: "#06b6d4",
icon: "📥",
description: "数据输入",
},
default: {
label: "赋值",
color: "#3b82f6",
icon: "⚙",
description: "赋值语句",
},
decision: {
label: "判断",
color: "#f59e0b",
icon: "❓",
description: "条件语句",
},
loop: {
label: "循环",
color: "#8b5cf6",
icon: "🔄",
description: "循环语句",
},
output: {
label: "输出",
color: "#84cc16",
icon: "📤",
description: "数据输出",
},
end: {
label: "结束",
color: "#ef4444",
icon: "⏹",
description: "流程结束",
},
}
return (
configs[type] || {
label: "节点",
color: "#6b7280",
icon: "⚪",
description: "未知节点",
}
)
}
/**
* 获取节点样式
*/
export function getNodeStyle(type: string, color: string) {
const baseStyle = {
background: color,
color: "white",
border: `2px solid ${color}`,
borderRadius: "10px",
fontSize: "16px",
fontWeight: "500",
width: "auto", // 自动宽度
height: "auto", // 自动高度
minWidth: "100px",
minHeight: "40px",
maxWidth: "400px",
maxHeight: "160px",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
}
// 根据节点类型调整样式
switch (type) {
case "start":
case "end":
return {
...baseStyle,
}
case "decision":
return {
...baseStyle,
borderRadius: "8px",
minWidth: "140px",
minHeight: "50px",
}
case "loop":
return {
...baseStyle,
borderRadius: "8px",
minWidth: "140px",
minHeight: "50px",
}
default:
return baseStyle
}
}
/**
* 创建节点样式
*/
export function createNodeStyle(type: string) {
const config = getNodeTypeConfig(type)
return getNodeStyle(type, config.color)
}
/**
* 获取节点尺寸
*/
export function getNodeDimensions(type: string) {
switch (type) {
case "start":
case "end":
return { width: 100, height: 40 }
case "decision":
case "loop":
return { width: 140, height: 50 }
default:
return { width: 120, height: 40 }
}
}