Files
OJ2/apps/web/src/shared/components/FlowchartEditor/index.vue
T
xuyueandClaude Opus 5.5 11368813cc feat(流程图): 「写代码 / 画流程图」变成工具栏上的切换,AI 的评分和历次分数挪进结果页签,题单里也能画
- 流程图题的工具栏最左边是「写代码 / 画流程图」。原来流程图藏在语言下拉里,叫一个名叫
  Flowchart 的「语言」;画完一道题之后它还被记成语言偏好,下一道能画的题一打开就是画布
- 「交给 AI 点评」交完切到「结果」页签:评的时候写着「AI 正在看你的图」、画布照样能改;
  评完是分数、评语、改进建议、四项得分,顶上一排历次分数,点哪次看哪次。
  原来是提交按钮旁一个 1000px 的弹框,盖住画布,翻历次评分要一页一页点
- 画到 A / S:给「照着它写代码 →」,题单里 1.5 秒后回题单(和代码通过一样),
  课堂条当场标成做完;只在这一页上刚评完的那次才算,进页面读到以前的 A 不跳
- 「把这一版载回画布」只在画布和那一版不一样时出现;载回、进页面读回草稿后都缩放到装下整张图
- 题单入口能画流程图了(原来只有题库入口能画),题单里的代码草稿、流程图草稿各存一份,
  加入题单之前写的、画的不会一进题单就摆在编辑器里
- 手机上不给「画流程图」(节点库是拖放,手机上拖不动),题面里以前画好的那张照样看得到

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:19:16 +08:00

348 lines
9.9 KiB
Vue

