fix flowchart
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled

This commit is contained in:
2026-05-07 06:09:05 -06:00
parent c11c3cf226
commit 6a31a47c5d
10 changed files with 151 additions and 77 deletions

View File

@@ -16,14 +16,6 @@ body {
overflow: auto; overflow: auto;
border: 1px solid rgba(148, 163, 184, 0.24); border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 8px; border-radius: 8px;
background:
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
linear-gradient(135deg, #ffffff 0%, #f8fafc 52%, #eef6ff 100%);
background-size:
24px 24px,
24px 24px,
auto;
} }
.oj-mermaid-surface > svg { .oj-mermaid-surface > svg {

View File

@@ -69,8 +69,6 @@ const page = ref(1)
// ==================== WebSocket 相关函数 ==================== // ==================== WebSocket 相关函数 ====================
// 处理 WebSocket 消息 // 处理 WebSocket 消息
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => { const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
console.log("收到流程图评分更新:", data)
if (data.type === "flowchart_evaluation_completed") { if (data.type === "flowchart_evaluation_completed") {
loading.value = false loading.value = false
latestRating.value = { latestRating.value = {
@@ -79,11 +77,8 @@ const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
} }
message.success(`流程图评分完成!得分: ${data.score}分 (${data.grade}级)`) message.success(`流程图评分完成!得分: ${data.score}分 (${data.grade}级)`)
} else if (data.type === "flowchart_evaluation_failed") { } else if (data.type === "flowchart_evaluation_failed") {
console.log("处理评分失败消息")
loading.value = false loading.value = false
message.error(`流程图评分失败: ${data.error}`) message.error(`流程图评分失败: ${data.error}`)
} else {
console.log("未知的消息类型:", data.type)
} }
} }
@@ -94,7 +89,6 @@ const { connect, disconnect, subscribe } = useFlowchartWebSocket(
// 订阅提交更新 // 订阅提交更新
function subscribeToSubmission(submissionId: string) { function subscribeToSubmission(submissionId: string) {
console.log("开始订阅WebSocket更新")
subscribe(submissionId) subscribe(submissionId)
} }
@@ -287,7 +281,6 @@ onUnmounted(() => {
<n-grid :cols="5" :x-gap="16"> <n-grid :cols="5" :x-gap="16">
<!-- 左侧流程图预览区域 --> <!-- 左侧流程图预览区域 -->
<n-gi :span="3"> <n-gi :span="3">
<n-card title="流程图预览">
<div class="flowchart"> <div class="flowchart">
<n-spin :show="rendering"> <n-spin :show="rendering">
<n-alert v-if="renderError" type="error" title="流程图渲染失败"> <n-alert v-if="renderError" type="error" title="流程图渲染失败">
@@ -296,7 +289,6 @@ onUnmounted(() => {
<div class="flowchart" v-else ref="mermaidContainer"></div> <div class="flowchart" v-else ref="mermaidContainer"></div>
</n-spin> </n-spin>
</div> </div>
</n-card>
<!-- 加载到编辑器按钮 --> <!-- 加载到编辑器按钮 -->
<n-flex style="margin-top: 16px" justify="center"> <n-flex style="margin-top: 16px" justify="center">
<n-button @click="loadToEditor" type="primary"> <n-button @click="loadToEditor" type="primary">

View File

@@ -1,5 +1,5 @@
<template> <template>
<n-button v-if="showLink" type="info" text @click="goto"> <n-button v-if="showLink" type="info" text @click="handleClick">
{{ flowchart.id.slice(0, 12) }} {{ flowchart.id.slice(0, 12) }}
</n-button> </n-button>
<n-text v-else class="flowchart-id" @click="handleClick"> <n-text v-else class="flowchart-id" @click="handleClick">
@@ -7,8 +7,8 @@
</n-text> </n-text>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { FlowchartSubmissionListItem } from "utils/types"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { FlowchartSubmissionListItem } from "utils/types"
const userStore = useUserStore() const userStore = useUserStore()
@@ -27,10 +27,6 @@ const showLink = computed(() => {
return props.flowchart.username === userStore.user?.username return props.flowchart.username === userStore.user?.username
}) })
function goto() {
emit("showDetail", props.flowchart.id)
}
function handleClick() { function handleClick() {
emit("showDetail", props.flowchart.id) emit("showDetail", props.flowchart.id)
} }

View File

@@ -4,7 +4,7 @@
:class="{ 'is-hovered': isHovered, 'is-editing': isEditing }" :class="{ 'is-hovered': isHovered, 'is-editing': isEditing }"
:data-node-type="nodeType" :data-node-type="nodeType"
:draggable="!isEditing" :draggable="!isEditing"
@mouseenter="isHovered = true" @mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave" @mouseleave="handleMouseLeave"
@dblclick="handleDoubleClick" @dblclick="handleDoubleClick"
@dragstart="handleDragStart" @dragstart="handleDragStart"
@@ -53,11 +53,17 @@ import { getNodeTypeConfig } from "./useNodeStyles"
import NodeHandles from "./NodeHandles.vue" import NodeHandles from "./NodeHandles.vue"
import NodeActions from "./NodeActions.vue" import NodeActions from "./NodeActions.vue"
// 类型定义 interface NodeData {
label: string
color: string
originalType: string
customLabel?: string
}
interface Props { interface Props {
id: string id: string
type: string type: string
data: any data: NodeData
} }
interface Emits { interface Emits {
@@ -147,6 +153,7 @@ const handleCancelEdit = () => {
} }
const handleMouseEnter = () => { const handleMouseEnter = () => {
isHovered.value = true
if (hideTimeout) { if (hideTimeout) {
clearTimeout(hideTimeout) clearTimeout(hideTimeout)
hideTimeout = null hideTimeout = null

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { computed } from "vue"
import { getNodeTypeConfig } from "./useNodeStyles" import { getNodeTypeConfig } from "./useNodeStyles"
import { currentDragNodeType } from "./useDnD"
// 拖拽开始处理 // 拖拽开始处理
const onDragStart = (event: DragEvent, type: string) => { const onDragStart = (event: DragEvent, type: string) => {
@@ -8,6 +9,17 @@ const onDragStart = (event: DragEvent, type: string) => {
event.dataTransfer.setData("application/vueflow", type) event.dataTransfer.setData("application/vueflow", type)
event.dataTransfer.effectAllowed = "move" 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 // Props
@@ -42,8 +54,7 @@ const nodeTypes = computed(() =>
), ),
) )
// 获取保存状态标题 const saveStatusTitle = computed(() => {
const getSaveStatusTitle = () => {
if (props.isSaving) { if (props.isSaving) {
return "正在保存..." return "正在保存..."
} else if (props.hasUnsavedChanges) { } else if (props.hasUnsavedChanges) {
@@ -53,7 +64,7 @@ const getSaveStatusTitle = () => {
} else { } else {
return "已保存" return "已保存"
} }
} })
</script> </script>
<template> <template>
<div class="toolbar"> <div class="toolbar">
@@ -68,7 +79,7 @@ const getSaveStatusTitle = () => {
unsaved: props.hasUnsavedChanges && !props.isSaving, unsaved: props.hasUnsavedChanges && !props.isSaving,
saved: !props.hasUnsavedChanges && !props.isSaving, saved: !props.hasUnsavedChanges && !props.isSaving,
}" }"
:title="getSaveStatusTitle()" :title="saveStatusTitle"
> >
<span v-if="props.isSaving" class="spinner"></span> <span v-if="props.isSaving" class="spinner"></span>
<span v-else-if="props.hasUnsavedChanges"></span> <span v-else-if="props.hasUnsavedChanges"></span>
@@ -86,6 +97,7 @@ const getSaveStatusTitle = () => {
class="node-item" class="node-item"
:draggable="true" :draggable="true"
@dragstart="onDragStart($event, nodeType.type)" @dragstart="onDragStart($event, nodeType.type)"
@dragend="onDragEnd"
:style="{ borderColor: nodeType.color }" :style="{ borderColor: nodeType.color }"
:title="`${nodeType.label} - ${nodeType.description}`" :title="`${nodeType.label} - ${nodeType.description}`"
> >

View File

@@ -14,7 +14,8 @@ import {
import { Controls } from "@vue-flow/controls" import { Controls } from "@vue-flow/controls"
import { Background } from "@vue-flow/background" import { Background } from "@vue-flow/background"
import { useDnD } from "./useDnD" import { useDnD, currentDragNodeType } from "./useDnD"
import { getNodeTypeConfig } from "./useNodeStyles"
import { useHistory } from "./useHistory" import { useHistory } from "./useHistory"
import { useFlowOperations } from "./useFlowOperations" import { useFlowOperations } from "./useFlowOperations"
import { useCache } from "./useCache" import { useCache } from "./useCache"
@@ -42,8 +43,14 @@ const { canUndo, canRedo, saveState, undo, redo } = useHistory()
const problemStore = useProblemStore() const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore) const { problem } = storeToRefs(problemStore)
// 缓存管理 // 缓存管理
const { isSaving, lastSaved, hasUnsavedChanges, loadFromCache, clearCache } = const {
useCache( isSaving,
lastSaved,
hasUnsavedChanges,
saveToCache,
loadFromCache,
clearCache,
} = useCache(
nodes, nodes,
edges, edges,
problem.value?._id problem.value?._id
@@ -52,7 +59,19 @@ const { isSaving, lastSaved, hasUnsavedChanges, loadFromCache, clearCache } =
) )
// 拖拽处理 // 拖拽处理
const { onDragOver, onDragLeave, onDrop } = useDnD() 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 { const {
@@ -93,16 +112,18 @@ const handleDrop = (event: DragEvent) => {
const handleUndo = () => { const handleUndo = () => {
const state = undo() const state = undo()
if (state) { if (state) {
nodes.value = [...state.nodes] nodes.value = state.nodes
edges.value = [...state.edges] edges.value = state.edges
saveToCache()
} }
} }
const handleRedo = () => { const handleRedo = () => {
const state = redo() const state = redo()
if (state) { if (state) {
nodes.value = [...state.nodes] nodes.value = state.nodes
edges.value = [...state.edges] edges.value = state.edges
saveToCache()
} }
} }
@@ -182,6 +203,19 @@ defineExpose({
<template> <template>
<div class="container" :style="{ height }"> <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 <VueFlow
v-model:nodes="nodes" v-model:nodes="nodes"
v-model:edges="edges" v-model:edges="edges"
@@ -191,7 +225,7 @@ defineExpose({
@connect="handleConnect" @connect="handleConnect"
@edge-click="handleEdgeClick" @edge-click="handleEdgeClick"
:default-edge-options="{ :default-edge-options="{
type: 'step', type: 'default',
style: { style: {
stroke: '#6366f1', stroke: '#6366f1',
strokeWidth: 2.5, strokeWidth: 2.5,
@@ -269,4 +303,36 @@ defineExpose({
height: 100%; height: 100%;
position: relative; 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> </style>

View File

@@ -7,28 +7,36 @@ import {
} from "./useNodeStyles" } from "./useNodeStyles"
import { getRandomId } from "utils/functions" import { getRandomId } from "utils/functions"
// 模块级共享当前拖拽的节点类型Toolbar 写入canvas 读取)
export const currentDragNodeType = ref<string | null>(null)
/** /**
* 简化的拖拽处理 * 简化的拖拽处理
*/ */
export function useDnD() { export function useDnD() {
const { addNodes, screenToFlowCoordinate } = useVueFlow() const { addNodes, screenToFlowCoordinate } = useVueFlow()
const isDragOver = ref(false) const isDragOver = ref(false)
const screenDragPos = ref<{ x: number; y: number } | null>(null)
// 拖拽悬停处理 // 拖拽悬停处理
const onDragOver = (event: DragEvent) => { const onDragOver = (event: DragEvent) => {
event.preventDefault() event.preventDefault()
isDragOver.value = true isDragOver.value = true
screenDragPos.value = { x: event.clientX, y: event.clientY }
} }
// 拖拽离开处理 // 拖拽离开处理
const onDragLeave = () => { const onDragLeave = () => {
isDragOver.value = false isDragOver.value = false
screenDragPos.value = null
} }
// 拖拽放置处理 // 拖拽放置处理
const onDrop = (event: DragEvent) => { const onDrop = (event: DragEvent) => {
event.preventDefault() event.preventDefault()
isDragOver.value = false isDragOver.value = false
screenDragPos.value = null
currentDragNodeType.value = null
const type = event.dataTransfer?.getData("application/vueflow") const type = event.dataTransfer?.getData("application/vueflow")
if (!type) return if (!type) return
@@ -68,6 +76,7 @@ export function useDnD() {
return { return {
isDragOver, isDragOver,
screenDragPos,
onDragOver, onDragOver,
onDragLeave, onDragLeave,
onDrop, onDrop,

View File

@@ -1,5 +1,5 @@
import type { Ref } from "vue" import type { Ref } from "vue"
import type { Node, Edge } from "@vue-flow/core" import type { Node, Edge, Connection } from "@vue-flow/core"
import { getRandomId } from "utils/functions" import { getRandomId } from "utils/functions"
export function useFlowOperations( export function useFlowOperations(
@@ -11,12 +11,11 @@ export function useFlowOperations(
removeEdges: (edgeIds: string[]) => void, removeEdges: (edgeIds: string[]) => void,
saveState: (nodes: Node[], edges: Edge[]) => void, saveState: (nodes: Node[], edges: Edge[]) => void,
) { ) {
// 根据节点类型和handle自动推断标签
const getAutoLabel = ( const getAutoLabel = (
sourceNode: any, sourceNode: Node | undefined,
targetNode: any, targetNode: Node | undefined,
sourceHandle: string, sourceHandle: string | null | undefined,
targetHandle: string, targetHandle: string | null | undefined,
) => { ) => {
const sourceType = sourceNode?.data?.originalType || sourceNode?.type const sourceType = sourceNode?.data?.originalType || sourceNode?.type
const targetType = targetNode?.data?.originalType || targetNode?.type const targetType = targetNode?.data?.originalType || targetNode?.type
@@ -51,9 +50,7 @@ export function useFlowOperations(
return "" return ""
} }
// 连接处理 const handleConnect = (params: Connection) => {
const handleConnect = (params: any) => {
// 获取源节点和目标节点
const sourceNode = nodes.value.find((node) => node.id === params.source) const sourceNode = nodes.value.find((node) => node.id === params.source)
const targetNode = nodes.value.find((node) => node.id === params.target) const targetNode = nodes.value.find((node) => node.id === params.target)
@@ -79,9 +76,8 @@ export function useFlowOperations(
saveState(nodes.value, edges.value) saveState(nodes.value, edges.value)
} }
// 边点击处理 - 单击删除 const handleEdgeClick = ({ edge }: { edge: Edge }) => {
const handleEdgeClick = (event: any) => { removeEdges([edge.id])
removeEdges([event.edge.id])
saveState(nodes.value, edges.value) saveState(nodes.value, edges.value)
} }
@@ -115,12 +111,7 @@ export function useFlowOperations(
}, },
} }
// 使用 Vue Flow 的更新方法
nodes.value[nodeIndex] = updatedNode nodes.value[nodeIndex] = updatedNode
// 强制触发响应式更新
nodes.value = [...nodes.value]
saveState(nodes.value, edges.value) saveState(nodes.value, edges.value)
} }
} }

View File

@@ -1,11 +1,11 @@
import { ref, computed } from "vue" import { shallowRef, computed } from "vue"
import type { Node, Edge } from "@vue-flow/core" import type { Node, Edge } from "@vue-flow/core"
/** /**
* 简化的历史记录管理 * 简化的历史记录管理
*/ */
export function useHistory() { export function useHistory() {
const history = ref<{ nodes: Node[]; edges: Edge[] }[]>([]) const history = shallowRef<{ nodes: Node[]; edges: Edge[] }[]>([])
const historyIndex = ref(-1) const historyIndex = ref(-1)
// 是否可以撤销 // 是否可以撤销
@@ -14,21 +14,30 @@ export function useHistory() {
// 是否可以重做 // 是否可以重做
const canRedo = computed(() => historyIndex.value < history.value.length - 1) const canRedo = computed(() => historyIndex.value < history.value.length - 1)
const deepCopyState = (
nodes: Node[],
edges: Edge[],
): { nodes: Node[]; edges: Edge[] } =>
JSON.parse(JSON.stringify({ nodes, edges })) as {
nodes: Node[]
edges: Edge[]
}
// 保存状态到历史记录 // 保存状态到历史记录
const saveState = (nodes: Node[], edges: Edge[]) => { const saveState = (nodes: Node[], edges: Edge[]) => {
const currentState = { nodes: [...nodes], edges: [...edges] } const currentState = deepCopyState(nodes, edges)
// 如果当前不在历史记录的末尾,删除后面的记录 // 如果当前不在历史记录的末尾,删除后面的记录
if (historyIndex.value < history.value.length - 1) { if (historyIndex.value < history.value.length - 1) {
history.value = history.value.slice(0, historyIndex.value + 1) history.value = history.value.slice(0, historyIndex.value + 1)
} }
history.value.push(currentState) history.value = [...history.value, currentState]
historyIndex.value = history.value.length - 1 historyIndex.value = history.value.length - 1
// 限制历史记录数量 // 限制历史记录数量
if (history.value.length > 20) { if (history.value.length > 20) {
history.value.shift() history.value = history.value.slice(1)
historyIndex.value-- historyIndex.value--
} }
} }
@@ -38,7 +47,7 @@ export function useHistory() {
if (canUndo.value) { if (canUndo.value) {
historyIndex.value-- historyIndex.value--
const state = history.value[historyIndex.value] const state = history.value[historyIndex.value]
return state return deepCopyState(state.nodes, state.edges)
} }
return null return null
} }
@@ -48,7 +57,7 @@ export function useHistory() {
if (canRedo.value) { if (canRedo.value) {
historyIndex.value++ historyIndex.value++
const state = history.value[historyIndex.value] const state = history.value[historyIndex.value]
return state return deepCopyState(state.nodes, state.edges)
} }
return null return null
} }