Compare commits
3
Commits
38360e8668
...
67b13a9345
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67b13a9345 | ||
|
|
d8e0efd05e | ||
|
|
940895f643 |
No files matched your search
@@ -0,0 +1,32 @@
|
|||||||
|
-- 流程图老办法(四项打总分)的明细,模型有时给成 {"逻辑正确性": 32, ...} 这样的裸数字,
|
||||||
|
-- 提交详情读不到 score / max,四项全显示「0 / 0」。2026-10-19 重新评分老提交时线上撞上 4 条
|
||||||
|
-- (模型换成 deepseek-flash 之后)。裸数字改成 { score, max, comment },满分按 40 / 30 / 20 / 10;
|
||||||
|
-- 本来就是对象的项原样不动。新评的由 flowchart/run.ts 的 normalizeCriteria 收好再存。
|
||||||
|
UPDATE "flowchart_submission" AS target
|
||||||
|
SET "ai_criteria_details" = fixed.details
|
||||||
|
FROM (
|
||||||
|
SELECT f."id",
|
||||||
|
jsonb_object_agg(
|
||||||
|
e.key,
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(e.value) = 'number' THEN jsonb_build_object(
|
||||||
|
'score', e.value,
|
||||||
|
'max', CASE e.key
|
||||||
|
WHEN '逻辑正确性' THEN 40
|
||||||
|
WHEN '完整性' THEN 30
|
||||||
|
WHEN '规范性' THEN 20
|
||||||
|
WHEN '清晰度' THEN 10
|
||||||
|
ELSE 100
|
||||||
|
END,
|
||||||
|
'comment', ''
|
||||||
|
)
|
||||||
|
ELSE e.value
|
||||||
|
END
|
||||||
|
) AS details
|
||||||
|
FROM "flowchart_submission" f, jsonb_each(f."ai_criteria_details") e
|
||||||
|
WHERE NOT f."ai_criteria_details" ? 'version'
|
||||||
|
AND jsonb_typeof(f."ai_criteria_details") = 'object'
|
||||||
|
GROUP BY f."id"
|
||||||
|
HAVING bool_or(jsonb_typeof(e.value) = 'number')
|
||||||
|
) AS fixed
|
||||||
|
WHERE target."id" = fixed."id";
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -253,6 +253,13 @@
|
|||||||
"when": 1792130722120,
|
"when": 1792130722120,
|
||||||
"tag": "0035_recount_solved_metrics",
|
"tag": "0035_recount_solved_metrics",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 36,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792415463343,
|
||||||
|
"tag": "0036_fix_flowchart_criteria_numbers",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -163,11 +163,36 @@ function evaluationPrompt(problem: Problem) {
|
|||||||
return `你是专业的编程教学助手,负责评估学生的 Mermaid 流程图。
|
return `你是专业的编程教学助手,负责评估学生的 Mermaid 流程图。
|
||||||
评分:逻辑正确性40分、完整性30分、规范性20分、清晰度10分。
|
评分:逻辑正确性40分、完整性30分、规范性20分、清晰度10分。
|
||||||
不要评价系统生成的节点ID。feedback不超过100字;suggestions最多3条且只针对真实问题。
|
不要评价系统生成的节点ID。feedback不超过100字;suggestions最多3条且只针对真实问题。
|
||||||
返回纯 JSON:{"score":85,"grade":"A","feedback":"...","suggestions":"...","criteria_details":{}}。
|
返回纯 JSON:{"score":85,"grade":"A","feedback":"...","suggestions":"...","criteria_details":{"逻辑正确性":{"score":34,"max":40,"comment":"..."},"完整性":{"score":26,"max":30,"comment":"..."},"规范性":{"score":17,"max":20,"comment":"..."},"清晰度":{"score":8,"max":10,"comment":"..."}}}。
|
||||||
等级:S=90-100,A=80-89,B=70-79,C=0-69。
|
等级:S=90-100,A=80-89,B=70-79,C=0-69。
|
||||||
题目:${problem.title}\n${problem.description.slice(0, 2000)}`
|
题目:${problem.title}\n${problem.description.slice(0, 2000)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CRITERIA_MAX: Record<string, number> = { 逻辑正确性: 40, 完整性: 30, 规范性: 20, 清晰度: 10 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 四项明细收成 `{ 项名: { score, max, comment } }`,提交详情和统计都按这个形状读。
|
||||||
|
* 原来 prompt 里只写了 `"criteria_details":{}`,模型有时直接给 `"逻辑正确性": 34`,
|
||||||
|
* 存进去页面上四项全是「0 / 0」(2026-10 重新评分老提交时撞上的)。缺 max 的按满分表补
|
||||||
|
*/
|
||||||
|
function normalizeCriteria(raw: unknown) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(asRecord(raw)).map(([key, value]) => {
|
||||||
|
const item = asRecord(value)
|
||||||
|
const score = typeof value === "number" ? value : Number(item.score)
|
||||||
|
const max = Number(item.max)
|
||||||
|
return [
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
score: Number.isFinite(score) ? score : 0,
|
||||||
|
max: Number.isFinite(max) && max > 0 ? max : (CRITERIA_MAX[key] ?? 100),
|
||||||
|
comment: typeof item.comment === "string" ? item.comment : "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function parseEvaluation(value: string): Evaluation {
|
function parseEvaluation(value: string): Evaluation {
|
||||||
const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1]
|
const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1]
|
||||||
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
|
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
|
||||||
@@ -181,7 +206,7 @@ function parseEvaluation(value: string): Evaluation {
|
|||||||
grade: gradeForScore(score),
|
grade: gradeForScore(score),
|
||||||
feedback: typeof data.feedback === "string" ? data.feedback : "",
|
feedback: typeof data.feedback === "string" ? data.feedback : "",
|
||||||
suggestions: typeof data.suggestions === "string" ? data.suggestions : "",
|
suggestions: typeof data.suggestions === "string" ? data.suggestions : "",
|
||||||
criteria: asRecord(data.criteria_details),
|
criteria: normalizeCriteria(data.criteria_details),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { getLoginWindow } from "../auth/session"
|
|||||||
import { presenceOf } from "../collab/state"
|
import { presenceOf } from "../collab/state"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, parseBody, success } from "../http"
|
import { failure, parseBody, success } from "../http"
|
||||||
|
import { isAccepted } from "../judge/status"
|
||||||
import {
|
import {
|
||||||
activityRows,
|
activityRows,
|
||||||
classMemberIds,
|
classMemberIds,
|
||||||
@@ -455,7 +456,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
// 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
|
// 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
|
||||||
const recentActivity = activityRows({ since: windowStart, users: userIds })
|
const recentActivity = activityRows({ since: windowStart, users: userIds })
|
||||||
const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')`
|
const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')`
|
||||||
const [cells, lastSubmits, lastDrawn, attendance, drawn, latests, seen] = await Promise.all([
|
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen] = await Promise.all([
|
||||||
ids.length && userIds.length
|
ids.length && userIds.length
|
||||||
? db
|
? db
|
||||||
.select({
|
.select({
|
||||||
@@ -548,16 +549,17 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
.groupBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId)
|
.groupBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId)
|
||||||
: [],
|
: [],
|
||||||
/**
|
/**
|
||||||
* 每人每题今天最后一次代码提交:没做对的格子靠它点开讲错误、按错法归堆。
|
* 每人每题今天交的每一次代码,从早到晚。最后一次给没做对的格子点开讲错误、按错法归堆,
|
||||||
* 范围和 cells 一样(非比赛、布置的语言),只多一个「今天」
|
* 整串给做对了的格子看轨迹。范围和 cells 一样(非比赛、布置的语言),只多一个「今天」
|
||||||
*/
|
*/
|
||||||
ids.length && userIds.length
|
ids.length && userIds.length
|
||||||
? db
|
? db
|
||||||
.selectDistinctOn([schema.submission.userId, schema.submission.problemId], {
|
.select({
|
||||||
userId: schema.submission.userId,
|
userId: schema.submission.userId,
|
||||||
problemId: schema.submission.problemId,
|
problemId: schema.submission.problemId,
|
||||||
id: schema.submission.id,
|
id: schema.submission.id,
|
||||||
result: schema.submission.result,
|
result: schema.submission.result,
|
||||||
|
createTime: schema.submission.createTime,
|
||||||
runtimeError: sql<
|
runtimeError: sql<
|
||||||
StatisticInfo["runtime_error"] | null
|
StatisticInfo["runtime_error"] | null
|
||||||
>`${schema.submission.statisticInfo}->'runtime_error'`,
|
>`${schema.submission.statisticInfo}->'runtime_error'`,
|
||||||
@@ -575,15 +577,11 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
lessonLanguageFilter(lesson.language),
|
lessonLanguageFilter(lesson.language),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(
|
.orderBy(schema.submission.createTime)
|
||||||
schema.submission.userId,
|
|
||||||
schema.submission.problemId,
|
|
||||||
desc(schema.submission.createTime),
|
|
||||||
)
|
|
||||||
: [],
|
: [],
|
||||||
seenToday(userIds),
|
seenToday(userIds),
|
||||||
])
|
])
|
||||||
const latestByKey = new Map(latests.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
const todayByKey = Map.groupBy(todays, (row) => `${row.userId}:${row.problemId}`)
|
||||||
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
|
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
|
||||||
@@ -623,7 +621,12 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
const cell = cellByKey.get(key)
|
const cell = cellByKey.get(key)
|
||||||
const flow = drawnByKey.get(key)
|
const flow = drawnByKey.get(key)
|
||||||
const acceptedAt = earliest(cell?.firstAcceptedAt, flow?.firstPassedAt)
|
const acceptedAt = earliest(cell?.firstAcceptedAt, flow?.firstPassedAt)
|
||||||
const latest = acceptedAt ? undefined : latestByKey.get(key)
|
const today = todayByKey.get(key) ?? []
|
||||||
|
const latest = acceptedAt ? undefined : today.at(-1)
|
||||||
|
const firstTry =
|
||||||
|
acceptedAt && Date.parse(acceptedAt) >= Date.parse(start)
|
||||||
|
? today.findIndex((row) => isAccepted(row.result))
|
||||||
|
: -1
|
||||||
return {
|
return {
|
||||||
status: !cell && !flow ? "none" : acceptedAt ? "accepted" : "tried",
|
status: !cell && !flow ? "none" : acceptedAt ? "accepted" : "tried",
|
||||||
attempts: (cell?.attempts ?? 0) + (flow?.attempts ?? 0),
|
attempts: (cell?.attempts ?? 0) + (flow?.attempts ?? 0),
|
||||||
@@ -638,6 +641,14 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
sampleCheck: latest.sampleCheck ?? undefined,
|
sampleCheck: latest.sampleCheck ?? undefined,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
trail: acceptedAt
|
||||||
|
? today.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
result: row.result,
|
||||||
|
createTime: row.createTime,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
lastSubmitAt: lastByUser.get(student.userId) ?? null,
|
lastSubmitAt: lastByUser.get(student.userId) ?? null,
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent }
|
|||||||
import ExplainModal, { type ExplainPerson } from "./components/ExplainModal.vue"
|
import ExplainModal, { type ExplainPerson } from "./components/ExplainModal.vue"
|
||||||
import LeaveModal from "./components/LeaveModal.vue"
|
import LeaveModal from "./components/LeaveModal.vue"
|
||||||
import LessonForm from "./components/LessonForm.vue"
|
import LessonForm from "./components/LessonForm.vue"
|
||||||
|
import TrailPanel from "./components/TrailPanel.vue"
|
||||||
import { errorKind, type ErrorKind } from "utils/errorKind"
|
import { errorKind, type ErrorKind } from "utils/errorKind"
|
||||||
import {
|
import {
|
||||||
cellStyle as cellStyleOf,
|
cellStyle as cellStyleOf,
|
||||||
cellText,
|
cellText,
|
||||||
cellTitle as cellTitleOf,
|
cellTitle as cellTitleOf,
|
||||||
STUCK_ATTEMPTS,
|
STUCK_ATTEMPTS,
|
||||||
|
trailSummary,
|
||||||
} from "./boardCells"
|
} from "./boardCells"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -378,6 +380,22 @@ const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day,
|
|||||||
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) =>
|
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) =>
|
||||||
cellTitleOf(cell, student, board.value?.day)
|
cellTitleOf(cell, student, board.value?.day)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点开着轨迹的那个格子(学生 id:第几道题)。做对了、今天交过代码的格子点了不再直接去提交列表,
|
||||||
|
* 先在格子下面看他是怎么一次次做对的,列表在弹出来的那块里
|
||||||
|
*/
|
||||||
|
const trailKey = ref<string | null>(null)
|
||||||
|
|
||||||
|
function toggleTrail(key: string) {
|
||||||
|
trailKey.value = trailKey.value === key ? null : key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点到别的格子时,那边自己会换过去;这里只管点到空白处收起 */
|
||||||
|
function trailOutside(event: MouseEvent) {
|
||||||
|
if ((event.target as Element | null)?.closest(".cell")) return
|
||||||
|
trailKey.value = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。
|
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。
|
||||||
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。
|
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。
|
||||||
@@ -520,10 +538,19 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
|
|
||||||
<section v-if="selectedIndex >= 0" class="panel">
|
<section v-if="selectedIndex >= 0" class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<b class="panel-title">
|
<a
|
||||||
|
class="panel-title"
|
||||||
|
:href="
|
||||||
|
router.resolve({
|
||||||
|
name: 'problem',
|
||||||
|
params: { problemID: board.problems[selectedIndex]!.problemDisplayId },
|
||||||
|
}).href
|
||||||
|
"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
<span class="muted">{{ board.problems[selectedIndex]!.problemDisplayId }}</span>
|
<span class="muted">{{ board.problems[selectedIndex]!.problemDisplayId }}</span>
|
||||||
{{ board.problems[selectedIndex]!.title }}
|
{{ board.problems[selectedIndex]!.title }}
|
||||||
</b>
|
</a>
|
||||||
<span>
|
<span>
|
||||||
错着的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
|
错着的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
|
||||||
</span>
|
</span>
|
||||||
@@ -688,6 +715,35 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
>
|
>
|
||||||
{{ cellText(cell) }}
|
{{ cellText(cell) }}
|
||||||
</button>
|
</button>
|
||||||
|
<n-popover
|
||||||
|
v-else-if="cell.trail.length"
|
||||||
|
:show="trailKey === `${student.userId}:${i}`"
|
||||||
|
trigger="manual"
|
||||||
|
placement="bottom-start"
|
||||||
|
:style="{ width: '620px', maxWidth: 'calc(100vw - 32px)' }"
|
||||||
|
@clickoutside="trailOutside"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cell"
|
||||||
|
:class="{ picked: trailKey === `${student.userId}:${i}` }"
|
||||||
|
:style="cellStyle(cell)"
|
||||||
|
:title="cellTitle(cell, student)"
|
||||||
|
@click="toggleTrail(`${student.userId}:${i}`)"
|
||||||
|
>
|
||||||
|
{{ cellText(cell) }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<TrailPanel
|
||||||
|
:username="student.username"
|
||||||
|
:problem="board.problems[i]!"
|
||||||
|
:cell="cell"
|
||||||
|
:summary="trailSummary(cell, board.day)"
|
||||||
|
:list-href="submissionsHref(student, board.problems[i], false)"
|
||||||
|
@close="trailKey = null"
|
||||||
|
/>
|
||||||
|
</n-popover>
|
||||||
<a
|
<a
|
||||||
v-else-if="cell.attempts"
|
v-else-if="cell.attempts"
|
||||||
class="cell"
|
class="cell"
|
||||||
@@ -941,6 +997,13 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title:hover {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
}
|
}
|
||||||
|
|
||||||
.groups {
|
.groups {
|
||||||
@@ -1157,6 +1220,10 @@ a.cell {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell.picked {
|
||||||
|
box-shadow: 0 0 0 2px v-bind("theme.textColor1");
|
||||||
|
}
|
||||||
|
|
||||||
.num {
|
.num {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export function acceptedToday(cell: ClassBoardCell, day: string | undefined) {
|
|||||||
return !!cell.acceptedAt && parseTime(cell.acceptedAt, "YYYY-MM-DD") === day
|
return !!cell.acceptedAt && parseTime(cell.acceptedAt, "YYYY-MM-DD") === day
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */
|
||||||
export function cellText(cell: ClassBoardCell) {
|
export function cellText(cell: ClassBoardCell) {
|
||||||
if (cell.status === "accepted") return "✓"
|
if (cell.status === "accepted") return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : "✓"
|
||||||
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts}` : "✗"
|
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts}` : "✗"
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -62,3 +63,13 @@ export function cellTitle(
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 点开做对的格子,方块串旁边那句 */
|
||||||
|
export function trailSummary(cell: ClassBoardCell, day: string | undefined) {
|
||||||
|
const total = cell.trail.length
|
||||||
|
if (cell.acceptedTry === 1) return total === 1 ? "一次就做对" : `交了 ${total} 次,第 1 次就做对`
|
||||||
|
if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对`
|
||||||
|
return acceptedToday(cell, day)
|
||||||
|
? `今天交了 ${total} 次代码`
|
||||||
|
: `以前就做对了,今天又交了 ${total} 次`
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { useTone } from "shared/composables/tone"
|
||||||
|
import UserName from "shared/components/UserName.vue"
|
||||||
|
import { attemptTone, cachedSubmission } from "oj/statistics/attempts"
|
||||||
|
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
|
import type { ClassBoardCell, ClassBoardProblem, Submission } from "utils/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点开一个做对了的格子:他这道题今天交的每一次,一格一次、从早到晚,下面是停着的那一次的代码
|
||||||
|
* (设计稿「课堂看板重设计」· 做对的格子也看得到轨迹)。
|
||||||
|
*
|
||||||
|
* 默认停在做对前最后错的那一次 —— 要讲的一般是「他最后是改了哪儿才对的」。
|
||||||
|
* 停上去就换,不用点:老师在几格之间来回比;点也行(手机上没有悬停)
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
username: string
|
||||||
|
problem: ClassBoardProblem
|
||||||
|
cell: ClassBoardCell
|
||||||
|
summary: string
|
||||||
|
/** 「在提交列表看这 N 次」:和格子原来点进去的是同一个列表 */
|
||||||
|
listHref: string
|
||||||
|
}>()
|
||||||
|
defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const tone = useTone()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
|
const picked = ref(
|
||||||
|
props.cell.acceptedTry
|
||||||
|
? Math.max(props.cell.acceptedTry - 2, 0)
|
||||||
|
: Math.max(props.cell.trail.length - 1, 0),
|
||||||
|
)
|
||||||
|
const item = computed(() => props.cell.trail[picked.value] ?? props.cell.trail.at(-1))
|
||||||
|
|
||||||
|
const code = reactive<Record<string, Submission | "loading" | "failed">>({})
|
||||||
|
watch(
|
||||||
|
() => item.value?.id,
|
||||||
|
async (id) => {
|
||||||
|
if (!id || (code[id] && code[id] !== "failed")) return
|
||||||
|
code[id] = "loading"
|
||||||
|
try {
|
||||||
|
code[id] = await cachedSubmission(id)
|
||||||
|
} catch {
|
||||||
|
code[id] = "failed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
const shown = computed(() => {
|
||||||
|
const value = item.value && code[item.value.id]
|
||||||
|
return typeof value === "object" ? value : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const squares = computed(() =>
|
||||||
|
props.cell.trail.map((row) => ({
|
||||||
|
...row,
|
||||||
|
color: tone(attemptTone(row.result)).solid,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="trail">
|
||||||
|
<div class="head">
|
||||||
|
<UserName :username="username" />
|
||||||
|
<span class="problem">
|
||||||
|
<b>{{ problem.problemDisplayId }}</b>
|
||||||
|
<span class="muted">{{ problem.title }}</span>
|
||||||
|
</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<n-button quaternary size="tiny" aria-label="收起" @click="$emit('close')">
|
||||||
|
<template #icon><Icon icon="ph:x-bold" /></template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="line">
|
||||||
|
<div class="squares">
|
||||||
|
<button
|
||||||
|
v-for="(row, index) in squares"
|
||||||
|
:key="row.id"
|
||||||
|
type="button"
|
||||||
|
class="square"
|
||||||
|
:class="{ on: index === picked }"
|
||||||
|
:style="{ background: row.color }"
|
||||||
|
:aria-label="`第 ${index + 1} 次:${JUDGE_STATUS[row.result]?.name ?? ''}`"
|
||||||
|
@mouseenter="picked = index"
|
||||||
|
@click="picked = index"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<span class="summary">{{ summary }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="item" class="code">
|
||||||
|
<div class="code-head">
|
||||||
|
<b :style="{ color: tone(attemptTone(item.result)).color }">
|
||||||
|
{{ JUDGE_STATUS[item.result]?.name }}
|
||||||
|
</b>
|
||||||
|
<span class="muted"
|
||||||
|
>{{ parseTime(item.createTime, "HH:mm") }} · 第 {{ picked + 1 }} 次</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<n-code
|
||||||
|
v-if="shown"
|
||||||
|
class="code-body"
|
||||||
|
:code="shown.code"
|
||||||
|
:language="LANGUAGE_FORMAT_VALUE[shown.language]"
|
||||||
|
show-line-numbers
|
||||||
|
/>
|
||||||
|
<span v-else-if="code[item.id] === 'failed'" class="muted">代码没拉下来,点下面打开看</span>
|
||||||
|
<span v-else class="muted">代码加载中…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links">
|
||||||
|
<a v-if="item" :href="`/submission/${item.id}`" target="_blank">打开这次提交 ↗</a>
|
||||||
|
<a :href="listHref" target="_blank">在提交列表看这 {{ cell.trail.length }} 次 ↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.trail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem .muted {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.squares {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.square {
|
||||||
|
flex: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.square.on {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px v-bind("theme.cardColor"),
|
||||||
|
0 0 0 4px v-bind("theme.textColor1");
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-body {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links a {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { getSubmission } from "oj/api"
|
||||||
|
import { SubmissionStatus } from "utils/constants"
|
||||||
|
import type { Submission } from "utils/types"
|
||||||
|
import type { Tone } from "shared/composables/tone"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次提交一格的那串方块(统计页的 AttemptSquares、课堂看板做对格子的轨迹)共用的两样:
|
||||||
|
* 方块的颜色,和停上去看代码时拉的提交。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 对了绿;编译失败、运行时错误、语法规则没过、部分通过橙;判题中蓝;其余红 */
|
||||||
|
export function attemptTone(result: number): Tone {
|
||||||
|
if (result === SubmissionStatus.accepted) return "success"
|
||||||
|
if (
|
||||||
|
result === SubmissionStatus.compile_error ||
|
||||||
|
result === SubmissionStatus.runtime_error ||
|
||||||
|
result === SubmissionStatus.ast_check_failed ||
|
||||||
|
result === SubmissionStatus.partial_accepted
|
||||||
|
) {
|
||||||
|
return "warning"
|
||||||
|
}
|
||||||
|
if (result === SubmissionStatus.pending || result === SubmissionStatus.judging) return "info"
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代码按提交 id 缓存,整页共用:交了就不会再变,老师在一排方块之间来回对比时
|
||||||
|
* 不用每次都等网络。失败的不进缓存,下次停上去再试。
|
||||||
|
*/
|
||||||
|
const codeCache = new Map<string, Promise<Submission>>()
|
||||||
|
|
||||||
|
export function cachedSubmission(id: string) {
|
||||||
|
let hit = codeCache.get(id)
|
||||||
|
if (!hit) {
|
||||||
|
hit = getSubmission(id)
|
||||||
|
hit.catch(() => codeCache.delete(id))
|
||||||
|
codeCache.set(id, hit)
|
||||||
|
}
|
||||||
|
return hit
|
||||||
|
}
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Submission } from "utils/types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 代码按提交 id 缓存,整页共用:交了就不会再变,老师在一排方块之间来回对比时
|
|
||||||
* 不用每次都等网络。失败的不进缓存,下次停上去再试。
|
|
||||||
*/
|
|
||||||
const codeCache = new Map<string, Submission>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getSubmission } from "oj/api"
|
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
||||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
|
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type { SUBMISSION_RESULT } from "utils/types"
|
import type { Submission, SUBMISSION_RESULT } from "utils/types"
|
||||||
import { useTone, type Tone } from "shared/composables/tone"
|
import { useTone } from "shared/composables/tone"
|
||||||
|
import { attemptTone, cachedSubmission } from "../attempts"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一个人在一道题上的每一次提交,一格一次、从早到晚(设计稿「统计重设计」)。
|
* 一个人在一道题上的每一次提交,一格一次、从早到晚(设计稿「统计重设计」)。
|
||||||
@@ -31,34 +21,13 @@ const props = withDefaults(
|
|||||||
const tone = useTone()
|
const tone = useTone()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
|
|
||||||
function toneOf(result: number): Tone {
|
|
||||||
if (result === SubmissionStatus.accepted) return "success"
|
|
||||||
if (
|
|
||||||
result === SubmissionStatus.compile_error ||
|
|
||||||
result === SubmissionStatus.runtime_error ||
|
|
||||||
result === SubmissionStatus.ast_check_failed ||
|
|
||||||
result === SubmissionStatus.partial_accepted
|
|
||||||
) {
|
|
||||||
return "warning"
|
|
||||||
}
|
|
||||||
if (result === SubmissionStatus.pending || result === SubmissionStatus.judging) return "info"
|
|
||||||
return "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = reactive<Record<string, Submission | "loading" | "failed">>({})
|
const code = reactive<Record<string, Submission | "loading" | "failed">>({})
|
||||||
|
|
||||||
async function load(id: string) {
|
async function load(id: string) {
|
||||||
const hit = codeCache.get(id)
|
if (code[id] && code[id] !== "failed") return
|
||||||
if (hit) {
|
|
||||||
code[id] = hit
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (code[id] === "loading") return
|
|
||||||
code[id] = "loading"
|
code[id] = "loading"
|
||||||
try {
|
try {
|
||||||
const res = await getSubmission(id)
|
code[id] = await cachedSubmission(id)
|
||||||
codeCache.set(id, res)
|
|
||||||
code[id] = res
|
|
||||||
} catch {
|
} catch {
|
||||||
code[id] = "failed"
|
code[id] = "failed"
|
||||||
}
|
}
|
||||||
@@ -72,7 +41,7 @@ const cells = computed(() =>
|
|||||||
props.items.map((item, index) => ({
|
props.items.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
color: tone(toneOf(item.result)).solid,
|
color: tone(attemptTone(item.result)).solid,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ export const classBoardCellSchema = z.object({
|
|||||||
sampleCheck: statisticInfoSchema.shape.sample_check,
|
sampleCheck: statisticInfoSchema.shape.sample_check,
|
||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
|
/**
|
||||||
|
* 做对了的格子:今天交的每一次代码,从早到晚(范围和 latest 一样)。点格子看他是怎么一次次
|
||||||
|
* 做对的,停在哪一次看哪次的代码。没做对的为空 —— 那边点开是讲错误,靠 latest
|
||||||
|
*/
|
||||||
|
trail: z.array(z.object({ id: z.string(), result: judgeStatusSchema, createTime: z.string() })),
|
||||||
|
/** 今天第几次代码提交做对的,1 = 一次就对;今天之前就做对了、今天没靠代码做对的为 null */
|
||||||
|
acceptedTry: z.number().int().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const classBoardStudentSchema = z.object({
|
export const classBoardStudentSchema = z.object({
|
||||||
|
|||||||
Reference in new issue
Block a user