feat(布置作业): 能布置「画流程图」「先画再写」,看板分清卡在画图还是写代码
Deploy / deploy (push) Canceled after 0s

布置时多一个「做什么」:写代码 / 先画再写 / 画流程图(只能选打开了画流程图的题)。
- 先画再写:一道题图画到 A/S、代码也交对才算做完;学生画一道写一道、全画完再写都行,
  打开题目自动停在该做的那一步(图没画对停画布,画对了换到布置的语言)
- 也能分开布置:先一节画流程图,看板上「用这几道题写代码」再开一节;学生那边把今天
  几节课的要求合起来,还没画完的照样看得到要画
- 看板格子是一条进度:图 1/6 → 空心「图 ✓」→ 代码 ✗ → ✓,先写代码没画图的写「缺图」;
  卡在画图上的点开讲那张图(要改的框标红 + AI 的哪里错/怎么改),按走对几组归堆
- 写代码的课不再拿画对的流程图顶替做完;数据统计里画流程图的课数流程图提交

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 21:50:48 +08:00
1 parent 71323d9b63
commit 38a331c2b7
33 files changed
+4987 -209

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
-- 这节课做什么:写代码 / 画流程图 / 先画再写。以前布置的都是写代码;带 DEFAULT,没升级的旧代码照样能布置
ALTER TABLE "class_lesson" ADD COLUMN "mode" text DEFAULT 'code' NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -288,6 +288,13 @@
"when": 1793622303180,
"tag": "0040_class_lesson_many_per_day",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1793626595966,
"tag": "0041_lesson_mode",
"breakpoints": true
}
]
}
+6
View File
@@ -45,6 +45,7 @@ import type {
HintDiagnosis,
JudgeStatus,
LessonLanguage,
LessonMode,
ProblemChallenges,
ProblemDifficulty,
ProblemLanguage,
@@ -1324,6 +1325,11 @@ export const classLesson = pgTable(
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过
*/
language: text().$type<LessonLanguage>(),
/**
* 这节课做什么:写代码 / 画流程图 / 先画再写(见契约 lessonModeSchema)。加这一列之前布置的
* 都是写代码。写入侧按 lessonModeSchema 校验过
*/
mode: text().$type<LessonMode>().notNull().default('code'),
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */
createdBy: integer('created_by').notNull(),
/**
+19 -7
View File
@@ -34,6 +34,7 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
day: schema.classLesson.day,
problemIds: schema.classLesson.problemIds,
language: schema.classLesson.language,
mode: schema.classLesson.mode,
createdAt: schema.classLesson.createdAt,
updatedAt: schema.classLesson.updatedAt,
createdBy: teacherName,
@@ -85,6 +86,7 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
updatedAt: row.updatedAt,
createdBy: row.createdBy,
language: row.language ?? null,
mode: row.mode,
// 题被删掉的(problem_ids 不挂外键)直接不列
problems: row.problemIds.flatMap((id) => {
const problem = problemById.get(id)
@@ -98,23 +100,33 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
} satisfies AdminLessonList)
})
/** 新补一节、改一节共用的校验:班得有人、题号都对得上、语言每道都能交 */
async function lessonValues(
body: AdminLessonRequest,
): Promise<
/** 新补一节、改一节共用的校验:班得有人、题号都对得上、语言每道都能交、要画的都打开了画流程图 */
async function lessonValues(body: AdminLessonRequest): Promise<
| { code: string; message: string }
| { values: { className: string; day: string; problemIds: number[]; language: AdminLessonRequest['language']; createdAt: string } }
| {
values: {
className: string
day: string
problemIds: number[]
language: AdminLessonRequest['language']
mode: AdminLessonRequest['mode']
createdAt: string
}
}
> {
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, body.className)).limit(1)
if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` }
const resolved = await resolveLessonProblems(body.problemDisplayIds, body.language)
// 只画流程图不用语言,存成 null(和看板布置一样)
const language = body.mode === 'flowchart' ? null : body.language
const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode)
if ('code' in resolved) return resolved
return {
values: {
className: body.className,
day: body.day,
problemIds: resolved.problemIds,
language: body.language,
language,
mode: body.mode,
createdAt: localDateTime(body.day, body.time),
},
}
+197 -40
View File
@@ -9,6 +9,8 @@ import {
type ClassActivityProblem,
type LastVisit,
type LessonLanguage,
type LessonMode,
lessonNeeds,
type StatisticInfo,
} from '@oj2/contract'
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from 'drizzle-orm'
@@ -24,7 +26,7 @@ import { failure, parseBody, success } from '../http'
import { isAccepted } from '../judge/status'
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
import { accepted } from '../services/learning-stats'
import { announceLessonChange, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson'
import { announceLessonChange, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson'
import { calendarDay, dayStart, localTime } from '../time'
export const classroomRoutes = new Hono<AppEnv>()
@@ -50,30 +52,49 @@ interface ClassDayGroup {
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
* 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
*
* 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数,
* 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数
*/
async function classDayGroups(
className: string,
since: string,
options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null } = {},
options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null; mode?: LessonMode | null } = {},
): Promise<ClassDayGroup[]> {
const { mode } = options
const activity = activityRows({
since,
users: classMemberIds(className),
problemIds: options.problemIds,
language: options.language,
only: mode === 'code' || mode === 'flowchart' ? mode : undefined,
})
const day = sql<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
const userCount = sql<number>`count(distinct ${activity.userId})::int`
const query = db
const perUser = db
.select({
day,
problemId: activity.problemId,
firstAt: sql<string>`min(${activity.createTime})`,
userCount,
acceptedCount: sql<number>`count(distinct ${activity.userId}) filter (where ${activity.solved})::int`,
day: day.as('day'),
problemId: sql<number>`${activity.problemId}`.as('problem_id'),
userId: sql<number>`${activity.userId}`.as('user_id'),
firstAt: sql<string>`min(${activity.createTime})`.as('first_at'),
solved: (mode === 'both'
? sql<boolean>`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})`
: sql<boolean>`bool_or(${activity.solved})`
).as('solved'),
})
.from(activity)
.groupBy(day, activity.problemId)
.groupBy(day, activity.problemId, activity.userId)
.as('per_user')
const userCount = sql<number>`count(*)::int`
const query = db
.select({
day: perUser.day,
problemId: perUser.problemId,
firstAt: sql<string>`min(${perUser.firstAt})`,
userCount,
acceptedCount: sql<number>`(count(*) filter (where ${perUser.solved}))::int`,
})
.from(perUser)
.groupBy(perUser.day, perUser.problemId)
return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query
}
@@ -93,7 +114,7 @@ async function visibleProblems(ids: number[]) {
}
/**
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言、布置时刻和布置的老师;
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师;
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
*/
async function lessonPlan(className: string, day: string) {
@@ -102,6 +123,7 @@ async function lessonPlan(className: string, day: string) {
id: schema.classLesson.id,
problemIds: schema.classLesson.problemIds,
language: schema.classLesson.language,
mode: schema.classLesson.mode,
createdAt: schema.classLesson.createdAt,
createdBy: sql<string>`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`,
})
@@ -129,7 +151,7 @@ async function classLessonProblems(className: string, lookbackDays: number) {
const planned = plan.problemIds
const [problems, groups] = await Promise.all([
visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language }),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }),
])
const groupById = new Map(groups.map((row) => [row.problemId, row]))
return {
@@ -137,6 +159,7 @@ async function classLessonProblems(className: string, lookbackDays: number) {
lessonId: plan.id,
day: today,
language: plan.language ?? null,
mode: plan.mode,
assignedAt: plan.createdAt,
assignedBy: plan.createdBy,
problems: planned.flatMap((id) => {
@@ -166,8 +189,9 @@ async function classLessonProblems(className: string, lookbackDays: number) {
source: picked.length ? ('inferred' as const) : null,
lessonId: null,
day: picked.length ? latest : null,
// 推断出来的课不知道老师要的是哪种语言,不限
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
language: null,
mode: null,
assignedAt: null,
assignedBy: null,
problems: picked.flatMap((row) => {
@@ -298,12 +322,15 @@ classroomRoutes.get('/me/class-activity', requireAuth, async (c) => {
day: null,
source: null,
language: null,
mode: null,
problems: [],
} satisfies ClassActivity)
}
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id)
const steps = lesson.source === 'teacher' && lesson.mode ? await todaySteps(user.className, ids, lesson) : null
const codeLanguage = steps ? steps.language : lesson.language
// 代码提交和流程图提交两张表,互不依赖,一起查
const [mine, drawn] = await Promise.all([
ids.length
@@ -319,14 +346,12 @@ classroomRoutes.get('/me/class-activity', requireAuth, async (c) => {
inArray(schema.submission.problemId, ids),
isNull(schema.submission.contestId),
// 布置的是 C:以前用 Python 做对过不算这份作业做完
lessonLanguageFilter(lesson.language),
lessonLanguageFilter(codeLanguage),
),
)
.groupBy(schema.submission.problemId)
: [],
// 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
// 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」。
// 交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
// 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
ids.length
? db
.select({
@@ -338,29 +363,92 @@ classroomRoutes.get('/me/class-activity', requireAuth, async (c) => {
.groupBy(schema.flowchartSubmission.problemId)
: [],
])
const mineById = new Map(mine.map((row) => [row.problemId, row.accepted]))
for (const row of drawn) {
mineById.set(row.problemId, (mineById.get(row.problemId) ?? false) || row.passed)
}
type Step = 'accepted' | 'tried' | 'none'
const stepOf = (value: boolean | undefined): Step => (value === undefined ? 'none' : value ? 'accepted' : 'tried')
const codeById = new Map(mine.map((row) => [row.problemId, row.accepted]))
const drawById = new Map(drawn.map((row) => [row.problemId, row.passed]))
return success(c, {
className: user.className,
day: lesson.day,
source: lesson.source,
language: lesson.language,
language: codeLanguage,
mode: lesson.mode,
problems: lesson.problems.map((problem) => {
const accepted = mineById.get(problem.id)
const code = stepOf(codeById.get(problem.id))
const draw = stepOf(drawById.get(problem.id))
const needs = steps?.needs.get(problem.id)
let myStatus: Step
if (needs) {
// 老师布置的:要哪几步就看哪几步,全做到才算做完
const wanted = [needs.draw ? draw : null, needs.code ? code : null].filter((step): step is Step => step !== null)
myStatus = wanted.every((step) => step === 'accepted') ? 'accepted' : wanted.some((step) => step !== 'none') ? 'tried' : 'none'
} else {
// 推断出来的课:代码交对或流程图画到 A / S 都算(有的题全班在画流程图,只看代码的话永远是「没做」)
myStatus = code === 'accepted' || draw === 'accepted' ? 'accepted' : code === 'tried' || draw === 'tried' ? 'tried' : 'none'
}
return {
problemDisplayId: problem.displayId,
title: problem.title,
userCount: problem.userCount,
acceptedCount: problem.acceptedCount,
myStatus: accepted === undefined ? 'none' : accepted ? 'accepted' : 'tried',
myStatus,
draw: needs?.draw ? draw : null,
code: needs?.code ? code : null,
} satisfies ClassActivityProblem
}),
} satisfies ClassActivity)
})
/**
* 学生那边一道题要画还是要写:今天这个班布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再布置一节同样的题写代码时,还没画完的人照样看得到要画 ——
* 学生只看得到今天最后布置的那一节,不合起来的话,画流程图那节一布置写代码就没了。
*
* 写代码用的语言:今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
async function todaySteps(
className: string,
problemIds: number[],
latest: { lessonId: number | null; language: LessonLanguage | null; mode: LessonMode | null },
) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = latest.mode && lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = !!latest.mode && lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.lessonId ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
return { needs, language }
}
/**
* 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2,
* 形状见契约 FlowchartCaseReport)里数。老评法、还没评完的、评失败的都是 null
*/
function flowchartCaseCounts() {
const details = schema.flowchartSubmission.aiCriteriaDetails
const isReport = sql`${schema.flowchartSubmission.status} = 2 and ${details}->>'version' = '2' and jsonb_typeof(${details}->'cases') = 'array'`
return {
passed: sql<number | null>`case when ${isReport} then (select count(*) from jsonb_array_elements(${details}->'cases') e where e->>'ok' = 'true')::int end`,
total: sql<number | null>`case when ${isReport} then jsonb_array_length(${details}->'cases') end`,
}
}
/**
* 看板上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
* 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」
@@ -385,6 +473,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
source: null,
lessonId: null,
language: null,
mode: null,
assignedAt: null,
assignedBy: null,
lastLanguage: null,
@@ -438,7 +527,10 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
const challengeDoneKeys = new Set(
challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`),
)
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen] = await Promise.all([
// 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样)
const mode = lesson.source === 'teacher' ? lesson.mode : null
const needs = mode ? lessonNeeds(mode) : null
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([
ids.length && userIds.length
? db
.select({
@@ -537,7 +629,27 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
.orderBy(schema.submission.createTime)
: [],
seenToday(userIds),
/**
* 要画流程图的课:每人每题最后一张图(不限今天,和 drawn 的 firstPassedAt 同一个范围),
* 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数,
* 老评法和还没评完的为 null
*/
needs?.draw && ids.length && userIds.length
? db
.selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], {
userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
id: schema.flowchartSubmission.id,
status: schema.flowchartSubmission.status,
grade: schema.flowchartSubmission.aiGrade,
...flowchartCaseCounts(),
})
.from(schema.flowchartSubmission)
.where(and(inArray(schema.flowchartSubmission.userId, userIds), inArray(schema.flowchartSubmission.problemId, ids)))
.orderBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId, desc(schema.flowchartSubmission.createTime))
: [],
])
const lastFlowByKey = new Map(lastFlows.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 drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
@@ -549,6 +661,8 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
if (!known || Date.parse(row.lastAt) > Date.parse(known)) lastByUser.set(row.userId, row.lastAt)
}
const earliest = (a: string | null | undefined, b: string | null | undefined) => (!a ? (b ?? null) : !b ? a : Date.parse(a) <= Date.parse(b) ? a : b)
const latestOf = (a: string, b: string) => (Date.parse(a) >= Date.parse(b) ? a : b)
type Step = 'accepted' | 'tried' | 'none'
const attendedByUser = new Map(attendance.map((row) => [row.userId, row.days]))
return success(c, {
@@ -557,6 +671,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
source: lesson.source,
lessonId: lesson.lessonId,
language: lesson.language,
mode,
assignedAt: lesson.assignedAt,
assignedBy: lesson.assignedBy,
lastLanguage: last?.language ?? null,
@@ -577,16 +692,50 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
const key = `${student.userId}:${id}`
const cell = cellByKey.get(key)
const flow = drawnByKey.get(key)
const acceptedAt = earliest(cell?.firstAcceptedAt, flow?.firstPassedAt)
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
const codeAt = cell?.firstAcceptedAt ?? null
const flowAt = flow?.firstPassedAt ?? null
const codeStatus: Step = codeAt ? 'accepted' : cell ? 'tried' : 'none'
const drawStatus: Step = flowAt ? 'accepted' : flow ? 'tried' : 'none'
let status: Step
let acceptedAt: string | null
let attempts: number
if (!needs) {
acceptedAt = earliest(codeAt, flowAt)
status = !cell && !flow ? 'none' : acceptedAt ? 'accepted' : 'tried'
attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0)
} else {
// 要哪几步看哪几步:两步都做到的时刻是晚的那一步
const steps = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter(
(step) => step !== null,
)
acceptedAt = steps.every((step) => step.at) ? steps.map((step) => step.at!).reduce(latestOf) : null
status = acceptedAt ? 'accepted' : steps.some((step) => step.status !== 'none') ? 'tried' : 'none'
attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0)
}
// 下面几样都是代码那一步的:只画流程图的课没有
const codeWanted = !needs || needs.code
const today = codeWanted ? (todayByKey.get(key) ?? []) : []
const codeDone = needs ? codeAt : acceptedAt
const latest = codeDone ? undefined : today.at(-1)
const firstTry = codeDone && Date.parse(codeDone) >= Date.parse(start) ? today.findIndex((row) => isAccepted(row.result)) : -1
const lastFlow = lastFlowByKey.get(key)
return {
status: !cell && !flow ? 'none' : acceptedAt ? 'accepted' : 'tried',
attempts: (cell?.attempts ?? 0) + (flow?.attempts ?? 0),
status,
attempts,
codeAttempts: cell?.attempts ?? 0,
flowchartAttempts: flow?.attempts ?? 0,
acceptedAt,
codeStatus,
flowchart: needs?.draw
? {
status: drawStatus,
passedAt: flowAt,
latest:
!flowAt && lastFlow
? { id: lastFlow.id, status: lastFlow.status, grade: lastFlow.grade, passed: lastFlow.passed, total: lastFlow.total }
: null,
}
: null,
latest: latest
? {
submissionId: latest.id,
@@ -595,7 +744,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
sampleCheck: latest.sampleCheck ?? undefined,
}
: null,
trail: acceptedAt
trail: codeDone
? today.map((row) => ({
id: row.id,
result: row.result,
@@ -604,11 +753,11 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
: [],
acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
challenge:
!acceptedAt || !liveChallenges.get(id)?.size
!codeWanted || !codeDone || !liveChallenges.get(id)?.size
? null
: challengeDoneKeys.has(key)
? 'done'
: today.some((row) => Date.parse(row.createTime) > Date.parse(acceptedAt))
: today.some((row) => Date.parse(row.createTime) > Date.parse(codeDone))
? 'doing'
: null,
}
@@ -660,13 +809,18 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
])
const ids = lesson.problems.map((problem) => problem.id)
let allDoneCount = 0
if (ids.length) {
// 做没做对不限今天(和看板的格子一样:以前做对的也算做完),语言跟着布置走
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
// 老师布置的:和看板、后台课堂作业同一个口径(要画的看图、要写的看代码)
const counts = await lessonDoneCounts([
{ id: lesson.lessonId, className, day: calendarDay(), problemIds: ids, language: lesson.language, mode: lesson.mode },
])
allDoneCount = counts.get(lesson.lessonId) ?? 0
} else if (ids.length) {
// 推断出来的:做没做对不限今天(和看板的格子一样:以前做对的也算做完),代码或流程图都算
const done = activityRows({
since: new Date(0).toISOString(),
users: classMemberIds(className),
problemIds: ids,
language: lesson.language,
})
const rows = await db
.select({
@@ -739,7 +893,9 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对')
if (!parsed.success) return parsed.response
const user = c.get('user')!
const { className, problemDisplayIds, language, lessonId, newLesson } = parsed.data
const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data
// 只画流程图不用语言:存成 null,看板和学生那边就不会写「C语言」
const language = mode === 'flowchart' ? null : parsed.data.language
const day = calendarDay()
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, className)).limit(1)
@@ -768,13 +924,13 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
return success(c, null)
}
const resolved = await resolveLessonProblems(problemDisplayIds, language)
const resolved = await resolveLessonProblems(problemDisplayIds, language, mode)
if ('code' in resolved) return failure(c, 400, resolved.code, resolved.message)
const { problemIds } = resolved
const now = new Date().toISOString()
if (target) {
// 「改」不换布置的人,和 createdAt 一样认第一次
await db.update(schema.classLesson).set({ problemIds, language, updatedAt: now }).where(eq(schema.classLesson.id, target.id))
await db.update(schema.classLesson).set({ problemIds, language, mode, updatedAt: now }).where(eq(schema.classLesson.id, target.id))
} else {
// 新开一节、今天还没有,或者点名的那一节已经没了(别的老师清掉了)—— 最后这种也照新开处理,
// 老师手上的题号不至于白填
@@ -783,6 +939,7 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
day,
problemIds,
language,
mode,
createdBy: user.id,
createdAt: now,
updatedAt: now,
+8
View File
@@ -81,6 +81,11 @@ function lessonLanguageFilter(raw: string | undefined) {
return language ? sql`${schema.problem.languages} @> ${JSON.stringify([language])}::jsonb` : undefined
}
/** 要画流程图的课(`?flowchart=1`):只列打开了「让学生自己画」的题,没打开的学生那边没有画布 */
function lessonFlowchartFilter(raw: string | undefined) {
return raw === '1' ? eq(schema.problem.allowFlowchart, true) : undefined
}
type ProblemStates = Awaited<ReturnType<typeof problemStates>> | null
type UserCounts = Awaited<ReturnType<typeof problemUserCounts>>
@@ -149,6 +154,8 @@ problemRoutes.get('/problems', optionalAuth, async (c) => {
if (byType) filters.push(byType)
const byLanguage = lessonLanguageFilter(c.req.query('language'))
if (byLanguage) filters.push(byLanguage)
const byFlowchart = lessonFlowchartFilter(c.req.query('flowchart'))
if (byFlowchart) filters.push(byFlowchart)
const states = user ? await problemStates(user.id) : null
if (undone && states && states.solved.size > 0) filters.push(notInArray(schema.problem.id, [...states.solved]))
@@ -226,6 +233,7 @@ problemRoutes.get('/problem-tags', async (c) => {
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
lessonLanguageFilter(c.req.query('language')),
lessonFlowchartFilter(c.req.query('flowchart')),
),
)
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
+26 -5
View File
@@ -7,6 +7,7 @@
*/
import {
FLOWCHART_PASS_GRADES,
NO_CLASS,
STUDENT_ROLES,
type ProblemLanguage,
@@ -537,6 +538,7 @@ async function lessonPlans(range: StatisticsRange) {
day: schema.classLesson.day,
problemIds: schema.classLesson.problemIds,
language: schema.classLesson.language,
mode: schema.classLesson.mode,
createdAt: schema.classLesson.createdAt,
})
.from(schema.classLesson)
@@ -560,6 +562,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
class_name: plan.className,
day: plan.day,
problem_id: problemId,
mode: plan.mode,
})),
),
)
@@ -571,7 +574,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
UNJUDGED_RESULTS.map((value) => sql`${value}`),
sql`, `,
)
const plan = sql`(select * from jsonb_to_recordset(${planRows}::jsonb) as p(k int, class_name text, day text, problem_id int))`
const plan = sql`(select * from jsonb_to_recordset(${planRows}::jsonb) as p(k int, class_name text, day text, problem_id int, mode text))`
const [counts, doneByLesson] = await Promise.all([
db.execute<{
k: number
@@ -584,22 +587,37 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
last: string
}>(sql`
with p as ${plan},
-- 这节课要写代码就数代码提交,要画流程图就数流程图(画到 A / S 算对、还在评算没判完),
-- 先画再写两种都数。同一天先布置画流程图、再布置同样的题写代码,两节各数各的
s as (
select p.k, s.user_id, s.problem_id, s.result, s.create_time
select p.k, s.user_id, s.problem_id, s.result in (${accepted}) as ok, s.result in (${judging}) as pending, s.create_time
from ${schema.submission} s
join ${schema.user} u on u.id = s.user_id
join p on p.class_name = u.class_name and p.problem_id = s.problem_id
and p.day = to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD')
where s.contest_id is null
where s.contest_id is null and p.mode <> 'flowchart'
and s.create_time <= ${range.end}
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
and u.admin_type = 'Regular User' and u.is_disabled = false
union all
select p.k, f.user_id, f.problem_id, (f.status = 2 and coalesce(f.ai_grade in (${sql.join(
FLOWCHART_PASS_GRADES.map((grade) => sql`${grade}`),
sql`, `,
)}), false)) as ok, f.status in (0, 1) as pending, f.create_time
from ${schema.flowchartSubmission} f
join ${schema.user} u on u.id = f.user_id
join p on p.class_name = u.class_name and p.problem_id = f.problem_id
and p.day = to_char(${localTime(sql`f.create_time`)}, 'YYYY-MM-DD')
where p.mode <> 'code'
and f.create_time <= ${range.end}
${range.start ? sql`and f.create_time >= ${range.start}` : sql``}
and u.admin_type = 'Regular User' and u.is_disabled = false
)
select k, problem_id,
count(distinct user_id)::int as users,
count(*)::int as total,
(count(*) filter (where result in (${accepted})))::int as accepted,
(count(*) filter (where result in (${judging})))::int as judging,
(count(*) filter (where ok))::int as accepted,
(count(*) filter (where pending))::int as judging,
min(create_time) as first,
max(create_time) as last
from s
@@ -624,6 +642,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
className: lesson.className,
day: lesson.day,
problemIds: lesson.problemIds,
mode: lesson.mode,
start: first,
end: last,
users: total?.users ?? 0,
@@ -868,6 +887,7 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
.map((row) => row!.displayId)
.slice(0, STATISTICS_MAX_PROBLEMS),
assigned: false,
mode: null,
doneCount: null,
fromProblemSet: lesson.from_ps * 2 > lesson.total,
// 次数和正确率只算这节课的题:点进去筛的就是这几道,两边的数才对得上
@@ -901,6 +921,7 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
.map((row) => row!.displayId)
.slice(0, STATISTICS_MAX_PROBLEMS),
assigned: true,
mode: lesson.mode,
doneCount: lesson.doneCount,
fromProblemSet: false,
total: lesson.total,
+10 -1
View File
@@ -53,14 +53,20 @@ export function activityRows(options: {
problemIds?: number[]
/** 老师布置时选了语言:代码只数这个语言交的(见 lessonLanguageFilter),流程图不受影响 */
language?: LessonLanguage | null
/**
* 只要哪一种:老师布置的课选了「写代码」就只数代码、「画流程图」就只数流程图
* (契约 lessonModeSchema)。不给 = 两种都数,`kind` 列分得出是哪种
*/
only?: 'code' | 'flowchart'
}) {
const { since, users, problemIds, language } = options
const { since, users, problemIds, language, only } = options
const code = db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
createTime: schema.submission.createTime,
solved: sql<boolean>`${inArray(schema.submission.result, SOLVED_RESULTS)}`.as('solved'),
kind: sql<'code' | 'flowchart'>`'code'`.as('kind'),
})
.from(schema.submission)
.where(
@@ -70,6 +76,7 @@ export function activityRows(options: {
users ? inArray(schema.submission.userId, users) : undefined,
problemIds ? inArray(schema.submission.problemId, problemIds) : undefined,
lessonLanguageFilter(language),
only === 'flowchart' ? sql`false` : undefined,
),
)
const flowchart = db
@@ -78,6 +85,7 @@ export function activityRows(options: {
problemId: schema.flowchartSubmission.problemId,
createTime: schema.flowchartSubmission.createTime,
solved: sql<boolean>`${FLOWCHART_PASSED}`.as('solved'),
kind: sql<'code' | 'flowchart'>`'flowchart'`.as('kind'),
})
.from(schema.flowchartSubmission)
.where(
@@ -85,6 +93,7 @@ export function activityRows(options: {
gte(schema.flowchartSubmission.createTime, since),
users ? inArray(schema.flowchartSubmission.userId, users) : undefined,
problemIds ? inArray(schema.flowchartSubmission.problemId, problemIds) : undefined,
only === 'code' ? sql`false` : undefined,
),
)
return code.unionAll(flowchart).as('activity')
+31 -16
View File
@@ -1,4 +1,4 @@
import { isSqlProblem, STUDENT_ROLES, type LessonLanguage, type LessonUpdate } from '@oj2/contract'
import { isSqlProblem, lessonNeeds, STUDENT_ROLES, type LessonLanguage, type LessonMode, type LessonUpdate } from '@oj2/contract'
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { db, schema } from '../db'
@@ -13,26 +13,34 @@ import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
/**
* 题号 → problem.id,保留输入顺序、去重、不分大小写。只认题库里公开的题;有对不上的整个拒掉并
* 点名是哪几个,免得存下半张单子老师还不知道。选了语言就得每道都能用它交(SQL 题除外:它只收
* SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL)
* 点名是哪几个,免得存下半张单子老师还不知道。要写代码的课选了语言就得每道都能用它交(SQL 题除外:
* 它只收 SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL);要画流程图的课
* 每道都得打开了「让学生自己画」,不然学生打开题目根本没有画布
*/
export async function resolveLessonProblems(
problemDisplayIds: string[],
language: LessonLanguage | null,
): Promise<{ problemIds: number[] } | { code: 'problem-not-found' | 'language-not-allowed'; message: string }> {
mode: LessonMode = 'code',
): Promise<{ problemIds: number[] } | { code: 'problem-not-found' | 'language-not-allowed' | 'flowchart-not-allowed'; message: string }> {
const wanted = [...new Set(problemDisplayIds.map((id) => id.toLowerCase()))]
const found = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
languages: schema.problem.languages,
allowFlowchart: schema.problem.allowFlowchart,
})
.from(schema.problem)
.where(and(inArray(sql<string>`lower(${schema.problem.displayId})`, wanted), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
const idByDisplay = new Map(found.map((row) => [row.displayId.toLowerCase(), row.id]))
const missing = wanted.filter((id) => !idByDisplay.has(id))
if (missing.length) return { code: 'problem-not-found', message: `这些题号不存在或没有公开:${missing.join('、')}` }
if (language) {
const needs = lessonNeeds(mode)
if (needs.draw) {
const undrawable = found.filter((row) => !row.allowFlowchart).map((row) => row.displayId)
if (undrawable.length) return { code: 'flowchart-not-allowed', message: `这些题没有打开画流程图:${undrawable.join('、')}` }
}
if (language && needs.code) {
const unsupported = found.filter((row) => !row.languages.includes(language) && !isSqlProblem(row)).map((row) => row.displayId)
if (unsupported.length) return { code: 'language-not-allowed', message: `这些题不能用 ${language} 交:${unsupported.join('、')}` }
}
@@ -45,17 +53,21 @@ export function lessonDayEnd(day: string) {
}
/**
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算),
* 代码只认布置的语言(SQL 题放行),流程图评到 A / S 也算 —— 和看板的格子同一个口径。
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。
* 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和看板的格子同一个口径。
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
*/
export async function lessonDoneCounts(lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null }[]) {
export async function lessonDoneCounts(
lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null; mode: LessonMode }[],
) {
const rows = lessons.flatMap((lesson) =>
lesson.problemIds.map((problemId) => ({
k: lesson.id,
class_name: lesson.className,
problem_id: problemId,
language: lesson.language,
mode: lesson.mode,
until: lessonDayEnd(lesson.day),
})),
)
@@ -66,10 +78,10 @@ export async function lessonDoneCounts(lessons: { id: number; className: string;
)
const result = await db.execute<{ k: number; done: number }>(sql`
with p as (
select * from jsonb_to_recordset(${JSON.stringify(rows)}::jsonb) as p(k int, class_name text, problem_id int, language text, until timestamptz)
select * from jsonb_to_recordset(${JSON.stringify(rows)}::jsonb) as p(k int, class_name text, problem_id int, language text, mode text, until timestamptz)
),
members as (
select p.k, p.problem_id, p.language, p.until, u.id as user_id
select p.k, p.problem_id, p.language, p.mode, p.until, u.id as user_id
from p join ${schema.user} u on u.class_name = p.class_name
where u.is_disabled = false and u.admin_type in (${sql.join(
STUDENT_ROLES.map((role) => sql`${role}`),
@@ -79,16 +91,16 @@ export async function lessonDoneCounts(lessons: { id: number; className: string;
solved as (
select m.k, m.user_id, m.problem_id
from members m
where exists (
where (m.mode = 'flowchart' or exists (
select 1 from ${schema.submission} s
where s.user_id = m.user_id and s.problem_id = m.problem_id and s.contest_id is null
and s.result in (${solved}) and s.create_time < m.until
and (m.language is null or s.language in (m.language, 'SQL'))
) or exists (
)) and (m.mode = 'code' or exists (
select 1 from ${schema.flowchartSubmission}
where ${schema.flowchartSubmission.userId} = m.user_id and ${schema.flowchartSubmission.problemId} = m.problem_id
and ${FLOWCHART_PASSED} and ${schema.flowchartSubmission.createTime} < m.until
)
))
),
per_user as (
select k, user_id from solved group by k, user_id
@@ -103,6 +115,7 @@ interface LessonSnapshot {
id: number
problemIds: number[]
language: LessonLanguage | null
mode: LessonMode
}
/**
@@ -111,7 +124,7 @@ interface LessonSnapshot {
*/
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> {
const [row] = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language })
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
@@ -128,7 +141,8 @@ export async function announceLessonChange(className: string, before: LessonSnap
const after = await todayLessonSnapshot(className)
if (!before && !after) return
const sameLesson = !!before && !!after && before.id === after.id
if (sameLesson && before.language === after.language && before.problemIds.join() === after.problemIds.join()) return
const sameWay = sameLesson && before.language === after.language && before.mode === after.mode
if (sameWay && before.problemIds.join() === after.problemIds.join()) return
const ids = [...new Set([...(before?.problemIds ?? []), ...(after?.problemIds ?? [])])]
const rows = ids.length
@@ -150,10 +164,11 @@ export async function announceLessonChange(className: string, before: LessonSnap
type: 'lesson_update',
kind: !after ? 'cleared' : sameLesson ? 'changed' : 'assigned',
language: after?.language ?? null,
mode: after?.mode ?? null,
problems: pick(afterIds),
added: sameLesson ? pick(added) : [],
removed: pick(removed),
languageChanged: sameLesson && before.language !== after.language,
languageChanged: sameLesson && !sameWay,
reordered: sameLesson && kept.join() !== keptBefore.join(),
} satisfies LessonUpdate)
} catch (error) {
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { AdminLesson, LessonLanguage } from '@oj2/contract'
import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract'
import ProblemPicker from 'oj/classroom/components/ProblemPicker.vue'
import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api'
@@ -24,12 +24,16 @@ const configStore = useConfigStore()
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
const languageOptions = LESSON_LANGUAGES.map((value) => ({ label: LANGUAGE_SHOW_VALUE[value], value }))
const modeOptions = (['code', 'both', 'flowchart'] as const).map((value) => ({ label: LESSON_MODE_LABEL[value], value }))
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
const className = ref<string | null>(null)
const day = ref<string | null>(null)
const time = ref<string | null>(null)
const language = ref<LessonLanguage>('Python')
/** 做什么:画流程图不用选语言(存成 null) */
const mode = ref<LessonMode>('code')
const needsCode = computed(() => lessonNeeds(mode.value).code)
const idsInput = ref('')
const saving = ref(false)
@@ -42,6 +46,7 @@ watch(show, (open) => {
day.value = lesson?.day ?? parseTime(now, 'YYYY-MM-DD')
time.value = parseTime(lesson?.createdAt ?? now, 'HH:mm')
language.value = lesson?.language ?? 'Python'
mode.value = lesson?.mode ?? 'code'
idsInput.value = lesson?.problems.map((p) => p.problemDisplayId).join(' ') ?? ''
})
@@ -71,7 +76,8 @@ async function save() {
className: className.value!,
day: day.value!,
time: time.value!,
language: language.value,
language: needsCode.value ? language.value : null,
mode: mode.value,
problemDisplayIds: inputIds(),
}
try {
@@ -90,7 +96,7 @@ async function save() {
<template>
<n-modal v-model:show="show" preset="card" :title="lesson ? '改这一节' : '补一节'" :bordered="false" :style="{ width: 'min(520px, calc(100vw - 32px))' }">
<n-form label-placement="left" label-width="56">
<n-form label-placement="left" label-width="64">
<n-form-item label="班级">
<n-select v-model:value="className" :options="classOptions" filterable placeholder="选班级" />
</n-form-item>
@@ -100,8 +106,11 @@ async function save() {
<n-time-picker v-model:formatted-value="time" value-format="HH:mm" format="HH:mm" class="time" />
</div>
</n-form-item>
<n-form-item label="语言">
<n-select v-model:value="language" :options="languageOptions" class="lang" />
<n-form-item label="做什么">
<div class="when">
<n-select v-model:value="mode" :options="modeOptions" class="lang" />
<n-select v-if="needsCode" v-model:value="language" :options="languageOptions" aria-label="用什么语言写" class="lang" />
</div>
</n-form-item>
<n-form-item label="题目">
<div class="ids">
@@ -119,7 +128,15 @@ async function save() {
<n-button type="primary" :disabled="!ready" :loading="saving" @click="save">{{ lesson ? '保存' : '补上' }}</n-button>
</div>
</template>
<ProblemPicker v-model:show="picking" v-model:language="language" :initial="pickerInitial" :saving="false" mode="pick" @confirm="picked" />
<ProblemPicker
v-model:show="picking"
v-model:language="language"
v-model:lesson-mode="mode"
:initial="pickerInitial"
:saving="false"
mode="pick"
@confirm="picked"
/>
</n-modal>
</template>
+8 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { AdminLesson, AdminLessonList } from '@oj2/contract'
import { LESSON_MODE_LABEL, type AdminLesson, type AdminLessonList } from '@oj2/contract'
import { useRouteQuery } from '@vueuse/router'
import PageHeader from 'admin/components/PageHeader.vue'
import { NButton, NPopconfirm, NTooltip, useThemeVars } from 'naive-ui'
@@ -133,16 +133,17 @@ function renderActions(lesson: AdminLesson) {
])
}
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */
function wayText(lesson: AdminLesson) {
return [lesson.mode !== 'code' ? LESSON_MODE_LABEL[lesson.mode] : '', lesson.language ? LANGUAGE_SHOW_VALUE[lesson.language] : ''].filter(Boolean).join(' · ')
}
/** 手机上一节课一张卡:日子 · 时刻 · 班级一行,题号一行,老师 · 语言 · 做完一行小字,操作一行 */
function renderMobileCard(lesson: AdminLesson) {
return h('div', { class: 'm-card' }, [
h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`),
renderProblems(lesson),
h(
'div',
{ class: 'm-meta' },
[lesson.createdBy, lesson.language ? LANGUAGE_SHOW_VALUE[lesson.language] : null, `做完 ${renderDone(lesson)}`].filter(Boolean).join(' · '),
),
h('div', { class: 'm-meta' }, [lesson.createdBy, wayText(lesson), `做完 ${renderDone(lesson)}`].filter(Boolean).join(' · ')),
renderActions(lesson),
])
}
@@ -152,7 +153,7 @@ const desktopColumns: DataTableColumn<AdminLesson>[] = [
{ title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') },
{ title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) },
{ title: '老师', key: 'createdBy', width: 90 },
{ title: '语言', key: 'language', width: 80, render: (row) => (row.language ? LANGUAGE_SHOW_VALUE[row.language] : '—') },
{ title: '做什么', key: 'language', width: 120, render: (row) => wayText(row) || '—' },
{ title: '题目', key: 'problems', width: 300, render: renderProblems },
{ title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone },
{ title: '', key: 'actions', render: renderActions },
+3 -1
View File
@@ -57,6 +57,7 @@ import {
type SubmissionStatistics,
type SubmissionLessons,
type LessonLanguage,
type LessonMode,
type SubmissionStatisticsGrid,
type TodaySubmissionStatistics,
} from '@oj2/contract'
@@ -264,11 +265,12 @@ export function getClassLesson(className?: string) {
* 给这个班布置今天的一节课,空数组 = 清掉这一节。`lessonId` 为 null 是今天新开一节
* (一个班一天可以上几节内容不同的课),否则改那一节
*/
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, lessonId: number | null) {
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, mode: LessonMode, lessonId: number | null) {
return api.put<null>('classroom/lesson', {
className,
problemDisplayIds,
language,
mode,
...(lessonId === null ? { newLesson: true } : { lessonId }),
} satisfies ClassLessonRequest)
}
+111 -31
View File
@@ -20,7 +20,8 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import LessonForm from './components/LessonForm.vue'
import TrailPanel from './components/TrailPanel.vue'
import { errorKind, type ErrorKind } from 'utils/errorKind'
import { cellStyle as cellStyleOf, cellText, cellTitle as cellTitleOf, STUCK_ATTEMPTS, trailSummary } from './boardCells'
import { LESSON_MODE_LABEL } from '@oj2/contract'
import { cellStage, cellStyle as cellStyleOf, cellText as cellTextOf, cellTitle as cellTitleOf, isStuck, trailSummary } from './boardCells'
/**
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
@@ -114,8 +115,14 @@ onUnmounted(pause)
// ---------- 这节课的题:上课时当场填 ----------
/** 顶上那条布置框开着没有:edit = 改这一节,new = 今天再开一节 */
const editing = ref<false | 'edit' | 'new'>(false)
/**
* 顶上那条布置框开着没有:edit = 改这一节,new = 今天再开一节,
* carry = 再开一节、带上这一节的题改成写代码(这一节是画流程图时才有)
*/
const editing = ref<false | 'edit' | 'new' | 'carry'>(false)
/** 这节课做什么;推断出来的课为 null(代码交对或流程图画对都算) */
const mode = computed(() => board.value?.mode ?? null)
/**
* 一个班一天可以上几节内容不同的课。打开看板(或换到这个班)时,这一节已经布置了这么久,
@@ -192,7 +199,7 @@ function groupOf(student: ClassBoardStudent): Group {
const cells = student.cells
if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done'
if (!student.lastSubmitAt) return 'idle'
if (cells.some((cell) => cell.status === 'tried' && cell.attempts >= STUCK_ATTEMPTS)) return 'stuck'
if (cells.some((cell) => isStuck(cell, mode.value))) return 'stuck'
return 'working'
}
@@ -296,25 +303,55 @@ interface WrongPerson {
student: ClassBoardStudent
cell: ClassBoardCell
kind: ErrorKind
/** 点名字讲的是哪一份:代码(cell.latest)还是最后一张图(cell.flowchart.latest);null = 没有可讲的 */
explain: 'code' | 'draw' | null
}
/**
* 卡在画流程图上的人按最后一张图归堆:一组都没走对的、对了一部分的分开讲;还在评、评失败的、
* 老评法(数不出几组)各一堆
*/
function drawKind(cell: ClassBoardCell): ErrorKind {
const latest = cell.flowchart?.latest
if (!latest) return { key: 'draw-none', label: '还没画对', line: null }
if (latest.status === 0 || latest.status === 1) return { key: 'draw-wait', label: 'AI 在评', line: null }
if (latest.status === 3) return { key: 'draw-failed', label: '评分失败', line: null }
if (latest.passed === null) return { key: 'draw-old', label: '还没画对', line: null }
return latest.passed > 0 ? { key: 'draw-some', label: '图对了一部分', line: null } : { key: 'draw-zero', label: '图一组都没走对', line: null }
}
/**
* 没做对的人:今天交过代码的按最后一次提交归错法。另外两种没有今天的代码可讲,单独成堆,
* 和卡片上「错着 N」的 N 对得上:今天只画了流程图的,以前交过没做对、今天还没动的
* 和卡片上「错着 N」的 N 对得上:今天只画了流程图的,以前交过没做对、今天还没动的。
* 要画流程图的课,卡在画图上的按最后一张图归堆(drawKind);先写了代码、没画图的单独一堆
*/
function wrongOf(index: number): WrongPerson[] {
return present.value.flatMap((student) => {
return present.value.flatMap((student): WrongPerson[] => {
const cell = student.cells[index]
if (!cell || cell.status !== 'tried') return []
const stage = cellStage(cell, mode.value)
if (stage === 'draw') {
const latest = cell.flowchart?.latest
const evaluated = !!latest && latest.status !== 0 && latest.status !== 1
return [{ student, cell, kind: drawKind(cell), explain: evaluated ? 'draw' : null }]
}
if (stage === 'nodraw') return [{ student, cell, kind: { key: 'nodraw', label: '代码对了,还没画图', line: null }, explain: null }]
if (mode.value === 'both' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none') {
return [{ student, cell, kind: { key: 'drawn', label: '图画对了,还没写代码', line: null }, explain: null }]
}
// 推断出来的课不分步:今天没代码、却画了流程图的单独一堆
const kind: ErrorKind = cell.latest
? errorKind(cell.latest)
: cell.attempts
: !mode.value && cell.attempts
? { key: 'flowchart', label: '今天只画了流程图', line: null }
: { key: 'before', label: '以前交过,今天还没交', line: null }
return [{ student, cell, kind }]
return [{ student, cell, kind, explain: cell.latest ? 'code' : null }]
})
}
/** 没有今天的错可讲的那几堆:灰底,不是今天的错 */
const QUIET_KINDS = new Set(['before', 'flowchart', 'nodraw', 'drawn', 'draw-wait'])
const problemStats = computed(() =>
(board.value?.problems ?? []).map((problem, i) => {
const done = present.value.filter((student) => student.cells[i]?.status === 'accepted').length
@@ -322,10 +359,23 @@ const problemStats = computed(() =>
const total = present.value.length
// 挑战成功几人:只有配了挑战的题才写
const challenged = problem.hasChallenges ? present.value.filter((student) => student.cells[i]?.challenge === 'done').length : null
return { problem, done, wrong, none: total - done - wrong, total, challenged }
// 先画再写:两步各几人做到,老师一眼看出全班卡在画图还是写代码
const steps =
mode.value === 'both'
? {
drawn: present.value.filter((student) => student.cells[i]?.flowchart?.status === 'accepted').length,
coded: present.value.filter((student) => student.cells[i]?.codeStatus === 'accepted').length,
}
: null
return { problem, done, wrong, none: total - done - wrong, total, challenged, steps }
}),
)
/**
* 先画再写的课,没做完的里面有图画对了、还没写代码的,说「错着」不对 —— 改说「没做完」
*/
const wrongWord = computed(() => (mode.value === 'both' ? '没做完' : '错着'))
const selectedId = ref<number | null>(null)
const selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value))
function toggleProblem(problemId: number) {
@@ -360,19 +410,18 @@ const explainProblem = computed(() => board.value?.problems[explainProblemIndex.
/** 弹层顶上那排人:这道题错着的人,顺序和错法堆一样 */
const explainPeople = computed<ExplainPerson[]>(() => {
if (explainProblemIndex.value < 0) return []
const people = wrongOf(explainProblemIndex.value).filter((person) => person.cell.latest)
const people = wrongOf(explainProblemIndex.value).filter((person) => person.explain)
const order = new Map<string, number>()
for (const person of people) {
order.set(person.kind.key, (order.get(person.kind.key) ?? 0) + 1)
}
return people
.sort((a, b) => order.get(b.kind.key)! - order.get(a.kind.key)! || a.kind.key.localeCompare(b.kind.key) || b.cell.attempts - a.cell.attempts)
.map((person) => ({
userId: person.student.userId,
username: person.student.username,
submissionId: person.cell.latest!.submissionId,
label: person.kind.label,
}))
.map((person) =>
person.explain === 'draw'
? { userId: person.student.userId, username: person.student.username, flowchartId: person.cell.flowchart!.latest!.id, label: person.kind.label }
: { userId: person.student.userId, username: person.student.username, submissionId: person.cell.latest!.submissionId, label: person.kind.label },
)
})
function openExplain(problemIndex: number, userId: number) {
@@ -390,9 +439,22 @@ const cellColors = computed(() => ({
tone,
errorColor: theme.value.errorColor,
actionColor: theme.value.actionColor,
successColor: theme.value.successColor,
}))
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value)
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) => cellTitleOf(cell, student, board.value?.day)
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value)
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value)
const cellText = (cell: ClassBoardCell) => cellTextOf(cell, mode.value)
/** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */
function explainable(cell: ClassBoardCell) {
if (cell.status !== 'tried') return false
const stage = cellStage(cell, mode.value)
if (stage === 'draw') {
const latest = cell.flowchart?.latest
return !!latest && latest.status !== 0 && latest.status !== 1
}
return stage === 'code' && !!cell.latest
}
/**
* 点开着轨迹的那个格子(学生 id:第几道题)。做对了、今天交过代码的格子点了不再直接去提交列表,
@@ -426,6 +488,8 @@ function submissionsHref(
) {
// SQL 题不受布置的语言限制,格子数的是 SQL 交的
const language = problem?.isSql ? 'SQL' : board.value?.language
// 只画流程图的课,格子数的全是流程图
const flowSide = flowchart || mode.value === 'flowchart'
return router.resolve({
name: 'submissions',
query: {
@@ -433,7 +497,7 @@ function submissionsHref(
exactUsername: '1',
...(today ? { today: '1' } : {}),
// 布置时选了语言的,代码那边只看这个语言交的(和格子里数的一致)
...(flowchart ? { language: 'Flowchart' } : language ? { language } : {}),
...(flowSide ? { language: 'Flowchart' } : language ? { language } : {}),
...(problem ? { problem: problem.problemDisplayId } : {}),
},
}).href
@@ -459,6 +523,7 @@ function lastText(student: ClassBoardStudent) {
:board="board"
:class-name="className"
:lesson-id="editing === 'edit' ? board.lessonId : null"
:carry="editing === 'carry'"
variant="inline"
class="lesson-edit"
@saved="lessonSaved"
@@ -466,6 +531,7 @@ function lastText(student: ClassBoardStudent) {
/>
<div v-else-if="board?.problems.length && !needLesson" class="lesson">
<span class="lesson-label">{{ board.source === 'teacher' ? '这节课' : '今天在做' }}</span>
<span v-if="board.mode && board.mode !== 'code'" class="chip lang">{{ LESSON_MODE_LABEL[board.mode] }}</span>
<span v-if="board.language" class="chip lang">{{ LANGUAGE_SHOW_VALUE[board.language] }}</span>
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">
{{ problem.problemDisplayId }}
@@ -480,6 +546,11 @@ function lastText(student: ClassBoardStudent) {
<template #icon><Icon icon="ph:plus" /></template>
新的一节
</n-button>
<!-- 画完流程图让全班写代码:新开一节、带上这几道题(学生那边还没画完的照样看得到要画) -->
<n-button v-if="board.source === 'teacher' && board.mode === 'flowchart'" text type="primary" class="edit" @click="editing = 'carry'">
<template #icon><Icon icon="ph:code" /></template>
用这几道题写代码
</n-button>
</div>
<div class="spacer"></div>
@@ -527,8 +598,9 @@ function lastText(student: ClassBoardStudent) {
<span class="bar">
<i :style="{ width: stat.total ? `${(stat.done / stat.total) * 100}%` : '0' }"></i>
</span>
<span v-if="stat.steps" class="card-steps">画对 {{ stat.steps.drawn }} · 写对 {{ stat.steps.coded }}</span>
<span class="card-foot">
<span :class="stat.wrong ? 'wrong' : 'muted'">错着 {{ stat.wrong }}</span>
<span :class="stat.wrong ? 'wrong' : 'muted'">{{ wrongWord }} {{ stat.wrong }}</span>
<span class="muted">没做 {{ stat.none }}</span>
<span v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span>
</span>
@@ -551,7 +623,7 @@ function lastText(student: ClassBoardStudent) {
{{ board.problems[selectedIndex]!.title }}
</a>
<span>
错着的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
{{ wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
</span>
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
<div class="spacer"></div>
@@ -560,12 +632,7 @@ function lastText(student: ClassBoardStudent) {
</n-button>
</div>
<div v-if="wrongGroups.length" class="groups">
<div
v-for="group in wrongGroups"
:key="group.kind.key"
class="err-group"
:class="{ quiet: group.kind.key === 'before' || group.kind.key === 'flowchart' }"
>
<div v-for="group in wrongGroups" :key="group.kind.key" class="err-group" :class="{ quiet: QUIET_KINDS.has(group.kind.key) }">
<div class="err-title">
{{ group.kind.label }}
<span class="wrong">{{ group.people.length }} 人</span>
@@ -573,15 +640,22 @@ function lastText(student: ClassBoardStudent) {
</div>
<div class="err-people">
<template v-for="person in group.people" :key="person.student.userId">
<button v-if="person.cell.latest" type="button" class="who" @click="openExplain(selectedIndex, person.student.userId)">
<button v-if="person.explain" type="button" class="who" @click="openExplain(selectedIndex, person.student.userId)">
<UserName :username="person.student.username" />
<span class="mini" :style="cellStyle(person.cell)">✗ {{ person.cell.attempts }}</span>
<span class="mini" :style="cellStyle(person.cell)">{{ cellText(person.cell) }}</span>
</button>
<!-- 没有今天的代码可讲:去提交列表看他交过的 -->
<a
v-else
class="who"
:href="submissionsHref(person.student, board.problems[selectedIndex], !!person.cell.attempts, !!person.cell.attempts)"
:href="
submissionsHref(
person.student,
board.problems[selectedIndex],
person.kind.key === 'flowchart' || person.kind.key.startsWith('draw'),
!!person.cell.attempts,
)
"
target="_blank"
>
<UserName :username="person.student.username" />
@@ -672,7 +746,7 @@ function lastText(student: ClassBoardStudent) {
</td>
<td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }">
<button
v-if="cell.status === 'tried' && cell.latest"
v-if="explainable(cell)"
type="button"
class="cell"
:data-challenge="cell.challenge ?? undefined"
@@ -919,6 +993,12 @@ function lastText(student: ClassBoardStudent) {
font-variant-numeric: tabular-nums;
}
.card-steps {
font-size: 13px;
color: v-bind('theme.textColor2');
font-variant-numeric: tabular-nums;
}
.card-foot {
display: flex;
gap: 10px;
+70 -4
View File
@@ -1,3 +1,4 @@
import type { LessonMode } from '@oj2/contract'
import type { Tone } from 'shared/composables/tone'
import { parseTime } from 'utils/functions'
import type { ClassBoardCell, ClassBoardStudent } from 'utils/types'
@@ -5,20 +6,71 @@ import type { ClassBoardCell, ClassBoardStudent } from 'utils/types'
/**
* 课堂看板的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
* 和 errorKind.ts 一样单放一个文件(看板组件只管排版和交互)。
*
* `mode` 是这节课做什么(契约 lessonModeSchema),推断出来的课为 null。先画再写的格子是一条进度:
* 在画 → 图画对了(空心绿「图 ✓」)→ 在写 → 两样都对(实心绿 ✓)。先写了代码、还没画图的写「缺图」
*/
/** 同一道题今天交了这么多次还没过,算卡住 */
export const STUCK_ATTEMPTS = 3
/**
* 没做完的格子卡在哪一步:draw = 在画流程图、code = 在写代码、nodraw = 代码对了但还没画图。
* 只画流程图的课一律是 draw;写代码的课和推断出来的课一律是 code
*/
export type CellStage = 'draw' | 'code' | 'nodraw'
export function cellStage(cell: ClassBoardCell, mode: LessonMode | null): CellStage {
if (mode === 'flowchart') return 'draw'
if (mode !== 'both' || !cell.flowchart) return 'code'
if (cell.flowchart.status === 'tried') return 'draw'
if (cell.flowchart.status === 'none' && cell.codeStatus === 'accepted') return 'nodraw'
return 'code'
}
/** 今天在卡着的那一步上交了几次:先画再写的课里,画了三张才画对的人写代码时不该算卡住 */
export function stageAttempts(cell: ClassBoardCell, mode: LessonMode | null) {
if (mode !== 'both') return cell.attempts
const stage = cellStage(cell, mode)
return stage === 'draw' ? cell.flowchartAttempts : stage === 'code' ? cell.codeAttempts : 0
}
/** 卡住了:没做完,而且在卡着的那一步上今天交了 STUCK_ATTEMPTS 次以上 */
export function isStuck(cell: ClassBoardCell, mode: LessonMode | null) {
return cell.status === 'tried' && stageAttempts(cell, mode) >= STUCK_ATTEMPTS
}
/** 先画再写:图画对了、代码还没交过 —— 空心绿的「图 ✓」 */
function halfDone(cell: ClassBoardCell, mode: LessonMode | null) {
return mode === 'both' && cell.status === 'tried' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none'
}
/** 今天之前就通过了的,颜色淡一档。`day` 是看板的「今天」(东八区日期) */
export function acceptedToday(cell: ClassBoardCell, day: string | undefined) {
return !!cell.acceptedAt && parseTime(cell.acceptedAt, 'YYYY-MM-DD') === day
}
/** 最后一张图的那句:评分中 / 评失败 / 走对几组 */
function drawText(cell: ClassBoardCell, prefix: string) {
const latest = cell.flowchart?.latest
if (!latest) return '✗'
if (latest.status === 0 || latest.status === 1) return '评分中'
if (latest.status === 3) return '评失败'
if (latest.passed !== null && latest.total) return `${prefix}${latest.passed}/${latest.total}`
return '✗'
}
/** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */
export function cellText(cell: ClassBoardCell) {
export function cellText(cell: ClassBoardCell, mode: LessonMode | null = null) {
if (cell.status === 'accepted') return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : '✓'
if (cell.status === 'tried') return cell.attempts ? `✗ ${cell.attempts}` : '✗'
if (cell.status === 'tried') {
const stage = cellStage(cell, mode)
if (stage === 'draw') return drawText(cell, mode === 'both' ? '图 ' : '')
if (stage === 'nodraw') return '缺图'
if (halfDone(cell, mode)) return '图 ✓'
const attempts = mode === 'both' ? cell.codeAttempts : cell.attempts
return attempts ? `✗ ${attempts}` : '✗'
}
return ''
}
@@ -29,14 +81,19 @@ export function cellStyle(
tone: (kind: Tone) => { color: string; background: string }
errorColor: string
actionColor: string
successColor: string
},
mode: LessonMode | null = null,
) {
if (cell.status === 'accepted') {
const t = colors.tone('success')
return acceptedToday(cell, day) ? { background: t.background, color: t.color } : { background: t.background, color: t.color, opacity: 0.5 }
}
if (cell.status === 'tried') {
if (cell.attempts >= STUCK_ATTEMPTS) {
if (halfDone(cell, mode)) {
return { background: 'transparent', color: colors.tone('success').color, boxShadow: `inset 0 0 0 1.5px ${colors.successColor}` }
}
if (isStuck(cell, mode)) {
return { background: colors.errorColor, color: '#ffffff' }
}
const t = colors.tone('warning')
@@ -45,7 +102,7 @@ export function cellStyle(
return { background: colors.actionColor }
}
export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day: string | undefined) {
export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day: string | undefined, mode: LessonMode | null = null) {
if (cell.status === 'accepted') {
const when = acceptedToday(cell, day) ? `${parseTime(cell.acceptedAt!, 'HH:mm')} 做对` : '以前就做对了'
if (cell.challenge === 'done') return `${when},挑战也做到了`
@@ -53,7 +110,16 @@ export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day:
return when
}
if (cell.status === 'tried') {
const stage = cellStage(cell, mode)
if (stage === 'draw') {
const latest = cell.flowchart?.latest
if (latest?.status === 0 || latest?.status === 1) return 'AI 正在评这张图'
return latest ? `讲讲 ${student.username} 这张图哪里画错了` : '还没画对'
}
if (stage === 'nodraw') return '代码做对了,流程图还没画'
if (halfDone(cell, mode)) return '流程图画对了,代码还没交'
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
if (mode === 'both' && cell.flowchart?.status === 'accepted') return '流程图画对了,代码以前交过、没做对'
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : '以前交过,没做对'
}
return ''
@@ -1,11 +1,14 @@
<script lang="ts">
export interface ExplainPerson {
/**
* 弹层顶上那排人里的一个。讲代码给 submissionId;要画流程图的课里卡在画图上的,给他最后
* 那张图的 flowchartId —— 下面就换成图和 AI 的点评
*/
export type ExplainPerson = {
userId: number
username: string
submissionId: string
/** 错法的短名(errorKind),挂在名字后面 */
label: string
}
} & ({ submissionId: string; flowchartId?: undefined } | { flowchartId: string; submissionId?: undefined })
</script>
<script setup lang="ts">
@@ -17,6 +20,7 @@ import UserName from 'shared/components/UserName.vue'
import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
import type { ClassBoardProblem } from 'utils/types'
import FlowchartExplain from './FlowchartExplain.vue'
import ProblemBrief from './ProblemBrief.vue'
/**
@@ -106,7 +110,7 @@ onKeyStroke(['ArrowLeft', 'ArrowRight'], (event) => {
>
题目
</n-button>
<n-button v-if="current" :size="isMobile ? 'small' : 'medium'" tag="a" :href="'/submission/' + current.submissionId" target="_blank">
<n-button v-if="current?.submissionId" :size="isMobile ? 'small' : 'medium'" tag="a" :href="'/submission/' + current.submissionId" target="_blank">
在新页面打开
</n-button>
</div>
@@ -119,7 +123,8 @@ onKeyStroke(['ArrowLeft', 'ArrowRight'], (event) => {
<ProblemBrief :display-id="problem.problemDisplayId" />
</aside>
<div class="main">
<SubmissionView v-if="current" :id="current.submissionId" embedded />
<FlowchartExplain v-if="current?.flowchartId" :id="current.flowchartId" />
<SubmissionView v-else-if="current?.submissionId" :id="current.submissionId" embedded />
</div>
</div>
</div>
@@ -0,0 +1,159 @@
<script setup lang="ts">
import { getFlowchartSubmission } from 'oj/api'
import FlowchartNote from 'shared/components/FlowchartNote.vue'
import { useMermaid } from 'shared/composables/useMermaid'
import { useTone } from 'shared/composables/tone'
import { caseReport, fitFlowchartGraph, flowchartVerdict, markFlowchartFocus } from 'utils/flowchart'
import type { FlowchartSubmission } from 'utils/types'
/**
* 课堂看板「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。
* 左边是他最后画的那张(要改的框标红),右边是 AI 写的哪里错、可以怎么改 —— 和提交列表右栏
* (FlowchartPane)、题目页结果页签同一套点评(FlowchartNote)。
*
* 挂在 n-modal 里(内容在 body 下),<style> 的 v-bind() 传不进来:颜色走外层弹层给的
* CSS 变量(--card、--divider…),标红的颜色走根节点的内联变量
*/
const props = defineProps<{ id: string }>()
const tone = useTone()
const { renderError, renderFlowchart } = useMermaid()
const detail = ref<FlowchartSubmission | null>(null)
const failed = ref(false)
const graph = useTemplateRef<HTMLElement>('graph')
const openCase = ref<number | null>(null)
const casesOpen = ref(false)
const report = computed(() => caseReport(detail.value?.aiCriteriaDetails))
const vars = computed(() => ({
'--focus-fill': tone('error').background,
'--focus-stroke': tone('error').solid,
}))
let seq = 0
async function load() {
const mine = ++seq
failed.value = false
openCase.value = null
casesOpen.value = false
try {
const res = await getFlowchartSubmission(props.id)
if (mine !== seq) return
detail.value = res
await nextTick()
await renderFlowchart(graph.value, res.mermaidCode)
fitFlowchartGraph(graph.value)
markFlowchartFocus(graph.value, report.value)
} catch {
if (mine !== seq) return
detail.value = null
failed.value = true
}
}
watch(() => props.id, load, { immediate: true })
</script>
<template>
<div class="explain-flow" :style="vars">
<div class="graph-box">
<span v-if="renderError" class="muted">这张流程图画不出来,可能有框没连上</span>
<div v-show="!renderError" ref="graph" class="graph"></div>
<span v-if="failed" class="muted">没拉下来,<a href="#" @click.prevent="load">再试一次</a></span>
</div>
<div v-if="detail" class="note-box">
<template v-if="report">
<b class="headline">{{ flowchartVerdict(report, detail.aiGrade) }}</b>
<FlowchartNote
v-model:selected="openCase"
v-model:open="casesOpen"
:report="report"
:feedback="detail.aiFeedback"
:suggestions="detail.aiSuggestions"
:previous="detail.previousCases"
/>
</template>
<!-- 2026-10 之前的老评法没有走例子的报告,只有一段评语 -->
<template v-else>
<b class="headline">{{ detail.aiGrade ? `${detail.aiGrade} 级` : '还没画对' }}</b>
<div v-if="detail.aiFeedback" class="plain">{{ detail.aiFeedback }}</div>
<div v-if="detail.aiSuggestions" class="plain">{{ detail.aiSuggestions }}</div>
</template>
</div>
</div>
</template>
<style scoped>
.explain-flow {
display: flex;
gap: 14px;
}
/* 图自己画了一层底框,外面不再套边框;高度压在一屏以内,讲的时候不用上下滚 */
.graph-box {
flex: 0 0 60%;
min-width: 0;
height: min(620px, calc(100vh - 190px));
display: flex;
align-items: center;
justify-content: center;
}
.graph {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.note-box {
border: 1px solid var(--divider);
border-radius: 8px;
background: var(--card);
}
.graph :deep(svg) {
max-width: 100%;
max-height: 100%;
}
.graph :deep(.pele-node.oj-focus > :not(text)) {
fill: var(--focus-fill) !important;
stroke: var(--focus-stroke) !important;
stroke-width: 3px !important;
}
.note-box {
flex: 1 1 0;
min-width: 0;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: auto;
}
.headline {
font-size: 18px;
}
.plain {
white-space: pre-wrap;
line-height: 1.7;
}
.muted {
color: var(--text3);
}
@media (max-width: 768px) {
.explain-flow {
flex-direction: column;
}
.graph-box {
flex: none;
height: 360px;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { LessonLanguage } from '@oj2/contract'
import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode } from '@oj2/contract'
import { setClassLesson } from 'oj/api'
import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
@@ -12,13 +12,15 @@ import ProblemPicker from './ProblemPicker.vue'
* - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条;
* - `start`:刚上课还没有题时,这页最大的那个框。
*
* 一个班一天可以上几节内容不同的课:`lessonId` 是要改的那一节,null = 今天新开一节
* 一个班一天可以上几节内容不同的课:`lessonId` 是要改的那一节,null = 今天新开一节。
* `carry`:新开的这一节带上这一节的题、改成写代码 —— 先布置画流程图、再让全班写代码时用
*/
const props = defineProps<{
board: ClassBoard
className: string
variant: 'inline' | 'start'
lessonId: number | null
carry?: boolean
}>()
const emit = defineEmits<{ saved: []; cancel: [] }>()
const message = useMessage()
@@ -33,6 +35,13 @@ const languageOptions = LESSON_LANGUAGES.map((value) => ({
label: LANGUAGE_SHOW_VALUE[value],
value,
}))
/**
* 这节课做什么:写代码 / 先画再写 / 画流程图。画流程图不用选语言(下拉藏起来,存成 null),
* 要画的两种只能布置打开了画流程图的题(后端会点名拒掉)
*/
const lessonMode = ref<LessonMode>('code')
const modeOptions = (['code', 'both', 'flowchart'] as const).map((value) => ({ label: LESSON_MODE_LABEL[value], value }))
const needsCode = computed(() => lessonNeeds(lessonMode.value).code)
const lessonInput = ref('')
const saving = ref(false)
@@ -40,8 +49,10 @@ const saving = ref(false)
const editingPlanned = computed(() => props.lessonId !== null && props.lessonId === props.board.lessonId)
function resetLessonInput() {
lessonInput.value = editingPlanned.value ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : ''
const keep = editingPlanned.value || props.carry
lessonInput.value = keep ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : ''
lessonLanguage.value = (editingPlanned.value ? props.board.language : null) ?? props.board.lastLanguage ?? 'Python'
lessonMode.value = props.carry ? 'code' : editingPlanned.value ? (props.board.mode ?? 'code') : 'code'
}
resetLessonInput()
@@ -52,7 +63,7 @@ if (props.variant === 'inline' && props.board.source === 'inferred' && !lessonIn
// 换了班、换了一节要把输入框换成那一节的题、布置时选的语言(15 秒一次的刷新不动它们)。
// 必须写成几个单值来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的,
// 回调次次都跑,老师打到一半的题号每 15 秒被清一次
watch([() => props.board.className, () => props.board.source, () => props.lessonId], resetLessonInput)
watch([() => props.board.className, () => props.board.source, () => props.lessonId, () => props.carry], resetLessonInput)
function inputIds() {
return lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
@@ -62,7 +73,7 @@ async function saveLesson() {
const ids = inputIds()
saving.value = true
try {
await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null, props.lessonId)
await setClassLesson(props.className, ids, ids.length && needsCode.value ? lessonLanguage.value : null, lessonMode.value, props.lessonId)
message.success(ids.length ? '已布置,学生首页会显示这几道题' : '已清掉')
picking.value = false
emit('saved')
@@ -89,7 +100,8 @@ function pickerConfirm(ids: string[]) {
<template>
<div v-if="variant === 'inline'">
<n-select v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言做" class="lang-select" />
<n-select v-model:value="lessonMode" :options="modeOptions" aria-label="这节课做什么" class="mode-select" />
<n-select v-if="needsCode" v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言写" class="lang-select" />
<n-input v-model:value="lessonInput" placeholder="这节课的题号,用空格隔开" clearable autofocus @keyup.enter="saveLesson" />
<n-button @click="openPicker">
<template #icon><Icon icon="ph:list-checks" /></template>
@@ -98,17 +110,32 @@ function pickerConfirm(ids: string[]) {
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
<n-button @click="emit('cancel')">取消</n-button>
<!-- 弹层挂在 body 下,放在根节点里面:两个根会让看板传进来的 class 落空 -->
<ProblemPicker v-model:show="picking" v-model:language="lessonLanguage" :initial="pickerInitial" :saving="saving" @confirm="pickerConfirm" />
<ProblemPicker
v-model:show="picking"
v-model:language="lessonLanguage"
:initial="pickerInitial"
:saving="saving"
@confirm="pickerConfirm"
v-model:lesson-mode="lessonMode"
/>
</div>
<div v-else>
<n-select v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言做" size="large" class="lang-select" />
<n-select v-model:value="lessonMode" :options="modeOptions" aria-label="这节课做什么" size="large" class="mode-select" />
<n-select v-if="needsCode" v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言写" size="large" class="lang-select" />
<n-input v-model:value="lessonInput" size="large" placeholder="输入题号,用空格隔开" class="start-input" @keyup.enter="saveLesson" />
<n-button size="large" @click="openPicker">
<template #icon><Icon icon="ph:list-checks" /></template>
选题
</n-button>
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">布置</n-button>
<ProblemPicker v-model:show="picking" v-model:language="lessonLanguage" :initial="pickerInitial" :saving="saving" @confirm="pickerConfirm" />
<ProblemPicker
v-model:show="picking"
v-model:language="lessonLanguage"
:initial="pickerInitial"
:saving="saving"
@confirm="pickerConfirm"
v-model:lesson-mode="lessonMode"
/>
</div>
</template>
@@ -118,6 +145,11 @@ function pickerConfirm(ids: string[]) {
flex-shrink: 0;
}
.mode-select {
width: 116px;
flex-shrink: 0;
}
.start-input {
flex: 1 1 0;
font-size: 18px;
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { LessonLanguage, Tag } from '@oj2/contract'
import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode, type Tag } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { getProblemList, getProblemTagList } from 'shared/api'
import { useTone } from 'shared/composables/tone'
@@ -10,7 +10,8 @@ import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
/**
* 看板布置作业的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
* 只列能用这次作业的语言交的题(左栏题数也按这个语言数),不用再一个个记题号。
* 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的,
* 要画流程图的只列打开了画流程图的。不用再一个个记题号。
* 点「布置」直接存,和在输入框里敲题号回车是一回事
*/
const props = defineProps<{
@@ -22,12 +23,18 @@ const props = defineProps<{
}>()
const show = defineModel<boolean>('show', { required: true })
const language = defineModel<LessonLanguage>('language', { required: true })
/** 这节课做什么(写代码 / 画流程图 / 先画再写),和看板上那个下拉是同一个 */
const lessonMode = defineModel<LessonMode>('lessonMode', { required: true })
const needs = computed(() => lessonNeeds(lessonMode.value))
/** 只画流程图不看语言 */
const languageFilter = computed(() => (needs.value.code ? language.value : ''))
const emit = defineEmits<{ confirm: [ids: string[]] }>()
const theme = useThemeVars()
const tone = useTone()
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
const LESSON_MODE_OPTIONS = (['code', 'both', 'flowchart'] as const).map((value) => ({ label: LESSON_MODE_LABEL[value], value }))
const languageOptions = LESSON_LANGUAGES.map((value) => ({ label: LANGUAGE_SHOW_VALUE[value], value }))
const SORTS = [
{ label: '最新出的', value: '' },
@@ -55,7 +62,7 @@ const mustDo = computed(() => tags.value.find((t) => t.name === MUST_DO) ?? null
const allCount = ref(0)
async function loadTags() {
const list = await getProblemTagList(language.value)
const list = await getProblemTagList(languageFilter.value, needs.value.draw)
tags.value = list
// 换了语言,原来选的知识点在这个语言下可能一道都没有
if (tag.value && !list.some((t) => t.name === tag.value)) tag.value = ''
@@ -70,7 +77,8 @@ async function loadRows() {
tag: tag.value,
keyword: keyword.value.trim(),
sort: sort.value,
language: language.value,
language: languageFilter.value,
...(needs.value.draw ? { flowchart: '1' } : {}),
})
if (mine !== seq) return
rows.value = res.results
@@ -95,7 +103,7 @@ watch(show, (open) => {
loadTags()
reload()
})
watch(language, () => {
watch([languageFilter, () => needs.value.draw], () => {
if (!show.value) return
loadTags()
reload()
@@ -125,6 +133,12 @@ function remove(id: string) {
picked.value = picked.value.filter((p) => p.id !== id)
}
const emptyText = computed(() => {
if (!needs.value.code) return '没有能画流程图的题'
const text = `没有能用 ${LANGUAGE_SHOW_VALUE[language.value]} 做的题`
return needs.value.draw ? `${text}(还要能画流程图)` : text
})
const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
function difficultyStyle(row: ProblemRow) {
if (!row.difficulty) return {}
@@ -151,9 +165,11 @@ const vars = computed(() => ({
<n-modal v-model:show="show" preset="card" :style="vars" :bordered="false" :auto-focus="false" title="选题">
<template #header-extra>
<div class="head-extra">
<span class="muted">用</span>
<n-select v-model:value="language" :options="languageOptions" aria-label="用什么语言做" size="small" class="lang" />
<span class="muted">做</span>
<n-select v-model:value="lessonMode" :options="LESSON_MODE_OPTIONS" aria-label="这节课做什么" size="small" class="mode" />
<template v-if="needs.code">
<span class="muted">用</span>
<n-select v-model:value="language" :options="languageOptions" aria-label="用什么语言写" size="small" class="lang" />
</template>
</div>
</template>
@@ -183,7 +199,7 @@ const vars = computed(() => ({
<div ref="listRef" class="list">
<n-spin :show="loading" class="spin">
<n-empty v-if="!loading && !rows.length" :description="`没有能用 ${LANGUAGE_SHOW_VALUE[language]} 做的题`" class="empty" />
<n-empty v-if="!loading && !rows.length" :description="emptyText" class="empty" />
<button v-for="row in rows" :key="row.id" type="button" class="row" :class="{ on: pickedIndex.has(row._id.toLowerCase()) }" @click="toggle(row)">
<span class="check">
<template v-if="pickedIndex.has(row._id.toLowerCase())">{{ pickedIndex.get(row._id.toLowerCase())! + 1 }}</template>
@@ -247,6 +263,10 @@ const vars = computed(() => ({
width: 110px;
}
.mode {
width: 116px;
}
.muted {
color: var(--text3);
}
+18 -4
View File
@@ -190,6 +190,9 @@ const classActivityTitle = computed(() => {
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
if (classActivity.value?.source === 'teacher') {
const language = classActivity.value.language
const mode = classActivity.value.mode
if (mode === 'flowchart') return '这节课 · 画流程图'
if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码`
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}`
}
if (classToday.value) return '这节课'
@@ -203,6 +206,17 @@ const MY_STATUS: Record<ClassActivityProblem['myStatus'], { label: string; type:
none: { label: '还没做', type: 'default' },
}
/**
* 每行前面那个小标签。要画又要写的题只做到一步时说差哪一步 ——「没做对」会让人以为是代码错了
*/
function statusTag(item: ClassActivityProblem) {
if (item.myStatus === 'tried' && item.draw && item.code) {
if (item.draw === 'accepted') return { label: '还要写代码', type: 'warning' as const }
if (item.code === 'accepted') return { label: '还要画图', type: 'warning' as const }
}
return MY_STATUS[item.myStatus]
}
async function loadWeekly() {
weekly.value = await getWeeklyRank(userStore.user?.className ? 'class' : 'global')
}
@@ -291,8 +305,8 @@ function contestTime(contest: Contest) {
<n-text depth="3" class="row-meta"> {{ classActivity.problems.length }} 道做对了 {{ doneInClass }} 道 </n-text>
</template>
<router-link v-for="item in classActivity.problems" :key="item.problemDisplayId" :to="`/problem/${item.problemDisplayId}`" class="row">
<n-tag :type="MY_STATUS[item.myStatus].type" size="small" :bordered="false">
{{ MY_STATUS[item.myStatus].label }}
<n-tag :type="statusTag(item).type" size="small" :bordered="false">
{{ statusTag(item).label }}
</n-tag>
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
<!-- 老师刚布置、还没人交的时候不显示「0/0」 -->
@@ -346,8 +360,8 @@ function contestTime(contest: Contest) {
<n-text depth="3" class="row-meta">{{ classActivity.className }} 班</n-text>
</template>
<router-link v-for="item in classActivity.problems" :key="item.problemDisplayId" :to="`/problem/${item.problemDisplayId}`" class="row">
<n-tag :type="MY_STATUS[item.myStatus].type" size="small" :bordered="false">
{{ MY_STATUS[item.myStatus].label }}
<n-tag :type="statusTag(item).type" size="small" :bordered="false">
{{ statusTag(item).label }}
</n-tag>
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
<n-text v-if="item.userCount" depth="3" class="row-meta"> {{ item.acceptedCount }}/{{ item.userCount }} 人做对 </n-text>
@@ -53,17 +53,17 @@ const currentId = computed(() => problem.value?._id ?? '')
* 换题时 id 跟着变,那一次不算;流程图只认这一页上刚评完的(evaluatedSeq)
*/
const flowchartStore = useFlowchartStore()
function onSolvedHere(callback: () => void) {
function onSolvedHere(callback: (step: 'code' | 'draw') => void) {
watch(
() => [problem.value?.id, problem.value?.myStatus] as const,
([id, status], [previousId, previousStatus]) => {
if (id !== undefined && id === previousId && status === 0 && previousStatus !== 0) callback()
if (id !== undefined && id === previousId && status === 0 && previousStatus !== 0) callback('code')
},
)
watch(
() => flowchartStore.evaluatedSeq,
() => {
if (isFlowchartPass(flowchartStore.latestRating.grade)) callback()
if (isFlowchartPass(flowchartStore.latestRating.grade)) callback('draw')
},
)
}
@@ -90,6 +90,7 @@ const lesson = computed(() => {
title: item.title,
status: item.myStatus === 'accepted' ? 'done' : item.myStatus === 'tried' ? 'tried' : ('none' as const),
to: `/problem/${item.problemDisplayId}`,
note: stepNote(item),
}))
return {
chips,
@@ -101,9 +102,20 @@ const lesson = computed(() => {
}
})
onSolvedHere(() => {
/**
* 要画又要写的题(先画再写,或者今天先后布置了画流程图和写代码):只做到一步时说清楚差哪一步,
* 光写「做过,还没对」学生会以为是代码错了
*/
function stepNote(item: { draw: string | null; code: string | null; myStatus: string }) {
if (!item.draw || !item.code || item.myStatus === 'accepted') return undefined
if (item.draw === 'accepted') return '流程图画对了,还要写代码'
if (item.code === 'accepted') return '代码做对了,还要画流程图'
return '先画流程图,再写代码'
}
onSolvedHere((step) => {
if (ctx.value.entry !== 'problem' || !lessonStore.includes(currentId.value)) return
lessonStore.markAccepted(currentId.value)
lessonStore.markAccepted(currentId.value, step)
lessonStore.load(true)
})
@@ -4,6 +4,7 @@ import { storeToRefs } from 'pinia'
import { useCodeStore } from 'oj/store/code'
import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart'
import { useProblemStore } from 'oj/store/problem'
import { useLessonStore } from 'shared/store/lesson'
import { useUserStore } from 'shared/store/user'
import { sortFlowchartCriteria } from 'utils/constants'
import { parseTime } from 'utils/functions'
@@ -173,9 +174,19 @@ watch(
onBeforeUnmount(() => editor.value?.setHighlight(null))
/** A / S 之后:照着图写代码。切回学生习惯的那门语言 */
const canCode = computed(() => problemStore.codeLanguages.length > 0)
/**
* 「照着它写代码」:只画流程图的课里不给(老师这节课只要画),这道题在要写代码的课里就换到
* 布置的语言,否则回到他习惯的那门
*/
const lessonStore = useLessonStore()
const lessonItem = computed(() => (problemStore.problem ? lessonStore.find(problemStore.problem._id) : undefined))
const canCode = computed(() => problemStore.codeLanguages.length > 0 && !(lessonItem.value?.draw && !lessonItem.value.code))
/** 课上这道题要写代码、还没交对:画对了不算做完 */
const codeStillNeeded = computed(() => !!lessonItem.value && lessonItem.value.code !== null && lessonItem.value.code !== 'accepted')
function goCode() {
problemStore.switchLanguage(problemStore.supportedLanguage(codeStore.preferredLanguage()))
const language = lessonStore.activity?.language
if (lessonItem.value?.code && language && problemStore.codeLanguages.includes(language)) problemStore.switchLanguage(language)
else problemStore.switchLanguage(problemStore.supportedLanguage(codeStore.preferredLanguage()))
}
</script>
@@ -236,14 +247,14 @@ function goCode() {
<div v-if="report" class="headline">
<span class="grade" :class="gradeType(current.grade)">{{ current.grade }} 级</span>
<span class="headline-text">{{ flowchartVerdict(report, current.grade) }}</span>
<span v-if="isFlowchartPass(current.grade)" class="headline-note">这道题算做完了</span>
<span v-if="isFlowchartPass(current.grade)" class="headline-note">{{ codeStillNeeded ? '接着写代码' : '这道题算做完了' }}</span>
</div>
<div v-else class="score-line">
<span class="score" :class="gradeType(current.grade)">{{ current.score }}</span>
<span class="score-unit">分</span>
<span class="grade" :class="gradeType(current.grade)">{{ current.grade }} 级</span>
<span v-if="detail?.aiFeedback || isFlowchartPass(current.grade)" class="feedback">
{{ detail?.aiFeedback ?? '' }}{{ isFlowchartPass(current.grade) ? ' 这道题算做完了。' : '' }}
{{ detail?.aiFeedback ?? '' }}{{ isFlowchartPass(current.grade) ? (codeStillNeeded ? ' 接着写代码吧。' : ' 这道题算做完了。') : '' }}
</span>
</div>
@@ -15,6 +15,8 @@ export interface ChipItem {
title: string
status: 'done' | 'tried' | 'none'
to: RouteLocationRaw
/** 悬停时代替「做过,还没对」那句(课堂里要画又要写的题,说差哪一步) */
note?: string
}
const props = defineProps<{
@@ -61,7 +63,7 @@ watch(
:to="item.to"
class="chip"
:class="[item.status, { current: isCurrent(item) }]"
:aria-label="`${item.displayId} ${item.title}(${STATUS_TEXT[item.status]})`"
:aria-label="`${item.displayId} ${item.title}(${item.note ?? STATUS_TEXT[item.status]})`"
:aria-current="isCurrent(item) ? 'page' : undefined"
>
<!-- 内嵌 SVG,不走 Iconify:图标源拉不到时状态就只剩颜色了 -->
@@ -109,7 +111,7 @@ watch(
<span>{{ item.displayId }}</span>
</router-link>
</template>
{{ item.displayId }} {{ item.title }} · {{ STATUS_TEXT[item.status] }}
{{ item.displayId }} {{ item.title }} · {{ item.note ?? STATUS_TEXT[item.status] }}
</n-tooltip>
</div>
</template>
@@ -172,20 +172,38 @@ watch(
* 编辑器停在默认的 Python(或者上一个用这台电脑的人留下的偏好)的话,学生交上去才发现
* 不算数。课堂条只在题库入口出现,这里同一个范围。
*
* 每道题只自动换一次:学生自己再切回别的语言,就按他的来。课堂状态可能比题面晚到
* (课堂条进页面才去拉),所以两边都等到了再判断;画着流程图时不动他。
* 要画流程图的课(画流程图 / 先画再写,或者今天先后布置了画和写):这道题的图还没画对,
* 就停在画布上;画对了、还要写代码的,换到布置的语言 —— 画一道写一道、全画完再回来写,
* 打开题目都是该做的那一步。
*
* 每道题、每一节课只自动换一次:学生自己再切回别的,就按他的来。老师换了一节(做法、语言变了)
* 再判断一次。画对图的那一刻不跟着换:他正在看点评,「照着它写代码」由他自己点。
* 课堂状态可能比题面晚到(课堂条进页面才去拉),所以两边都等到了再判断;
* 没有画这一步的课,画着流程图时不动他
*/
const lessonStore = useLessonStore()
let lessonLanguageFor: string | null = null
watch(
() => [problem.value?._id, lessonStore.activity] as const,
([id, activity]) => {
if (!id || !activity || lessonLanguageFor === id) return
lessonLanguageFor = id
if (!id || !activity) return
const key = [id, activity.day, activity.mode, activity.language].join('|')
if (lessonLanguageFor === key) return
lessonLanguageFor = key
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
const item = lessonStore.find(id)
if (!item) return
if (item.draw && item.draw !== 'accepted') {
if (problemStore.canDraw && codeStore.code.language !== 'Flowchart') problemStore.switchLanguage('Flowchart')
return
}
// 只画流程图、已经画对了:没有要写的
if (item.draw && !item.code) return
const language = activity.language
if (!language || ctx.value.entry !== 'problem' || teacherCollab.value) return
if (!lessonStore.includes(id) || !problemStore.codeLanguages.includes(language)) return
if (codeStore.code.language === language || codeStore.code.language === 'Flowchart') return
if (!language || !problemStore.codeLanguages.includes(language)) return
if (codeStore.code.language === language) return
// 没有画这一步、自己在画流程图的,不打断他
if (codeStore.code.language === 'Flowchart' && !item.draw) return
problemStore.switchLanguage(language)
},
{ immediate: true },
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { LESSON_MODE_LABEL } from '@oj2/contract'
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui'
import type { SubmissionLessons } from '@oj2/contract'
@@ -88,8 +89,9 @@ function attendance(lesson: Lesson) {
return lesson.classSize >= lesson.userCount ? `${lesson.userCount} / ${lesson.classSize} 人` : `${lesson.userCount} 人`
}
/** 要画流程图的课写出来:这一行的次数和正确率连流程图一起数(画到 A / S 算对) */
function problemText(lesson: Lesson) {
if (lesson.assigned) return `布置 ${lesson.problems.length} 道`
if (lesson.assigned) return `布置 ${lesson.problems.length} 道${lesson.mode && lesson.mode !== 'code' ? ` · ${LESSON_MODE_LABEL[lesson.mode]}` : ''}`
return `${lesson.fromProblemSet ? '题单 ' : ''}${lesson.problems.length} 道`
}
@@ -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, flowchartVerdict } from 'utils/flowchart'
import { caseReport, fitFlowchartGraph, flowchartVerdict, markFlowchartFocus } from 'utils/flowchart'
/**
* 流程图模式的右栏:左边画出来的图,右边四项评分和建议。原来要点编号再开一个
@@ -85,34 +85,8 @@ 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 上
*/
function markFocus(el: HTMLElement | null) {
const focus = report.value?.focus ?? report.value?.cases.find((row) => !row.ok)?.wrongAt
if (!el || !focus) return
el.querySelector(`.pele-node[data-id="${focus}"]`)?.classList.add('oj-focus')
fitFlowchartGraph(graph.value)
markFlowchartFocus(graph.value, report.value)
}
// 分开写三个来源:列表每次轮询都换一批新的行对象,一个 getter 返回数组的话回调次次都跑
@@ -124,7 +98,7 @@ watch(bigOpen, async (open) => {
if (!open || !detail.value) return
await nextTick()
await renderFlowchart(bigGraph.value, detail.value.mermaidCode)
markFocus(bigGraph.value)
markFlowchartFocus(bigGraph.value, report.value)
})
const shown = computed(() => (detail.value?.id === props.row.id ? detail.value : null))
+3 -2
View File
@@ -33,8 +33,9 @@ export async function getProfile(username: string = ''): Promise<Profile | null>
}
/** 带 language 只数能用这个语言交的题(看板「选题」弹框用) */
export function getProblemTagList(language = '') {
return api.get<Tag[]>('problem-tags', { params: { language } })
/** 课堂布置选题用:language 只数能用它交的题,flowchart 只数打开了画流程图的题 */
export function getProblemTagList(language = '', flowchart = false) {
return api.get<Tag[]>('problem-tags', { params: { language, ...(flowchart ? { flowchart: '1' } : {}) } })
}
export function getHitokoto() {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { LessonUpdate } from '@oj2/contract'
import { LESSON_MODE_LABEL, type LessonUpdate } from '@oj2/contract'
import { pendingLessonUpdate } from 'shared/composables/lessonUpdate'
import { useThemeVars } from 'naive-ui'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
@@ -17,8 +17,10 @@ function names(list: Item[]) {
return list.map((item) => `${item.problemDisplayId} ${item.title}`).join('、')
}
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */
function languageText(value: LessonUpdate) {
return value.language ? LANGUAGE_SHOW_VALUE[value.language] : ''
const mode = value.mode && value.mode !== 'code' ? LESSON_MODE_LABEL[value.mode] : ''
return [mode, value.language ? LANGUAGE_SHOW_VALUE[value.language] : ''].filter(Boolean).join(' · ')
}
const title = computed(() => {
@@ -36,12 +38,15 @@ const lines = computed(() => {
if (value.kind === 'cleared') return []
if (value.kind === 'assigned') {
const language = languageText(value)
return [{ label: '', text: names(value.problems) }, ...(language ? [{ label: '用', text: language }] : [])]
return [{ label: '', text: names(value.problems) }, ...(language ? [{ label: value.mode && value.mode !== 'code' ? '' : '用', text: language }] : [])]
}
const result: { label: string; text: string }[] = []
if (value.added.length) result.push({ label: '加了', text: names(value.added) })
if (value.removed.length) result.push({ label: '去掉了', text: names(value.removed) })
if (value.languageChanged) result.push({ label: '语言', text: value.language ? `改成 ${languageText(value)}` : '不限了' })
if (value.languageChanged) {
const text = languageText(value)
result.push({ label: value.mode && value.mode !== 'code' ? '做法' : '语言', text: text ? `改成 ${text}` : value.mode === 'code' ? '改成写代码' : '不限了' })
}
if (value.reordered) result.push({ label: '', text: '换了题目的顺序' })
return result
})
+21 -6
View File
@@ -1,3 +1,4 @@
import { LESSON_MODE_LABEL } from '@oj2/contract'
import { defineStore } from 'pinia'
import { getClassActivity } from 'shared/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
@@ -42,10 +43,20 @@ export const useLessonStore = defineStore('lesson', () => {
return loading
}
/** 刚通过:不等重拉,先把这道标成做完,课堂条上的「做完 N/M」马上加一 */
function markAccepted(problemDisplayId: string) {
/**
* 刚做对:不等重拉,先把这道标上,课堂条上的「做完 N/M」马上加一。`step` 是做对的哪一步:
* 代码交对、还是流程图画到 A / S。老师布置的课要这道题画和写两步时,只做对一步还不算做完;
* 推断出来的课不分步(draw / code 都是 null),哪样做对都算
*/
function markAccepted(problemDisplayId: string, step: 'code' | 'draw') {
const item = find(problemDisplayId)
if (item) item.myStatus = 'accepted'
if (!item) return
if (step === 'code' && item.code) item.code = 'accepted'
if (step === 'draw' && item.draw) item.draw = 'accepted'
const steps = [item.draw, item.code].filter((value) => value !== null)
// 这节课不要这一步(只画流程图的课交对了代码):不算
if (steps.length && (step === 'code' ? !item.code : !item.draw)) return
item.myStatus = steps.every((value) => value === 'accepted') ? 'accepted' : 'tried'
}
function find(problemDisplayId: string): ClassActivityProblem | undefined {
@@ -73,14 +84,18 @@ export const useLessonStore = defineStore('lesson', () => {
return { total: list.length, next: rest[0] ?? null, remaining: rest.length }
}
/** 老师布置时选的语言,给界面写「C语言」;没选(推断出来的课、以前布置的)为空 */
/**
* 老师布置时选的做法,给界面写「画流程图」「先画再写 · C语言」「C语言」;没选(推断出来的课、
* 以前布置的)为空。写代码是默认的,不写出来
*/
const languageText = computed(() => {
const mode = activity.value?.mode
const language = activity.value?.language
return language ? LANGUAGE_SHOW_VALUE[language] : ''
return [mode && mode !== 'code' ? LESSON_MODE_LABEL[mode] : '', language ? LANGUAGE_SHOW_VALUE[language] : ''].filter(Boolean).join(' · ')
})
/** 老师布置的叫「老师布置的题」(选了语言带上语言),推断出来的叫「这节课的题」 */
const label = computed(() => (activity.value?.source === 'teacher' ? `老师布置的题${languageText.value ? ` · ${languageText.value}` : ''}` : '这节课的题'))
return { activity, label, languageText, load, markAccepted, includes, nextAfter }
return { activity, label, languageText, load, markAccepted, find, includes, nextAfter }
})
+26
View File
@@ -45,3 +45,29 @@ export function flowchartVerdict(report: FlowchartCaseReport, grade: string | nu
export function caseHeadline(report: FlowchartCaseReport) {
return report.passed === report.total ? `${report.total} 组全部走对` : `${report.total} 组走对 ${report.passed} 组`
}
/**
* 图填满图框:大图照旧缩小,小图也放大,但最多到原尺寸的 1.5 倍(再大字就粗笨了)。
* 原来只缩不放,比大小这种五六个框的图在一大块空白中间只占一小团
*/
export function fitFlowchartGraph(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`
}
/**
* 要改的那个框标红(加 oj-focus,颜色由用的地方的样式定):AI 对着答案指出的,没有就退回
* 程序找到的第一处出错的框。pele 把 Mermaid 的框号放在节点 <g> 的 data-id 上
*/
export function markFlowchartFocus(el: HTMLElement | null, report: FlowchartCaseReport | null) {
const focus = report?.focus ?? report?.cases.find((row) => !row.ok)?.wrongAt
if (!el || !focus) return
el.querySelector(`.pele-node[data-id="${focus}"]`)?.classList.add('oj-focus')
}
+56 -2
View File
@@ -1,9 +1,12 @@
import { z } from 'zod'
import { judgeStatusSchema } from './judge-status'
import { lessonLanguageSchema as lessonLanguageValueSchema } from './language'
import { lessonLanguageSchema as lessonLanguageValueSchema, lessonModeSchema } from './language'
import { statisticInfoSchema } from './submission'
/** 课堂条上一道题一步的状态(画 / 写),和 myStatus 同一套取值 */
const stepStatusSchema = z.enum(['accepted', 'tried', 'none'])
/**
* 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。
* `day` 是东八区日历日;最近 7 天都没有这样的题时为 null,`problems` 为空。
@@ -20,6 +23,15 @@ export const classActivityProblemSchema = z.object({
* 代码只认用那个语言交的(流程图照旧算)
*/
myStatus: z.enum(['accepted', 'tried', 'none']),
/**
* 今天布置的课要这道题画流程图 / 写代码时,这一步我做到哪了;不要这一步为 null。
* 推断出来的课两个都是 null(不分步,myStatus 照旧代码或流程图做对都算)。
*
* 一道题今天可能被布置了两次(先布置画流程图、再布置写代码):两节课的要求合起来算,
* 学生那边就和「先画再写」一样是两步 —— 老师开了写代码的那节,还没画完的人照样看得到要画
*/
draw: stepStatusSchema.nullable(),
code: stepStatusSchema.nullable(),
})
/**
@@ -34,7 +46,10 @@ export const classActivitySchema = z.object({
day: z.string().nullable(),
/** teacher = 老师在课堂看板里布置的;inferred = 从同班提交记录推断的 */
source: z.enum(['teacher', 'inferred']).nullable(),
/** 写代码那一步用的语言:今天最后布置的、要写代码的那节选的(只画流程图的课为 null) */
language: lessonLanguageSchema,
/** 今天最后布置的那节做什么;推断出来的课为 null */
mode: lessonModeSchema.nullable(),
problems: z.array(classActivityProblemSchema),
})
@@ -86,6 +101,8 @@ export const classLessonRequestSchema = z.object({
className: z.string().trim().min(1),
problemDisplayIds: z.array(z.string().trim().min(1)).max(20),
language: lessonLanguageSchema.nullable(),
/** 不带 = 写代码(开着的旧页面发的就是这种)。画流程图时 language 不用,存成 null */
mode: lessonModeSchema.default('code'),
lessonId: z.number().int().positive().optional(),
newLesson: z.boolean().optional(),
})
@@ -114,8 +131,39 @@ export const classBoardCellSchema = z.object({
*/
codeAttempts: z.number().int(),
flowchartAttempts: z.number().int(),
/** 第一次通过的时刻;今天之前通过的,前端显示成「之前」 */
/**
* 第一次通过的时刻;今天之前通过的,前端显示成「之前」。先画再写的课是两步都做到的那一刻
* (晚的那一步)
*/
acceptedAt: z.string().nullable(),
/**
* 代码这一步:交对过(不限今天)/ 交过没对 / 没交过。写代码的课和 status 一样;先画再写的课
* 要它分出「图画对了、代码还没对」
*/
codeStatus: stepStatusSchema,
/**
* 流程图这一步,只在这节课要画流程图时有(mode 是 flowchart / both),否则 null。
* - `passedAt`:第一次画到 A / S 的时刻(不限今天)
* - `latest`:还没画对时最后一张图(不限今天,和 passedAt 同一个范围):点格子打开它讲哪里画错了,
* 题卡片下面按走对几组归堆。评分中、评失败的也是它(passed / total 为 null)
*/
flowchart: z
.object({
status: stepStatusSchema,
passedAt: z.string().nullable(),
latest: z
.object({
id: z.string(),
/** 0 排队 1 评分中 2 评完 3 评失败(flowchartStatusSchema) */
status: z.number().int(),
grade: z.string().nullable(),
/** 走对几组 / 一共几组;评完之前、2026-10 之前的老评法为 null */
passed: z.number().int().nullable(),
total: z.number().int().nullable(),
})
.nullable(),
})
.nullable(),
/**
* 还没做对时,今天最后一次**代码**提交:点格子打开它讲错误,题卡片下面按错法归堆。
* 做对了、今天没交代码(只画了流程图)的为 null。
@@ -188,6 +236,8 @@ export const classBoardSchema = z.object({
lessonId: z.number().int().nullable(),
/** 这一节布置的语言(见 lessonLanguageSchema) */
language: lessonLanguageSchema,
/** 这一节做什么;推断出来的课为 null(代码交对或流程图画对都算) */
mode: lessonModeSchema.nullable(),
/** 这一节第一次布置的时刻,之后改题不变;推断出来的课为 null */
assignedAt: z.string().nullable(),
/** 第一次布置的老师(真名,没有真名用用户名);推断出来的课为 null */
@@ -266,6 +316,8 @@ export const lessonUpdateSchema = z.object({
type: z.literal('lesson_update'),
kind: z.enum(['assigned', 'changed', 'cleared']),
language: lessonLanguageSchema,
/** 清掉了为 null。languageChanged 连它一起算:改成画流程图、改成先画再写也是「做法变了」 */
mode: lessonModeSchema.nullable(),
problems: z.array(lessonUpdateProblemSchema),
added: z.array(lessonUpdateProblemSchema),
removed: z.array(lessonUpdateProblemSchema),
@@ -301,6 +353,7 @@ export const adminLessonSchema = z.object({
/** 布置的老师:真名,没有真名用用户名(和看板「谁 布置」一样) */
createdBy: z.string(),
language: lessonLanguageSchema,
mode: lessonModeSchema,
/** 按布置的顺序;题后来被藏起来的 visible 为 false(统计、学生那边都不再出现) */
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string(), visible: z.boolean() })),
/** 班里的学生数(STUDENT_ROLES、没禁用),和 doneCount 同一个花名册 */
@@ -325,6 +378,7 @@ export const adminLessonRequestSchema = z.object({
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
language: lessonLanguageSchema,
mode: lessonModeSchema.default('code'),
problemDisplayIds: z.array(z.string().trim().min(1)).min(1).max(20),
})
export type AdminLesson = z.infer<typeof adminLessonSchema>
+18
View File
@@ -47,6 +47,24 @@ export const runnableLanguageSchema = judgeLanguageSchema.extract(['C', 'C++', '
*/
export const lessonLanguageSchema = problemLanguageSchema.extract(['Python', 'C', 'C++', 'SQL'])
/**
* 这节课做什么,老师布置时选(2026-11 加):
* - `code`:写代码,「做完」只认代码交对(加这一项之前布置的课都是它);
* - `flowchart`:画流程图,只认画到 A / S,不选语言(language 为 null);
* - `both`:先画再写,一道题画到 A / S **并且**代码交对才算做完。学生画一道写一道、
* 全画完再写都行,所以是一节课里的两步,不是两节课。
*
* 画流程图的两种只能布置打开了「让学生自己画」的题(problem.allowFlowchart)。
* 推断出来的课没有它(null):代码交对或流程图画到 A / S 都算做完,和加这一项之前一样
*/
export const lessonModeSchema = z.enum(['code', 'flowchart', 'both'])
export type LessonMode = z.infer<typeof lessonModeSchema>
export const LESSON_MODE_LABEL: Record<LessonMode, string> = { code: '写代码', flowchart: '画流程图', both: '先画再写' }
/** 这种课要不要画、要不要写 */
export function lessonNeeds(mode: LessonMode) {
return { draw: mode !== 'code', code: mode !== 'flowchart' }
}
/**
* SQL 题:可选语言里有 SQL(后端保存时强校验 SQL 只能单独出现)。题型只认这一条,
* 前后端一律走它,别再各自看 sqlConfig 或 languages
+6 -1
View File
@@ -2,7 +2,7 @@ import { z } from 'zod'
import { paginatedSchema } from './common'
import { judgeStatusSchema, type JudgeStatus } from './judge-status'
import { problemLanguageSchema, runnableLanguageSchema } from './language'
import { lessonModeSchema, problemLanguageSchema, runnableLanguageSchema } from './language'
/**
* 判题机原始输出(`submission.info` 的 JSONB 原文)。**只是类型,不作运行时校验。**
@@ -478,6 +478,11 @@ export const submissionLessonsSchema = z.object({
problems: z.array(z.string()),
/** 老师在课堂看板布置的课(不是从提交推断的) */
assigned: z.boolean(),
/**
* 布置的课做什么(写代码 / 画流程图 / 先画再写);推断的课为 null。要画流程图的课,次数和
* 正确率连流程图一起数(画到 A / S 算对)
*/
mode: lessonModeSchema.nullable(),
/**
* 布置的课:班里把布置的题全做对了的人数(那天结束前,以前做对的也算;布置时选了语言的
* 只认那个语言,流程图评到 A / S 也算 —— 和看板同一个口径)。推断的课为 null