Compare commits
10 Commits
64facc5701
...
e00ab7b876
| Author | SHA1 | Date | |
|---|---|---|---|
| e00ab7b876 | |||
| 74d3b97f05 | |||
| f5ad5318a0 | |||
| 9e41855610 | |||
| d10172f017 | |||
| 0392445dd2 | |||
| 5480abaaea | |||
| 5f0fe713dc | |||
| c27c9fdbf9 | |||
| 8f08ed03a0 |
@@ -15,22 +15,38 @@ function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
|
||||
题目:${problem.title}\n${problem.description.slice(0, 2000)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 等级一律由分数推出来,不采信模型自报的 grade。
|
||||
* 提示词里写死了这四档,但模型偶尔会给出 88 分配 S 级这种自相矛盾的结果,
|
||||
* 甚至直接吐「优秀」;脏值会一路串到等级分布图和「A/S 才展示流程图」的判断里。
|
||||
*/
|
||||
function gradeForScore(score: number) {
|
||||
if (score >= 90) return "S"
|
||||
if (score >= 80) return "A"
|
||||
if (score >= 70) return "B"
|
||||
return "C"
|
||||
}
|
||||
|
||||
function parseEvaluation(value: string) {
|
||||
const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1]
|
||||
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
|
||||
if (!json) throw new Error("AI response did not contain JSON")
|
||||
const data = JSON.parse(json) as Record<string, unknown>
|
||||
if (typeof data.score !== "number" || typeof data.grade !== "string") throw new Error("AI response is missing score or grade")
|
||||
if (typeof data.score !== "number" || Number.isNaN(data.score)) throw new Error("AI response is missing score")
|
||||
const score = Math.max(0, Math.min(100, data.score))
|
||||
return {
|
||||
score: Math.max(0, Math.min(100, data.score)),
|
||||
grade: data.grade,
|
||||
score,
|
||||
grade: gradeForScore(score),
|
||||
feedback: typeof data.feedback === "string" ? data.feedback : "",
|
||||
suggestions: typeof data.suggestions === "string" ? data.suggestions : "",
|
||||
criteria: data.criteria_details && typeof data.criteria_details === "object" ? data.criteria_details : {},
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluateFlowchart(job: FlowchartJobData) {
|
||||
export async function evaluateFlowchart(
|
||||
job: FlowchartJobData,
|
||||
{ isFinalAttempt = true }: { isFinalAttempt?: boolean } = {},
|
||||
) {
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(eq(schema.flowchartSubmission.id, job.submissionId)).limit(1)
|
||||
@@ -69,6 +85,11 @@ export async function evaluateFlowchart(job: FlowchartJobData) {
|
||||
// AI provider 的地址、内部报错就这么进了浏览器。真实原因留在服务端日志里,
|
||||
// 学生只需要知道「失败了,再试一次」;error 字段留空,前端有兜底文案。
|
||||
console.error(`Failed to evaluate flowchart ${row.flowchart.id}`, error)
|
||||
// 只有最后一次尝试才落 FAILED。中间几次必须把状态留在 PROCESSING(1):
|
||||
// 上面那道 `![0, 1].includes(status)` 的守卫会把状态为 3 的任务直接放行返回,
|
||||
// 一旦提前写成 3,队列配的 attempts: 3 就成了摆设 —— 后两次尝试进来什么都不做
|
||||
// 就算成功,AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。
|
||||
if (!isFinalAttempt) throw error
|
||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({
|
||||
type: "flowchart_evaluation_failed",
|
||||
|
||||
@@ -18,6 +18,8 @@ import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { flowchartQueue } from "../queue"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { consumeToken } from "../services/throttling"
|
||||
import { buildWordFrequencies } from "../services/word-frequency"
|
||||
import {
|
||||
isAdminRole,
|
||||
@@ -30,6 +32,11 @@ import {
|
||||
|
||||
export const flowchartRoutes = new Hono<AppEnv>()
|
||||
|
||||
// AI 评分单独一个限流桶,与代码提交的 `throttling:user:<id>` 分开计数
|
||||
function flowchartThrottleKey(userId: number) {
|
||||
return `flowchart:${userId}`
|
||||
}
|
||||
|
||||
function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) {
|
||||
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
|
||||
}
|
||||
@@ -67,6 +74,13 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!problem.allow) return failure(c, 400, "flowchart-not-allowed", "This problem does not allow flowchart submission")
|
||||
// 限流:每次提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源。
|
||||
// 身份前缀单独开一个桶,**不能**直接用 user id —— 那是代码提交在用的桶,
|
||||
// 共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙交不上去。
|
||||
const throttle = await consumeToken("user", flowchartThrottleKey(c.get("user")!.id))
|
||||
if (!throttle.allowed) {
|
||||
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`)
|
||||
}
|
||||
const id = randomBytes(16).toString("hex")
|
||||
await db.insert(schema.flowchartSubmission).values({
|
||||
id,
|
||||
@@ -103,6 +117,12 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
const displayId = c.req.query("problemId")?.trim()
|
||||
const username = c.req.query("username")?.trim()
|
||||
const grade = c.req.query("grade")
|
||||
// 与代码提交列表同一套口径(submission.ts 的 GET /submissions):关掉
|
||||
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
|
||||
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
|
||||
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
|
||||
return success(c, flowchartListSchema.parse({ results: [], total: 0 }))
|
||||
}
|
||||
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
|
||||
if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id))
|
||||
else if (username) filters.push(ilike(schema.user.username, `%${username}%`))
|
||||
@@ -138,6 +158,19 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
|
||||
const FLOWCHART_COMPLETED = 2
|
||||
|
||||
/**
|
||||
* 词云的分词条数上限。
|
||||
*
|
||||
* 数值统计(总数、均分、等级分布、各项平均分、完成人数)仍然按整个时间窗**精确**
|
||||
* 计算 —— 那只是已取回行上的算术,不额外花钱。真正会随数据量线性变重的是分词:
|
||||
* 每条 feedback / suggestions / comment 都要走一遍 jieba,而前端的「全部时段」
|
||||
* 是不带 start 的,攒一学年就得把所有评语重新 cut 一遍。
|
||||
*
|
||||
* 词云是辅助性的,看的是高频问题,取最近这些条足够;数值不能采样 —— 采了之后
|
||||
* 老师看到的完成率和均分就是错的,而且从界面上看不出来。
|
||||
*/
|
||||
const WORDCLOUD_TEXT_LIMIT = 3000
|
||||
|
||||
flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
const end = c.req.query("end")?.trim()
|
||||
if (!end) return failure(c, 400, "invalid-request", "end is required")
|
||||
@@ -191,6 +224,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.where(and(...filters))
|
||||
// 按时间倒序,好让词云取到的那部分是最近的
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
|
||||
const empty = {
|
||||
totalCount: 0,
|
||||
@@ -207,6 +242,9 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
const gradeDistribution: Record<string, number> = {}
|
||||
const criteriaTotals = new Map<string, { sum: number; count: number; max: number }>()
|
||||
const texts: string[] = []
|
||||
const pushText = (value: string) => {
|
||||
if (texts.length < WORDCLOUD_TEXT_LIMIT) texts.push(value)
|
||||
}
|
||||
const submitted = new Set<string>()
|
||||
let scoreSum = 0
|
||||
let scoreCount = 0
|
||||
@@ -235,10 +273,10 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
max: typeof detail.max === "number" ? detail.max : 100,
|
||||
})
|
||||
}
|
||||
if (typeof detail.comment === "string" && detail.comment) texts.push(detail.comment)
|
||||
if (typeof detail.comment === "string" && detail.comment) pushText(detail.comment)
|
||||
}
|
||||
if (row.feedback) texts.push(row.feedback)
|
||||
if (row.suggestions) texts.push(row.suggestions)
|
||||
if (row.feedback) pushText(row.feedback)
|
||||
if (row.suggestions) pushText(row.suggestions)
|
||||
}
|
||||
|
||||
const criteriaAverages: Record<string, { avg: number; max: number }> = {}
|
||||
@@ -274,11 +312,20 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||||
})
|
||||
|
||||
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1)
|
||||
if (!row || !canView(c.get("user")!, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
if (!row || !canView(user, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
if (![2, 3].includes(row.flowchart.status)) return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
|
||||
// canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。
|
||||
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
|
||||
if (!isAdminRole(user)) {
|
||||
const throttle = await consumeToken("user", flowchartThrottleKey(user.id))
|
||||
if (!throttle.allowed) {
|
||||
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`)
|
||||
}
|
||||
}
|
||||
await db.update(schema.flowchartSubmission).set({
|
||||
status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null,
|
||||
aiCriteriaDetails: {}, processingTime: null, evaluationTime: null,
|
||||
|
||||
@@ -15,10 +15,18 @@ function requestBody(messages: ChatMessage[], stream: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式调用的超时。fetch 默认不超时,AI 侧一挂就会把 worker 的并发位一直占着,
|
||||
* 学生那边的按钮也就一直转。流式调用不设:那边超时会把正在推的长回答直接掐断,
|
||||
* 客户端断开本来就能收尾。
|
||||
*/
|
||||
const COMPLETE_TIMEOUT_MS = 60_000
|
||||
|
||||
export async function completeChat(system: string, user: string) {
|
||||
if (!config.aiKey) throw new Error("缺少 AI_KEY")
|
||||
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS),
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
|
||||
body: JSON.stringify(requestBody([
|
||||
{ role: "system", content: system },
|
||||
|
||||
@@ -18,7 +18,11 @@ const worker = new Worker<JudgeJobData>(
|
||||
|
||||
const flowchartWorker = new Worker<FlowchartJobData>(
|
||||
flowchartQueueName,
|
||||
async (job) => evaluateFlowchart(job.data),
|
||||
// attemptsMade 是「此前已经失败过几次」,当前这次还没计进去,
|
||||
// 所以最后一次尝试的判据是 attemptsMade + 1 >= attempts
|
||||
async (job) => evaluateFlowchart(job.data, {
|
||||
isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1),
|
||||
}),
|
||||
{ connection: createBlockingRedis(), concurrency: 2 },
|
||||
)
|
||||
|
||||
|
||||
@@ -173,6 +173,16 @@ const languageOptions = [
|
||||
const isSQLProblem = computed(() => !!problem.value?.languages.includes("SQL"))
|
||||
|
||||
// SQL 题联动:SQL 必须是唯一语言(后端强校验),不需要预制代码,自动初始化 sql_config
|
||||
// 两个流程图开关是互斥的:allowFlowchart 为真时后端不会把 mermaidCode 下发给
|
||||
// 学生,showFlowchart 就成了一个点进去什么都没有的 tab。UI 上已经把开关置灰,
|
||||
// 这里再把存量数据里两个都开着的情况纠正掉。
|
||||
watch(
|
||||
() => problem.value?.allowFlowchart,
|
||||
(allow) => {
|
||||
if (allow) problem.value.showFlowchart = false
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => problem.value?.languages,
|
||||
(langs) => {
|
||||
@@ -310,9 +320,10 @@ function downloadTestcases() {
|
||||
download(`problems/${problem.value.id}/test-cases`)
|
||||
}
|
||||
|
||||
// Mermaid 渲染事件处理
|
||||
function onMermaidRenderSuccess() {
|
||||
mermaidRenderSuccess.value = true
|
||||
// Mermaid 渲染事件处理。这里必须原样接受 false ——
|
||||
// 原来只在成功时置 true、永不复位,先写对再改坏就能把语法错误的代码存进库
|
||||
function onMermaidRenderState(ok: boolean) {
|
||||
mermaidRenderSuccess.value = ok
|
||||
}
|
||||
|
||||
// 题目是否有漏写的
|
||||
@@ -864,7 +875,15 @@ watch(
|
||||
<n-switch v-model:value="problem.allowFlowchart" />
|
||||
</n-form-item>
|
||||
<n-form-item label="显示标准流程图">
|
||||
<n-switch v-model:value="problem.showFlowchart" />
|
||||
<n-flex align="center">
|
||||
<n-switch
|
||||
v-model:value="problem.showFlowchart"
|
||||
:disabled="problem.allowFlowchart"
|
||||
/>
|
||||
<n-text v-if="problem.allowFlowchart" depth="3" style="font-size: 12px">
|
||||
让学生自己画图时,标准流程图不会下发给学生,这个开关没有意义
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
@@ -872,7 +891,7 @@ watch(
|
||||
<n-form-item>
|
||||
<MermaidEditor
|
||||
v-model="problem.mermaidCode"
|
||||
@render-success="onMermaidRenderSuccess"
|
||||
@render-state="onMermaidRenderState"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="流程图提示信息(选填)">
|
||||
|
||||
@@ -22,7 +22,11 @@ watch(
|
||||
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div v-else ref="mermaidContainer" class="flowchart-container"></div>
|
||||
<div
|
||||
v-show="!renderError"
|
||||
ref="mermaidContainer"
|
||||
class="flowchart-container"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ watch(() => problem.value?.mermaidCode, renderProblemFlowchart)
|
||||
{{ renderError }}
|
||||
</template>
|
||||
</n-alert>
|
||||
<div v-else ref="mermaidContainer" class="container"></div>
|
||||
<!-- 容器必须常驻:用 v-else 卸载掉之后 mermaidContainer 变成 null,
|
||||
下一次渲染会因为拿不到容器直接 return,图就再也画不出来了 -->
|
||||
<div v-show="!renderError" ref="mermaidContainer" class="container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -314,26 +314,40 @@ async function getSubmission(submissionPage = 0) {
|
||||
}
|
||||
}
|
||||
|
||||
// 请求失败时 rendering 必须复位:拦截器对普通业务错误是静默 reject 的,
|
||||
// 少了这个 finally,弹框就会永远停在转圈状态,也没有任何提示
|
||||
async function updatePage(val: number) {
|
||||
page.value = val
|
||||
rendering.value = true
|
||||
await getSubmission(val)
|
||||
// 等待 DOM 更新
|
||||
await nextTick()
|
||||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
||||
rendering.value = false
|
||||
try {
|
||||
await getSubmission(val)
|
||||
// 等待 DOM 更新
|
||||
await nextTick()
|
||||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
||||
} catch (error) {
|
||||
message.error("加载这次提交失败,请稍后重试")
|
||||
console.error("加载流程图提交失败:", error)
|
||||
} finally {
|
||||
rendering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模态框相关函数 ====================
|
||||
async function openDetailModal() {
|
||||
showDetailModal.value = true
|
||||
rendering.value = true
|
||||
await getSubmission()
|
||||
page.value = submissionCount.value
|
||||
// 等待 DOM 更新,确保弹框已经渲染
|
||||
await nextTick()
|
||||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
||||
rendering.value = false
|
||||
try {
|
||||
await getSubmission()
|
||||
page.value = submissionCount.value
|
||||
// 等待 DOM 更新,确保弹框已经渲染
|
||||
await nextTick()
|
||||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
||||
} catch (error) {
|
||||
message.error("加载评分详情失败,请稍后重试")
|
||||
console.error("加载流程图评分详情失败:", error)
|
||||
} finally {
|
||||
rendering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -342,14 +356,18 @@ function closeModal() {
|
||||
|
||||
function loadToEditor() {
|
||||
if (myFlowchartZippedStr.value) {
|
||||
const str = atou(myFlowchartZippedStr.value)
|
||||
const json = JSON.parse(str)
|
||||
const processedData = {
|
||||
nodes: json.nodes || [],
|
||||
edges: json.edges || [],
|
||||
}
|
||||
if (flowchartEditorRef?.value) {
|
||||
flowchartEditorRef.value.setFlowchartData(processedData)
|
||||
// 老提交的压缩数据可能是坏的(格式换过、存了一半),
|
||||
// 不兜住的话 atou/JSON.parse 直接抛,按钮点了毫无反应
|
||||
try {
|
||||
const json = JSON.parse(atou(myFlowchartZippedStr.value))
|
||||
flowchartEditorRef?.value?.setFlowchartData({
|
||||
nodes: json.nodes || [],
|
||||
edges: json.edges || [],
|
||||
})
|
||||
} catch (error) {
|
||||
message.error("这份流程图数据已损坏,无法加载到编辑器")
|
||||
console.error("解析流程图数据失败:", error)
|
||||
return
|
||||
}
|
||||
}
|
||||
closeModal()
|
||||
@@ -432,7 +450,11 @@ onUnmounted(() => {
|
||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div class="flowchart" v-else ref="mermaidContainer"></div>
|
||||
<div
|
||||
class="flowchart"
|
||||
v-show="!renderError"
|
||||
ref="mermaidContainer"
|
||||
></div>
|
||||
</n-spin>
|
||||
</div>
|
||||
<!-- 加载到编辑器按钮 -->
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
/**
|
||||
* 将流程图JSON数据转换为Mermaid格式
|
||||
* 节点/连线标签是学生自己敲的任意文字,而下面是直接把它塞进 "..." 里。
|
||||
* 出现一个双引号就会把 mermaid 语法撑破:图渲染不出来,坏掉的代码还会原样送去
|
||||
* 给 AI 打分,学生完全不知道自己被扣分是因为一个引号。
|
||||
*
|
||||
* mermaid 用 `#NN;` 形式的实体转义。`#` 必须**先**转,否则标签里本来就有的
|
||||
* `#quot;` 之类会被当成实体解释。换行转成 <br/>,不然会截断整条语句。
|
||||
*/
|
||||
function escapeLabel(raw: unknown) {
|
||||
return String(raw ?? "")
|
||||
.replace(/#/g, "#35;")
|
||||
.replace(/"/g, "#quot;")
|
||||
.replace(/\r?\n/g, "<br/>")
|
||||
}
|
||||
|
||||
/**
|
||||
* 将流程图 JSON 数据转换为 Mermaid 格式
|
||||
*/
|
||||
export function useMermaidConverter() {
|
||||
const convertToMermaid = (flowchartData: any) => {
|
||||
const { nodes, edges } = flowchartData
|
||||
const nodes = flowchartData?.nodes
|
||||
// edges 原来没做空值保护(nodes 有),拿到 undefined 会直接抛
|
||||
const edges = flowchartData?.edges ?? []
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return "graph TD\n A[空流程图]"
|
||||
@@ -22,7 +39,9 @@ export function useMermaidConverter() {
|
||||
// 处理节点 - 根据原始类型和自定义标签
|
||||
nodes.forEach((node: any) => {
|
||||
const nodeId = safeId(node.id)
|
||||
const label = node.data?.customLabel || node.data?.label || "节点"
|
||||
const label = escapeLabel(
|
||||
node.data?.customLabel || node.data?.label || "节点",
|
||||
)
|
||||
const originalType = node.data?.originalType || node.type
|
||||
|
||||
// 根据节点原始类型确定Mermaid语法
|
||||
@@ -60,10 +79,10 @@ export function useMermaidConverter() {
|
||||
edges.forEach((edge: any) => {
|
||||
const source = safeId(edge.source)
|
||||
const target = safeId(edge.target)
|
||||
const label = edge.label ?? ""
|
||||
const rawLabel = String(edge.label ?? "")
|
||||
|
||||
if (label && label.trim() !== "") {
|
||||
mermaid += ` ${source} -->|"${label}"| ${target}\n`
|
||||
if (rawLabel.trim() !== "") {
|
||||
mermaid += ` ${source} -->|"${escapeLabel(rawLabel)}"| ${target}\n`
|
||||
} else {
|
||||
mermaid += ` ${source} --> ${target}\n`
|
||||
}
|
||||
|
||||
@@ -53,9 +53,17 @@ const { shouldShowProblem } = storeToRefs(screenModeStore)
|
||||
|
||||
const { isMobile, isDesktop } = useBreakpoints()
|
||||
|
||||
// tab 选项和面板必须用同一个条件。后端在 allowFlowchart 为真时会把 mermaidCode
|
||||
// 置成 null(不能把标准答案下发给正要自己画图的学生),只看 showFlowchart 的话,
|
||||
// 两个开关同时打开就会做出一个「选项存在、面板不存在」的 tab —— URL 里带
|
||||
// ?tab=flowchart 会选中一个渲染不出任何东西的页签。
|
||||
const canShowFlowchart = computed(
|
||||
() => !!problem.value?.showFlowchart && !!problem.value?.mermaidCode,
|
||||
)
|
||||
|
||||
const tabOptions = computed(() => {
|
||||
const options: string[] = ["content"]
|
||||
if (problem.value?.showFlowchart) {
|
||||
if (canShowFlowchart.value) {
|
||||
options.push("flowchart")
|
||||
}
|
||||
|
||||
@@ -159,7 +167,7 @@ watch(
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="problem.showFlowchart && problem.mermaidCode"
|
||||
v-if="canShowFlowchart"
|
||||
name="flowchart"
|
||||
tab="流程图表"
|
||||
>
|
||||
@@ -211,7 +219,7 @@ watch(
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="problem.showFlowchart && problem.mermaidCode"
|
||||
v-if="canShowFlowchart"
|
||||
name="flowchart"
|
||||
tab="流程图表"
|
||||
>
|
||||
@@ -251,7 +259,7 @@ watch(
|
||||
<n-tab-pane name="content" tab="描述">
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="problem.showFlowchart" name="flowchart" tab="流程">
|
||||
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程">
|
||||
<ProblemFlowchart />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="editor" tab="代码">
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<template>
|
||||
<n-button v-if="showLink" type="info" text @click="handleClick">
|
||||
<n-button v-if="flowchart.showLink" type="info" text @click="handleClick">
|
||||
{{ flowchart.id.slice(0, 12) }}
|
||||
</n-button>
|
||||
<n-text v-else class="flowchart-id" @click="handleClick">
|
||||
<!-- 没权限时不能挂 @click:后端 GET /flowcharts/:id 会以 404 挡下,
|
||||
前端只会得到一个静默失败的空白面板 -->
|
||||
<n-text v-else class="flowchart-id" depth="3">
|
||||
{{ flowchart.id.slice(0, 12) }}
|
||||
</n-text>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { FlowchartSubmissionListItem } from "utils/types"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
interface Props {
|
||||
flowchart: FlowchartSubmissionListItem
|
||||
@@ -21,12 +20,9 @@ const emit = defineEmits<{
|
||||
showDetail: [id: string]
|
||||
}>()
|
||||
|
||||
const showLink = computed(() => {
|
||||
if (!userStore.isAuthed) return false
|
||||
if (userStore.isSuperAdmin) return true
|
||||
return props.flowchart.username === userStore.user?.username
|
||||
})
|
||||
|
||||
// showLink 由后端逐行下发(见 routes/flowchart.ts 的 canView),与
|
||||
// GET /flowcharts/:id 的放行条件同源。原来前端自己按「超管或本人」算了一遍,
|
||||
// 既漏了教师,也和后端对不上。
|
||||
function handleClick() {
|
||||
emit("showDetail", props.flowchart.id)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<Teleport v-else to="body" :disabled="!showLargeImage">
|
||||
<Teleport to="body" :disabled="!showLargeImage">
|
||||
<div
|
||||
v-show="!renderError"
|
||||
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
|
||||
ref="mermaidContainer"
|
||||
></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from "naive-ui"
|
||||
import { NButton, NTag, NText } from "naive-ui"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import {
|
||||
adminRejudge,
|
||||
@@ -22,6 +22,19 @@ import { usePagination } from "shared/composables/pagination"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import { FlowchartSubmissionStatus } from "utils/types"
|
||||
|
||||
// 流程图提交的四种状态,列表里原来一列都没有 ——
|
||||
// 老师分不出「还在评」和「评失败了」,两种都只是分数栏空着
|
||||
const FLOWCHART_STATUS_TAG: Record<
|
||||
number,
|
||||
{ text: string; type: "default" | "info" | "success" | "error" }
|
||||
> = {
|
||||
[FlowchartSubmissionStatus.PENDING]: { text: "排队中", type: "default" },
|
||||
[FlowchartSubmissionStatus.PROCESSING]: { text: "评分中", type: "info" },
|
||||
[FlowchartSubmissionStatus.COMPLETED]: { text: "已完成", type: "success" },
|
||||
[FlowchartSubmissionStatus.FAILED]: { text: "评分失败", type: "error" },
|
||||
}
|
||||
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
|
||||
import SubmissionLink from "./components/SubmissionLink.vue"
|
||||
import Grade from "./components/Grade.vue"
|
||||
@@ -195,7 +208,21 @@ async function rejudge(submissionID: string) {
|
||||
}
|
||||
|
||||
async function retryFlowchart(submissionId: string) {
|
||||
await retryFlowchartSubmission(submissionId)
|
||||
// 后端会拒掉「还在评分中」的提交(409 retry-not-allowed),也可能撞上限流。
|
||||
// 不兜住的话拦截器静默 reject,老师点下去完全没反应。
|
||||
// 按错误码分支而不是 match 文案(见 utils/api.ts 的约定)——后端文案是英文的,
|
||||
// 直接弹给老师看不合适
|
||||
const retryTips: Record<string, string> = {
|
||||
"retry-not-allowed": "这条还在评分中,等出了结果再重新评分",
|
||||
"too-many-submissions": "操作太频繁了,缓一下再试",
|
||||
"flowchart-not-found": "提交不存在,或者没有权限",
|
||||
}
|
||||
try {
|
||||
await retryFlowchartSubmission(submissionId)
|
||||
} catch (err: any) {
|
||||
message.error(retryTips[err?.error] ?? "重新评分失败")
|
||||
return
|
||||
}
|
||||
message.success("重新评分已提交")
|
||||
listSubmissions()
|
||||
}
|
||||
@@ -384,17 +411,33 @@ const flowchartColumns = computed(() => {
|
||||
() => `${row.problem} ${row.problemTitle}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("状态", "fluent-emoji:hourglass-not-done"),
|
||||
key: "status",
|
||||
render: (row) => {
|
||||
const tag = FLOWCHART_STATUS_TAG[row.status]
|
||||
return h(
|
||||
NTag,
|
||||
{ size: "small", round: true, type: tag?.type ?? "default" },
|
||||
() => tag?.text ?? "未知",
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"评分",
|
||||
"streamline-ultimate-color:analytics-bars-3d",
|
||||
),
|
||||
key: "ai_score",
|
||||
// 只有评完的才有分数。没评完也渲染 Grade 的话会显示成 0 分,
|
||||
// 看着像「评了但得了 0 分」
|
||||
render: (row) =>
|
||||
h(Grade, {
|
||||
score: row.aiScore ?? 0,
|
||||
grade: (row.aiGrade ?? "") as GradeValue,
|
||||
}),
|
||||
row.status === FlowchartSubmissionStatus.COMPLETED
|
||||
? h(Grade, {
|
||||
score: row.aiScore ?? 0,
|
||||
grade: (row.aiGrade ?? "") as GradeValue,
|
||||
})
|
||||
: h(NText, { depth: 3 }, () => "—"),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
@@ -421,6 +464,8 @@ const flowchartColumns = computed(() => {
|
||||
res.push({
|
||||
title: renderTableTitle("选项", "streamline-emojis:wrench"),
|
||||
key: "retry",
|
||||
// 后端只接受已完成 / 已失败的重判(其余返回 409),这里同步置灰,
|
||||
// 免得老师点了才发现不行
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
@@ -428,6 +473,9 @@ const flowchartColumns = computed(() => {
|
||||
quaternary: true,
|
||||
size: "small",
|
||||
type: "primary",
|
||||
disabled:
|
||||
row.status !== FlowchartSubmissionStatus.COMPLETED &&
|
||||
row.status !== FlowchartSubmissionStatus.FAILED,
|
||||
onClick: () => retryFlowchart(row.id),
|
||||
},
|
||||
() => "重新判题",
|
||||
|
||||
@@ -19,6 +19,7 @@ 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"
|
||||
|
||||
@@ -38,7 +39,7 @@ const nodes = ref([]) as Ref<Node[]>
|
||||
const edges = ref([]) as Ref<Edge[]>
|
||||
|
||||
// 历史记录管理
|
||||
const { canUndo, canRedo, saveState, undo, redo } = useHistory()
|
||||
const { canUndo, canRedo, resetHistory, saveState, undo, redo } = useHistory()
|
||||
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
@@ -55,7 +56,11 @@ const {
|
||||
saveToCache,
|
||||
loadFromCache,
|
||||
clearCache,
|
||||
} = useCache(nodes, edges, cacheKey)
|
||||
} = useCache(nodes, edges, cacheKey, () => {
|
||||
// 换题后画布已经被换成新题的草稿,历史必须跟着重建,
|
||||
// 否则一次撤销就会把上一题的图还原到这一题里
|
||||
resetHistory(nodes.value, edges.value)
|
||||
})
|
||||
|
||||
// 拖拽处理
|
||||
const { onDragOver, onDragLeave, onDrop, isDragOver, screenDragPos } = useDnD()
|
||||
@@ -116,10 +121,21 @@ const handleRedo = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 清空画布
|
||||
// 清空画布。工具栏那个按钮点一下就全没了,且 clearCache() 会连本题存着的草稿
|
||||
// 一起删掉,刷新也找不回来 —— 学生误点的代价太大,加一道确认
|
||||
const dialog = useDialog()
|
||||
const handleClear = () => {
|
||||
clearCanvas()
|
||||
clearCache()
|
||||
if (nodes.value.length === 0 && edges.value.length === 0) return
|
||||
dialog.warning({
|
||||
title: "清空画布",
|
||||
content: "画布上的内容会被清掉,这道题存着的草稿也会一起删除。确定吗?",
|
||||
positiveText: "清空",
|
||||
negativeText: "再想想",
|
||||
onPositiveClick: () => {
|
||||
clearCanvas()
|
||||
clearCache()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 键盘事件
|
||||
@@ -135,10 +151,12 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === "z" && !event.shiftKey) {
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === "z" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleUndo()
|
||||
} else if (event.key === "z" && event.shiftKey) {
|
||||
} else if ((key === "z" && event.shiftKey) || key === "y") {
|
||||
// 工具栏上的提示写的就是 Ctrl+Y,但原来只实现了 Ctrl+Shift+Z,按 Y 没反应
|
||||
event.preventDefault()
|
||||
handleRedo()
|
||||
}
|
||||
@@ -148,8 +166,10 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
// 从缓存恢复数据
|
||||
// 从缓存恢复数据,并把当前画布作为历史起点,
|
||||
// 否则第一步操作没有可回退的目标,撤销按钮一直是灰的
|
||||
loadFromCache()
|
||||
resetHistory(nodes.value, edges.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -182,9 +202,10 @@ const setFlowchartData = (data: { nodes: Node[]; edges: Edge[] }) => {
|
||||
defineExpose({
|
||||
nodes,
|
||||
edges,
|
||||
// 提交出去的这份会压缩后长期存进数据库,同样裁掉运行时内部字段
|
||||
getFlowchartData: () => ({
|
||||
nodes: nodes.value,
|
||||
edges: edges.value,
|
||||
nodes: toPortableNodes(nodes.value),
|
||||
edges: toPortableEdges(edges.value),
|
||||
}),
|
||||
setFlowchartData,
|
||||
})
|
||||
|
||||
45
apps/web/src/shared/components/FlowchartEditor/serialize.ts
Normal file
45
apps/web/src/shared/components/FlowchartEditor/serialize.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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,6 +1,7 @@
|
||||
import { ref, watch, type Ref, type MaybeRefOrGetter } from "vue"
|
||||
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
|
||||
@@ -9,6 +10,7 @@ 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)
|
||||
@@ -27,8 +29,8 @@ export function useCache(
|
||||
|
||||
// 防抖保存:isSaving 在 watch 中置 true,保存完成后置 false,使 UI 能感知保存中状态
|
||||
const debouncedSave = useDebounceFn(() => {
|
||||
storedData.value.nodes = nodes.value
|
||||
storedData.value.edges = edges.value
|
||||
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
|
||||
@@ -38,8 +40,8 @@ export function useCache(
|
||||
// 立即保存
|
||||
const saveToCache = () => {
|
||||
isSaving.value = true
|
||||
storedData.value.nodes = nodes.value
|
||||
storedData.value.edges = edges.value
|
||||
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
|
||||
@@ -67,6 +69,26 @@ export function useCache(
|
||||
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],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { nextTick } from "vue"
|
||||
import type { Ref } from "vue"
|
||||
import type { Node, Edge, Connection } from "@vue-flow/core"
|
||||
import { useVueFlow } from "@vue-flow/core"
|
||||
@@ -51,7 +52,7 @@ export function useFlowOperations(
|
||||
return ""
|
||||
}
|
||||
|
||||
const handleConnect = (params: Connection) => {
|
||||
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)
|
||||
|
||||
@@ -74,25 +75,24 @@ export function useFlowOperations(
|
||||
}
|
||||
|
||||
addEdges([newEdge])
|
||||
// vue-flow 的 store → v-model 回写走的是 watch(pre flush,异步),
|
||||
// 紧接着读 nodes/edges 拿到的还是改动前的数组,存进历史就会错开一步。
|
||||
// 画布上 handleDrop 早就这么等了,这几处一直漏了。
|
||||
await nextTick()
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
const handleEdgeClick = ({ edge }: { edge: Edge }) => {
|
||||
const handleEdgeClick = async ({ edge }: { edge: Edge }) => {
|
||||
removeEdges([edge.id])
|
||||
await nextTick()
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
// 节点删除
|
||||
const handleNodeDelete = (nodeId: string) => {
|
||||
// 删除相关边
|
||||
const relatedEdges = edges.value.filter(
|
||||
(edge) => edge.source === nodeId || edge.target === nodeId,
|
||||
)
|
||||
if (relatedEdges.length > 0) {
|
||||
removeEdges(relatedEdges.map((edge) => edge.id))
|
||||
}
|
||||
|
||||
// 节点删除。removeNodes 的 removeConnectedEdges 默认就是 true,
|
||||
// 相连的边不用自己再删一遍
|
||||
const handleNodeDelete = async (nodeId: string) => {
|
||||
removeNodes([nodeId])
|
||||
await nextTick()
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
@@ -118,9 +118,10 @@ export function useFlowOperations(
|
||||
}
|
||||
|
||||
// 删除选中的节点和边
|
||||
const deleteSelected = () => {
|
||||
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))
|
||||
@@ -128,6 +129,7 @@ export function useFlowOperations(
|
||||
if (selectedEdges.length > 0) {
|
||||
removeEdges(selectedEdges.map((edge) => edge.id))
|
||||
}
|
||||
await nextTick()
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { shallowRef, computed } from "vue"
|
||||
import type { Node, Edge } from "@vue-flow/core"
|
||||
import { toPortableEdges, toPortableNodes } from "./serialize"
|
||||
|
||||
/**
|
||||
* 简化的历史记录管理
|
||||
@@ -18,7 +19,14 @@ export function useHistory() {
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
): { nodes: Node[]; edges: Edge[] } =>
|
||||
JSON.parse(JSON.stringify({ nodes, edges })) as {
|
||||
// 先裁掉 vue-flow 的运行时内部字段再深拷贝:20 份快照 × 每个节点几百字节的
|
||||
// handleBounds/dimensions,纯属白拷
|
||||
JSON.parse(
|
||||
JSON.stringify({
|
||||
nodes: toPortableNodes(nodes),
|
||||
edges: toPortableEdges(edges),
|
||||
}),
|
||||
) as {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
@@ -42,6 +50,14 @@ export function useHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
// 用当前画布重建历史。挂载时和换题后都要调一次:
|
||||
// 不播下这个初始快照的话 historyIndex 会从 -1 开始,canUndo 要求 index > 0,
|
||||
// 第一步操作永远撤销不了;换题后不重建则一次撤销会把上一题的图还原到这一题里。
|
||||
const resetHistory = (nodes: Node[], edges: Edge[]) => {
|
||||
history.value = [deepCopyState(nodes, edges)]
|
||||
historyIndex.value = 0
|
||||
}
|
||||
|
||||
// 撤销
|
||||
const undo = () => {
|
||||
if (canUndo.value) {
|
||||
@@ -65,6 +81,7 @@ export function useHistory() {
|
||||
return {
|
||||
canUndo,
|
||||
canRedo,
|
||||
resetHistory,
|
||||
saveState,
|
||||
undo,
|
||||
redo,
|
||||
|
||||
@@ -203,6 +203,8 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const durationOptions: SelectOption[] = [
|
||||
{ label: "10分钟内", value: "minutes:10" },
|
||||
{ label: "20分钟内", value: "minutes:20" },
|
||||
@@ -528,12 +530,18 @@ async function handleStatistics() {
|
||||
query.duration === "all"
|
||||
? { end }
|
||||
: { start: formatISO(sub(current, subOptions.value)), end }
|
||||
const res = await getFlowchartStatistics(
|
||||
duration,
|
||||
query.problem,
|
||||
query.username,
|
||||
)
|
||||
Object.assign(data, res)
|
||||
try {
|
||||
const res = await getFlowchartStatistics(
|
||||
duration,
|
||||
query.problem,
|
||||
query.username,
|
||||
)
|
||||
Object.assign(data, res)
|
||||
} catch (error) {
|
||||
message.error("获取流程图统计失败")
|
||||
console.error("获取流程图统计失败:", error)
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
renderWordCloud()
|
||||
}
|
||||
|
||||
@@ -7,20 +7,29 @@ const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
|
||||
const { renderFlowchart, renderError, renderSuccess } = useMermaid()
|
||||
|
||||
// 上报渲染结果而不是只上报成功:调用方拿它做保存前校验,只进不出的话,
|
||||
// 「先写对再改坏」照样能存进库
|
||||
const emit = defineEmits<{
|
||||
renderSuccess: []
|
||||
renderState: [ok: boolean]
|
||||
}>()
|
||||
|
||||
const renderMermaid = async () => {
|
||||
await renderFlowchart(mermaidContainer.value, modelValue.value)
|
||||
if (renderSuccess.value) emit("renderSuccess")
|
||||
emit("renderState", renderSuccess.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(renderMermaid)
|
||||
})
|
||||
|
||||
watch(modelValue, renderMermaid)
|
||||
// 一改动就立刻把状态打回「未验证」,等防抖后的渲染真跑完再报结果。
|
||||
// 只挂防抖那一支的话,改完 300ms 内点保存,读到的还是上一次渲染的结论 ——
|
||||
// 刚改坏的代码会被当成校验通过。宁可让用户多等一下,也不能放脏数据进库。
|
||||
watch(modelValue, () => emit("renderState", false))
|
||||
|
||||
// 出题页是边敲边预览,不防抖的话每个字符都会触发一次完整的 mermaid 渲染,
|
||||
// 而中间态几乎全是语法错误
|
||||
watchDebounced(modelValue, renderMermaid, { debounce: 300, maxWait: 1000 })
|
||||
|
||||
const clearCode = () => {
|
||||
modelValue.value = ""
|
||||
|
||||
@@ -256,25 +256,41 @@ function getChromeVersion(): number {
|
||||
return match ? parseInt(match[1]) : Infinity
|
||||
}
|
||||
|
||||
let mermaidInstance: any = null
|
||||
let mermaidPromise: Promise<any> | null = null
|
||||
let mermaidIsLegacy = false
|
||||
|
||||
async function loadMermaid() {
|
||||
if (!mermaidInstance) {
|
||||
if (getChromeVersion() < 94) {
|
||||
mermaidInstance = (await import("mermaid-legacy")).default
|
||||
mermaidIsLegacy = true
|
||||
} else {
|
||||
mermaidInstance = (await import("mermaid")).default
|
||||
}
|
||||
mermaidInstance.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
themeVariables: mermaidThemeVariables,
|
||||
function loadMermaid(): Promise<any> {
|
||||
// 缓存 Promise 而不是实例:同一屏里两个组件一起挂载时,缓存实例会让两边都
|
||||
// 落进 if (!mermaidInstance),import 两次、initialize 两次
|
||||
if (!mermaidPromise) {
|
||||
mermaidPromise = (async () => {
|
||||
let instance: any
|
||||
if (getChromeVersion() < 94) {
|
||||
instance = (await import("mermaid-legacy")).default
|
||||
mermaidIsLegacy = true
|
||||
} else {
|
||||
instance = (await import("mermaid")).default
|
||||
}
|
||||
instance.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
// 解析失败时 mermaid 默认会先把一张「错误图」画进挂在 document.body 上的
|
||||
// 临时容器,再抛异常,而清理临时容器的那行在 throw 之后,永远执行不到;
|
||||
// 每次 render 用的又是新的随机 id,旧的也清不掉。于是每失败一次就往
|
||||
// body 里留一个 div#dmermaid-xxx —— 出题页每敲一个字符渲染一次,一段
|
||||
// 代码写下来能堆几十个。打开这个开关后 mermaid 会先清理再抛。
|
||||
suppressErrorRendering: true,
|
||||
themeVariables: mermaidThemeVariables,
|
||||
})
|
||||
return instance
|
||||
})().catch((error) => {
|
||||
// 失败的 Promise 不能留在缓存里,否则后面每次渲染都直接复用这个失败结果
|
||||
mermaidPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
return mermaidInstance
|
||||
return mermaidPromise
|
||||
}
|
||||
|
||||
export function useMermaid() {
|
||||
|
||||
Reference in New Issue
Block a user