<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from "vue"
import Toolbar from "./Toolbar.vue"
import "@vue-flow/core/dist/style.css"
import "@vue-flow/core/dist/theme-default.css"
import "@vue-flow/controls/dist/style.css"
import { useVueFlow, VueFlow, type Node, type Edge, MarkerType } from "@vue-flow/core"
import { Controls } from "@vue-flow/controls"
import { Background } from "@vue-flow/background"
import { useDnD, currentDragNodeType } from "./useDnD"
import { getNodeTypeConfig } from "./useNodeStyles"
import { useHistory } from "./useHistory"
import { useFlowOperations } from "./useFlowOperations"
import { useCache } from "./useCache"
import { toPortableEdges, toPortableNodes } from "./serialize"
import CustomNode from "./CustomNode.vue"
import { useProblemStore } from "oj/store/problem"
interface Props {
height?: string
}
const { height = "calc(100vh - 133px)" } = defineProps<Props>()
// Vue Flow 实例
const { addEdges, removeNodes, removeEdges, fitView, onNodesInitialized } = useVueFlow()
/**
* 进页面读回草稿、载回旧版之后缩放到正好装下整张图。原来画布停在默认的位置和缩放上,
* 图大一点就有一半在外面,学生以为草稿丢了。只在这两种时候缩:拖进一个新节点时也会
* 触发 nodes-initialized,那时候跟着缩放会让画布跳一下
*/
let fitPending = false
function fitSoon() {
fitPending = true
}
onNodesInitialized(() => {
if (!fitPending) return
fitPending = false
fitView({ padding: 0.15, maxZoom: 1.2 })
})
// 节点和边的响应式数据
// 显式标注成 Ref<Node[]>,不走 ref<T>() 的 UnwrapRef 推导 —— vue-flow 的
// Node/Edge 嵌套很深,让 TS 去调和两种形态会直接撞上「实例化层级过深」
const nodes = ref([]) as Ref<Node[]>
const edges = ref([]) as Ref<Edge[]>
// 历史记录管理
const { canUndo, canRedo, resetHistory, saveState, undo, redo } = useHistory()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const route = useRoute()
/**
* 缓存管理:用 computed key 支持题目 ID 异步加载后自动切换到正确的 storage。
* 题单入口单独一份草稿:加入题单之前在题库里画到 A 的那张,不能一进题单就摆在画布上
* (设计文档第 11 节 3,和代码草稿同理)
*/
const cacheKey = computed(() => {
if (!problem.value?._id) return "flowchart-editor-data"
const base = `flowchart-editor-data-problem-${problem.value._id}`
const problemSetId = route.params.problemSetId
return problemSetId ? `${base}-problemset-${problemSetId}` : base
})
const { isSaving, lastSaved, hasUnsavedChanges, saveToCache, loadFromCache, clearCache } = useCache(
nodes,
edges,
cacheKey,
() => {
// 换题后画布已经被换成新题的草稿,历史必须跟着重建,
// 否则一次撤销就会把上一题的图还原到这一题里
resetHistory(nodes.value, edges.value)
fitSoon()
},
)
// 拖拽处理
const { onDragOver, onDragLeave, onDrop, isDragOver, screenDragPos } = useDnD()
const dragPreviewStyle = computed(() => {
if (!screenDragPos.value || !currentDragNodeType.value) return null
const config = getNodeTypeConfig(currentDragNodeType.value)
const type = currentDragNodeType.value
return {
left: `${screenDragPos.value.x}px`,
top: `${screenDragPos.value.y}px`,
background: config.color,
borderRadius: type === "start" || type === "end" ? "20px" : "8px",
}
})
// 流程操作
const {
handleConnect,
handleEdgeClick,
handleNodeDelete,
handleNodeUpdate,
clearCanvas,
deleteSelected,
} = useFlowOperations(nodes, edges, addEdges, removeNodes, removeEdges, saveState)
const handleDrop = async (event: DragEvent) => {
const newNode = onDrop(event)
if (newNode) {
await nextTick()
saveState(nodes.value, edges.value)
}
}
// 撤销/重做处理
const handleUndo = () => {
const state = undo()
if (state) {
nodes.value = state.nodes
edges.value = state.edges
saveToCache()
}
}
const handleRedo = () => {
const state = redo()
if (state) {
nodes.value = state.nodes
edges.value = state.edges
saveToCache()
}
}
// 清空画布。工具栏那个按钮点一下就全没了,且 clearCache() 会连本题存着的草稿
// 一起删掉,刷新也找不回来 —— 学生误点的代价太大,加一道确认
const dialog = useDialog()
const handleClear = () => {
if (nodes.value.length === 0 && edges.value.length === 0) return
dialog.warning({
title: "清空画布",
content: "画布上的内容会被清掉,这道题存着的草稿也会一起删除。确定吗?",
positiveText: "清空",
negativeText: "再想想",
onPositiveClick: () => {
clearCanvas()
clearCache()
},
})
}
// 键盘事件
const handleKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)
return
if (event.key === "Delete" || event.key === "Backspace") {
deleteSelected()
}
if (event.ctrlKey || event.metaKey) {
const key = event.key.toLowerCase()
if (key === "z" && !event.shiftKey) {
event.preventDefault()
handleUndo()
} else if ((key === "z" && event.shiftKey) || key === "y") {
// 工具栏上的提示写的就是 Ctrl+Y,但原来只实现了 Ctrl+Shift+Z,按 Y 没反应
event.preventDefault()
handleRedo()
}
}
}
onMounted(() => {
document.addEventListener("keydown", handleKeyDown)
// 从缓存恢复数据,并把当前画布作为历史起点,
// 否则第一步操作没有可回退的目标,撤销按钮一直是灰的
loadFromCache()
resetHistory(nodes.value, edges.value)
fitSoon()
})
onUnmounted(() => {
document.removeEventListener("keydown", handleKeyDown)
})
// 加载外部数据到编辑器
const setFlowchartData = (data: { nodes: Node[]; edges: Edge[] }) => {
if (data && data.nodes && data.edges) {
// 确保节点数据包含必要的位置信息
const processedNodes = data.nodes.map((node) => ({
...node,
position: node.position || { x: 0, y: 0 },
}))
// 确保边数据包含必要的 handle 信息
const processedEdges = data.edges.map((edge) => ({
...edge,
sourceHandle: edge.sourceHandle || null,
targetHandle: edge.targetHandle || null,
}))
nodes.value = processedNodes
edges.value = processedEdges
saveState(nodes.value, edges.value)
fitSoon()
}
}
// 暴露节点和边数据给父组件
defineExpose({
nodes,
edges,
// 提交出去的这份会压缩后长期存进数据库,同样裁掉运行时内部字段
getFlowchartData: () => ({
nodes: toPortableNodes(nodes.value),
edges: toPortableEdges(edges.value),
}),
setFlowchartData,
})
</script>
<template>
<div class="container" :style="{ height }">
<!-- 拖拽时跟随鼠标的节点预览 -->
<Transition name="drag-preview">
<div
v-if="isDragOver && dragPreviewStyle && currentDragNodeType"
class="drag-node-preview"
:style="dragPreviewStyle"
>
<span class="preview-icon">{{ getNodeTypeConfig(currentDragNodeType).icon }}</span>
<span>{{ getNodeTypeConfig(currentDragNodeType).label }}</span>
</div>
</Transition>
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="handleDrop"
@connect="handleConnect"
@edge-click="handleEdgeClick"
:default-edge-options="{
type: 'default',
style: {
stroke: '#6366f1',
strokeWidth: 2.5,
cursor: 'pointer',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#6366f1',
width: 16,
height: 16,
},
}"
:connection-line-style="{
stroke: '#6366f1',
strokeWidth: 2.5,
strokeDasharray: '8,4',
markerEnd: 'url(#connection-arrow)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
}"
:fit-view-on-init="false"
:connect-on-click="false"
:multi-selection-key-code="null"
:delete-key-code="null"
>
<!-- SVG 定义用于连接线箭头 -->
<defs>
<marker
id="connection-arrow"
markerWidth="12"
markerHeight="12"
refX="10"
refY="3"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L10,3 z" fill="#6366f1" stroke="#6366f1" strokeWidth="0.5" />
</marker>
</defs>
<template #node-custom="{ data, id, type }">
<CustomNode
:id="id"
:type="type"
:data="data"
@delete="handleNodeDelete"
@update="handleNodeUpdate"
/>
</template>
<Background variant="lines" :gap="20" :size="1" />
<Controls />
<Toolbar
:can-undo="canUndo"
:can-redo="canRedo"
:is-saving="isSaving"
:last-saved="lastSaved"
:has-unsaved-changes="hasUnsavedChanges"
@clear="handleClear"
@undo="handleUndo"
@redo="handleRedo"
@deleteNode="handleNodeDelete"
/>
</VueFlow>
</div>
</template>
<style scoped>
.container {
width: 100%;
height: 100%;
position: relative;
}
.drag-node-preview {
position: fixed;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 9999;
padding: 8px 18px;
color: white;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
opacity: 0.55;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
white-space: nowrap;
border: 2px dashed rgba(255, 255, 255, 0.6);
user-select: none;
}
.preview-icon {
font-size: 16px;
}
.drag-preview-enter-active,
.drag-preview-leave-active {
transition: opacity 0.1s ease;
}
.drag-preview-enter-from,
.drag-preview-leave-to {
opacity: 0;
}
</style>