refactor(课堂任务): 「课堂看板」改名「课堂任务」,地址换成 /task;班级 PK 换成 /pk
- 界面上的「课堂看板」「课堂作业」统一叫「课堂任务」,顶栏、老师首页、后台菜单一起改 - 前端 oj/classroom → oj/task、oj/class → oj/pk,两个名字不再只差几个字母却毫不相干;旧地址不留跳转 - 后端 routes/classroom.ts 拆开:课堂任务的接口进 routes/task.ts(/task/board|lesson|today), 首页的「上次来」「班里在做」进 routes/home.ts;共用的取题口径挪进 services/lesson.ts - 契约 classroom.ts 同样拆成 task.ts / home.ts,ClassBoard* 改叫 TaskBoard* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46d3fe2dbe
commit
29c31640b5
82 files changed
+798
-766
No files matched your search
@@ -22,7 +22,7 @@ const ONLINE_WINDOW_MS = 5 * 60 * 1000
|
||||
/**
|
||||
* 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。
|
||||
*
|
||||
* 课堂看板拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着,
|
||||
* 课堂任务拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着,
|
||||
* 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。
|
||||
* 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。
|
||||
*/
|
||||
|
||||
@@ -428,7 +428,7 @@ function acceptRequest(ws: CollabSocket, request: HelpRequest) {
|
||||
|
||||
/**
|
||||
* 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找),
|
||||
* 「帮完了」要回到哪个班的看板
|
||||
* 「帮完了」要回到哪个班的任务页
|
||||
*/
|
||||
function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) {
|
||||
const frame = (peer: Record<string, unknown>) =>
|
||||
@@ -630,7 +630,7 @@ function handlePresence(ws: CollabSocket, problemId: unknown, language: unknown)
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师在看板上主动打开一个学生正在写的代码(学生没举手)。
|
||||
* 老师在任务页上主动打开一个学生正在写的代码(学生没举手)。
|
||||
*
|
||||
* 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」;
|
||||
* 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。
|
||||
|
||||
@@ -24,7 +24,7 @@ export function normalizeCollabLanguage(value: unknown): CollabLanguage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂看板的格子同一份形状,
|
||||
* 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂任务的格子同一份形状,
|
||||
* 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码
|
||||
*/
|
||||
export interface CollabLatest {
|
||||
@@ -111,7 +111,7 @@ export interface Room {
|
||||
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
|
||||
studentId: number
|
||||
/**
|
||||
* help = 学生举了手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。
|
||||
* help = 学生举了手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。
|
||||
* 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清
|
||||
*/
|
||||
mode: 'help' | 'peek'
|
||||
@@ -144,7 +144,7 @@ export function roomOf(ws: CollabSocket) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在看板上「在写」那一列看它、
|
||||
* 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在任务页上「在写」那一列看它、
|
||||
* 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报,
|
||||
* 连接断了就清掉。同一账号开了两个标签页时,后报的算
|
||||
*/
|
||||
|
||||
@@ -1293,10 +1293,10 @@ export const exerciseAttempt = pgTable(
|
||||
)
|
||||
|
||||
/**
|
||||
* 老师给某个班布置的一节课的题(课堂看板里布置的),一次布置一行。一个班一天可以有几节
|
||||
* 老师给某个班布置的一节课的题(课堂任务里布置的),一次布置一行。一个班一天可以有几节
|
||||
* 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。
|
||||
*
|
||||
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。
|
||||
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(services/lesson.ts 的 classLessonProblems)。
|
||||
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
|
||||
*
|
||||
* - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列:
|
||||
@@ -1322,7 +1322,7 @@ export const classLesson = pgTable(
|
||||
problemIds: jsonb('problem_ids').notNull().$type<number[]>(),
|
||||
/**
|
||||
* 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的
|
||||
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过
|
||||
* 没有它。写入侧在 PUT /task/lesson 按 lessonLanguageSchema 校验过
|
||||
*/
|
||||
language: text().$type<LessonLanguage>(),
|
||||
/**
|
||||
@@ -1330,11 +1330,11 @@ export const classLesson = pgTable(
|
||||
* 都是写代码。写入侧按 lessonModeSchema 校验过
|
||||
*/
|
||||
mode: text().$type<LessonMode>().notNull().default('code'),
|
||||
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */
|
||||
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。任务页上写「HH:mm 谁 布置」 */
|
||||
createdBy: integer('created_by').notNull(),
|
||||
/**
|
||||
* 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
|
||||
* 同一天有几节时,按它分先后:最后布置的那一节是看板上的「这节课」。
|
||||
* 同一天有几节时,按它分先后:最后布置的那一节是任务页上的「这节课」。
|
||||
* 加这一列之前布置的用 updated_at 回填(0034)
|
||||
*/
|
||||
createdAt: timestamp('created_at', {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { authRoutes } from './routes/auth'
|
||||
import { accountRoutes } from './routes/account'
|
||||
import { contestRoutes } from './routes/contest'
|
||||
import { contentRoutes } from './routes/content'
|
||||
import { classroomRoutes } from './routes/classroom'
|
||||
import { homeRoutes } from './routes/home'
|
||||
import { taskRoutes } from './routes/task'
|
||||
import { rankingRoutes } from './routes/ranking'
|
||||
import { problemsetRoutes } from './routes/problemset'
|
||||
import { achievementRoutes } from './routes/achievement'
|
||||
@@ -32,7 +33,8 @@ app.route('/api', accountRoutes)
|
||||
app.route('/api', siteRoutes)
|
||||
app.route('/api', contestRoutes)
|
||||
app.route('/api', contentRoutes)
|
||||
app.route('/api', classroomRoutes)
|
||||
app.route('/api', homeRoutes)
|
||||
app.route('/api', taskRoutes)
|
||||
app.route('/api', rankingRoutes)
|
||||
app.route('/api', problemsetRoutes)
|
||||
app.route('/api', achievementRoutes)
|
||||
|
||||
@@ -446,7 +446,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
|
||||
return users.length
|
||||
})
|
||||
|
||||
// 新班级顺手加进班级列表:不加的话全站的班级下拉(看板、提交列表、排名……)里都找不到它,
|
||||
// 新班级顺手加进班级列表:不加的话全站的班级下拉(任务页、提交列表、排名……)里都找不到它,
|
||||
// 原来要再去设置页手动加,经常忘
|
||||
const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))]
|
||||
const options = await getWebsiteOptions()
|
||||
|
||||
@@ -64,7 +64,7 @@ function studentCondition(value: string | null) {
|
||||
/**
|
||||
* 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数,
|
||||
* 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、
|
||||
* 课堂看板(routes/classroom.ts 的 activityRows)同一个口径。不论早晚:读这课之前
|
||||
* 课堂任务(services/class-activity.ts 的 activityRows)同一个口径。不论早晚:读这课之前
|
||||
* 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。
|
||||
*/
|
||||
async function problemActivity(userIds: number[], problemIds: number[]) {
|
||||
|
||||
@@ -10,10 +10,10 @@ import { localDateTime } from '../../time'
|
||||
import { queryInteger } from '../helpers'
|
||||
|
||||
/**
|
||||
* 后台「课堂作业」:布置过的每一节课(class_lesson)。看板上布置错了班、错了题可以在这里改,
|
||||
* 看板外上的课(以前的、没在看板布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
|
||||
* 后台「课堂任务」:布置过的每一节课(class_lesson)。任务页上布置错了班、错了题可以在这里改,
|
||||
* 任务页外上的课(以前的、没在任务页布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
|
||||
*
|
||||
* 权限和看板一样是老师:看板上任何老师都能改任何班的这节课,这里不另设门槛。
|
||||
* 权限和任务页一样是老师:任务页上任何老师都能改任何班的这节课,这里不另设门槛。
|
||||
*/
|
||||
export const adminLessonRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -116,7 +116,7 @@ async function lessonValues(body: AdminLessonRequest): Promise<
|
||||
> {
|
||||
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} 这个班` }
|
||||
// 只画流程图不用语言,存成 null(和看板布置一样)
|
||||
// 只画流程图不用语言,存成 null(和任务页布置一样)
|
||||
const language = body.mode === 'flowchart' ? null : body.language
|
||||
const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode)
|
||||
if ('code' in resolved) return resolved
|
||||
@@ -156,7 +156,7 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
|
||||
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
|
||||
const classes = await affectedClasses(id, result.values.className)
|
||||
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
|
||||
// 布置的老师不换,和看板上「改」一样认第一次
|
||||
// 布置的老师不换,和任务页上「改」一样认第一次
|
||||
const [row] = await db
|
||||
.update(schema.classLesson)
|
||||
.set({ ...result.values, updatedAt: new Date().toISOString() })
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { type ClassActivity, type ClassActivityProblem, type LastVisit, type LessonLanguage, type LessonMode, lessonNeeds } from '@oj2/contract'
|
||||
import { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { requireAuth, type AppEnv } from '../auth/middleware'
|
||||
import { getLoginWindow } from '../auth/session'
|
||||
import { db, schema } from '../db'
|
||||
import { success } from '../http'
|
||||
import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity'
|
||||
import { accepted } from '../services/learning-stats'
|
||||
import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson'
|
||||
import { calendarDay } from '../time'
|
||||
|
||||
/**
|
||||
* 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。
|
||||
* 「这节课是哪几道题」的取法在 services/lesson.ts(课堂任务、后台、统计共用)
|
||||
*/
|
||||
export const homeRoutes = new Hono<AppEnv>()
|
||||
|
||||
/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */
|
||||
const LAST_VISIT_MAX_DAYS = 30
|
||||
|
||||
/**
|
||||
* 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。
|
||||
* 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述,
|
||||
* 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」
|
||||
*/
|
||||
homeRoutes.get('/me/last-visit', requireAuth, async (c) => {
|
||||
const user = c.get('user')!
|
||||
const window = await getLoginWindow(c)
|
||||
const previousLogin = window?.previousLogin ?? null
|
||||
if (!window || !previousLogin || Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5) {
|
||||
return success(c, { previousLogin, summary: null } satisfies LastVisit)
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
problemDisplayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
result: schema.submission.result,
|
||||
createTime: schema.submission.createTime,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, previousLogin),
|
||||
lt(schema.submission.createTime, window.loginAt),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.submission.createTime))
|
||||
if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit)
|
||||
|
||||
// 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved
|
||||
const byProblem = new Map<
|
||||
number,
|
||||
{
|
||||
problemDisplayId: string
|
||||
title: string
|
||||
attempts: number
|
||||
lastResult: (typeof rows)[number]['result']
|
||||
lastTime: string
|
||||
solved: boolean
|
||||
}
|
||||
>()
|
||||
const solved: { problemDisplayId: string; title: string }[] = []
|
||||
for (const row of rows) {
|
||||
const item = byProblem.get(row.problemId) ?? {
|
||||
problemDisplayId: row.problemDisplayId,
|
||||
title: row.title,
|
||||
attempts: 0,
|
||||
lastResult: row.result,
|
||||
lastTime: row.createTime,
|
||||
solved: false,
|
||||
}
|
||||
item.attempts++
|
||||
item.lastResult = row.result
|
||||
item.lastTime = row.createTime
|
||||
if (!item.solved && accepted.includes(row.result)) {
|
||||
item.solved = true
|
||||
solved.push({ problemDisplayId: row.problemDisplayId, title: row.title })
|
||||
}
|
||||
byProblem.set(row.problemId, item)
|
||||
}
|
||||
// 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」
|
||||
const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id)
|
||||
const everSolved = tried.length
|
||||
? new Set(
|
||||
(
|
||||
await db
|
||||
.selectDistinct({ problemId: schema.submission.problemId })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
isNull(schema.submission.contestId),
|
||||
inArray(schema.submission.problemId, tried),
|
||||
inArray(schema.submission.result, accepted),
|
||||
),
|
||||
)
|
||||
).map((row) => row.problemId),
|
||||
)
|
||||
: new Set<number>()
|
||||
const unsolved = tried
|
||||
.filter((id) => !everSolved.has(id))
|
||||
.map((id) => byProblem.get(id)!)
|
||||
.sort((a, b) => b.lastTime.localeCompare(a.lastTime))
|
||||
.slice(0, 3)
|
||||
.map(({ problemDisplayId, title, attempts, lastResult }) => ({
|
||||
problemDisplayId,
|
||||
title,
|
||||
attempts,
|
||||
lastResult,
|
||||
}))
|
||||
return success(c, {
|
||||
previousLogin,
|
||||
summary: {
|
||||
submissionCount: rows.length,
|
||||
solvedCount: solved.length,
|
||||
lastSubmitTime: rows.at(-1)!.createTime,
|
||||
unsolved,
|
||||
solved: solved.slice(0, 12),
|
||||
},
|
||||
} satisfies LastVisit)
|
||||
})
|
||||
|
||||
/**
|
||||
* 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的
|
||||
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
|
||||
*/
|
||||
homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
|
||||
const user = c.get('user')!
|
||||
if (!user.className) {
|
||||
return success(c, {
|
||||
className: null,
|
||||
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
|
||||
? db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
inArray(schema.submission.problemId, ids),
|
||||
isNull(schema.submission.contestId),
|
||||
// 布置的是 C:以前用 Python 做对过不算这份作业做完
|
||||
lessonLanguageFilter(codeLanguage),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.problemId)
|
||||
: [],
|
||||
// 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
|
||||
ids.length
|
||||
? db
|
||||
.select({
|
||||
problemId: schema.flowchartSubmission.problemId,
|
||||
passed: sql<boolean>`coalesce(bool_or(${FLOWCHART_PASSED}), false)`,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(and(eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.problemId, ids)))
|
||||
.groupBy(schema.flowchartSubmission.problemId)
|
||||
: [],
|
||||
])
|
||||
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: codeLanguage,
|
||||
mode: lesson.mode,
|
||||
problems: lesson.problems.map((problem) => {
|
||||
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,
|
||||
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 }
|
||||
}
|
||||
@@ -73,7 +73,7 @@ function typeFilter(type: ProblemTypeFilter | undefined) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 课堂看板「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
|
||||
* 课堂任务「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
|
||||
* 学生的题目列表不带这个参数
|
||||
*/
|
||||
function lessonLanguageFilter(raw: string | undefined) {
|
||||
|
||||
@@ -361,7 +361,7 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
|
||||
/**
|
||||
* 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
|
||||
*
|
||||
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
|
||||
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计
|
||||
* 也是任何老师看任何班,这里和它们一致。
|
||||
*
|
||||
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
|
||||
|
||||
@@ -529,7 +529,7 @@ const LESSONS_MAX = 500
|
||||
|
||||
type StatisticsRange = { start: string | null; end: string }
|
||||
|
||||
/** 这段时间里(按东八区日子)老师在课堂看板布置过的课,同一天的按布置先后 */
|
||||
/** 这段时间里(按东八区日子)老师在课堂任务布置过的课,同一天的按布置先后 */
|
||||
async function lessonPlans(range: StatisticsRange) {
|
||||
return db
|
||||
.select({
|
||||
|
||||
@@ -1,440 +1,40 @@
|
||||
import {
|
||||
STUDENT_ROLES,
|
||||
classLessonRequestSchema,
|
||||
type ClassActivity,
|
||||
type ClassToday,
|
||||
type ClassBoard,
|
||||
type ClassBoardStudent,
|
||||
type TaskBoard,
|
||||
type TaskBoardStudent,
|
||||
type ClassLesson,
|
||||
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'
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { requireAuth, requireTeacher, type AppEnv } from '../auth/middleware'
|
||||
import { requireTeacher, type AppEnv } from '../auth/middleware'
|
||||
import { seenToday } from '../auth/presence'
|
||||
import { getLoginWindow } from '../auth/session'
|
||||
import { presenceOf } from '../collab/state'
|
||||
import { db, schema } from '../db'
|
||||
import { challengeSuccessesOf } from '../services/challenge'
|
||||
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, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson'
|
||||
import {
|
||||
announceLessonChange,
|
||||
CLASS_ACTIVITY_MIN_USERS,
|
||||
classDayGroups,
|
||||
classLessonProblems,
|
||||
lessonDoneCounts,
|
||||
resolveLessonProblems,
|
||||
todayLessonSnapshot,
|
||||
} from '../services/lesson'
|
||||
import { calendarDay, dayStart, localTime } from '../time'
|
||||
|
||||
export const classroomRoutes = new Hono<AppEnv>()
|
||||
|
||||
/**
|
||||
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
|
||||
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
|
||||
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
|
||||
* 课堂任务(老师):布置这节课的题、看全班做到哪、老师首页的「今天的课」。
|
||||
* 「这节课是哪几道题」的取法在 services/lesson.ts,学生首页的「班里在做」也用它(routes/home.ts)
|
||||
*/
|
||||
const CLASS_ACTIVITY_MIN_USERS = 5
|
||||
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
|
||||
const CLASS_ACTIVITY_LOOKBACK_DAYS = 7
|
||||
const CLASS_ACTIVITY_LIMIT = 8
|
||||
interface ClassDayGroup {
|
||||
day: string
|
||||
problemId: number
|
||||
firstAt: string
|
||||
userCount: number
|
||||
acceptedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
|
||||
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
|
||||
* 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
|
||||
*
|
||||
* 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数,
|
||||
* 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数
|
||||
*/
|
||||
async function classDayGroups(
|
||||
className: string,
|
||||
since: string,
|
||||
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 perUser = db
|
||||
.select({
|
||||
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, 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
|
||||
}
|
||||
|
||||
/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */
|
||||
async function visibleProblems(ids: number[]) {
|
||||
if (!ids.length) return new Map<number, { id: number; displayId: string; title: string; isSql: boolean }>()
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
isSql: sql<boolean>`${schema.problem.languages} @> '["SQL"]'::jsonb`,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
||||
return new Map(rows.map((row) => [row.id, row]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师;
|
||||
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
|
||||
*/
|
||||
async function lessonPlan(className: string, day: string) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
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})`,
|
||||
})
|
||||
.from(schema.classLesson)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
|
||||
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
|
||||
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||
.limit(1)
|
||||
return row?.problemIds.length ? row : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 班里「这节课」的题。老师在课堂看板布置过就用老师的(source = teacher),
|
||||
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
|
||||
* **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。
|
||||
*
|
||||
* 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的
|
||||
* 学生最需要它的时候。
|
||||
*/
|
||||
async function classLessonProblems(className: string, lookbackDays: number) {
|
||||
const today = calendarDay()
|
||||
const plan = await lessonPlan(className, today)
|
||||
if (plan) {
|
||||
const planned = plan.problemIds
|
||||
const [problems, groups] = await Promise.all([
|
||||
visibleProblems(planned),
|
||||
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }),
|
||||
])
|
||||
const groupById = new Map(groups.map((row) => [row.problemId, row]))
|
||||
return {
|
||||
source: 'teacher' as const,
|
||||
lessonId: plan.id,
|
||||
day: today,
|
||||
language: plan.language ?? null,
|
||||
mode: plan.mode,
|
||||
assignedAt: plan.createdAt,
|
||||
assignedBy: plan.createdBy,
|
||||
problems: planned.flatMap((id) => {
|
||||
const problem = problems.get(id)
|
||||
if (!problem) return []
|
||||
const group = groupById.get(id)
|
||||
return [
|
||||
{
|
||||
...problem,
|
||||
userCount: group?.userCount ?? 0,
|
||||
acceptedCount: group?.acceptedCount ?? 0,
|
||||
},
|
||||
]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000)
|
||||
const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS })
|
||||
const latest = groups.reduce<string | null>((acc, row) => (!acc || row.day > acc ? row.day : acc), null)
|
||||
const picked = groups
|
||||
.filter((row) => row.day === latest)
|
||||
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
|
||||
.slice(0, CLASS_ACTIVITY_LIMIT)
|
||||
const problems = await visibleProblems(picked.map((row) => row.problemId))
|
||||
return {
|
||||
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) => {
|
||||
const problem = problems.get(row.problemId)
|
||||
return problem ? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */
|
||||
const LAST_VISIT_MAX_DAYS = 30
|
||||
|
||||
/**
|
||||
* 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。
|
||||
* 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述,
|
||||
* 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」
|
||||
*/
|
||||
classroomRoutes.get('/me/last-visit', requireAuth, async (c) => {
|
||||
const user = c.get('user')!
|
||||
const window = await getLoginWindow(c)
|
||||
const previousLogin = window?.previousLogin ?? null
|
||||
if (!window || !previousLogin || Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5) {
|
||||
return success(c, { previousLogin, summary: null } satisfies LastVisit)
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
problemDisplayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
result: schema.submission.result,
|
||||
createTime: schema.submission.createTime,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, previousLogin),
|
||||
lt(schema.submission.createTime, window.loginAt),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.submission.createTime))
|
||||
if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit)
|
||||
|
||||
// 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved
|
||||
const byProblem = new Map<
|
||||
number,
|
||||
{
|
||||
problemDisplayId: string
|
||||
title: string
|
||||
attempts: number
|
||||
lastResult: (typeof rows)[number]['result']
|
||||
lastTime: string
|
||||
solved: boolean
|
||||
}
|
||||
>()
|
||||
const solved: { problemDisplayId: string; title: string }[] = []
|
||||
for (const row of rows) {
|
||||
const item = byProblem.get(row.problemId) ?? {
|
||||
problemDisplayId: row.problemDisplayId,
|
||||
title: row.title,
|
||||
attempts: 0,
|
||||
lastResult: row.result,
|
||||
lastTime: row.createTime,
|
||||
solved: false,
|
||||
}
|
||||
item.attempts++
|
||||
item.lastResult = row.result
|
||||
item.lastTime = row.createTime
|
||||
if (!item.solved && accepted.includes(row.result)) {
|
||||
item.solved = true
|
||||
solved.push({ problemDisplayId: row.problemDisplayId, title: row.title })
|
||||
}
|
||||
byProblem.set(row.problemId, item)
|
||||
}
|
||||
// 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」
|
||||
const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id)
|
||||
const everSolved = tried.length
|
||||
? new Set(
|
||||
(
|
||||
await db
|
||||
.selectDistinct({ problemId: schema.submission.problemId })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
isNull(schema.submission.contestId),
|
||||
inArray(schema.submission.problemId, tried),
|
||||
inArray(schema.submission.result, accepted),
|
||||
),
|
||||
)
|
||||
).map((row) => row.problemId),
|
||||
)
|
||||
: new Set<number>()
|
||||
const unsolved = tried
|
||||
.filter((id) => !everSolved.has(id))
|
||||
.map((id) => byProblem.get(id)!)
|
||||
.sort((a, b) => b.lastTime.localeCompare(a.lastTime))
|
||||
.slice(0, 3)
|
||||
.map(({ problemDisplayId, title, attempts, lastResult }) => ({
|
||||
problemDisplayId,
|
||||
title,
|
||||
attempts,
|
||||
lastResult,
|
||||
}))
|
||||
return success(c, {
|
||||
previousLogin,
|
||||
summary: {
|
||||
submissionCount: rows.length,
|
||||
solvedCount: solved.length,
|
||||
lastSubmitTime: rows.at(-1)!.createTime,
|
||||
unsolved,
|
||||
solved: solved.slice(0, 12),
|
||||
},
|
||||
} satisfies LastVisit)
|
||||
})
|
||||
|
||||
/**
|
||||
* 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的
|
||||
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
|
||||
*/
|
||||
classroomRoutes.get('/me/class-activity', requireAuth, async (c) => {
|
||||
const user = c.get('user')!
|
||||
if (!user.className) {
|
||||
return success(c, {
|
||||
className: null,
|
||||
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
|
||||
? db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
inArray(schema.submission.problemId, ids),
|
||||
isNull(schema.submission.contestId),
|
||||
// 布置的是 C:以前用 Python 做对过不算这份作业做完
|
||||
lessonLanguageFilter(codeLanguage),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.problemId)
|
||||
: [],
|
||||
// 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
|
||||
ids.length
|
||||
? db
|
||||
.select({
|
||||
problemId: schema.flowchartSubmission.problemId,
|
||||
passed: sql<boolean>`coalesce(bool_or(${FLOWCHART_PASSED}), false)`,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(and(eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.problemId, ids)))
|
||||
.groupBy(schema.flowchartSubmission.problemId)
|
||||
: [],
|
||||
])
|
||||
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: codeLanguage,
|
||||
mode: lesson.mode,
|
||||
problems: lesson.problems.map((problem) => {
|
||||
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,
|
||||
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 }
|
||||
}
|
||||
export const taskRoutes = new Hono<AppEnv>()
|
||||
|
||||
/**
|
||||
* 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2,
|
||||
@@ -450,7 +50,7 @@ function flowchartCaseCounts() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 看板上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
|
||||
* 任务页上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
|
||||
* 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」
|
||||
* —— 当堂那份名单把这两种人排在同一行里,老师分不出来。
|
||||
*/
|
||||
@@ -459,11 +59,11 @@ const RECENT_LESSONS = 5
|
||||
const RECENT_LESSONS_WINDOW_DAYS = 45
|
||||
|
||||
/**
|
||||
* 课堂看板:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
|
||||
* 课堂任务:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
|
||||
* 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一),
|
||||
* 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。
|
||||
*/
|
||||
classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
||||
taskRoutes.get('/task/board', requireTeacher, async (c) => {
|
||||
const day = calendarDay()
|
||||
const className = c.req.query('className')?.trim() || (await suggestActiveClass())
|
||||
if (!className) {
|
||||
@@ -480,10 +80,10 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
||||
problems: [],
|
||||
students: [],
|
||||
recentLessons: 0,
|
||||
} satisfies ClassBoard)
|
||||
} satisfies TaskBoard)
|
||||
}
|
||||
|
||||
// 推断只看今天(回看 1 天):看板是给这节课用的,昨天的题不该冒出来。
|
||||
// 推断只看今天(回看 1 天):任务页是给这节课用的,昨天的题不该冒出来。
|
||||
// lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种)
|
||||
const [lesson, [last]] = await Promise.all([
|
||||
classLessonProblems(className, 1),
|
||||
@@ -562,7 +162,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
||||
.where(and(inArray(schema.submission.userId, userIds), gte(schema.submission.createTime, start)))
|
||||
.groupBy(schema.submission.userId)
|
||||
: [],
|
||||
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在看板上是一直没动手。
|
||||
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在任务页上是一直没动手。
|
||||
// 和上面代码那条同一个范围:今天、所有题、不论评没评完
|
||||
userIds.length
|
||||
? db
|
||||
@@ -586,7 +186,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
||||
: [],
|
||||
/**
|
||||
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
|
||||
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」。
|
||||
* 有的题这节课整个班都在画流程图,只数代码提交的话,任务页上满屏「没开始」。
|
||||
* 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算
|
||||
*/
|
||||
ids.length && userIds.length
|
||||
@@ -769,18 +369,18 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
||||
editing: presenceOf(student.userId)?.problemId ?? null,
|
||||
online: seen.get(student.userId)?.online ?? false,
|
||||
seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null,
|
||||
}) satisfies ClassBoardStudent,
|
||||
}) satisfies TaskBoardStudent,
|
||||
),
|
||||
recentLessons: recentDays.length,
|
||||
} satisfies ClassBoard)
|
||||
} satisfies TaskBoard)
|
||||
})
|
||||
|
||||
/**
|
||||
* 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」
|
||||
* 同一个门槛),或者老师今天在看板上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数
|
||||
* 和看板同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天
|
||||
* 同一个门槛),或者老师今天在任务页上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数
|
||||
* 和任务页同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天
|
||||
*/
|
||||
classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
|
||||
taskRoutes.get('/task/today', requireTeacher, async (c) => {
|
||||
const start = dayStart()
|
||||
const today = calendarDay()
|
||||
const activity = activityRows({ since: start })
|
||||
@@ -810,13 +410,13 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
|
||||
const ids = lesson.problems.map((problem) => problem.id)
|
||||
let allDoneCount = 0
|
||||
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),
|
||||
@@ -857,10 +457,10 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂看板(老师布置的优先,
|
||||
* 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂任务(老师布置的优先,
|
||||
* 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班
|
||||
*/
|
||||
classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
|
||||
taskRoutes.get('/task/lesson', requireTeacher, async (c) => {
|
||||
const className = c.req.query('className')?.trim() || (await suggestActiveClass())
|
||||
if (!className)
|
||||
return success(c, {
|
||||
@@ -869,7 +469,7 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
|
||||
language: null,
|
||||
problems: [],
|
||||
} satisfies ClassLesson)
|
||||
// 和看板一样只回看今天:「这节课」不该冒出昨天的题
|
||||
// 和任务页一样只回看今天:「这节课」不该冒出昨天的题
|
||||
const lesson = await classLessonProblems(className, 1)
|
||||
// C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language)
|
||||
const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL'))
|
||||
@@ -889,12 +489,12 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
|
||||
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节,
|
||||
* 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。
|
||||
*/
|
||||
classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
|
||||
taskRoutes.put('/task/lesson', requireTeacher, async (c) => {
|
||||
const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对')
|
||||
if (!parsed.success) return parsed.response
|
||||
const user = c.get('user')!
|
||||
const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data
|
||||
// 只画流程图不用语言:存成 null,看板和学生那边就不会写「C语言」
|
||||
// 只画流程图不用语言:存成 null,任务页和学生那边就不会写「C语言」
|
||||
const language = mode === 'flowchart' ? null : parsed.data.language
|
||||
const day = calendarDay()
|
||||
|
||||
@@ -333,7 +333,7 @@ export async function challengeList(user: { id: number; className: string | null
|
||||
})
|
||||
}
|
||||
|
||||
/** 课堂看板用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
|
||||
/** 课堂任务用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
|
||||
export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) {
|
||||
if (userIds.length === 0 || problemIds.length === 0) return []
|
||||
return db
|
||||
|
||||
@@ -5,8 +5,8 @@ import { db, schema } from '../db'
|
||||
import { JudgeStatus } from '../judge/status'
|
||||
|
||||
/**
|
||||
* 课堂上「谁做了哪道题」的取数口径:课堂条、看板、自学情况、后台首页「正在上课的班」
|
||||
* 都从这里数。原来写在 routes/classroom.ts 里,后台 conf.ts 要猜正在上课的班,
|
||||
* 课堂上「谁做了哪道题」的取数口径:课堂条、任务页、自学情况、后台首页「正在上课的班」
|
||||
* 都从这里数。原来写在课堂任务的路由里,后台 conf.ts 要猜正在上课的班,
|
||||
* 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。
|
||||
*/
|
||||
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
|
||||
@@ -30,7 +30,7 @@ export function classMemberIds(className: string) {
|
||||
/**
|
||||
* 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计
|
||||
* (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数,
|
||||
* 口径和课堂条 / 看板的「做完」一致:
|
||||
* 口径和课堂条 / 任务页的「做完」一致:
|
||||
*
|
||||
* - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败;
|
||||
* - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。
|
||||
@@ -99,7 +99,7 @@ export function activityRows(options: {
|
||||
return code.unionAll(flowchart).as('activity')
|
||||
}
|
||||
|
||||
/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
|
||||
/** 任务页没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
|
||||
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*
|
||||
* 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」,
|
||||
* 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。
|
||||
* 一节课的题怎么定,和课堂看板 / 首页「班里在做」同一个口径(routes/classroom.ts):
|
||||
* 老师在看板布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。
|
||||
* 一节课的题怎么定,和课堂任务 / 首页「班里在做」同一个口径(services/lesson.ts 的 classLessonProblems):
|
||||
* 老师在任务页布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。
|
||||
*
|
||||
* 做对的口径也同看板:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算
|
||||
* 做对的口径也同任务页:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算
|
||||
* (比赛是课堂测验,题目也不在题库里)。
|
||||
*/
|
||||
import { FLOWCHART_PASS_GRADES, STUDENT_ROLES, type LearningDay, type LearningItem, type LearningPeriod, type LearningReport } from '@oj2/contract'
|
||||
@@ -19,7 +19,7 @@ import { calendarDay, monthStart, termStart, weekStart } from '../time'
|
||||
|
||||
export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
|
||||
/** 同 routes/classroom.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */
|
||||
/** 同 services/lesson.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */
|
||||
const LESSON_MIN_USERS = 5
|
||||
|
||||
/** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */
|
||||
|
||||
@@ -3,12 +3,12 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||
|
||||
import { db, schema } from '../db'
|
||||
import { publishLessonUpdate } from '../events'
|
||||
import { calendarDay, dayNumber, dayStart } from '../time'
|
||||
import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
|
||||
import { calendarDay, dayNumber, dayStart, localTime } from '../time'
|
||||
import { activityRows, classMemberIds, FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
|
||||
|
||||
/**
|
||||
* 布置的课(class_lesson)几处共用的口径:课堂看板布置、后台「课堂作业」增改、
|
||||
* 数据统计按布置切课。放在一起,免得看板能布置的题后台布置不上,或者两边「做完」数得不一样。
|
||||
* 布置的课(class_lesson)几处共用的口径:课堂任务布置、后台「课堂任务」增改、
|
||||
* 数据统计按布置切课。放在一起,免得任务页能布置的题后台布置不上,或者两边「做完」数得不一样。
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -55,7 +55,7 @@ export function lessonDayEnd(day: string) {
|
||||
/**
|
||||
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。
|
||||
* 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言
|
||||
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和看板的格子同一个口径。
|
||||
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和任务页的格子同一个口径。
|
||||
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
|
||||
*/
|
||||
export async function lessonDoneCounts(
|
||||
@@ -119,7 +119,7 @@ interface LessonSnapshot {
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生此刻看到的那一节:这个班今天最后布置的(和 routes/classroom.ts 的 lessonPlan 同一个取法),
|
||||
* 学生此刻看到的那一节:这个班今天最后布置的(和下面 lessonPlan 同一个取法),
|
||||
* 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比
|
||||
*/
|
||||
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> {
|
||||
@@ -175,3 +175,173 @@ export async function announceLessonChange(className: string, before: LessonSnap
|
||||
console.error('Failed to announce lesson change', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
|
||||
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
|
||||
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
|
||||
*/
|
||||
export const CLASS_ACTIVITY_MIN_USERS = 5
|
||||
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
|
||||
export const CLASS_ACTIVITY_LOOKBACK_DAYS = 7
|
||||
const CLASS_ACTIVITY_LIMIT = 8
|
||||
interface ClassDayGroup {
|
||||
day: string
|
||||
problemId: number
|
||||
firstAt: string
|
||||
userCount: number
|
||||
acceptedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
|
||||
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
|
||||
* 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
|
||||
*
|
||||
* 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数,
|
||||
* 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数
|
||||
*/
|
||||
export async function classDayGroups(
|
||||
className: string,
|
||||
since: string,
|
||||
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 perUser = db
|
||||
.select({
|
||||
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, 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
|
||||
}
|
||||
|
||||
/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */
|
||||
async function visibleProblems(ids: number[]) {
|
||||
if (!ids.length) return new Map<number, { id: number; displayId: string; title: string; isSql: boolean }>()
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
isSql: sql<boolean>`${schema.problem.languages} @> '["SQL"]'::jsonb`,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
||||
return new Map(rows.map((row) => [row.id, row]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师;
|
||||
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
|
||||
*/
|
||||
async function lessonPlan(className: string, day: string) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
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})`,
|
||||
})
|
||||
.from(schema.classLesson)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
|
||||
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
|
||||
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||
.limit(1)
|
||||
return row?.problemIds.length ? row : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher),
|
||||
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
|
||||
* **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。
|
||||
*
|
||||
* 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的
|
||||
* 学生最需要它的时候。
|
||||
*/
|
||||
export async function classLessonProblems(className: string, lookbackDays: number) {
|
||||
const today = calendarDay()
|
||||
const plan = await lessonPlan(className, today)
|
||||
if (plan) {
|
||||
const planned = plan.problemIds
|
||||
const [problems, groups] = await Promise.all([
|
||||
visibleProblems(planned),
|
||||
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }),
|
||||
])
|
||||
const groupById = new Map(groups.map((row) => [row.problemId, row]))
|
||||
return {
|
||||
source: 'teacher' as const,
|
||||
lessonId: plan.id,
|
||||
day: today,
|
||||
language: plan.language ?? null,
|
||||
mode: plan.mode,
|
||||
assignedAt: plan.createdAt,
|
||||
assignedBy: plan.createdBy,
|
||||
problems: planned.flatMap((id) => {
|
||||
const problem = problems.get(id)
|
||||
if (!problem) return []
|
||||
const group = groupById.get(id)
|
||||
return [
|
||||
{
|
||||
...problem,
|
||||
userCount: group?.userCount ?? 0,
|
||||
acceptedCount: group?.acceptedCount ?? 0,
|
||||
},
|
||||
]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000)
|
||||
const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS })
|
||||
const latest = groups.reduce<string | null>((acc, row) => (!acc || row.day > acc ? row.day : acc), null)
|
||||
const picked = groups
|
||||
.filter((row) => row.day === latest)
|
||||
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
|
||||
.slice(0, CLASS_ACTIVITY_LIMIT)
|
||||
const problems = await visibleProblems(picked.map((row) => row.problemId))
|
||||
return {
|
||||
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) => {
|
||||
const problem = problems.get(row.problemId)
|
||||
return problem ? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function dayNumber(day: string): number {
|
||||
return Date.UTC(year!, month! - 1, date!) / DAY_MS
|
||||
}
|
||||
|
||||
/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂作业用 */
|
||||
/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂任务用 */
|
||||
export function localDateTime(day: string, time: string): string {
|
||||
const [hour, minute] = time.split(':').map(Number)
|
||||
return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString()
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
|
||||
|
||||
Shared logic lives in `shared/`:
|
||||
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (登录框、注册框开没开), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
|
||||
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render,渲染器是 pele 不是 mermaid), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity`
|
||||
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render,渲染器是 pele 不是 mermaid), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂任务的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity`
|
||||
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
||||
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
||||
|
||||
|
||||
@@ -780,7 +780,7 @@ export function deleteAchievement(id: number) {
|
||||
return api.delete(`admin/achievements/${id}`)
|
||||
}
|
||||
|
||||
// ---------- 课堂作业(class_lesson) ----------
|
||||
// ---------- 课堂任务(class_lesson) ----------
|
||||
|
||||
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) {
|
||||
return api.get<AdminLessonList>('admin/lessons', {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract'
|
||||
import ProblemPicker from 'oj/classroom/components/ProblemPicker.vue'
|
||||
import ProblemPicker from 'oj/task/components/ProblemPicker.vue'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { groupClassOptions } from 'utils/classes'
|
||||
@@ -10,8 +10,8 @@ import { parseTime } from 'utils/functions'
|
||||
import { createAdminLesson, updateAdminLesson } from '../../api'
|
||||
|
||||
/**
|
||||
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 看板上点错了班、隔天才想起来布置,
|
||||
* 都在这里纠正;补的是看板外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
|
||||
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 任务页上点错了班、隔天才想起来布置,
|
||||
* 都在这里纠正;补的是任务页外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** null = 补一节 */
|
||||
@@ -54,7 +54,7 @@ function inputIds() {
|
||||
return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean)
|
||||
}
|
||||
|
||||
// ---- 选题弹框,和看板上那个是同一个 ----
|
||||
// ---- 选题弹框,和任务页上那个是同一个 ----
|
||||
const picking = ref(false)
|
||||
const pickerInitial = ref<{ id: string; title?: string }[]>([])
|
||||
function openPicker() {
|
||||
|
||||
@@ -17,8 +17,8 @@ import { deleteAdminLesson, getAdminLessons } from '../api'
|
||||
import LessonEditModal from './components/LessonEditModal.vue'
|
||||
|
||||
/**
|
||||
* 后台「课堂作业」:布置过的每一节课一行,新的在前。课堂看板上当场布置的都在这里,
|
||||
* 布置错了能改、删;看板外上的课能补一节 —— 数据统计按布置切课,补了才认得到
|
||||
* 后台「课堂任务」:布置过的每一节课一行,新的在前。课堂任务上当场布置的都在这里,
|
||||
* 布置错了能改、删;任务页外上的课能补一节 —— 数据统计按布置切课,补了才认得到
|
||||
*/
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
@@ -26,7 +26,7 @@ const theme = useThemeVars()
|
||||
const configStore = useConfigStore()
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
// 从看板「布置过的课」进来带着 ?className=
|
||||
// 从任务页「布置过的课」进来带着 ?className=
|
||||
const { query } = usePagination<{ className: string; teacher: string }>({
|
||||
className: useRouteQuery('className', '').value,
|
||||
teacher: useRouteQuery('teacher', '').value,
|
||||
@@ -163,7 +163,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="课堂作业">
|
||||
<PageHeader title="课堂任务">
|
||||
<template #extra>
|
||||
<n-text v-if="data" depth="3">{{ data.total }} 节</n-text>
|
||||
</template>
|
||||
|
||||
@@ -94,7 +94,7 @@ const shortcuts = [
|
||||
{ label: '新建题目', to: '/admin/problem/create' },
|
||||
{ label: '新建比赛', to: '/admin/contest/create' },
|
||||
{ label: '批量建号', to: '/admin/user/generate' },
|
||||
{ label: '课堂看板', to: '/classroom' },
|
||||
{ label: '课堂任务', to: '/task' },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------- 抽一个人
|
||||
@@ -170,7 +170,7 @@ onUnmounted(stopRolling)
|
||||
<div class="tile">
|
||||
<span class="label">正在上课</span>
|
||||
<b>{{ info.activeClass ? classLabel(info.activeClass) : '没有' }}</b>
|
||||
<router-link v-if="info.activeClass" class="link" :to="{ name: 'classroom board', query: { className: info.activeClass } }"> 去课堂看板 → </router-link>
|
||||
<router-link v-if="info.activeClass" class="link" :to="{ name: 'task', query: { className: info.activeClass } }"> 去课堂任务 → </router-link>
|
||||
<span v-else class="note">最近两小时没有哪个班在交题</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type Tutorial,
|
||||
type ClassToday,
|
||||
type LastVisit,
|
||||
type ClassBoard,
|
||||
type TaskBoard,
|
||||
type ClassLesson,
|
||||
type KnowledgeMap,
|
||||
type ClassBattleItem,
|
||||
@@ -251,14 +251,14 @@ export function getWeeklyRank(scope: 'global' | 'class') {
|
||||
return api.get<WeeklyRank>('rankings/weekly', { params: { scope } })
|
||||
}
|
||||
|
||||
/** 课堂看板(老师)。不给班级时后端猜最近两小时在交题的那个班 */
|
||||
export function getClassBoard(className?: string) {
|
||||
return api.get<ClassBoard>('classroom/board', { params: { className } })
|
||||
/** 课堂任务(老师)。不给班级时后端猜最近两小时在交题的那个班 */
|
||||
export function getTaskBoard(className?: string) {
|
||||
return api.get<TaskBoard>('task/board', { params: { className } })
|
||||
}
|
||||
|
||||
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和看板一样 */
|
||||
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和任务页一样 */
|
||||
export function getClassLesson(className?: string) {
|
||||
return api.get<ClassLesson>('classroom/lesson', { params: { className } })
|
||||
return api.get<ClassLesson>('task/lesson', { params: { className } })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,7 +266,7 @@ export function getClassLesson(className?: string) {
|
||||
* (一个班一天可以上几节内容不同的课),否则改那一节
|
||||
*/
|
||||
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, mode: LessonMode, lessonId: number | null) {
|
||||
return api.put<null>('classroom/lesson', {
|
||||
return api.put<null>('task/lesson', {
|
||||
className,
|
||||
problemDisplayIds,
|
||||
language,
|
||||
@@ -282,7 +282,7 @@ export function getKnowledgeMap() {
|
||||
|
||||
/** 老师首页「今天的课」:今天在上课的每个班 */
|
||||
export function getClassToday() {
|
||||
return api.get<ClassToday>('classroom/today')
|
||||
return api.get<ClassToday>('task/today')
|
||||
}
|
||||
|
||||
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useVisibleInterval } from 'shared/composables/visibleInterval'
|
||||
*
|
||||
* 名字和排行榜一样用用户名(ks241XXX),不用真名。
|
||||
* 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面,
|
||||
* 没进来的人在最下面(看板里标了请假的写「请假」)。
|
||||
* 没进来的人在最下面(课堂任务里标了请假的写「请假」)。
|
||||
* 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。
|
||||
* 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 buildClassView)
|
||||
*/
|
||||
@@ -357,7 +357,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
<span v-for="p in problems" :key="p.id"></span>
|
||||
<span></span>
|
||||
<span class="flag-wrap">
|
||||
<span class="flag grey">{{ user.leave ? '请假(看板里标的)' : '没进来' }}</span>
|
||||
<span class="flag grey">{{ user.leave ? '请假(课堂任务里标的)' : '没进来' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { parseTime, zonedParts, zonedYear } from 'utils/functions'
|
||||
/**
|
||||
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
|
||||
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
|
||||
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。
|
||||
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂任务」进那个班。
|
||||
* 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前)
|
||||
*/
|
||||
const theme = useThemeVars()
|
||||
@@ -61,13 +61,13 @@ function metaOf(row: ClassRow) {
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function openBoard(row: ClassRow) {
|
||||
router.push({ name: 'classroom board', query: { className: row.className } })
|
||||
function openTask(row: ClassRow) {
|
||||
router.push({ name: 'task', query: { className: row.className } })
|
||||
}
|
||||
|
||||
const shortcuts = computed(() =>
|
||||
[
|
||||
{ label: '课堂看板', to: '/classroom', show: true },
|
||||
{ label: '课堂任务', to: '/task', show: true },
|
||||
{ label: '数据统计', to: '/statistics', show: true },
|
||||
{ label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission },
|
||||
{ label: '后台', to: userStore.isSuperAdmin ? '/admin' : '/admin/problem/list', show: true },
|
||||
@@ -92,7 +92,7 @@ const shortcuts = computed(() =>
|
||||
<span class="state" :class="{ live: stateOf(row).live }">{{ stateOf(row).text }}</span>
|
||||
<span class="muted">{{ metaOf(row) }}</span>
|
||||
<span class="spacer"></span>
|
||||
<n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openBoard(row)"> 课堂看板 </n-button>
|
||||
<n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openTask(row)"> 课堂任务 </n-button>
|
||||
</div>
|
||||
<div v-if="row.problems.length" class="minis">
|
||||
<router-link
|
||||
|
||||
@@ -187,7 +187,7 @@ const solvedText = computed(() => (visit.value?.solved ?? []).map((item) => `${i
|
||||
const classActivityTitle = computed(() => {
|
||||
const day = classActivity.value?.day
|
||||
if (!day) return ''
|
||||
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
|
||||
// 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
|
||||
if (classActivity.value?.source === 'teacher') {
|
||||
const language = classActivity.value.language
|
||||
const mode = classActivity.value.mode
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -8,10 +8,10 @@ import UserName from 'shared/components/UserName.vue'
|
||||
* 老师协作时右栏顶上的一条(设计稿「举手与协作」HcPageA):正在帮谁、举手多久了、
|
||||
* 看他交过什么、帮完了、下一个。语言不写(用户说的):编辑器工具栏的语言下拉就摆在下面。
|
||||
*
|
||||
* 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开看板,
|
||||
* 所以「帮完了」自动回到这个班的看板,还有人举手就给「下一个」直接去下一个人的题。
|
||||
* 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开任务页,
|
||||
* 所以「帮完了」自动回到这个班的任务页,还有人举手就给「下一个」直接去下一个人的题。
|
||||
*
|
||||
* 老师在看板上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。
|
||||
* 老师在任务页上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。
|
||||
*/
|
||||
const collabStore = useCollabStore()
|
||||
const { problem } = storeToRefs(useProblemStore())
|
||||
@@ -46,7 +46,7 @@ function openHisSubmissions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 帮完了:结束这次(求助记录一并清掉),回到这个班的看板。
|
||||
* 帮完了:结束这次(求助记录一并清掉),回到这个班的任务页。
|
||||
* 等 room_closed 回来、room 清空了再跳 —— 房间还开着时离开题目页,题目页的拦截
|
||||
* (detail.vue 的 confirmLeavingHelp)会把跳转改成开新标签
|
||||
*/
|
||||
@@ -54,7 +54,7 @@ async function finish() {
|
||||
const className = room.value?.peerClassName
|
||||
collabStore.leave('done')
|
||||
await until(() => collabStore.room).toBe(null, { timeout: 3000 })
|
||||
router.push({ name: 'classroom board', query: className ? { className } : {} })
|
||||
router.push({ name: 'task', query: className ? { className } : {} })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,7 +86,7 @@ const showCollabBar = useTeacherCollab()
|
||||
*/
|
||||
const collabHere = useCollabHere()
|
||||
/**
|
||||
* 老师在看板上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 ——
|
||||
* 老师在任务页上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 ——
|
||||
* 按钮就写「xuyue 在看你的代码」,和「在帮你」一样亮着、点不了
|
||||
*/
|
||||
const peeked = computed(() => collabHere.value && collabStore.room?.mode === 'peek' && collabStore.helpStatus === 'idle')
|
||||
|
||||
@@ -168,7 +168,7 @@ watch(
|
||||
)
|
||||
|
||||
/**
|
||||
* 老师在课堂看板布置这节课时选了语言:打开这节课的题,编辑器默认就用它。学 C 的班,
|
||||
* 老师在课堂任务布置这节课时选了语言:打开这节课的题,编辑器默认就用它。学 C 的班,
|
||||
* 编辑器停在默认的 Python(或者上一个用这台电脑的人留下的偏好)的话,学生交上去才发现
|
||||
* 不算数。课堂条只在题库入口出现,这里同一个范围。
|
||||
*
|
||||
|
||||
@@ -174,7 +174,7 @@ function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNorm
|
||||
window.open(router.resolve(to).href, '_blank')
|
||||
return false
|
||||
}
|
||||
// 老师在看板上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在
|
||||
// 老师在任务页上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在
|
||||
const peeked = collabHere.value && collabStore.room?.mode === 'peek'
|
||||
if (collabStore.isTeacher || (collabStore.helpStatus === 'idle' && !peeked)) return true
|
||||
if (approvedTarget === to.fullPath) {
|
||||
|
||||
@@ -55,7 +55,7 @@ function shortLabel(className: string) {
|
||||
<div class="battle" :class="{ bare }">
|
||||
<div class="title">
|
||||
<b v-if="!bare">班级对抗</b><span class="muted">人均做对</span>
|
||||
<router-link v-if="pk" :to="{ path: '/class', query: mine ? { classes: mine } : {} }" class="pk">班级 PK ›</router-link>
|
||||
<router-link v-if="pk" :to="{ path: '/pk', query: mine ? { classes: mine } : {} }" class="pk">班级 PK ›</router-link>
|
||||
</div>
|
||||
<div v-for="item in shown" :key="item.className" class="row" :class="{ mine: item.className === mine }">
|
||||
<span class="rank">{{ item.rank }}</span>
|
||||
|
||||
@@ -213,7 +213,7 @@ function go(path: string, query: Record<string, string>) {
|
||||
<Markdown v-if="report" :content="report" />
|
||||
</section>
|
||||
|
||||
<a v-if="pk" href="#" class="pk" @click.prevent="go('/class', { classes: className! })">和别的班比 → 班级 PK ›</a>
|
||||
<a v-if="pk" href="#" class="pk" @click.prevent="go('/pk', { classes: className! })">和别的班比 → 班级 PK ›</a>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="muted">这个班的数据没取到</div>
|
||||
</n-spin>
|
||||
|
||||
@@ -441,7 +441,7 @@ async function restore(userId: number) {
|
||||
secondary
|
||||
@click="
|
||||
router.push({
|
||||
path: '/class',
|
||||
path: '/pk',
|
||||
query: classContext ? { classes: classContext } : {},
|
||||
})
|
||||
"
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Submission } from 'utils/types'
|
||||
import type { Tone } from 'shared/composables/tone'
|
||||
|
||||
/**
|
||||
* 一次提交一格的那串方块(统计页的 AttemptSquares、课堂看板做对格子的轨迹)共用的两样:
|
||||
* 一次提交一格的那串方块(统计页的 AttemptSquares、课堂任务做对格子的轨迹)共用的两样:
|
||||
* 方块的颜色,和停上去看代码时拉的提交。
|
||||
*/
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ function percent(value: number, total: number) {
|
||||
<div v-if="liveLessons.length" class="live" :style="{ background: tone('success').background, color: tone('success').color }">
|
||||
<span>{{ liveLessons.map((item) => classLabel(item.className)).join('、') }}正在上课</span>
|
||||
<span class="spacer"></span>
|
||||
<a href="#" class="live-link" @click.prevent="router.push('/classroom')"> <Icon icon="ph:monitor" :width="14" />去课堂看板 </a>
|
||||
<a href="#" class="live-link" @click.prevent="router.push('/task')"> <Icon icon="ph:monitor" :width="14" />去课堂任务 </a>
|
||||
</div>
|
||||
|
||||
<div v-if="!lessonCount && !data.scattered.length" class="empty">这段时间没有人交</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ import StatItem from './StatItem.vue'
|
||||
|
||||
/**
|
||||
* 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框
|
||||
* (老师平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页之类从别处进来的,
|
||||
* (老师平时从这里看),和 /statistics 页面(课堂任务「回头看」、题目页之类从别处进来的,
|
||||
* 条件放在地址栏里能刷新、能转发)。
|
||||
*
|
||||
* 代码页签按条件分三种看法:
|
||||
@@ -27,8 +27,8 @@ import StatItem from './StatItem.vue'
|
||||
* - 选了班或填了学生:左边题目、右边学生(CodeStats)
|
||||
* 从前两种点进去时左上角有「← 全部」回来。
|
||||
*
|
||||
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有
|
||||
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「一节课」。
|
||||
* 分工:**课堂任务管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有
|
||||
* 投影用的大字名单和请假隐藏 —— 那些在任务页上。课堂统计一般看一两节课,时间段默认「一节课」。
|
||||
*
|
||||
* 查询条件都是 v-model:页面接地址栏,弹框接提交列表那边的一份本地状态。
|
||||
*/
|
||||
@@ -66,7 +66,7 @@ const custom = computed<[number, number] | null>(() => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 「做完」只数所选时段之内做对的,和看板、后台「课堂作业」、一行一节课的「做完」不一样
|
||||
* 「做完」只数所选时段之内做对的,和任务页、后台「课堂任务」、一行一节课的「做完」不一样
|
||||
* (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的
|
||||
*/
|
||||
const doneLabel = computed(() => {
|
||||
@@ -229,7 +229,7 @@ const judged = computed(() => (stats.value ? stats.value.submissionCount - stats
|
||||
/**
|
||||
* 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭,
|
||||
* 1366 的屏上整排放不下,所以弹框里宽一档才出字。
|
||||
* 原来旁边还有一个「这节课去课堂看板」:顶栏已经有看板,统计里再放一个成了干扰项(用户说的),
|
||||
* 原来旁边还有一个「这节课去课堂任务」:顶栏已经有任务页,统计里再放一个成了干扰项(用户说的),
|
||||
* 现在只在有班正在上课时,在课的列表上方引过去
|
||||
*/
|
||||
const narrow = useMediaQuery(props.modal ? '(max-width: 1439px)' : '(max-width: 1365px)')
|
||||
|
||||
@@ -4,7 +4,7 @@ import StatisticsView from './components/StatisticsView.vue'
|
||||
import { DEFAULT_PERIOD } from './period'
|
||||
|
||||
/**
|
||||
* 统计的页面形态:课堂看板「回头看」、题目页统计页签这些从别处进来时用。
|
||||
* 统计的页面形态:课堂任务「回头看」、题目页统计页签这些从别处进来时用。
|
||||
* 提交列表里是弹框(用户定的),两边是同一个 StatisticsView。
|
||||
*
|
||||
* 查询条件都在地址栏里:带过来、刷新、转发给别的老师都还是这一份。
|
||||
|
||||
@@ -9,7 +9,7 @@ const DAY = 86_400_000
|
||||
* 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、
|
||||
* 默认「一节课」(用户定的);后面几档是回头看。名字后面不写「1 小时内」:选框旁边
|
||||
* 本来就写着具体几点到几点。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的,
|
||||
* 那件事现在归课堂看板。
|
||||
* 那件事现在归课堂任务。
|
||||
*/
|
||||
export const PERIOD_OPTIONS = [
|
||||
{ label: '一节课', value: '1h' },
|
||||
|
||||
@@ -30,7 +30,7 @@ import StatusPill from './StatusPill.vue'
|
||||
* 入口是提交列表右栏的「新页面打开」、统计页的方块、后台卡点分析 —— 多半是老师在看某个学生
|
||||
* 怎么一次次改对的。
|
||||
*
|
||||
* 独立的提交详情页(detail.vue)和课堂看板「讲错误」的弹层共用这一个。`embedded`(弹层里):
|
||||
* 独立的提交详情页(detail.vue)和课堂任务「讲错误」的弹层共用这一个。`embedded`(弹层里):
|
||||
* 不出名字和题目那一行(弹层顶上有一排人名),不占 ← →(弹层拿它换人),
|
||||
* 点「第几次」就地换、不改地址栏。
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import { classLabel } from 'utils/classes'
|
||||
import type { ClassLesson } from 'utils/types'
|
||||
import type { SubmissionQuery } from './listQuery'
|
||||
|
||||
/** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂看板 */
|
||||
/** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂任务 */
|
||||
export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref<boolean>; info: (text: string) => void }) {
|
||||
const { query } = options
|
||||
const lesson = ref<ClassLesson | null>(null)
|
||||
@@ -36,13 +36,13 @@ export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref
|
||||
}
|
||||
query.className = res.className
|
||||
if (!res.problems.length) {
|
||||
options.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂看板布置`)
|
||||
options.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂任务布置`)
|
||||
return
|
||||
}
|
||||
lesson.value = res
|
||||
query.problem = lessonIds.value
|
||||
query.today = '1'
|
||||
// 老师布置时选了语言:看板上只数这个语言交的,列表也只列它(流程图那边不动)
|
||||
// 老师布置时选了语言:任务页上只数这个语言交的,列表也只列它(流程图那边不动)
|
||||
if (res.language && !options.flowMode.value) query.language = res.language
|
||||
} finally {
|
||||
lessonLoading.value = false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import SubmissionView from './components/SubmissionView.vue'
|
||||
|
||||
/** 单独打开的一条提交,主体在 SubmissionView(课堂看板「讲错误」的弹层也用它) */
|
||||
/** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */
|
||||
defineProps<{ submissionID: string }>()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useStatsLink } from './composables/statsLink'
|
||||
*
|
||||
* 老师课上的三种用法 —— 一条条翻全班的代码、看某一个学生、看完成情况 —— 前两种在
|
||||
* 这一页里做完:↑ ↓ 换条,右边跟着换,不再「点开弹框 → 关掉 → 点下一行」。
|
||||
* 完成情况在课堂看板和「统计」里,这页不重复造一张表。
|
||||
* 完成情况在课堂任务和「统计」里,这页不重复造一张表。
|
||||
*/
|
||||
|
||||
// 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着
|
||||
@@ -579,7 +579,7 @@ function dayBreak(index: number) {
|
||||
<span v-if="userStore.isAuthed" class="vsep"></span>
|
||||
|
||||
<!-- 「我的」时这节课 / 班级 / 学生都用不上:后端只看自己时本来就不认它们 -->
|
||||
<!-- 学生界面没有班级下拉:地址里带着班级(比如从统计页、看板跳过来)就摆一个能点掉的标签,
|
||||
<!-- 学生界面没有班级下拉:地址里带着班级(比如从统计页、任务页跳过来)就摆一个能点掉的标签,
|
||||
不然一直被一个看不见的条件筛着 -->
|
||||
<button v-if="!teacher && query.className" class="chip on" title="点一下去掉班级筛选" @click="query.className = ''">
|
||||
{{ classLabel(query.className) }}<Icon icon="ph:x-bold" :width="12" />
|
||||
@@ -605,7 +605,7 @@ function dayBreak(index: number) {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 按整名只看一个人(「只看他」、协作条、看板点进来):用户名有八九个字,塞进 108px 的
|
||||
<!-- 按整名只看一个人(「只看他」、协作条、任务页点进来):用户名有八九个字,塞进 108px 的
|
||||
输入框只剩半截,还被清除按钮压着 —— 换成一个带名字的标签,点 × 取消 -->
|
||||
<button v-if="!mine && exactPerson" class="chip on person" :title="`只看 ${query.username}(点一下取消)`" @click="clearPerson">
|
||||
<Icon icon="ph:user-bold" :width="12" />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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'
|
||||
import type { TaskBoardCell, TaskBoardStudent } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 课堂看板的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
|
||||
* 和 errorKind.ts 一样单放一个文件(看板组件只管排版和交互)。
|
||||
* 课堂任务的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
|
||||
* 和 errorKind.ts 一样单放一个文件(任务页组件只管排版和交互)。
|
||||
*
|
||||
* `mode` 是这节课做什么(契约 lessonModeSchema),推断出来的课为 null。先画再写的格子是一条进度:
|
||||
* 在画 → 图画对了(空心绿「图 ✓」)→ 在写 → 两样都对(实心绿 ✓)。先写了代码、还没画图的写「缺图」
|
||||
@@ -20,7 +20,7 @@ export const STUCK_ATTEMPTS = 3
|
||||
*/
|
||||
export type CellStage = 'draw' | 'code' | 'nodraw'
|
||||
|
||||
export function cellStage(cell: ClassBoardCell, mode: LessonMode | null): CellStage {
|
||||
export function cellStage(cell: TaskBoardCell, mode: LessonMode | null): CellStage {
|
||||
if (mode === 'flowchart') return 'draw'
|
||||
if (mode !== 'both' || !cell.flowchart) return 'code'
|
||||
if (cell.flowchart.status === 'tried') return 'draw'
|
||||
@@ -29,29 +29,29 @@ export function cellStage(cell: ClassBoardCell, mode: LessonMode | null): CellSt
|
||||
}
|
||||
|
||||
/** 今天在卡着的那一步上交了几次:先画再写的课里,画了三张才画对的人写代码时不该算卡住 */
|
||||
export function stageAttempts(cell: ClassBoardCell, mode: LessonMode | null) {
|
||||
export function stageAttempts(cell: TaskBoardCell, 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) {
|
||||
export function isStuck(cell: TaskBoardCell, mode: LessonMode | null) {
|
||||
return cell.status === 'tried' && stageAttempts(cell, mode) >= STUCK_ATTEMPTS
|
||||
}
|
||||
|
||||
/** 先画再写:图画对了、代码还没交过 —— 空心绿的「图 ✓」 */
|
||||
function halfDone(cell: ClassBoardCell, mode: LessonMode | null) {
|
||||
function halfDone(cell: TaskBoardCell, 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) {
|
||||
/** 今天之前就通过了的,颜色淡一档。`day` 是任务页的「今天」(东八区日期) */
|
||||
export function acceptedToday(cell: TaskBoardCell, day: string | undefined) {
|
||||
return !!cell.acceptedAt && parseTime(cell.acceptedAt, 'YYYY-MM-DD') === day
|
||||
}
|
||||
|
||||
/** 最后一张图的那句:评分中 / 评失败 / 走对几组 */
|
||||
function drawText(cell: ClassBoardCell, prefix: string) {
|
||||
function drawText(cell: TaskBoardCell, prefix: string) {
|
||||
const latest = cell.flowchart?.latest
|
||||
if (!latest) return '✗'
|
||||
if (latest.status === 0 || latest.status === 1) return '评分中'
|
||||
@@ -61,7 +61,7 @@ function drawText(cell: ClassBoardCell, prefix: string) {
|
||||
}
|
||||
|
||||
/** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */
|
||||
export function cellText(cell: ClassBoardCell, mode: LessonMode | null = null) {
|
||||
export function cellText(cell: TaskBoardCell, mode: LessonMode | null = null) {
|
||||
if (cell.status === 'accepted') return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : '✓'
|
||||
if (cell.status === 'tried') {
|
||||
const stage = cellStage(cell, mode)
|
||||
@@ -75,7 +75,7 @@ export function cellText(cell: ClassBoardCell, mode: LessonMode | null = null) {
|
||||
}
|
||||
|
||||
export function cellStyle(
|
||||
cell: ClassBoardCell,
|
||||
cell: TaskBoardCell,
|
||||
day: string | undefined,
|
||||
colors: {
|
||||
tone: (kind: Tone) => { color: string; background: string }
|
||||
@@ -102,7 +102,7 @@ export function cellStyle(
|
||||
return { background: colors.actionColor }
|
||||
}
|
||||
|
||||
export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day: string | undefined, mode: LessonMode | null = null) {
|
||||
export function cellTitle(cell: TaskBoardCell, student: TaskBoardStudent, 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},挑战也做到了`
|
||||
@@ -126,7 +126,7 @@ export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day:
|
||||
}
|
||||
|
||||
/** 点开做对的格子,方块串旁边那句 */
|
||||
export function trailSummary(cell: ClassBoardCell, day: string | undefined) {
|
||||
export function trailSummary(cell: TaskBoardCell, day: string | undefined) {
|
||||
const total = cell.trail.length
|
||||
if (cell.acceptedTry === 1) return total === 1 ? '一次就做对' : `交了 ${total} 次,第 1 次就做对`
|
||||
if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对`
|
||||
+5
-5
@@ -19,23 +19,23 @@ import SubmissionView from 'oj/submission/components/SubmissionView.vue'
|
||||
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 type { TaskBoardProblem } from 'utils/types'
|
||||
import FlowchartExplain from './FlowchartExplain.vue'
|
||||
import ProblemBrief from './ProblemBrief.vue'
|
||||
|
||||
/**
|
||||
* 课堂看板「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
|
||||
* 课堂任务「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
|
||||
* 一个个讲这道题错着的人,所以顶上是这几个人,← → 换人;下面和独立的提交详情页同一块
|
||||
* (代码占满宽、为什么错在后),只是不出名字那一行、点「第几次」就地换。
|
||||
*
|
||||
* 「题目」按钮:演示错误时题目和代码要并排(用户要的),点了左边出题目,再点收起。
|
||||
* 平时收着,代码照样占满宽。开没开记在这台电脑上,讲下一个人、下一道题还是那样
|
||||
*
|
||||
* 手机上(老师拿手机看看板):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮;
|
||||
* 手机上(老师拿手机看任务页):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮;
|
||||
* 没有键盘所以不写「← → 换人」;题目展开时叠在代码上面,不并排
|
||||
*/
|
||||
const props = defineProps<{
|
||||
problem: ClassBoardProblem | null
|
||||
problem: TaskBoardProblem | null
|
||||
people: ExplainPerson[]
|
||||
}>()
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
@@ -43,7 +43,7 @@ const index = defineModel<number>('index', { required: true })
|
||||
|
||||
const theme = useThemeVars()
|
||||
const { isMobile } = useBreakpoints()
|
||||
// 手机上是整屏的:按返回键关掉它回到看板,不是退出看板
|
||||
// 手机上是整屏的:按返回键关掉它回到任务页,不是退出任务页
|
||||
useBackClose(show, isMobile)
|
||||
/**
|
||||
* 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面,
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { caseReport, fitFlowchartGraph, flowchartVerdict, markFlowchartFocus } f
|
||||
import type { FlowchartSubmission } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 课堂看板「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。
|
||||
* 课堂任务「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。
|
||||
* 左边是他最后画的那张(要改的框标红),右边是 AI 写的哪里错、可以怎么改 —— 和提交列表右栏
|
||||
* (FlowchartPane)、题目页结果页签同一套点评(FlowchartNote)。
|
||||
*
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassBoardStudent } from 'utils/types'
|
||||
import type { TaskBoardStudent } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 看板的「请假」框:勾今天不在的人。规则在看板那边(只有今天还没交过的人能算请假、
|
||||
* 任务页的「请假」框:勾今天不在的人。规则在任务页那边(只有今天还没交过的人能算请假、
|
||||
* 交了题或举手就自动算到,见 board.vue 的 canLeave),这里只管勾。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 能勾的人:今天还没交过的(刚上课时就是全班),按名字排好 */
|
||||
candidates: ClassBoardStudent[]
|
||||
candidates: TaskBoardStudent[]
|
||||
/** 打开时已经标着请假的 */
|
||||
current: string[]
|
||||
}>()
|
||||
@@ -23,7 +23,7 @@ watch(show, (open) => {
|
||||
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
|
||||
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
|
||||
*/
|
||||
const unseen = (student: ClassBoardStudent | undefined) => !!student && !student.seenAt
|
||||
const unseen = (student: TaskBoardStudent | undefined) => !!student && !student.seenAt
|
||||
|
||||
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
|
||||
const sections = computed(() =>
|
||||
@@ -36,7 +36,7 @@ const sections = computed(() =>
|
||||
].filter((section) => section.students.length),
|
||||
)
|
||||
|
||||
function selectAll(list: ClassBoardStudent[]) {
|
||||
function selectAll(list: TaskBoardStudent[]) {
|
||||
selected.value = [...new Set([...selected.value, ...list.map((student) => student.username)])]
|
||||
}
|
||||
|
||||
+5
-5
@@ -3,12 +3,12 @@ import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode }
|
||||
import { setClassLesson } from 'oj/api'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import type { ClassBoard } from 'utils/types'
|
||||
import type { TaskBoard } from 'utils/types'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import ProblemPicker from './ProblemPicker.vue'
|
||||
|
||||
/**
|
||||
* 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
|
||||
* 任务页上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
|
||||
* - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条;
|
||||
* - `start`:刚上课还没有题时,这页最大的那个框。
|
||||
*
|
||||
@@ -16,7 +16,7 @@ import ProblemPicker from './ProblemPicker.vue'
|
||||
* `carry`:新开的这一节带上这一节的题、改成写代码 —— 先布置画流程图、再让全班写代码时用
|
||||
*/
|
||||
const props = defineProps<{
|
||||
board: ClassBoard
|
||||
board: TaskBoard
|
||||
className: string
|
||||
variant: 'inline' | 'start'
|
||||
lessonId: number | null
|
||||
@@ -27,7 +27,7 @@ const message = useMessage()
|
||||
|
||||
/**
|
||||
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
|
||||
* 上一次布置时选的;选了之后看板和学生那边的「做完」都只认这个语言交对的
|
||||
* 上一次布置时选的;选了之后任务页和学生那边的「做完」都只认这个语言交对的
|
||||
*/
|
||||
const lessonLanguage = ref<LessonLanguage>('Python')
|
||||
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
|
||||
@@ -109,7 +109,7 @@ function pickerConfirm(ids: string[]) {
|
||||
</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
|
||||
<n-button @click="emit('cancel')">取消</n-button>
|
||||
<!-- 弹层挂在 body 下,放在根节点里面:两个根会让看板传进来的 class 落空 -->
|
||||
<!-- 弹层挂在 body 下,放在根节点里面:两个根会让任务页传进来的 class 落空 -->
|
||||
<ProblemPicker
|
||||
v-model:show="picking"
|
||||
v-model:language="lessonLanguage"
|
||||
File renamed without changes.
+2
-2
@@ -9,7 +9,7 @@ import type { ProblemRow } from 'utils/types'
|
||||
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
|
||||
|
||||
/**
|
||||
* 看板布置作业的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
|
||||
* 任务页布置任务的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
|
||||
* 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的,
|
||||
* 要画流程图的只列打开了画流程图的。不用再一个个记题号。
|
||||
* 点「布置」直接存,和在输入框里敲题号回车是一回事
|
||||
@@ -23,7 +23,7 @@ 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))
|
||||
/** 只画流程图不看语言 */
|
||||
+3
-3
@@ -6,7 +6,7 @@ import UserName from 'shared/components/UserName.vue'
|
||||
import { attemptTone, cachedSubmission } from 'oj/statistics/attempts'
|
||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { ClassBoardCell, ClassBoardProblem, Submission } from 'utils/types'
|
||||
import type { TaskBoardCell, TaskBoardProblem, Submission } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 点开一个做对了的格子:他这道题今天交的每一次,一格一次、从早到晚,下面是停着的那一次的代码
|
||||
@@ -17,8 +17,8 @@ import type { ClassBoardCell, ClassBoardProblem, Submission } from 'utils/types'
|
||||
*/
|
||||
const props = defineProps<{
|
||||
username: string
|
||||
problem: ClassBoardProblem
|
||||
cell: ClassBoardCell
|
||||
problem: TaskBoardProblem
|
||||
cell: TaskBoardCell
|
||||
summary: string
|
||||
/** 「在提交列表看这 N 次」:和格子原来点进去的是同一个列表 */
|
||||
listHref: string
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getClassBoard } from 'oj/api'
|
||||
import { getTaskBoard } from 'oj/api'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { groupClassOptions } from 'utils/classes'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
@@ -12,7 +12,7 @@ import { useConfigStore } from 'shared/store/config'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from 'utils/types'
|
||||
import type { TaskBoard, TaskBoardCell, TaskBoardProblem, TaskBoardStudent } from 'utils/types'
|
||||
import ExplainModal, { type ExplainPerson } from './components/ExplainModal.vue'
|
||||
import LeaveModal from './components/LeaveModal.vue'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
@@ -21,10 +21,10 @@ import LessonForm from './components/LessonForm.vue'
|
||||
import TrailPanel from './components/TrailPanel.vue'
|
||||
import { errorKind, type ErrorKind } from 'utils/errorKind'
|
||||
import { LESSON_MODE_LABEL } from '@oj2/contract'
|
||||
import { cellStage, cellStyle as cellStyleOf, cellText as cellTextOf, cellTitle as cellTitleOf, isStuck, trailSummary } from './boardCells'
|
||||
import { cellStage, cellStyle as cellStyleOf, cellText as cellTextOf, cellTitle as cellTitleOf, isStuck, trailSummary } from './cells'
|
||||
|
||||
/**
|
||||
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
|
||||
* 课堂任务(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
|
||||
*
|
||||
* 用法(用户说的):开在教师电脑上,不投影;看完成情况、谁卡住了,讲没交对的代码错在哪;
|
||||
* 题号是上课时当场填的。所以:
|
||||
@@ -49,7 +49,7 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { help, peek } = useTeacherHelp()
|
||||
|
||||
const board = ref<ClassBoard | null>(null)
|
||||
const board = ref<TaskBoard | null>(null)
|
||||
/**
|
||||
* 带着 ?className= 进来的(协作条「帮完了」回来、举手面板「讲这道题」)就看那个班,
|
||||
* 否则第一次由后端猜
|
||||
@@ -67,7 +67,7 @@ const classOptions = computed(() => {
|
||||
async function load() {
|
||||
if (document.hidden) return
|
||||
try {
|
||||
const data = await getClassBoard(className.value ?? undefined)
|
||||
const data = await getTaskBoard(className.value ?? undefined)
|
||||
board.value = data
|
||||
// 第一次由后端猜,之后就固定在这个班上,别每次刷新都换
|
||||
className.value = data.className
|
||||
@@ -90,7 +90,7 @@ function changeClass(value: string) {
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
// 已经停在看板上时又从举手面板点了「讲这道题」:同一条路由只换 query,组件不重建
|
||||
// 已经停在任务页上时又从举手面板点了「讲这道题」:同一条路由只换 query,组件不重建
|
||||
watch(
|
||||
() => route.query,
|
||||
(query) => {
|
||||
@@ -125,9 +125,9 @@ const editing = ref<false | 'edit' | 'new' | 'carry'>(false)
|
||||
const mode = computed(() => board.value?.mode ?? null)
|
||||
|
||||
/**
|
||||
* 一个班一天可以上几节内容不同的课。打开看板(或换到这个班)时,这一节已经布置了这么久,
|
||||
* 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久,
|
||||
* 多半是上一节课的 —— 先问这节课做哪几道题,底下能点回去接着看上一节。
|
||||
* 只在第一次看到这一节时判一次:老师一直开着看板时,15 秒一刷不会把表突然换掉
|
||||
* 只在第一次看到这一节时判一次:老师一直开着任务页时,15 秒一刷不会把表突然换掉
|
||||
*/
|
||||
const STALE_LESSON_MINUTES = 100
|
||||
const staleLessonId = ref<number | null>(null)
|
||||
@@ -159,10 +159,10 @@ type Group = 'help' | 'idle' | 'stuck' | 'working' | 'done' | 'leave'
|
||||
|
||||
/**
|
||||
* 正在课堂求助的学生。列表是老师端 WebSocket 实时推的(collabStore,全局常驻连接),
|
||||
* 不经看板接口 —— 求助只在 serve 进程的内存里,本来就不落库。所以也只看得到连着
|
||||
* 不经任务页接口 —— 求助只在 serve 进程的内存里,本来就不落库。所以也只看得到连着
|
||||
* **本站**的学生:服务器和机房各有各的求助队列,和课堂求助本身一样。
|
||||
*/
|
||||
function helpOf(student: ClassBoardStudent) {
|
||||
function helpOf(student: TaskBoardStudent) {
|
||||
return collabStore.requests.find((request) => request.studentId === student.userId) ?? null
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ function helpOf(student: ClassBoardStudent) {
|
||||
* 经常不动手:最近几节课里最多只交过一节。至少有 3 节课可比才下结论,
|
||||
* 开学头两节谁都一样。没交过的人里把他们排在前面,「最后交」那格写上来过几节
|
||||
*/
|
||||
function oftenIdle(student: ClassBoardStudent) {
|
||||
function oftenIdle(student: TaskBoardStudent) {
|
||||
const total = board.value?.recentLessons ?? 0
|
||||
return total >= 3 && student.recentAttended <= 1
|
||||
}
|
||||
@@ -179,7 +179,7 @@ function oftenIdle(student: ClassBoardStudent) {
|
||||
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
|
||||
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
|
||||
*/
|
||||
function unseen(student: ClassBoardStudent) {
|
||||
function unseen(student: TaskBoardStudent) {
|
||||
return !student.seenAt
|
||||
}
|
||||
|
||||
@@ -189,11 +189,11 @@ function seenText(seenAt: string) {
|
||||
return minutes < 60 ? `${minutes} 分钟前在` : `${Math.floor(minutes / 60)} 小时前在`
|
||||
}
|
||||
|
||||
function doneCount(student: ClassBoardStudent) {
|
||||
function doneCount(student: TaskBoardStudent) {
|
||||
return student.cells.filter((cell) => cell.status === 'accepted').length
|
||||
}
|
||||
|
||||
function groupOf(student: ClassBoardStudent): Group {
|
||||
function groupOf(student: TaskBoardStudent): Group {
|
||||
if (absent(student)) return 'leave'
|
||||
if (helpOf(student)) return 'help'
|
||||
const cells = student.cells
|
||||
@@ -230,10 +230,10 @@ const GROUP_LABEL: Record<Group, string> = {
|
||||
* 就是来了(迟到),自动取消标记,回到正常的分组里 —— 老师得看得见他卡住、举手
|
||||
*/
|
||||
const { onLeave, setLeave, cancelLeave } = useLeaveStudents('oj_leave_students')
|
||||
function canLeave(student: ClassBoardStudent) {
|
||||
function canLeave(student: TaskBoardStudent) {
|
||||
return !student.lastSubmitAt && !helpOf(student)
|
||||
}
|
||||
function absent(student: ClassBoardStudent) {
|
||||
function absent(student: TaskBoardStudent) {
|
||||
return canLeave(student) && onLeave(student.username)
|
||||
}
|
||||
const boardStudents = computed(() => board.value?.students ?? [])
|
||||
@@ -255,7 +255,7 @@ function saveLeave(selected: string[]) {
|
||||
)
|
||||
}
|
||||
|
||||
function byName(a: ClassBoardStudent, b: ClassBoardStudent) {
|
||||
function byName(a: TaskBoardStudent, b: TaskBoardStudent) {
|
||||
return a.username.localeCompare(b.username, 'zh-CN')
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ const groupCount = computed(() => {
|
||||
return count
|
||||
})
|
||||
/**
|
||||
* 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 classBoardCellSchema.challenge)。
|
||||
* 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 taskBoardCellSchema.challenge)。
|
||||
* 做完了又没在挑战的,就是空着的
|
||||
*/
|
||||
const challengingCount = computed(
|
||||
@@ -300,8 +300,8 @@ const groupStarts = computed(() => {
|
||||
// ---------- 题卡片和「错着的人按错法归堆」 ----------
|
||||
|
||||
interface WrongPerson {
|
||||
student: ClassBoardStudent
|
||||
cell: ClassBoardCell
|
||||
student: TaskBoardStudent
|
||||
cell: TaskBoardCell
|
||||
kind: ErrorKind
|
||||
/** 点名字讲的是哪一份:代码(cell.latest)还是最后一张图(cell.flowchart.latest);null = 没有可讲的 */
|
||||
explain: 'code' | 'draw' | null
|
||||
@@ -311,7 +311,7 @@ interface WrongPerson {
|
||||
* 卡在画流程图上的人按最后一张图归堆:一组都没走对的、对了一部分的分开讲;还在评、评失败的、
|
||||
* 老评法(数不出几组)各一堆
|
||||
*/
|
||||
function drawKind(cell: ClassBoardCell): ErrorKind {
|
||||
function drawKind(cell: TaskBoardCell): 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 }
|
||||
@@ -433,7 +433,7 @@ function openExplain(problemIndex: number, userId: number) {
|
||||
explainShow.value = true
|
||||
}
|
||||
|
||||
// ---------- 格子(显示规则在 boardCells.ts) ----------
|
||||
// ---------- 格子(显示规则在 cells.ts) ----------
|
||||
|
||||
const cellColors = computed(() => ({
|
||||
tone,
|
||||
@@ -441,12 +441,12 @@ const cellColors = computed(() => ({
|
||||
actionColor: theme.value.actionColor,
|
||||
successColor: theme.value.successColor,
|
||||
}))
|
||||
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)
|
||||
const cellStyle = (cell: TaskBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value)
|
||||
const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value)
|
||||
const cellText = (cell: TaskBoardCell) => cellTextOf(cell, mode.value)
|
||||
|
||||
/** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */
|
||||
function explainable(cell: ClassBoardCell) {
|
||||
function explainable(cell: TaskBoardCell) {
|
||||
if (cell.status !== 'tried') return false
|
||||
const stage = cellStage(cell, mode.value)
|
||||
if (stage === 'draw') {
|
||||
@@ -473,15 +473,15 @@ function trailOutside(event: MouseEvent) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。
|
||||
* 点格子 / 名字到提交列表看他今天交的。新标签打开:任务页一般开着不关,别把它顶掉。
|
||||
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。
|
||||
*
|
||||
* 今天只画了流程图、没写代码的,跳到列表的「流程图」那边 —— 不然这节课整班在画流程图时,
|
||||
* 点进去是一片「没有符合条件的提交」
|
||||
*/
|
||||
function submissionsHref(
|
||||
student: ClassBoardStudent,
|
||||
problem: ClassBoardProblem | undefined,
|
||||
student: TaskBoardStudent,
|
||||
problem: TaskBoardProblem | undefined,
|
||||
flowchart: boolean,
|
||||
/** 「以前交过、今天还没交」的人要看以前的,不能只列今天 */
|
||||
today = true,
|
||||
@@ -503,7 +503,7 @@ function submissionsHref(
|
||||
}).href
|
||||
}
|
||||
|
||||
function lastText(student: ClassBoardStudent) {
|
||||
function lastText(student: TaskBoardStudent) {
|
||||
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
|
||||
if (oftenIdle(student)) {
|
||||
return `近 ${board.value?.recentLessons} 节交过 ${student.recentAttended} 节`
|
||||
@@ -559,7 +559,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
</n-button>
|
||||
<!-- 布置过的每一节课:改错了班、隔天想起来补,去后台 -->
|
||||
<AdminLink v-if="isDesktop" :to="{ name: 'admin lesson list', query: className ? { className } : {} }">布置过的课</AdminLink>
|
||||
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
|
||||
<!-- 任务页只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
|
||||
<n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })"> 回头看 → </n-button>
|
||||
</div>
|
||||
|
||||
@@ -78,9 +78,9 @@ export const ojs: RouteRecordRaw = {
|
||||
component: () => import('oj/rank/list.vue'),
|
||||
},
|
||||
{
|
||||
path: 'classroom',
|
||||
name: 'classroom board',
|
||||
component: () => import('oj/classroom/board.vue'),
|
||||
path: 'task',
|
||||
name: 'task',
|
||||
component: () => import('oj/task/index.vue'),
|
||||
meta: { requiresAuth: true, requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
@@ -90,9 +90,9 @@ export const ojs: RouteRecordRaw = {
|
||||
meta: { requiresAuth: true, requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'class',
|
||||
name: 'class',
|
||||
component: () => import('oj/class/pk.vue'),
|
||||
path: 'pk',
|
||||
name: 'pk',
|
||||
component: () => import('oj/pk/index.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
@@ -303,7 +303,7 @@ export const admins: RouteRecordRaw = {
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
// 课堂看板布置的每一节课:改、删、补(数据统计按布置切课)
|
||||
// 课堂任务布置的每一节课:改、删、补(数据统计按布置切课)
|
||||
path: 'lesson',
|
||||
name: 'admin lesson list',
|
||||
component: () => import('admin/lesson/list.vue'),
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function getProfile(username: string = ''): Promise<Profile | null>
|
||||
return contractDeferred('GET /profiles/:username', () => import('./profileSchema').then((m) => m.userProfileSchema), response)
|
||||
}
|
||||
|
||||
/** 带 language 只数能用这个语言交的题(看板「选题」弹框用) */
|
||||
/** 带 language 只数能用这个语言交的题(任务页「选题」弹框用) */
|
||||
/** 课堂布置选题用:language 只数能用它交的题,flowchart 只数打开了画流程图的题 */
|
||||
export function getProblemTagList(language = '', flowchart = false) {
|
||||
return api.get<Tag[]>('problem-tags', { params: { language, ...(flowchart ? { flowchart: '1' } : {}) } })
|
||||
|
||||
@@ -39,7 +39,7 @@ watch(
|
||||
)
|
||||
|
||||
/**
|
||||
* 老师这边房间开好了(「帮他」、看板上点「在写」、协作条的「下一个」):跳到那道题。
|
||||
* 老师这边房间开好了(「帮他」、任务页上点「在写」、协作条的「下一个」):跳到那道题。
|
||||
* 等 room_open 到了才跳,不在点的那一刻跳 —— 服务端可能拒了(别的老师先接走、学生刚走),
|
||||
* 那时候跳过去就是对着一个空编辑器。已经在这道题上就不跳(同一道题换下一个人)
|
||||
*/
|
||||
|
||||
@@ -23,7 +23,7 @@ const { isDark, toggleDark } = useDarkTransition()
|
||||
const theme = useThemeVars()
|
||||
|
||||
/**
|
||||
* 1280 以下(老师的笔记本)收一收:题号框变窄、「课堂看板」只留图标。
|
||||
* 1280 以下(老师的笔记本)收一收:题号框变窄、「课堂任务」只留图标。
|
||||
* 机房的两种屏是 1280 和 1366,都走完整那版 —— 老师也会用机房的电脑。
|
||||
*/
|
||||
const compact = useMediaQuery('(max-width: 1279px)')
|
||||
@@ -142,7 +142,7 @@ function renderThemeRow() {
|
||||
|
||||
/**
|
||||
* 个人菜单只放「自己的东西」+ 后台入口 + 暗色开关。
|
||||
* 课堂求助、课堂看板挪到了顶栏上(HelpButton、下面的课堂看板链接),不在这里。
|
||||
* 课堂求助、课堂任务挪到了顶栏上(HelpButton、下面的课堂任务链接),不在这里。
|
||||
*/
|
||||
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
|
||||
const work = userStore.isAdminRole || userStore.canToggleDemoMode
|
||||
@@ -220,13 +220,13 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
|
||||
<n-button
|
||||
quaternary
|
||||
class="tool-link"
|
||||
:type="active === 'classroom' ? 'primary' : 'default'"
|
||||
title="课堂看板"
|
||||
aria-label="课堂看板"
|
||||
@click="router.push('/classroom')"
|
||||
:type="active === 'task' ? 'primary' : 'default'"
|
||||
title="课堂任务"
|
||||
aria-label="课堂任务"
|
||||
@click="router.push('/task')"
|
||||
>
|
||||
<template #icon><Icon icon="ph:monitor" /></template>
|
||||
<template v-if="!compact">课堂看板</template>
|
||||
<template v-if="!compact">课堂任务</template>
|
||||
</n-button>
|
||||
<span class="divider"></span>
|
||||
</template>
|
||||
|
||||
@@ -10,8 +10,8 @@ import HelpRequestList from './HelpRequestList.vue'
|
||||
* 按钮闪一下(约 1 秒、没声音、不挡内容),代替原来每来一个就弹一条的 toast:
|
||||
* 那条 toast 一节课能弹十几次,讲台电脑投着屏的时候还弹在全班面前。
|
||||
*
|
||||
* 点开是挂在按钮下面的举手面板(HelpRequestList),不是弹框。课堂看板的表里举手的人
|
||||
* 排最前、行里就有「帮他」,在看板上不用点这里。
|
||||
* 点开是挂在按钮下面的举手面板(HelpRequestList),不是弹框。课堂任务的表里举手的人
|
||||
* 排最前、行里就有「帮他」,在任务页上不用点这里。
|
||||
*/
|
||||
const props = withDefaults(defineProps<{ size?: 'medium' | 'small' }>(), { size: 'medium' })
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import UserName from './UserName.vue'
|
||||
/**
|
||||
* 顶栏「N 人举手」点开的面板(设计稿「举手与协作」HcPanel),挂在按钮下面,不是弹框。
|
||||
*
|
||||
* 按题分组,每人写错法(和课堂看板同一套 errorKind)和等了多久。同一道题两人以上举手,
|
||||
* 用户的做法是**停下来全班讲**:组头给「讲这道题」(去看板、选中这道题看错法)和
|
||||
* 按题分组,每人写错法(和课堂任务同一套 errorKind)和等了多久。同一道题两人以上举手,
|
||||
* 用户的做法是**停下来全班讲**:组头给「讲这道题」(去任务页、选中这道题看错法)和
|
||||
* 「讲完了,都放下」(这道题还在排队的手一次放下)。
|
||||
*/
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
@@ -48,12 +48,12 @@ function onHelp(item: CollabRequestItem) {
|
||||
if (help(item.studentId)) emit('close')
|
||||
}
|
||||
|
||||
/** 「讲这道题」:去这个班的看板、选中这道题,下面展开错着的人按错法归堆,点人就是讲错误弹层 */
|
||||
/** 「讲这道题」:去这个班的任务页、选中这道题,下面展开错着的人按错法归堆,点人就是讲错误弹层 */
|
||||
function explain(items: CollabRequestItem[], problemId: string) {
|
||||
const className = items.find((item) => item.className)?.className
|
||||
emit('close')
|
||||
router.push({
|
||||
name: 'classroom board',
|
||||
name: 'task',
|
||||
query: { ...(className ? { className } : {}), problem: problemId },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ watch(
|
||||
)
|
||||
|
||||
/**
|
||||
* 学生开着这道题的编辑器:报给服务端,老师看板「在写」那一列就是它,点了能直接打开他的
|
||||
* 学生开着这道题的编辑器:报给服务端,老师任务页「在写」那一列就是它,点了能直接打开他的
|
||||
* 代码(不用等他举手)。换题、换语言重报一次,编辑器卸载(切走、切成流程图)报 null。
|
||||
* 比赛入口不报(collab = false):比赛题号和公开题撞号,老师打开的会是另一道题
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { parseTime } from 'utils/functions'
|
||||
|
||||
/**
|
||||
* 课堂看板的「请假」:老师手动标记今天不在的学生,看板的人数、进度、「还没交过」都不算他们。
|
||||
* 课堂任务的「请假」:老师手动标记今天不在的学生,任务页的人数、进度、「还没交过」都不算他们。
|
||||
*
|
||||
* **今天有效**(按东八区的日子),第二天自动清掉 —— 请假一般是请一整天,原来按两小时算,
|
||||
* 连上两节课的第二节中途人又冒出来,得重标一遍。
|
||||
@@ -11,7 +11,7 @@ import { parseTime } from 'utils/functions'
|
||||
* 换个人、换台机器都不该继承(2026-10 用户定的,不上服务器)。
|
||||
*
|
||||
* 原来叫「请假隐藏」(开关 → 名字变成带 × 的标签 → 点 ×),用户说不会用,2026-10 改成
|
||||
* 看板上一个「请假」按钮勾名单。旧键 `oj_hidden_students` 存的是两小时的过期时刻,
|
||||
* 任务页上一个「请假」按钮勾名单。旧键 `oj_hidden_students` 存的是两小时的过期时刻,
|
||||
* 口径不同,不迁移,直接删掉。
|
||||
*
|
||||
* @param storageKey localStorage 的键
|
||||
@@ -36,7 +36,7 @@ export function useLeaveStudents(storageKey: string) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(data))
|
||||
} catch {
|
||||
// 存不下就只在这次打开里有效,不影响看板
|
||||
// 存不下就只在这次打开里有效,不影响任务页
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useLeaveStudents(storageKey: string) {
|
||||
|
||||
/**
|
||||
* 把 `usernames` 这批人的标记换成 `selected`:在里面但没选的取消,选了的标上。
|
||||
* 只动这一批(看板上这个班),别的班标着的人不受影响
|
||||
* 只动这一批(任务页上这个班),别的班标着的人不受影响
|
||||
*/
|
||||
function setLeave(usernames: string[], selected: string[]) {
|
||||
const scope = new Set(usernames)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
|
||||
/**
|
||||
* 老师接单 / 主动打开学生的编辑器。两处入口(顶栏举手面板、课堂看板)共用。
|
||||
* 老师接单 / 主动打开学生的编辑器。两处入口(顶栏举手面板、课堂任务)共用。
|
||||
*
|
||||
* 只发消息,不跳页面:房间开好(room_open 到了)才跳到那道题,跳转统一在 CollabHost 里做
|
||||
* —— 原来是点完就跳,服务端拒了(别的老师先接走、学生刚走)前端也已经跳过去了。
|
||||
|
||||
@@ -7,9 +7,9 @@ import { useUserStore } from 'shared/store/user'
|
||||
import { getAccessToken } from 'utils/auth'
|
||||
import { STORAGE_KEY } from 'utils/constants'
|
||||
import storage from 'utils/storage'
|
||||
import type { ClassBoardCell } from 'utils/types'
|
||||
import type { TaskBoardCell } from 'utils/types'
|
||||
|
||||
type BoardLatest = NonNullable<ClassBoardCell['latest']>
|
||||
type BoardLatest = NonNullable<TaskBoardCell['latest']>
|
||||
|
||||
// 全站唯一一处,脱离 n-message-provider 也能弹 —— 强制登出跟当前挂着哪个组件无关。
|
||||
// utils/api.ts 里也是这么做的
|
||||
@@ -580,7 +580,7 @@ export interface CollabRequestItem {
|
||||
className: string | null
|
||||
problemId: string
|
||||
problemTitle: string
|
||||
/** 他这道题最后一次代码提交(和看板格子的 latest 同形),面板按它写错法;没交过为 null */
|
||||
/** 他这道题最后一次代码提交(和任务页格子的 latest 同形),面板按它写错法;没交过为 null */
|
||||
latest: BoardLatest | null
|
||||
createdAt: number
|
||||
status: 'pending' | 'active'
|
||||
|
||||
@@ -67,7 +67,7 @@ const NAV: NavGroup[] = [
|
||||
to: '/admin/problemset/list',
|
||||
role: 'teacher',
|
||||
},
|
||||
{ label: '课堂作业', icon: 'ph:chalkboard-teacher', path: '/admin/lesson', role: 'teacher' },
|
||||
{ label: '课堂任务', icon: 'ph:chalkboard-teacher', path: '/admin/lesson', role: 'teacher' },
|
||||
{
|
||||
label: '教程',
|
||||
icon: 'ph:book-open',
|
||||
|
||||
@@ -6,13 +6,13 @@ export type HelpStatus = 'idle' | 'pending' | 'active'
|
||||
|
||||
export interface RoomInfo {
|
||||
/**
|
||||
* help = 学生举手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。
|
||||
* help = 学生举手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。
|
||||
* 学生那边按钮写「在帮你」还是「在看你的代码」、结束时说「帮完了」还是「看完了」看它
|
||||
*/
|
||||
mode: 'help' | 'peek'
|
||||
peerName: string
|
||||
peerRole: 'student' | 'teacher'
|
||||
/** 教师端才有:学生的 id(协作条按它在列表里找他举手多久了)和班级(「帮完了」回哪个班的看板) */
|
||||
/** 教师端才有:学生的 id(协作条按它在列表里找他举手多久了)和班级(「帮完了」回哪个班的任务页) */
|
||||
peerId: number | null
|
||||
peerClassName: string | null
|
||||
problemId: string
|
||||
@@ -107,7 +107,7 @@ export const useCollabStore = defineStore('collab', () => {
|
||||
// 自己离开了这道题(跳走页面、把语言切成流程图)
|
||||
case 'self_left':
|
||||
return teacher ? '你已离开这道题,求助退回队列了' : '你已离开这道题,协作结束'
|
||||
// done:老师自己点的「帮完了」,页面跟着回看板或者去下一个,不用再说一句
|
||||
// done:老师自己点的「帮完了」,页面跟着回任务页或者去下一个,不用再说一句
|
||||
default:
|
||||
return teacher ? '' : '老师已结束这次帮忙'
|
||||
}
|
||||
@@ -231,7 +231,7 @@ export const useCollabStore = defineStore('collab', () => {
|
||||
ws.send({ type: 'accept', studentId })
|
||||
}
|
||||
|
||||
/** 老师在看板上主动打开一个学生正在写的代码(他没举手也行;举着手就等于接单) */
|
||||
/** 老师在任务页上主动打开一个学生正在写的代码(他没举手也行;举着手就等于接单) */
|
||||
function peek(studentId: number) {
|
||||
ws.send({ type: 'peek', studentId })
|
||||
}
|
||||
@@ -242,7 +242,7 @@ export const useCollabStore = defineStore('collab', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生报告他开着哪道题的编辑器(null = 离开了),老师看板「在写」那一列用。
|
||||
* 学生报告他开着哪道题的编辑器(null = 离开了),老师任务页「在写」那一列用。
|
||||
* 老师不报:老师的编辑器不会被别人打开
|
||||
*/
|
||||
function reportPresence(problemId: string | null, language: LANGUAGE = 'Python') {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ClassActivity, ClassActivityProblem } from 'utils/types'
|
||||
const STALE_MS = 2 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 「这节课的题」:首页「班里在做」那几道(老师在课堂看板布置的,或者从同班提交推断的)。
|
||||
* 「这节课的题」:首页「班里在做」那几道(老师在课堂任务布置的,或者从同班提交推断的)。
|
||||
*
|
||||
* 题目页有两处要用:顶部的课堂条(做完几道、每道的状态),和通过之后的「下一题」。
|
||||
* 原来「下一题」每次通过都自己拉一遍,现在两处读同一份,通过之后由判题那边
|
||||
|
||||
@@ -19,7 +19,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏。
|
||||
// 开关存在 storage 里、只有主动退登才清,会话过期后换人登录它还在 —— 所以只对超管
|
||||
// 生效。不然老师在超管用过的机房电脑上一登录,后台、看板、求助全没了,
|
||||
// 生效。不然老师在超管用过的机房电脑上一登录,后台、任务页、求助全没了,
|
||||
// 而「退出演示」只给超管,他自己关不掉
|
||||
const demoSwitch = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
|
||||
const demoMode = computed(() => demoSwitch.value && realIsSuperAdmin.value)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { classifyDiff, type DiffKind } from 'utils/wrongAnswer'
|
||||
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
|
||||
import type { ClassBoardCell } from 'utils/types'
|
||||
import type { TaskBoardCell } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 课堂看板:一道题错着的人「按错法归堆」(设计稿「课堂看板重设计」A+B)。老师讲错误时
|
||||
* 课堂任务:一道题错着的人「按错法归堆」(设计稿「课堂看板重设计」A+B)。老师讲错误时
|
||||
* 要的是「这道题全班错在哪」:同一种错讲一遍就够,所以按最后一次提交归类,堆名要短,
|
||||
* 一眼能读完 —— 长的中文解释在点开之后的弹层里(和提交详情同一份)。
|
||||
*
|
||||
* 依据是提交里存的 runtime_error / sample_check(判题机写的),和提交详情、题目页结果区同源。
|
||||
*/
|
||||
export type BoardLatest = NonNullable<ClassBoardCell['latest']>
|
||||
export type BoardLatest = NonNullable<TaskBoardCell['latest']>
|
||||
|
||||
export interface ErrorKind {
|
||||
/** 归堆用:同一个 key 是同一种错 */
|
||||
|
||||
@@ -251,8 +251,8 @@ export type { WeeklyRank, WeeklyRankItem } from '@oj2/contract'
|
||||
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
|
||||
export type { ClassActivity, ClassActivityProblem, LastVisit } from '@oj2/contract'
|
||||
|
||||
/** 课堂看板:一个班今天这节课的题 × 全班学生 */
|
||||
export type { ClassBoard, ClassBoardProblem, ClassBoardStudent, ClassBoardCell, ClassLesson } from '@oj2/contract'
|
||||
/** 课堂任务:一个班今天这节课的题 × 全班学生 */
|
||||
export type { TaskBoard, TaskBoardProblem, TaskBoardStudent, TaskBoardCell, ClassLesson } from '@oj2/contract'
|
||||
|
||||
/** 知识点地图:每个知识点做对几道、在第几档,只给自己看 */
|
||||
export type { KnowledgeLevel, KnowledgeMap } from '@oj2/contract'
|
||||
|
||||
@@ -377,9 +377,9 @@ export const dashboardInfoSchema = z.object({
|
||||
todaySystemErrorCount: z.number().int(),
|
||||
/** 现在在线的人数(5 分钟内有动静,auth/presence.ts) */
|
||||
onlineCount: z.number().int(),
|
||||
/** 正在上课的班(最近两小时同班在交题,和课堂看板猜班同一个办法);没有为 null */
|
||||
/** 正在上课的班(最近两小时同班在交题,和课堂任务猜班同一个办法);没有为 null */
|
||||
activeClass: z.string().nullable(),
|
||||
/** 全站的数(看板那一排)。学生 = 普通用户 + 学生管理员,老师 = 教师管理员 + 超管 */
|
||||
/** 全站的数(后台首页看板那一排)。学生 = 普通用户 + 学生管理员,老师 = 教师管理员 + 超管 */
|
||||
site: z.object({
|
||||
students: z.number().int(),
|
||||
teachers: z.number().int(),
|
||||
|
||||
@@ -16,7 +16,7 @@ export const LEARNING_PERIOD_LABELS = {
|
||||
} as const satisfies Record<z.infer<typeof learningPeriodSchema>, string>
|
||||
|
||||
/**
|
||||
* 一道题在这段时间里的结果。代码提交和流程图合起来算,口径同课堂看板:代码 AC /
|
||||
* 一道题在这段时间里的结果。代码提交和流程图合起来算,口径同课堂任务:代码 AC /
|
||||
* AST_CHECK_FAILED、流程图评到 A / S 都算做对。
|
||||
*
|
||||
* - `first`:第一次交就对了
|
||||
@@ -38,7 +38,7 @@ export const learningItemSchema = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* 一天一行。`lesson` 是班上这节课的题:老师在课堂看板布置过就用布置的,否则同班同一天
|
||||
* 一天一行。`lesson` 是班上这节课的题:老师在课堂任务布置过就用布置的,否则同班同一天
|
||||
* ≥ 5 人做过的题(同「班里在做」的推断);一道题只归到一天。`own` 是这个学生这天自己练的、
|
||||
* 不属于任何一节课的题,按他第一次交的那天归。
|
||||
*/
|
||||
|
||||
@@ -64,7 +64,7 @@ export const createFlowchartResponseSchema = z.object({
|
||||
status: z.literal('pending'),
|
||||
})
|
||||
/**
|
||||
* 流程图画到这两档算这道题做完:题单进度、课堂条、课堂看板、「你画的流程图」、结果页签的 ✓
|
||||
* 流程图画到这两档算这道题做完:题单进度、课堂条、课堂任务、「你画的流程图」、结果页签的 ✓
|
||||
* 都按它(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 4、6)。前后端只有这一份
|
||||
*/
|
||||
export const FLOWCHART_PASS_GRADES = ['S', 'A'] as const
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { judgeStatusSchema } from './judge-status'
|
||||
import { lessonModeSchema } from './language'
|
||||
import { assignedLanguageSchema, lessonStepStatusSchema } from './task'
|
||||
|
||||
/**
|
||||
* 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。
|
||||
* `day` 是东八区日历日;最近 7 天都没有这样的题时为 null,`problems` 为空。
|
||||
*/
|
||||
export const classActivityProblemSchema = z.object({
|
||||
problemDisplayId: z.string(),
|
||||
title: z.string(),
|
||||
/** 那天做过这道题的同班人数(含自己) */
|
||||
userCount: z.number().int(),
|
||||
/** 其中那天通过了的人数 */
|
||||
acceptedCount: z.number().int(),
|
||||
/**
|
||||
* 我自己在题库里(不含比赛)做这道题的状态,不限那一天。老师布置时选了语言的,
|
||||
* 代码只认用那个语言交的(流程图照旧算)
|
||||
*/
|
||||
myStatus: z.enum(['accepted', 'tried', 'none']),
|
||||
/**
|
||||
* 今天布置的课要这道题画流程图 / 写代码时,这一步我做到哪了;不要这一步为 null。
|
||||
* 推断出来的课两个都是 null(不分步,myStatus 照旧代码或流程图做对都算)。
|
||||
*
|
||||
* 一道题今天可能被布置了两次(先布置画流程图、再布置写代码):两节课的要求合起来算,
|
||||
* 学生那边就和「先画再写」一样是两步 —— 老师开了写代码的那节,还没画完的人照样看得到要画
|
||||
*/
|
||||
draw: lessonStepStatusSchema.nullable(),
|
||||
code: lessonStepStatusSchema.nullable(),
|
||||
})
|
||||
|
||||
export const classActivitySchema = z.object({
|
||||
className: z.string().nullable(),
|
||||
day: z.string().nullable(),
|
||||
/** teacher = 老师在课堂任务里布置的;inferred = 从同班提交记录推断的 */
|
||||
source: z.enum(['teacher', 'inferred']).nullable(),
|
||||
/** 写代码那一步用的语言:今天最后布置的、要写代码的那节选的(只画流程图的课为 null) */
|
||||
language: assignedLanguageSchema,
|
||||
/** 今天最后布置的那节做什么;推断出来的课为 null */
|
||||
mode: lessonModeSchema.nullable(),
|
||||
problems: z.array(classActivityProblemSchema),
|
||||
})
|
||||
|
||||
/**
|
||||
* 首页的「上次来」卡(原来登录后弹的「登录速报」并进了首页,AI 那两句不要了)。
|
||||
* 窗口是**上次登录到这次登录之间**,不是到现在 —— 这次登录期间卡片内容不变,
|
||||
* 今天做的题不会混进「上次」里。只算题库提交,比赛的题号在题库里点不开。
|
||||
*/
|
||||
export const lastVisitSchema = z.object({
|
||||
/** 上次登录的时刻。null = 头一回登录(或会话早于这个字段) */
|
||||
previousLogin: z.string().nullable(),
|
||||
/** null = 上次来已经是 30 天以前,或那次一条代码都没交 */
|
||||
summary: z
|
||||
.object({
|
||||
submissionCount: z.number().int(),
|
||||
/** 那次做对的题数(去重) */
|
||||
solvedCount: z.number().int(),
|
||||
/** 那次最后一条提交的时刻,前端拿它和「班里那天」比是不是同一天 */
|
||||
lastSubmitTime: z.string(),
|
||||
/**
|
||||
* 那次交过、**到现在都还没做对**的题(这次登录后补做对了的不算),
|
||||
* 按最后一次提交倒序,最多 3 道
|
||||
*/
|
||||
unsolved: z.array(
|
||||
z.object({
|
||||
problemDisplayId: z.string(),
|
||||
title: z.string(),
|
||||
attempts: z.number().int(),
|
||||
lastResult: judgeStatusSchema,
|
||||
}),
|
||||
),
|
||||
/** 那次做对的题,按第一次做对的先后,最多 12 道(总数看 solvedCount) */
|
||||
solved: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
export type ClassActivity = z.infer<typeof classActivitySchema>
|
||||
export type LastVisit = z.infer<typeof lastVisitSchema>
|
||||
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
|
||||
@@ -3,7 +3,8 @@ export * from './auth'
|
||||
export * from './account'
|
||||
export * from './achievement'
|
||||
export * from './ai'
|
||||
export * from './classroom'
|
||||
export * from './home'
|
||||
export * from './task'
|
||||
export * from './common'
|
||||
export * from './content'
|
||||
export * from './contest'
|
||||
|
||||
@@ -42,7 +42,7 @@ export const problemLanguageSchema = z.enum([...judgeLanguageSchema.options, 'SQ
|
||||
export const runnableLanguageSchema = judgeLanguageSchema.extract(['C', 'C++', 'Python'])
|
||||
|
||||
/**
|
||||
* 老师布置作业能选的语言:判题机跑得了的三种,加 SQL(数据库课一整节都是 SQL 题)。
|
||||
* 老师布置任务能选的语言:判题机跑得了的三种,加 SQL(数据库课一整节都是 SQL 题)。
|
||||
* SQL 题只收 SQL,所以 C / Python 的作业里夹 SQL 题也行 —— 那几道不受作业语言限制
|
||||
*/
|
||||
export const lessonLanguageSchema = problemLanguageSchema.extract(['Python', 'C', 'C++', 'SQL'])
|
||||
|
||||
@@ -451,7 +451,7 @@ export const NO_CLASS = 'none'
|
||||
* 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38,
|
||||
* 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。
|
||||
*
|
||||
* **老师在课堂看板布置过的课以布置为准**(`assigned`):题目和顺序照布置的,那天这个班
|
||||
* **老师在课堂任务布置过的课以布置为准**(`assigned`):题目和顺序照布置的,那天这个班
|
||||
* 交这几道题的提交全算这节课(不论隔了多久、几个人),完成率按全班算。这些提交不再参与上面
|
||||
* 的切段 —— 同一天没布置的另一节课照旧推断,也不会被算成零散提交。
|
||||
*/
|
||||
@@ -476,7 +476,7 @@ export const submissionLessonsSchema = z.object({
|
||||
* 推断的课是这一段里 5 人以上做过的,做的人多的在前
|
||||
*/
|
||||
problems: z.array(z.string()),
|
||||
/** 老师在课堂看板布置的课(不是从提交推断的) */
|
||||
/** 老师在课堂任务布置的课(不是从提交推断的) */
|
||||
assigned: z.boolean(),
|
||||
/**
|
||||
* 布置的课做什么(写代码 / 画流程图 / 先画再写);推断的课为 null。要画流程图的课,次数和
|
||||
@@ -485,7 +485,7 @@ export const submissionLessonsSchema = z.object({
|
||||
mode: lessonModeSchema.nullable(),
|
||||
/**
|
||||
* 布置的课:班里把布置的题全做对了的人数(那天结束前,以前做对的也算;布置时选了语言的
|
||||
* 只认那个语言,流程图评到 A / S 也算 —— 和看板同一个口径)。推断的课为 null
|
||||
* 只认那个语言,流程图评到 A / S 也算 —— 和任务页同一个口径)。推断的课为 null
|
||||
*/
|
||||
doneCount: z.number().int().nullable(),
|
||||
/** 多半是从题单里交的 —— 「题单 7 道」 */
|
||||
@@ -494,7 +494,7 @@ export const submissionLessonsSchema = z.object({
|
||||
total: z.number().int(),
|
||||
/** 同上,判完的里面对了多少,百分数 */
|
||||
correctRate: z.number(),
|
||||
/** 最后一条在 15 分钟内:还在上课,界面上引去课堂看板 */
|
||||
/** 最后一条在 15 分钟内:还在上课,界面上引去课堂任务 */
|
||||
live: z.boolean(),
|
||||
/** 这节课的题里没过次数最多的那道;一次都没错过为 null */
|
||||
hardest: z
|
||||
|
||||
@@ -1,113 +1,40 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { judgeStatusSchema } from './judge-status'
|
||||
import { lessonLanguageSchema as lessonLanguageValueSchema, lessonModeSchema } from './language'
|
||||
import { lessonLanguageSchema, lessonModeSchema } from './language'
|
||||
import { statisticInfoSchema } from './submission'
|
||||
|
||||
/** 课堂条上一道题一步的状态(画 / 写),和 myStatus 同一套取值 */
|
||||
const stepStatusSchema = z.enum(['accepted', 'tried', 'none'])
|
||||
|
||||
/**
|
||||
* 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。
|
||||
* `day` 是东八区日历日;最近 7 天都没有这样的题时为 null,`problems` 为空。
|
||||
*/
|
||||
export const classActivityProblemSchema = z.object({
|
||||
problemDisplayId: z.string(),
|
||||
title: z.string(),
|
||||
/** 那天做过这道题的同班人数(含自己) */
|
||||
userCount: z.number().int(),
|
||||
/** 其中那天通过了的人数 */
|
||||
acceptedCount: z.number().int(),
|
||||
/**
|
||||
* 我自己在题库里(不含比赛)做这道题的状态,不限那一天。老师布置时选了语言的,
|
||||
* 代码只认用那个语言交的(流程图照旧算)
|
||||
*/
|
||||
myStatus: z.enum(['accepted', 'tried', 'none']),
|
||||
/**
|
||||
* 今天布置的课要这道题画流程图 / 写代码时,这一步我做到哪了;不要这一步为 null。
|
||||
* 推断出来的课两个都是 null(不分步,myStatus 照旧代码或流程图做对都算)。
|
||||
*
|
||||
* 一道题今天可能被布置了两次(先布置画流程图、再布置写代码):两节课的要求合起来算,
|
||||
* 学生那边就和「先画再写」一样是两步 —— 老师开了写代码的那节,还没画完的人照样看得到要画
|
||||
*/
|
||||
draw: stepStatusSchema.nullable(),
|
||||
code: stepStatusSchema.nullable(),
|
||||
})
|
||||
export const lessonStepStatusSchema = z.enum(['accepted', 'tried', 'none'])
|
||||
|
||||
/**
|
||||
* 老师布置这节课时选的语言(Python / C / C++ / SQL)。null = 没选:推断出来的课、加这一项之前
|
||||
* 布置的课。选了之后这份作业「做完」只认这个语言交对的(SQL 题例外,交对 SQL 就算),
|
||||
* 学生打开这几道题编辑器默认就是它
|
||||
*/
|
||||
const lessonLanguageSchema = lessonLanguageValueSchema.nullable()
|
||||
|
||||
export const classActivitySchema = z.object({
|
||||
className: z.string().nullable(),
|
||||
day: z.string().nullable(),
|
||||
/** teacher = 老师在课堂看板里布置的;inferred = 从同班提交记录推断的 */
|
||||
source: z.enum(['teacher', 'inferred']).nullable(),
|
||||
/** 写代码那一步用的语言:今天最后布置的、要写代码的那节选的(只画流程图的课为 null) */
|
||||
language: lessonLanguageSchema,
|
||||
/** 今天最后布置的那节做什么;推断出来的课为 null */
|
||||
mode: lessonModeSchema.nullable(),
|
||||
problems: z.array(classActivityProblemSchema),
|
||||
})
|
||||
export const assignedLanguageSchema = lessonLanguageSchema.nullable()
|
||||
|
||||
/**
|
||||
* 首页的「上次来」卡(原来登录后弹的「登录速报」并进了首页,AI 那两句不要了)。
|
||||
* 窗口是**上次登录到这次登录之间**,不是到现在 —— 这次登录期间卡片内容不变,
|
||||
* 今天做的题不会混进「上次」里。只算题库提交,比赛的题号在题库里点不开。
|
||||
*/
|
||||
export const lastVisitSchema = z.object({
|
||||
/** 上次登录的时刻。null = 头一回登录(或会话早于这个字段) */
|
||||
previousLogin: z.string().nullable(),
|
||||
/** null = 上次来已经是 30 天以前,或那次一条代码都没交 */
|
||||
summary: z
|
||||
.object({
|
||||
submissionCount: z.number().int(),
|
||||
/** 那次做对的题数(去重) */
|
||||
solvedCount: z.number().int(),
|
||||
/** 那次最后一条提交的时刻,前端拿它和「班里那天」比是不是同一天 */
|
||||
lastSubmitTime: z.string(),
|
||||
/**
|
||||
* 那次交过、**到现在都还没做对**的题(这次登录后补做对了的不算),
|
||||
* 按最后一次提交倒序,最多 3 道
|
||||
*/
|
||||
unsolved: z.array(
|
||||
z.object({
|
||||
problemDisplayId: z.string(),
|
||||
title: z.string(),
|
||||
attempts: z.number().int(),
|
||||
lastResult: judgeStatusSchema,
|
||||
}),
|
||||
),
|
||||
/** 那次做对的题,按第一次做对的先后,最多 12 道(总数看 solvedCount) */
|
||||
solved: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 课堂看板里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉。
|
||||
* 课堂任务里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉。
|
||||
* `language` 是这份作业用什么语言做,null = 没选。
|
||||
*
|
||||
* 一个班一天可以上几节内容不同的课,每次布置是一行(class_lesson.id):
|
||||
* - `lessonId`:改 / 清掉这一节(看板上的「改」);
|
||||
* - `newLesson`:今天再开一节(看板上的「新的一节」);
|
||||
* - `lessonId`:改 / 清掉这一节(任务页上的「改」);
|
||||
* - `newLesson`:今天再开一节(任务页上的「新的一节」);
|
||||
* - 两个都不带:改今天最后布置的那一节,今天还没有就新开 —— 一天只能一份时的老写法,
|
||||
* 开着旧页面的老师、没跟上的机房站点发的都是这种,别让它们每改一次就多出一节课
|
||||
*/
|
||||
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: assignedLanguageSchema.nullable(),
|
||||
/** 不带 = 写代码(开着的旧页面发的就是这种)。画流程图时 language 不用,存成 null */
|
||||
mode: lessonModeSchema.default('code'),
|
||||
lessonId: z.number().int().positive().optional(),
|
||||
newLesson: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const classBoardProblemSchema = z.object({
|
||||
export const taskBoardProblemSchema = z.object({
|
||||
problemId: z.number().int(),
|
||||
problemDisplayId: z.string(),
|
||||
title: z.string(),
|
||||
@@ -117,7 +44,7 @@ export const classBoardProblemSchema = z.object({
|
||||
hasChallenges: z.boolean(),
|
||||
})
|
||||
|
||||
export const classBoardCellSchema = z.object({
|
||||
export const taskBoardCellSchema = z.object({
|
||||
/**
|
||||
* accepted 看的是题库里(不含比赛)有没有通过过,不限今天 —— 以前做过的也算做完。
|
||||
* 布置时选了语言的,代码的次数和通过都只数那个语言(流程图照旧)
|
||||
@@ -140,7 +67,7 @@ export const classBoardCellSchema = z.object({
|
||||
* 代码这一步:交对过(不限今天)/ 交过没对 / 没交过。写代码的课和 status 一样;先画再写的课
|
||||
* 要它分出「图画对了、代码还没对」
|
||||
*/
|
||||
codeStatus: stepStatusSchema,
|
||||
codeStatus: lessonStepStatusSchema,
|
||||
/**
|
||||
* 流程图这一步,只在这节课要画流程图时有(mode 是 flowchart / both),否则 null。
|
||||
* - `passedAt`:第一次画到 A / S 的时刻(不限今天)
|
||||
@@ -149,7 +76,7 @@ export const classBoardCellSchema = z.object({
|
||||
*/
|
||||
flowchart: z
|
||||
.object({
|
||||
status: stepStatusSchema,
|
||||
status: lessonStepStatusSchema,
|
||||
passedAt: z.string().nullable(),
|
||||
latest: z
|
||||
.object({
|
||||
@@ -193,12 +120,12 @@ export const classBoardCellSchema = z.object({
|
||||
challenge: z.enum(['done', 'doing']).nullable(),
|
||||
})
|
||||
|
||||
export const classBoardStudentSchema = z.object({
|
||||
export const taskBoardStudentSchema = z.object({
|
||||
userId: z.number().int(),
|
||||
username: z.string(),
|
||||
realName: z.string().nullable(),
|
||||
/** 和 problems 同序 */
|
||||
cells: z.array(classBoardCellSchema),
|
||||
cells: z.array(taskBoardCellSchema),
|
||||
/** 今天最后一次提交(任何题),没交过为 null */
|
||||
lastSubmitAt: z.string().nullable(),
|
||||
/**
|
||||
@@ -207,7 +134,7 @@ export const classBoardStudentSchema = z.object({
|
||||
*/
|
||||
codeToday: z.boolean(),
|
||||
drawnToday: z.boolean(),
|
||||
/** 最近几节课(不含今天,个数见 ClassBoard.recentLessons)里交过题的有几节 */
|
||||
/** 最近几节课(不含今天,个数见 TaskBoard.recentLessons)里交过题的有几节 */
|
||||
recentAttended: z.number().int(),
|
||||
/**
|
||||
* 他此刻开着哪道题的代码编辑器(展示题号),没开着为 null。老师点它就能打开他正在写的
|
||||
@@ -224,18 +151,18 @@ export const classBoardStudentSchema = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* 课堂看板:一个班、今天、这节课的几道题 × 全班学生。「这节课」是今天最后布置的那一节,
|
||||
* 课堂任务:一个班、今天、这节课的几道题 × 全班学生。「这节课」是今天最后布置的那一节,
|
||||
* 今天没布置过就按今天同班的提交推断。
|
||||
* className 为 null = 没指定班级、最近两小时也没有哪个班在交(没法猜)。
|
||||
*/
|
||||
export const classBoardSchema = z.object({
|
||||
export const taskBoardSchema = z.object({
|
||||
className: z.string().nullable(),
|
||||
day: z.string(),
|
||||
source: z.enum(['teacher', 'inferred']).nullable(),
|
||||
/** 老师布置的这一节的 id(「改」带着它);推断出来的课为 null */
|
||||
lessonId: z.number().int().nullable(),
|
||||
/** 这一节布置的语言(见 lessonLanguageSchema) */
|
||||
language: lessonLanguageSchema,
|
||||
/** 这一节布置的语言(见 assignedLanguageSchema) */
|
||||
language: assignedLanguageSchema,
|
||||
/** 这一节做什么;推断出来的课为 null(代码交对或流程图画对都算) */
|
||||
mode: lessonModeSchema.nullable(),
|
||||
/** 这一节第一次布置的时刻,之后改题不变;推断出来的课为 null */
|
||||
@@ -243,9 +170,9 @@ export const classBoardSchema = z.object({
|
||||
/** 第一次布置的老师(真名,没有真名用用户名);推断出来的课为 null */
|
||||
assignedBy: z.string().nullable(),
|
||||
/** 这个班最近一次布置选的语言(含今天),给语言下拉当默认值;从没选过为 null */
|
||||
lastLanguage: lessonLanguageSchema,
|
||||
problems: z.array(classBoardProblemSchema),
|
||||
students: z.array(classBoardStudentSchema),
|
||||
lastLanguage: assignedLanguageSchema,
|
||||
problems: z.array(taskBoardProblemSchema),
|
||||
students: z.array(taskBoardStudentSchema),
|
||||
/**
|
||||
* 「最近几节课」一共几节(最多 5,不含今天)。一节课 = 这个班同学一起做题的一天
|
||||
* (同班同一天 ≥ 5 人做同一道题,和「班里在做」同一个判据)。新班、开学头几周不满 5。
|
||||
@@ -254,7 +181,7 @@ export const classBoardSchema = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* 提交列表「这节课」那颗按钮要的:哪个班、这节课是哪几道题。和课堂看板同一套取法
|
||||
* 提交列表「这节课」那颗按钮要的:哪个班、这节课是哪几道题。和课堂任务同一套取法
|
||||
* (老师布置过就用布置的,否则按今天同班提交推断),但不带花名册 —— 列表只拿它当筛选条件。
|
||||
* className 为 null = 没指定、最近两小时也没有哪个班在交。
|
||||
*/
|
||||
@@ -265,13 +192,13 @@ export const classLessonSchema = z.object({
|
||||
* 选了语言时,列表那边连语言一起筛。C / Python 的作业里夹了 SQL 题就是 null:
|
||||
* 列表只能筛一种语言,按 C 筛会把 SQL 题交的全藏起来,宁可多列一些
|
||||
*/
|
||||
language: lessonLanguageSchema,
|
||||
language: assignedLanguageSchema,
|
||||
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
|
||||
})
|
||||
|
||||
/**
|
||||
* 老师首页「今天的课」:今天在上课的每个班一行(同班 ≥5 人一起交题,或者老师在看板上布置过)。
|
||||
* 题目的人数是今天的(和看板同一套取法),「全做对」看的是这几道题有没有做对过,不限今天
|
||||
* 老师首页「今天的课」:今天在上课的每个班一行(同班 ≥5 人一起交题,或者老师在任务页上布置过)。
|
||||
* 题目的人数是今天的(和任务页同一套取法),「全做对」看的是这几道题有没有做对过,不限今天
|
||||
*/
|
||||
export const classTodaySchema = z.object({
|
||||
classes: z.array(
|
||||
@@ -302,7 +229,7 @@ export const classTodaySchema = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* 课上老师在看板(或后台)改了**今天这节**布置的题,推给这个班还开着页面的学生:告诉他们改了哪些,
|
||||
* 课上老师在任务页(或后台)改了**今天这节**布置的题,推给这个班还开着页面的学生:告诉他们改了哪些,
|
||||
* 点「刷新」重新载入。只有学生看到的那一节(今天最后布置的)变了才推 —— 改的是今天早些时候的一节、
|
||||
* 或者改完和原来一样,都不推。走 /ws/config 那条全局常驻的连接。
|
||||
*
|
||||
@@ -315,7 +242,7 @@ export const lessonUpdateProblemSchema = z.object({ problemDisplayId: z.string()
|
||||
export const lessonUpdateSchema = z.object({
|
||||
type: z.literal('lesson_update'),
|
||||
kind: z.enum(['assigned', 'changed', 'cleared']),
|
||||
language: lessonLanguageSchema,
|
||||
language: assignedLanguageSchema,
|
||||
/** 清掉了为 null。languageChanged 连它一起算:改成画流程图、改成先画再写也是「做法变了」 */
|
||||
mode: lessonModeSchema.nullable(),
|
||||
problems: z.array(lessonUpdateProblemSchema),
|
||||
@@ -327,19 +254,16 @@ export const lessonUpdateSchema = z.object({
|
||||
|
||||
export type LessonUpdate = z.infer<typeof lessonUpdateSchema>
|
||||
|
||||
export type ClassActivity = z.infer<typeof classActivitySchema>
|
||||
export type ClassToday = z.infer<typeof classTodaySchema>
|
||||
export type LastVisit = z.infer<typeof lastVisitSchema>
|
||||
export type ClassLessonRequest = z.input<typeof classLessonRequestSchema>
|
||||
export type ClassBoard = z.infer<typeof classBoardSchema>
|
||||
export type TaskBoard = z.infer<typeof taskBoardSchema>
|
||||
export type ClassLesson = z.infer<typeof classLessonSchema>
|
||||
export type ClassBoardProblem = z.infer<typeof classBoardProblemSchema>
|
||||
export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
|
||||
export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
|
||||
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
|
||||
export type TaskBoardProblem = z.infer<typeof taskBoardProblemSchema>
|
||||
export type TaskBoardStudent = z.infer<typeof taskBoardStudentSchema>
|
||||
export type TaskBoardCell = z.infer<typeof taskBoardCellSchema>
|
||||
|
||||
/**
|
||||
* 后台「课堂作业」:布置过的每一节课一行(class_lesson),新的在前。看板上当场布置的、
|
||||
* 后台「课堂任务」:布置过的每一节课一行(class_lesson),新的在前。任务页上当场布置的、
|
||||
* 后台补的都在这里,数据统计按它们切课。
|
||||
*/
|
||||
export const adminLessonSchema = z.object({
|
||||
@@ -347,18 +271,18 @@ export const adminLessonSchema = z.object({
|
||||
className: z.string(),
|
||||
/** 东八区的日子,`2026-10-08` */
|
||||
day: z.string(),
|
||||
/** 布置的时刻(看板上第一次布置那一刻;后台补的是填的时刻) */
|
||||
/** 布置的时刻(任务页上第一次布置那一刻;后台补的是填的时刻) */
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
/** 布置的老师:真名,没有真名用用户名(和看板「谁 布置」一样) */
|
||||
/** 布置的老师:真名,没有真名用用户名(和任务页「谁 布置」一样) */
|
||||
createdBy: z.string(),
|
||||
language: lessonLanguageSchema,
|
||||
language: assignedLanguageSchema,
|
||||
mode: lessonModeSchema,
|
||||
/** 按布置的顺序;题后来被藏起来的 visible 为 false(统计、学生那边都不再出现) */
|
||||
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string(), visible: z.boolean() })),
|
||||
/** 班里的学生数(STUDENT_ROLES、没禁用),和 doneCount 同一个花名册 */
|
||||
classSize: z.number().int(),
|
||||
/** 把布置的题全做对了的人数(那天结束前;口径同看板) */
|
||||
/** 把布置的题全做对了的人数(那天结束前;口径同任务页) */
|
||||
doneCount: z.number().int(),
|
||||
})
|
||||
|
||||
@@ -370,14 +294,14 @@ export const adminLessonListSchema = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* 后台新补一节 / 改一节。日子和时刻按东八区填:补的是看板外上的课(没在看板布置、
|
||||
* 后台新补一节 / 改一节。日子和时刻按东八区填:补的是任务页外上的课(没在任务页布置、
|
||||
* 以前的课),时刻决定同一天几节课的先后。题目至少一道 —— 删掉一节走 DELETE
|
||||
*/
|
||||
export const adminLessonRequestSchema = z.object({
|
||||
className: z.string().trim().min(1),
|
||||
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||
language: lessonLanguageSchema,
|
||||
language: assignedLanguageSchema,
|
||||
mode: lessonModeSchema.default('code'),
|
||||
problemDisplayIds: z.array(z.string().trim().min(1)).min(1).max(20),
|
||||
})
|
||||
Reference in new issue
Block a user