题目页重构之后按「功能对照 / 状态 / 结果页 / 布局与后端」四路审查了一遍,查出的问题一起修: 提交与换题 - 提交、画流程图途中换题:A 题格式化好的代码会盖掉 B 的草稿,A 判对了把 B 标成已解决、 弹 B 的点评、题单里跳回题单。现在每个请求回来先核对题目,轮询和推送回来的也核对提交编号 - 画着流程图换到不能画的题,语言退到题目的第一门、还被记成偏好;现在回到学生习惯的那门 - 强制点评评完点「继续 / 下一题」,学生之前在看「题目」页签的话会停在原题 - 离开题目页之后判题、流程图的 WebSocket 一直连着,现在闲 15 分钟断开 运行例子 - 输出开头的空格被削掉,打印菱形这类题对的代码也显示「没对上」 - C 的编译警告拼进了「你的输出」(后台生成测试点也会写进 .out) - Python 的语法错误(中文标点)被说成「下标有没有越界、除数是不是 0」 - 比赛里仍然把空格画成「·」、把不同的地方标红 入口与协作 - 比赛里不给「课堂统计」、不绑协作:房间和统计都按题号找,比赛题号和公开题撞号 - 「看他交过什么」按整名匹配,不再混进学号相近的同学 - 协作中点上下文条、相似题、下一题开新标签,协作不断 - 题单入口给管理员的「⋯」里留「本题提交」 结果与题面 - 「去自己输入拿 51 这些数试试」取到了题面 HTML 里的颜色值;「题目原话」露出 " - 流程图 A 级的学生标题下还写着「还没交过」 - 「我的提交」上方补回「答案错误 × 3」这样的计数 - 流程图历次评分能点开看那一版的图;读分数失败不再说「还没交过流程图」;手机上能看流程图点评 - 「结果」页签有新结果没看时加粗上色,看过就恢复 - 空画布拖进第一个节点画布会跳一下;画布底下空着一条 - 比赛没开始写成「已结束」、倒计时超过一个月算错;分隔条拖到头工具栏被挤坏;题单里的题目页有备案页脚 - 流程图历次分数只读需要的几列(一个学生一道题 23 次提交:96 kB → 2 kB) - 比赛密码、比赛没开始等几条学生看得到的英文报错换成中文 设计文档照实际做法更新了第 5.5、5.6、6、8、9、10、11 节。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
10 KiB
Vue
350 lines
10 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,那时候跟着缩放会让画布跳一下。
|
|
* 画布是空的就不挂:空画布不会触发 nodes-initialized,挂着的这一下会一直留到学生拖进
|
|
* 第一个节点,正好把画布拉近、对准那一个节点 —— 就是上面要躲开的那一跳
|
|
*/
|
|
let fitPending = false
|
|
function fitSoon() {
|
|
fitPending = nodes.value.length > 0
|
|
}
|
|
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>
|