feat(流程图): 没画对也分得出远近——五档说法、完成度条、比上次多对了几组;右栏的图放大
只说「还没画对」,学生改了几次还是这一句,看不出在往前走。等级照旧存 S/A/B/C, 标题换成话:画对了,画得很清楚 / 画对了 / 快画对了 / 对了一部分 / 还没画对; 下面一根完成度条,走对几组亮几段;第二次起没画对时说比上次多(少)对了几组 (详情接口带上同一道题上一次走对了几组)。提交列表右栏的图从 42% 加宽到 62%, 小图也放大填满图框(最多 1.5 倍)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba90fe893e
commit
9ae6287fb1
6 files changed
+154
-13
No files matched your search
@@ -12,7 +12,7 @@ import {
|
||||
NO_CLASS,
|
||||
STUDENT_ROLES,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
||||
import { and, asc, count, desc, eq, inArray, isNull, lt, sql, type SQL } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
@@ -80,9 +80,31 @@ function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: num
|
||||
return problemSetLockCutoffs(user.id, [...new Set(own)])
|
||||
}
|
||||
|
||||
/** 上一次走对了几组(见契约 previousCases):同一个人、同一道题、更早、评完了的那一次 */
|
||||
async function previousCases(flowchart: typeof schema.flowchartSubmission.$inferSelect) {
|
||||
const [previous] = await db
|
||||
.select({ details: schema.flowchartSubmission.aiCriteriaDetails })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, flowchart.userId),
|
||||
eq(schema.flowchartSubmission.problemId, flowchart.problemId),
|
||||
eq(schema.flowchartSubmission.status, 2),
|
||||
lt(schema.flowchartSubmission.createTime, flowchart.createTime),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
.limit(1)
|
||||
const details = asRecord(previous?.details)
|
||||
if (details.version !== 2 || !Array.isArray(details.cases)) return null
|
||||
const cases = details.cases.map((item) => asRecord(item))
|
||||
return { passed: cases.filter((item) => item.ok === true).length, total: cases.length }
|
||||
}
|
||||
|
||||
function flowchartData(
|
||||
flowchart: typeof schema.flowchartSubmission.$inferSelect,
|
||||
username: string,
|
||||
previous: { passed: number; total: number } | null = null,
|
||||
) {
|
||||
return {
|
||||
id: flowchart.id,
|
||||
@@ -101,6 +123,7 @@ function flowchartData(
|
||||
aiModel: flowchart.aiModel,
|
||||
processingTime: flowchart.processingTime,
|
||||
evaluationTime: flowchart.evaluationTime,
|
||||
previousCases: previous,
|
||||
} satisfies FlowchartSubmission
|
||||
}
|
||||
|
||||
@@ -716,7 +739,7 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
if (!row || !canView(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart])))
|
||||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
return success(c, flowchartData(row.flowchart, row.username))
|
||||
return success(c, flowchartData(row.flowchart, row.username, await previousCases(row.flowchart)))
|
||||
})
|
||||
|
||||
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { sortFlowchartCriteria } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { mermaidOrder, signature } from "shared/components/FlowchartEditor/model"
|
||||
import FlowchartNote from "shared/components/FlowchartNote.vue"
|
||||
import { caseReport } from "utils/flowchart"
|
||||
import { caseReport, flowchartVerdict } from "utils/flowchart"
|
||||
|
||||
/**
|
||||
* 「结果」页签里流程图作业的那一块:AI 在评 / 评完了几分 / 历次分数 / 评语和改进建议。
|
||||
@@ -252,9 +252,7 @@ function goCode() {
|
||||
<!-- 设计稿 FlowResFail / FlowResPass:等级 + 几组走对,下面逐组 -->
|
||||
<div v-if="report" class="headline">
|
||||
<span class="grade" :class="gradeType(current.grade)">{{ current.grade }} 级</span>
|
||||
<span class="headline-text">{{
|
||||
report.passed === report.total ? "画对了" : "还没画对"
|
||||
}}</span>
|
||||
<span class="headline-text">{{ flowchartVerdict(report, current.grade) }}</span>
|
||||
<span v-if="isFlowchartPass(current.grade)" class="headline-note">这道题算做完了</span>
|
||||
</div>
|
||||
<div v-else class="score-line">
|
||||
@@ -287,6 +285,7 @@ function goCode() {
|
||||
:report="report"
|
||||
:feedback="detail.aiFeedback"
|
||||
:suggestions="detail.aiSuggestions"
|
||||
:previous="detail.previousCases"
|
||||
class="block"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { submissionTimeText } from "../utils"
|
||||
import FlowchartState from "./FlowchartState.vue"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import FlowchartNote from "shared/components/FlowchartNote.vue"
|
||||
import { caseReport } from "utils/flowchart"
|
||||
import { caseReport, flowchartVerdict } from "utils/flowchart"
|
||||
|
||||
/**
|
||||
* 流程图模式的右栏:左边画出来的图,右边四项评分和建议。原来要点编号再开一个
|
||||
@@ -89,9 +89,26 @@ async function load() {
|
||||
detail.value = res
|
||||
await nextTick()
|
||||
await renderFlowchart(graph.value, res.mermaidCode)
|
||||
fitGraph(graph.value)
|
||||
markFocus(graph.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图填满图框:大图照旧缩小,小图也放大,但最多到原尺寸的 1.5 倍(再大字就粗笨了)。
|
||||
* 原来只缩不放,比大小这种五六个框的图在一大块空白中间只占一小团
|
||||
*/
|
||||
function fitGraph(el: HTMLElement | null) {
|
||||
const svg = el?.querySelector("svg")
|
||||
const w = Number.parseFloat(svg?.getAttribute("width") ?? "")
|
||||
const h = Number.parseFloat(svg?.getAttribute("height") ?? "")
|
||||
if (!svg || !w || !h) return
|
||||
if (!svg.getAttribute("viewBox")) svg.setAttribute("viewBox", `0 0 ${w} ${h}`)
|
||||
svg.style.width = "100%"
|
||||
svg.style.height = "100%"
|
||||
svg.style.maxWidth = `${w * 1.5}px`
|
||||
svg.style.maxHeight = `${h * 1.5}px`
|
||||
}
|
||||
|
||||
/**
|
||||
* 要改的那个框标红:AI 对着答案指出的,没有就退回程序找到的第一处出错的框。
|
||||
* pele 把 Mermaid 的框号放在节点 <g> 的 data-id 上
|
||||
@@ -141,7 +158,6 @@ function barColor(score: number, max: number) {
|
||||
|
||||
/** 2026-10 起的「走例子」报告;老提交是 null,照旧显示四项分 */
|
||||
const report = computed(() => caseReport(shown.value?.aiCriteriaDetails))
|
||||
const allPassed = computed(() => !!report.value && report.value.passed === report.value.total)
|
||||
const openCase = ref<number | null>(null)
|
||||
/** 逐组数据默认收着,老师和学生一样(设计稿 FlowNotePane);翻到下一张收回去 */
|
||||
const casesOpen = ref(false)
|
||||
@@ -275,13 +291,14 @@ const canRetry = computed(
|
||||
<span class="muted">这次评分失败了{{ teacher ? ",可以点「重新评分」" : "" }}</span>
|
||||
</template>
|
||||
<template v-else-if="shown && report">
|
||||
<b class="headline">{{ allPassed ? "画对了" : "还没画对" }}</b>
|
||||
<b class="headline">{{ flowchartVerdict(report, shown.aiGrade) }}</b>
|
||||
<FlowchartNote
|
||||
v-model:selected="openCase"
|
||||
v-model:open="casesOpen"
|
||||
:report="report"
|
||||
:feedback="shown.aiFeedback"
|
||||
:suggestions="shown.aiSuggestions"
|
||||
:previous="shown.previousCases"
|
||||
compact
|
||||
/>
|
||||
</template>
|
||||
@@ -512,8 +529,9 @@ const canRetry = computed(
|
||||
background: v-bind("theme.cardColor");
|
||||
}
|
||||
|
||||
/* 图占大头(设计稿 FlowNotePane):42% 时蜜橘分拣这种宽图只剩三成大,框里的字看不清 */
|
||||
.graph-box {
|
||||
flex: 0 0 42%;
|
||||
flex: 0 0 62%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -19,6 +19,8 @@ const props = defineProps<{
|
||||
report: FlowchartCaseReport
|
||||
feedback: string | null
|
||||
suggestions: string | null
|
||||
/** 上一次走对了几组(详情接口的 previousCases) */
|
||||
previous?: { passed: number; total: number } | null
|
||||
/** 提交列表右栏窄:字小一号 */
|
||||
compact?: boolean
|
||||
}>()
|
||||
@@ -40,6 +42,24 @@ const better = computed(() => (passed.value ? (props.suggestions?.trim() ?? "")
|
||||
|
||||
/** 收起时连点开的那一组一起收掉(画布回到只标要改的框)。先算好再写:外面接着 v-model 时,
|
||||
* 写完马上读 open.value 读到的还是旧值 */
|
||||
/**
|
||||
* 「比上次多对了 n 组」:只在这次没画对时说(画对了标题已经够了),一样多不说;
|
||||
* 两次的组数不一样(老师中途改了例子)就没法比,也不说
|
||||
*/
|
||||
const compare = computed(() => {
|
||||
const previous = props.previous
|
||||
if (passed.value || !previous || previous.total !== props.report.total) return null
|
||||
const diff = props.report.passed - previous.passed
|
||||
if (diff > 0)
|
||||
return { up: true, text: `比上次多对了 ${diff} 组${props.compact ? "" : ",接着改"}` }
|
||||
if (diff < 0)
|
||||
return {
|
||||
up: false,
|
||||
text: `比上次少对了 ${-diff} 组${props.compact ? "" : ",看看刚才改了哪里"}`,
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
const next = !open.value
|
||||
open.value = next
|
||||
@@ -49,6 +69,28 @@ function toggle() {
|
||||
|
||||
<template>
|
||||
<div class="flowchart-note" :class="{ compact }">
|
||||
<!-- 完成度:一组一段,走对几组亮几段(不写「组」,学生不关心是几组数据) -->
|
||||
<div class="progress">
|
||||
<div
|
||||
class="segments"
|
||||
role="img"
|
||||
:aria-label="`完成度:${report.total} 段亮了 ${report.passed} 段`"
|
||||
>
|
||||
<!-- 从左往右亮,是条进度,不按哪一组对哪一组错排 -->
|
||||
<span
|
||||
v-for="index in report.total"
|
||||
:key="index"
|
||||
:class="{ lit: index <= report.passed }"
|
||||
></span>
|
||||
</div>
|
||||
<span
|
||||
v-if="compare"
|
||||
class="compare"
|
||||
:style="{ color: tone(compare.up ? 'success' : 'warning').color }"
|
||||
>
|
||||
{{ compare.text }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="wrong || fix || better" class="card">
|
||||
<div v-if="wrong">
|
||||
<div class="label" :style="{ color: tone('error').color }">
|
||||
@@ -85,6 +127,46 @@ function toggle() {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
}
|
||||
|
||||
.segments {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.segments span {
|
||||
width: 36px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.segments span.lit {
|
||||
background: v-bind("theme.successColor");
|
||||
}
|
||||
|
||||
.compact .segments {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.compact .segments span {
|
||||
width: 22px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.compare {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.compact .compare {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -96,7 +178,7 @@ function toggle() {
|
||||
|
||||
.compact .card {
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -120,8 +202,8 @@ function toggle() {
|
||||
|
||||
.compact .card p {
|
||||
margin-top: 4px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
|
||||
@@ -30,6 +30,17 @@ export function caseReport(details: Record<string, unknown> | null | undefined)
|
||||
} satisfies FlowchartCaseReport
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题那句话(设计稿 FlowNote*)。等级照旧存 S/A/B/C,学生看到的是话:全对时 S「画得很清楚」、
|
||||
* A「画对了」;没全对按走对几组分三档,C 里一组都没对和对了几组分开说 —— 一直是「还没画对」
|
||||
* 会让人泄气
|
||||
*/
|
||||
export function flowchartVerdict(report: FlowchartCaseReport, grade: string | null) {
|
||||
if (report.passed === report.total) return grade === "S" ? "画对了,画得很清楚" : "画对了"
|
||||
if (report.passed * 2 >= report.total) return "快画对了"
|
||||
return report.passed > 0 ? "对了一部分" : "还没画对"
|
||||
}
|
||||
|
||||
/** 「6 组走对 3 组」「6 组全部走对」 */
|
||||
export function caseHeadline(report: FlowchartCaseReport) {
|
||||
return report.passed === report.total
|
||||
|
||||
@@ -40,6 +40,14 @@ export const flowchartSubmissionSchema = z.object({
|
||||
aiModel: z.string(),
|
||||
processingTime: z.number().nullable(),
|
||||
evaluationTime: z.string().nullable(),
|
||||
/**
|
||||
* 同一个人同一道题的上一次(评完了的)走对了几组,给「比上次多对了 n 组」用。
|
||||
* 上一次不是走例子的评法(2026-10 之前的四项分)、或者没有上一次,给 null
|
||||
*/
|
||||
previousCases: z
|
||||
.object({ passed: z.number().int(), total: z.number().int() })
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const flowchartListItemSchema = z.object({
|
||||
|
||||
Reference in new issue
Block a user