Compare commits

...
2 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 48590eee60 refactor(目录): 目录名对上界面上的名字,自学的「一课」不再也叫 lesson
Deploy / deploy (push) Canceled after 0s
- 后台课堂任务 admin/lesson → admin/task(/admin/task),和前台 oj/task 对上
- 后台首页 admin/setting/home.vue → admin/home/,设置 → admin/config/
- 卡点分析、年度趋势从 admin/problem 搬到 admin/analytics(/admin/analytics/stuck|trend),和菜单的「学情分析」、后端 analytics.ts 对上
- 首页 dashboard.vue / TeacherHome.vue → student.vue / teacher.vue;自学首页 home.vue → index.vue,看一课 index.vue → tutorial.vue
- lesson 只表示课堂上的一节:自学那边改叫 tutorial(oj/learn/lesson.ts → utils.ts、isLessonDone → isTutorialDone、Lesson*.vue → Tutorial*.vue、自学情况的 lessons 字段 → tutorials)
- 旧地址不留跳转

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:59:43 +08:00
xuyueandClaude Opus 5.5 29c31640b5 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>
2026-11-02 22:53:07 +08:00
108 changed files with 2035 additions and 2001 deletions

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ const ONLINE_WINDOW_MS = 5 * 60 * 1000
/** /**
* 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。 * 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。
* *
* 课堂看板拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着, * 课堂任务拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着,
* 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。 * 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。
* 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。 * 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。
*/ */
+2 -2
View File
@@ -428,7 +428,7 @@ function acceptRequest(ws: CollabSocket, request: HelpRequest) {
/** /**
* 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找), * 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找),
* 「帮完了」要回到哪个班的看板 * 「帮完了」要回到哪个班的任务页
*/ */
function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) { function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) {
const frame = (peer: Record<string, unknown>) => const frame = (peer: Record<string, unknown>) =>
@@ -630,7 +630,7 @@ function handlePresence(ws: CollabSocket, problemId: unknown, language: unknown)
} }
/** /**
* 老师在看板上主动打开一个学生正在写的代码(学生没举手)。 * 老师在任务页上主动打开一个学生正在写的代码(学生没举手)。
* *
* 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」; * 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」;
* 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。 * 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。
+3 -3
View File
@@ -24,7 +24,7 @@ export function normalizeCollabLanguage(value: unknown): CollabLanguage {
} }
/** /**
* 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂看板的格子同一份形状, * 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂任务的格子同一份形状,
* 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码 * 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码
*/ */
export interface CollabLatest { export interface CollabLatest {
@@ -111,7 +111,7 @@ export interface Room {
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */ /** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
studentId: number studentId: number
/** /**
* help = 学生举了手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。 * help = 学生举了手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。
* 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清 * 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清
*/ */
mode: 'help' | 'peek' mode: 'help' | 'peek'
@@ -144,7 +144,7 @@ export function roomOf(ws: CollabSocket) {
} }
/** /**
* 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在看板上「在写」那一列看它、 * 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在任务页上「在写」那一列看它、
* 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报, * 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报,
* 连接断了就清掉。同一账号开了两个标签页时,后报的算 * 连接断了就清掉。同一账号开了两个标签页时,后报的算
*/ */
+6 -6
View File
@@ -1195,7 +1195,7 @@ export const problemsetBadge = pgTable(
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。 * 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
* *
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`), * **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。 * 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isTutorialDone`)。
* *
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户 * 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。 * 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
@@ -1293,10 +1293,10 @@ export const exerciseAttempt = pgTable(
) )
/** /**
* 老师给某个班布置的一节课的题(课堂看板里布置的),一次布置一行。一个班一天可以有几节 * 老师给某个班布置的一节课的题(课堂任务里布置的),一次布置一行。一个班一天可以有几节
* 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。 * 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。
* *
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。 * 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(services/lesson.ts 的 classLessonProblems)。
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。 * 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
* *
* - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列: * - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列:
@@ -1322,7 +1322,7 @@ export const classLesson = pgTable(
problemIds: jsonb('problem_ids').notNull().$type<number[]>(), problemIds: jsonb('problem_ids').notNull().$type<number[]>(),
/** /**
* 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的 * 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过 * 没有它。写入侧在 PUT /task/lesson 按 lessonLanguageSchema 校验过
*/ */
language: text().$type<LessonLanguage>(), language: text().$type<LessonLanguage>(),
/** /**
@@ -1330,11 +1330,11 @@ export const classLesson = pgTable(
* 都是写代码。写入侧按 lessonModeSchema 校验过 * 都是写代码。写入侧按 lessonModeSchema 校验过
*/ */
mode: text().$type<LessonMode>().notNull().default('code'), mode: text().$type<LessonMode>().notNull().default('code'),
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */ /** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。任务页上写「HH:mm 谁 布置」 */
createdBy: integer('created_by').notNull(), createdBy: integer('created_by').notNull(),
/** /**
* 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。 * 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
* 同一天有几节时,按它分先后:最后布置的那一节是看板上的「这节课」。 * 同一天有几节时,按它分先后:最后布置的那一节是任务页上的「这节课」。
* 加这一列之前布置的用 updated_at 回填(0034) * 加这一列之前布置的用 updated_at 回填(0034)
*/ */
createdAt: timestamp('created_at', { createdAt: timestamp('created_at', {
+4 -2
View File
@@ -10,7 +10,8 @@ import { authRoutes } from './routes/auth'
import { accountRoutes } from './routes/account' import { accountRoutes } from './routes/account'
import { contestRoutes } from './routes/contest' import { contestRoutes } from './routes/contest'
import { contentRoutes } from './routes/content' 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 { rankingRoutes } from './routes/ranking'
import { problemsetRoutes } from './routes/problemset' import { problemsetRoutes } from './routes/problemset'
import { achievementRoutes } from './routes/achievement' import { achievementRoutes } from './routes/achievement'
@@ -32,7 +33,8 @@ app.route('/api', accountRoutes)
app.route('/api', siteRoutes) app.route('/api', siteRoutes)
app.route('/api', contestRoutes) app.route('/api', contestRoutes)
app.route('/api', contentRoutes) app.route('/api', contentRoutes)
app.route('/api', classroomRoutes) app.route('/api', homeRoutes)
app.route('/api', taskRoutes)
app.route('/api', rankingRoutes) app.route('/api', rankingRoutes)
app.route('/api', problemsetRoutes) app.route('/api', problemsetRoutes)
app.route('/api', achievementRoutes) app.route('/api', achievementRoutes)
+1 -1
View File
@@ -446,7 +446,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
return users.length return users.length
}) })
// 新班级顺手加进班级列表:不加的话全站的班级下拉(看板、提交列表、排名……)里都找不到它, // 新班级顺手加进班级列表:不加的话全站的班级下拉(任务页、提交列表、排名……)里都找不到它,
// 原来要再去设置页手动加,经常忘 // 原来要再去设置页手动加,经常忘
const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))] const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))]
const options = await getWebsiteOptions() const options = await getWebsiteOptions()
+12 -12
View File
@@ -1,7 +1,7 @@
import { import {
FLOWCHART_PASS_GRADES, FLOWCHART_PASS_GRADES,
STUDENT_ROLES, STUDENT_ROLES,
isLessonDone, isTutorialDone,
type LearnBoard, type LearnBoard,
type LearnBoardStudent, type LearnBoardStudent,
type LearnExerciseAttemptList, type LearnExerciseAttemptList,
@@ -64,7 +64,7 @@ function studentCondition(value: string | null) {
/** /**
* 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数, * 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数,
* 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、 * 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、
* 课堂看板(routes/classroom.ts 的 activityRows)同一个口径。不论早晚:读这课之前 * 课堂任务(services/class-activity.ts 的 activityRows)同一个口径。不论早晚:读这课之前
* 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。 * 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。
*/ */
async function problemActivity(userIds: number[], problemIds: number[]) { async function problemActivity(userIds: number[], problemIds: number[]) {
@@ -198,7 +198,7 @@ adminLearnRoutes.get('/learn-analytics/board', requireTeacher, async (c) => {
students: students.map((student) => { students: students.map((student) => {
const opened: number[] = [] const opened: number[] = []
const done: number[] = [] const done: number[] = []
const lessons: LearnBoardStudent['lessons'] = [] const attempts: LearnBoardStudent['tutorials'] = []
let exerciseSolved = 0 let exerciseSolved = 0
let totalSeconds = 0 let totalSeconds = 0
let lastViewedAt: string | null = null let lastViewedAt: string | null = null
@@ -210,26 +210,26 @@ adminLearnRoutes.get('/learn-analytics/board', requireTeacher, async (c) => {
exerciseSolved += solvedHere exerciseSolved += solvedHere
if (exercises) { if (exercises) {
const { tried, solved, wrongAttempts } = exercises const { tried, solved, wrongAttempts } = exercises
lessons.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts }) attempts.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts })
} }
if (row) { if (row) {
opened.push(tutorial.id) opened.push(tutorial.id)
totalSeconds += row.totalSeconds totalSeconds += row.totalSeconds
if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt
} }
const lesson = { const here = {
viewCount: row?.viewCount ?? 0, viewCount: row?.viewCount ?? 0,
exerciseTotal: tutorial.exerciseTotal, exerciseTotal: tutorial.exerciseTotal,
exerciseSolved: solvedHere, exerciseSolved: solvedHere,
} }
if (isLessonDone(lesson)) done.push(tutorial.id) if (isTutorialDone(here)) done.push(tutorial.id)
} }
return { return {
...student, ...student,
opened, opened,
done, done,
exerciseSolved, exerciseSolved,
lessons, tutorials: attempts,
problems: problemsOf.get(student.userId) ?? [], problems: problemsOf.get(student.userId) ?? [],
totalSeconds, totalSeconds,
lastViewedAt, lastViewedAt,
@@ -394,27 +394,27 @@ adminLearnRoutes.get('/learn-analytics/students/:id', requireTeacher, async (c)
).map((row) => [row.problemId, row]), ).map((row) => [row.problemId, row]),
) )
const byLesson = new Map<number, LearnStudentDetail['lessons'][number]['exercises']>() const byTutorial = new Map<number, LearnStudentDetail['tutorials'][number]['exercises']>()
for (const { tutorialId, ...row } of exercises) { for (const { tutorialId, ...row } of exercises) {
const list = byLesson.get(tutorialId) ?? [] const list = byTutorial.get(tutorialId) ?? []
list.push({ list.push({
...row, ...row,
attempts: row.attempts ?? 0, attempts: row.attempts ?? 0,
solved: row.solved ?? false, solved: row.solved ?? false,
}) })
byLesson.set(tutorialId, list) byTutorial.set(tutorialId, list)
} }
return success(c, { return success(c, {
...student, ...student,
lessons: tutorials tutorials: tutorials
.map((row) => ({ .map((row) => ({
tutorialId: row.tutorialId, tutorialId: row.tutorialId,
title: row.title, title: row.title,
viewCount: row.viewCount ?? 0, viewCount: row.viewCount ?? 0,
totalSeconds: row.totalSeconds ?? 0, totalSeconds: row.totalSeconds ?? 0,
lastViewedAt: row.lastViewedAt, lastViewedAt: row.lastViewedAt,
exercises: byLesson.get(row.tutorialId) ?? [], exercises: byTutorial.get(row.tutorialId) ?? [],
problems: related problems: related
.filter((problem) => problem.tutorialId === row.tutorialId) .filter((problem) => problem.tutorialId === row.tutorialId)
.map(({ id: problemId, displayId, title }) => { .map(({ id: problemId, displayId, title }) => {
+5 -5
View File
@@ -10,10 +10,10 @@ import { localDateTime } from '../../time'
import { queryInteger } from '../helpers' import { queryInteger } from '../helpers'
/** /**
* 后台「课堂作业」:布置过的每一节课(class_lesson)。看板上布置错了班、错了题可以在这里改, * 后台「课堂任务」:布置过的每一节课(class_lesson)。任务页上布置错了班、错了题可以在这里改,
* 看板外上的课(以前的、没在看板布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。 * 任务页外上的课(以前的、没在任务页布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
* *
* 权限和看板一样是老师:看板上任何老师都能改任何班的这节课,这里不另设门槛。 * 权限和任务页一样是老师:任务页上任何老师都能改任何班的这节课,这里不另设门槛。
*/ */
export const adminLessonRoutes = new Hono<AppEnv>() 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) 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} 这个班` } if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` }
// 只画流程图不用语言,存成 null(和看板布置一样) // 只画流程图不用语言,存成 null(和任务页布置一样)
const language = body.mode === 'flowchart' ? null : body.language const language = body.mode === 'flowchart' ? null : body.language
const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode) const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode)
if ('code' in resolved) return resolved 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 classes = await affectedClasses(id, result.values.className)
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className))) const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
// 布置的老师不换,和看板上「改」一样认第一次 // 布置的老师不换,和任务页上「改」一样认第一次
const [row] = await db const [row] = await db
.update(schema.classLesson) .update(schema.classLesson)
.set({ ...result.values, updatedAt: new Date().toISOString() }) .set({ ...result.values, updatedAt: new Date().toISOString() })
+253
View File
@@ -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 }
}
+1 -1
View File
@@ -73,7 +73,7 @@ function typeFilter(type: ProblemTypeFilter | undefined) {
} }
/** /**
* 课堂看板「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。 * 课堂任务「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
* 学生的题目列表不带这个参数 * 学生的题目列表不带这个参数
*/ */
function lessonLanguageFilter(raw: string | undefined) { function lessonLanguageFilter(raw: string | undefined) {
+1 -1
View File
@@ -361,7 +361,7 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
/** /**
* 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。 * 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
* *
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计 * 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计
* 也是任何老师看任何班,这里和它们一致。 * 也是任何老师看任何班,这里和它们一致。
* *
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它, * 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
+1 -1
View File
@@ -529,7 +529,7 @@ const LESSONS_MAX = 500
type StatisticsRange = { start: string | null; end: string } type StatisticsRange = { start: string | null; end: string }
/** 这段时间里(按东八区日子)老师在课堂看板布置过的课,同一天的按布置先后 */ /** 这段时间里(按东八区日子)老师在课堂任务布置过的课,同一天的按布置先后 */
async function lessonPlans(range: StatisticsRange) { async function lessonPlans(range: StatisticsRange) {
return db return db
.select({ .select({
@@ -1,440 +1,40 @@
import { import {
STUDENT_ROLES, STUDENT_ROLES,
classLessonRequestSchema, classLessonRequestSchema,
type ClassActivity,
type ClassToday, type ClassToday,
type ClassBoard, type TaskBoard,
type ClassBoardStudent, type TaskBoardStudent,
type ClassLesson, type ClassLesson,
type ClassActivityProblem,
type LastVisit,
type LessonLanguage,
type LessonMode,
lessonNeeds, lessonNeeds,
type StatisticInfo, type StatisticInfo,
} from '@oj2/contract' } 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 { Hono } from 'hono'
import { requireAuth, requireTeacher, type AppEnv } from '../auth/middleware' import { requireTeacher, type AppEnv } from '../auth/middleware'
import { seenToday } from '../auth/presence' import { seenToday } from '../auth/presence'
import { getLoginWindow } from '../auth/session'
import { presenceOf } from '../collab/state' import { presenceOf } from '../collab/state'
import { db, schema } from '../db' import { db, schema } from '../db'
import { challengeSuccessesOf } from '../services/challenge' import { challengeSuccessesOf } from '../services/challenge'
import { failure, parseBody, success } from '../http' import { failure, parseBody, success } from '../http'
import { isAccepted } from '../judge/status' import { isAccepted } from '../judge/status'
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity' import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
import { accepted } from '../services/learning-stats' import {
import { announceLessonChange, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson' announceLessonChange,
CLASS_ACTIVITY_MIN_USERS,
classDayGroups,
classLessonProblems,
lessonDoneCounts,
resolveLessonProblems,
todayLessonSnapshot,
} from '../services/lesson'
import { calendarDay, dayStart, localTime } from '../time' import { calendarDay, dayStart, localTime } from '../time'
export const classroomRoutes = new Hono<AppEnv>()
/** /**
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的: * 课堂任务(老师):布置这节课的题、看全班做到哪、老师首页的「今天的课」。
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题), * 「这节课是哪几道题」的取法在 services/lesson.ts,学生首页的「班里在做」也用它(routes/home.ts)
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
*/ */
const CLASS_ACTIVITY_MIN_USERS = 5 export const taskRoutes = new Hono<AppEnv>()
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
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 }
}
/** /**
* 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2, * 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2,
@@ -450,7 +50,7 @@ function flowchartCaseCounts() {
} }
/** /**
* 看板上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过 * 任务页上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
* 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」 * 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」
* —— 当堂那份名单把这两种人排在同一行里,老师分不出来。 * —— 当堂那份名单把这两种人排在同一行里,老师分不出来。
*/ */
@@ -459,11 +59,11 @@ const RECENT_LESSONS = 5
const RECENT_LESSONS_WINDOW_DAYS = 45 const RECENT_LESSONS_WINDOW_DAYS = 45
/** /**
* 课堂看板:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、 * 课堂任务:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
* 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一), * 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一),
* 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。 * 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。
*/ */
classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { taskRoutes.get('/task/board', requireTeacher, async (c) => {
const day = calendarDay() const day = calendarDay()
const className = c.req.query('className')?.trim() || (await suggestActiveClass()) const className = c.req.query('className')?.trim() || (await suggestActiveClass())
if (!className) { if (!className) {
@@ -480,10 +80,10 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
problems: [], problems: [],
students: [], students: [],
recentLessons: 0, recentLessons: 0,
} satisfies ClassBoard) } satisfies TaskBoard)
} }
// 推断只看今天(回看 1 天):看板是给这节课用的,昨天的题不该冒出来。 // 推断只看今天(回看 1 天):任务页是给这节课用的,昨天的题不该冒出来。
// lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种) // lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种)
const [lesson, [last]] = await Promise.all([ const [lesson, [last]] = await Promise.all([
classLessonProblems(className, 1), 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))) .where(and(inArray(schema.submission.userId, userIds), gte(schema.submission.createTime, start)))
.groupBy(schema.submission.userId) .groupBy(schema.submission.userId)
: [], : [],
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在看板上是一直没动手。 // 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在任务页上是一直没动手。
// 和上面代码那条同一个范围:今天、所有题、不论评没评完 // 和上面代码那条同一个范围:今天、所有题、不论评没评完
userIds.length userIds.length
? db ? db
@@ -586,7 +186,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
: [], : [],
/** /**
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。 * 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」。 * 有的题这节课整个班都在画流程图,只数代码提交的话,任务页上满屏「没开始」。
* 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算 * 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算
*/ */
ids.length && userIds.length ids.length && userIds.length
@@ -769,18 +369,18 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
editing: presenceOf(student.userId)?.problemId ?? null, editing: presenceOf(student.userId)?.problemId ?? null,
online: seen.get(student.userId)?.online ?? false, online: seen.get(student.userId)?.online ?? false,
seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null, seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null,
}) satisfies ClassBoardStudent, }) satisfies TaskBoardStudent,
), ),
recentLessons: recentDays.length, recentLessons: recentDays.length,
} satisfies ClassBoard) } satisfies TaskBoard)
}) })
/** /**
* 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」 * 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」
* 同一个门槛),或者老师今天在看板上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数 * 同一个门槛),或者老师今天在任务页上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数
* 和看板同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天 * 和任务页同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天
*/ */
classroomRoutes.get('/classroom/today', requireTeacher, async (c) => { taskRoutes.get('/task/today', requireTeacher, async (c) => {
const start = dayStart() const start = dayStart()
const today = calendarDay() const today = calendarDay()
const activity = activityRows({ since: start }) const activity = activityRows({ since: start })
@@ -810,13 +410,13 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
const ids = lesson.problems.map((problem) => problem.id) const ids = lesson.problems.map((problem) => problem.id)
let allDoneCount = 0 let allDoneCount = 0
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) { if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
// 老师布置的:和看板、后台课堂作业同一个口径(要画的看图、要写的看代码) // 老师布置的:和任务页、后台课堂任务同一个口径(要画的看图、要写的看代码)
const counts = await lessonDoneCounts([ const counts = await lessonDoneCounts([
{ id: lesson.lessonId, className, day: calendarDay(), problemIds: ids, language: lesson.language, mode: lesson.mode }, { id: lesson.lessonId, className, day: calendarDay(), problemIds: ids, language: lesson.language, mode: lesson.mode },
]) ])
allDoneCount = counts.get(lesson.lessonId) ?? 0 allDoneCount = counts.get(lesson.lessonId) ?? 0
} else if (ids.length) { } else if (ids.length) {
// 推断出来的:做没做对不限今天(和看板的格子一样:以前做对的也算做完),代码或流程图都算 // 推断出来的:做没做对不限今天(和任务页的格子一样:以前做对的也算做完),代码或流程图都算
const done = activityRows({ const done = activityRows({
since: new Date(0).toISOString(), since: new Date(0).toISOString(),
users: classMemberIds(className), 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()) const className = c.req.query('className')?.trim() || (await suggestActiveClass())
if (!className) if (!className)
return success(c, { return success(c, {
@@ -869,7 +469,7 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
language: null, language: null,
problems: [], problems: [],
} satisfies ClassLesson) } satisfies ClassLesson)
// 和看板一样只回看今天:「这节课」不该冒出昨天的题 // 和任务页一样只回看今天:「这节课」不该冒出昨天的题
const lesson = await classLessonProblems(className, 1) const lesson = await classLessonProblems(className, 1)
// C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language) // C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language)
const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL')) const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL'))
@@ -889,12 +489,12 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节, * 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节,
* 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。 * 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。
*/ */
classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => { taskRoutes.put('/task/lesson', requireTeacher, async (c) => {
const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对') const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对')
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const user = c.get('user')! const user = c.get('user')!
const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data
// 只画流程图不用语言:存成 null,看板和学生那边就不会写「C语言」 // 只画流程图不用语言:存成 null,任务页和学生那边就不会写「C语言」
const language = mode === 'flowchart' ? null : parsed.data.language const language = mode === 'flowchart' ? null : parsed.data.language
const day = calendarDay() const day = calendarDay()
+1 -1
View File
@@ -333,7 +333,7 @@ export async function challengeList(user: { id: number; className: string | null
}) })
} }
/** 课堂看板用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */ /** 课堂任务用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) { export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) {
if (userIds.length === 0 || problemIds.length === 0) return [] if (userIds.length === 0 || problemIds.length === 0) return []
return db return db
+4 -4
View File
@@ -5,8 +5,8 @@ import { db, schema } from '../db'
import { JudgeStatus } from '../judge/status' import { JudgeStatus } from '../judge/status'
/** /**
* 课堂上「谁做了哪道题」的取数口径:课堂条、看板、自学情况、后台首页「正在上课的班」 * 课堂上「谁做了哪道题」的取数口径:课堂条、任务页、自学情况、后台首页「正在上课的班」
* 都从这里数。原来写在 routes/classroom.ts 里,后台 conf.ts 要猜正在上课的班, * 都从这里数。原来写在课堂任务的路由里,后台 conf.ts 要猜正在上课的班,
* 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。 * 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。
*/ */
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */ /** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
@@ -30,7 +30,7 @@ export function classMemberIds(className: string) {
/** /**
* 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计 * 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计
* (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数, * (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数,
* 口径和课堂条 / 看板的「做完」一致: * 口径和课堂条 / 任务页的「做完」一致:
* *
* - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败; * - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败;
* - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。 * - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。
@@ -99,7 +99,7 @@ export function activityRows(options: {
return code.unionAll(flowchart).as('activity') return code.unionAll(flowchart).as('activity')
} }
/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */ /** 任务页没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000 const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
/** /**
+4 -4
View File
@@ -3,10 +3,10 @@
* *
* 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」, * 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」,
* 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。 * 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。
* 一节课的题怎么定,和课堂看板 / 首页「班里在做」同一个口径(routes/classroom.ts): * 一节课的题怎么定,和课堂任务 / 首页「班里在做」同一个口径(services/lesson.ts 的 classLessonProblems):
* 老师在看板布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。 * 老师在任务页布置过就用布置的,否则同班同一天 ≥ 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' 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] 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 const LESSON_MIN_USERS = 5
/** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */ /** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */
+176 -6
View File
@@ -3,12 +3,12 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { db, schema } from '../db' import { db, schema } from '../db'
import { publishLessonUpdate } from '../events' import { publishLessonUpdate } from '../events'
import { calendarDay, dayNumber, dayStart } from '../time' import { calendarDay, dayNumber, dayStart, localTime } from '../time'
import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity' import { activityRows, classMemberIds, FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
/** /**
* 布置的课(class_lesson)几处共用的口径:课堂看板布置、后台「课堂作业」增改、 * 布置的课(class_lesson)几处共用的口径:课堂任务布置、后台「课堂任务」增改、
* 数据统计按布置切课。放在一起,免得看板能布置的题后台布置不上,或者两边「做完」数得不一样。 * 数据统计按布置切课。放在一起,免得任务页能布置的题后台布置不上,或者两边「做完」数得不一样。
*/ */
/** /**
@@ -55,7 +55,7 @@ export function lessonDayEnd(day: string) {
/** /**
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。 * 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。
* 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言 * 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和看板的格子同一个口径。 * (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和任务页的格子同一个口径。
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上 * 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
*/ */
export async function lessonDoneCounts( export async function lessonDoneCounts(
@@ -119,7 +119,7 @@ interface LessonSnapshot {
} }
/** /**
* 学生此刻看到的那一节:这个班今天最后布置的(和 routes/classroom.ts 的 lessonPlan 同一个取法), * 学生此刻看到的那一节:这个班今天最后布置的(和下面 lessonPlan 同一个取法),
* 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比 * 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比
*/ */
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> { 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) 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 }] : []
}),
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function dayNumber(day: string): number {
return Date.UTC(year!, month! - 1, date!) / DAY_MS 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 { export function localDateTime(day: string, time: string): string {
const [hour, minute] = time.split(':').map(Number) const [hour, minute] = time.split(':').map(Number)
return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString() return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString()
+1 -1
View File
@@ -64,7 +64,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
Shared logic lives in `shared/`: 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 源码) - `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 - `layout/` — `default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha) - `api.ts` — shared API calls (auth, profile, tags, captcha)
@@ -8,7 +8,7 @@ import { avgTries, rate } from './stuckMath'
/** /**
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、 * 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
* 还没做对的人)。从 Stuck.vue 拆出来,取数和三栏的联动还在那边。 * 还没做对的人)。从 stuck.vue 拆出来,取数和三栏的联动还在那边。
*/ */
const props = defineProps<{ const props = defineProps<{
detail: StuckProblemDetail detail: StuckProblemDetail
@@ -1,4 +1,4 @@
/** 卡点分析(Stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */ /** 卡点分析(stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
/** 百分比,取整;分母是 0 时给 0 */ /** 百分比,取整;分母是 0 时给 0 */
export function rate(part: number, whole: number) { export function rate(part: number, whole: number) {
File renamed without changes.
File renamed without changes.
+1 -1
View File
@@ -780,7 +780,7 @@ export function deleteAchievement(id: number) {
return api.delete(`admin/achievements/${id}`) return api.delete(`admin/achievements/${id}`)
} }
// ---------- 课堂作业(class_lesson) ---------- // ---------- 课堂任务(class_lesson) ----------
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) { export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) {
return api.get<AdminLessonList>('admin/lessons', { return api.get<AdminLessonList>('admin/lessons', {
File renamed without changes.
@@ -94,7 +94,7 @@ const shortcuts = [
{ label: '新建题目', to: '/admin/problem/create' }, { label: '新建题目', to: '/admin/problem/create' },
{ label: '新建比赛', to: '/admin/contest/create' }, { label: '新建比赛', to: '/admin/contest/create' },
{ label: '批量建号', to: '/admin/user/generate' }, { label: '批量建号', to: '/admin/user/generate' },
{ label: '课堂看板', to: '/classroom' }, { label: '课堂任务', to: '/task' },
] ]
// ---------------------------------------------------------------- 抽一个人 // ---------------------------------------------------------------- 抽一个人
@@ -170,7 +170,7 @@ onUnmounted(stopRolling)
<div class="tile"> <div class="tile">
<span class="label">正在上课</span> <span class="label">正在上课</span>
<b>{{ info.activeClass ? classLabel(info.activeClass) : '没有' }}</b> <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> <span v-else class="note">最近两小时没有哪个班在交题</span>
</div> </div>
</div> </div>
+45 -43
View File
@@ -7,7 +7,7 @@ import type { LearnStudentDetail } from 'utils/types'
import UserName from 'shared/components/UserName.vue' import UserName from 'shared/components/UserName.vue'
import { useBackClose } from 'shared/composables/backClose' import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import { splitTitle, type TutorialType } from 'oj/learn/lesson' import { splitTitle, type TutorialType } from 'oj/learn/utils'
import { EXERCISE_KIND } from './kinds' import { EXERCISE_KIND } from './kinds'
/** /**
@@ -30,31 +30,31 @@ const theme = useThemeVars()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
useBackClose(show, () => !isDesktop.value) useBackClose(show, () => !isDesktop.value)
type Lesson = LearnStudentDetail['lessons'][number] type Tutorial = LearnStudentDetail['tutorials'][number]
type Exercise = Lesson['exercises'][number] type Exercise = Tutorial['exercises'][number]
type Problem = Lesson['problems'][number] type Problem = Tutorial['problems'][number]
const detail = ref<LearnStudentDetail | null>(null) const detail = ref<LearnStudentDetail | null>(null)
const loading = ref(false) const loading = ref(false)
const expanded = ref(new Set<number>()) const expanded = ref(new Set<number>())
const summary = computed(() => { const summary = computed(() => {
const lessons = detail.value?.lessons ?? [] const tutorials = detail.value?.tutorials ?? []
const exercises = lessons.flatMap((lesson) => lesson.exercises) const exercises = tutorials.flatMap((tutorial) => tutorial.exercises)
// 一道题挂在两课只算一次 // 一道题挂在两课只算一次
const problems = new Map(lessons.flatMap((lesson) => lesson.problems).map((p) => [p.id, p])) const problems = new Map(tutorials.flatMap((tutorial) => tutorial.problems).map((p) => [p.id, p]))
return { return {
problemSolved: [...problems.values()].filter((p) => p.solved).length, problemSolved: [...problems.values()].filter((p) => p.solved).length,
problemTotal: problems.size, problemTotal: problems.size,
opened: lessons.filter((lesson) => lesson.viewCount > 0).length, opened: tutorials.filter((tutorial) => tutorial.viewCount > 0).length,
seconds: lessons.reduce((n, lesson) => n + lesson.totalSeconds, 0), seconds: tutorials.reduce((n, tutorial) => n + tutorial.totalSeconds, 0),
solved: exercises.filter((ex) => ex.solved).length, solved: exercises.filter((ex) => ex.solved).length,
total: exercises.length, total: exercises.length,
} }
}) })
function solvedOf(lesson: Lesson) { function solvedOf(tutorial: Tutorial) {
return lesson.exercises.filter((ex) => ex.solved).length return tutorial.exercises.filter((ex) => ex.solved).length
} }
function stateOf(ex: Exercise) { function stateOf(ex: Exercise) {
@@ -92,13 +92,13 @@ watch(
const data = await getLearnStudent(userId, { type }) const data = await getLearnStudent(userId, { type })
detail.value = data detail.value = data
expanded.value = new Set( expanded.value = new Set(
data.lessons data.tutorials
.filter( .filter(
(lesson) => (tutorial) =>
(lesson.exercises.some((ex) => ex.attempts > 0) && lesson.exercises.some((ex) => !ex.solved)) || (tutorial.exercises.some((ex) => ex.attempts > 0) && tutorial.exercises.some((ex) => !ex.solved)) ||
lesson.problems.some((p) => p.submissions > 0 && !p.solved), tutorial.problems.some((p) => p.submissions > 0 && !p.solved),
) )
.map((lesson) => lesson.tutorialId), .map((tutorial) => tutorial.tutorialId),
) )
} catch { } catch {
detail.value = null detail.value = null
@@ -133,40 +133,42 @@ watch(
'--bad': theme.errorColor, '--bad': theme.errorColor,
}" }"
> >
<div v-if="detail && !detail.lessons.length" class="muted none">还没打开过</div> <div v-if="detail && !detail.tutorials.length" class="muted none">还没打开过</div>
<div v-for="lesson in detail?.lessons ?? []" :key="lesson.tutorialId" class="lesson"> <div v-for="tutorial in detail?.tutorials ?? []" :key="tutorial.tutorialId" class="tutorial">
<button type="button" class="lesson-head" @click="toggle(lesson.tutorialId)"> <button type="button" class="tutorial-head" @click="toggle(tutorial.tutorialId)">
<span class="lesson-line"> <span class="tutorial-line">
<span class="lesson-title"> <span class="tutorial-title">
<span class="muted num">第 {{ steps.get(lesson.tutorialId) }} 课</span> <span class="muted num">第 {{ steps.get(tutorial.tutorialId) }} 课</span>
{{ splitTitle(lesson.title).kp }} {{ splitTitle(tutorial.title).kp }}
</span> </span>
<span class="grow" /> <span class="grow" />
<span v-if="lesson.exercises.length" class="small num"> {{ solvedOf(lesson) }}/{{ lesson.exercises.length }} </span> <span v-if="tutorial.exercises.length" class="small num"> {{ solvedOf(tutorial) }}/{{ tutorial.exercises.length }} </span>
<span class="squares"> <span class="squares">
<span v-for="ex in lesson.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" /> <span v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
</span> </span>
<Icon <Icon
v-if="lesson.exercises.length || lesson.problems.length" v-if="tutorial.exercises.length || tutorial.problems.length"
:icon="expanded.has(lesson.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :icon="expanded.has(tutorial.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
:width="12" :width="12"
class="muted" class="muted"
/> />
</span> </span>
<span class="lesson-line small muted num"> <span class="tutorial-line small muted num">
<template v-if="lesson.viewCount"> <template v-if="tutorial.viewCount">
打开 {{ lesson.viewCount }} 次 打开 {{ tutorial.viewCount }} 次
<template v-if="lesson.totalSeconds"> · 停留 {{ readableDuration(lesson.totalSeconds) }}</template> <template v-if="tutorial.totalSeconds"> · 停留 {{ readableDuration(tutorial.totalSeconds) }}</template>
<template v-if="lesson.lastViewedAt"> · {{ parseTime(lesson.lastViewedAt, 'M月D日 HH:mm') }}</template> <template v-if="tutorial.lastViewedAt"> · {{ parseTime(tutorial.lastViewedAt, 'M月D日 HH:mm') }}</template>
</template> </template>
<template v-else>没打开过,直接做的练一练</template> <template v-else>没打开过,直接做的练一练</template>
<template v-if="!lesson.exercises.length"> · 这课没有练一练</template> <template v-if="!tutorial.exercises.length"> · 这课没有练一练</template>
<template v-if="lesson.problems.length"> · 配套题 {{ lesson.problems.filter((p) => p.solved).length }}/{{ lesson.problems.length }}</template> <template v-if="tutorial.problems.length">
· 配套题 {{ tutorial.problems.filter((p) => p.solved).length }}/{{ tutorial.problems.length }}</template
>
</span> </span>
</button> </button>
<div v-if="expanded.has(lesson.tutorialId) && (lesson.exercises.length || lesson.problems.length)" class="ex-list"> <div v-if="expanded.has(tutorial.tutorialId) && (tutorial.exercises.length || tutorial.problems.length)" class="ex-list">
<div v-for="ex in lesson.exercises" :key="ex.exerciseId" class="ex"> <div v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="ex">
<span class="square" :class="stateOf(ex)" /> <span class="square" :class="stateOf(ex)" />
<div class="ex-body"> <div class="ex-body">
<div class="ex-question"> <div class="ex-question">
@@ -183,8 +185,8 @@ watch(
</div> </div>
</div> </div>
</div> </div>
<div v-if="lesson.problems.length" class="sub-head small muted">配套题</div> <div v-if="tutorial.problems.length" class="sub-head small muted">配套题</div>
<div v-for="problem in lesson.problems" :key="problem.id" class="ex"> <div v-for="problem in tutorial.problems" :key="problem.id" class="ex">
<span class="square" :class="problemStateOf(problem)" /> <span class="square" :class="problemStateOf(problem)" />
<div class="ex-body"> <div class="ex-body">
<div class="ex-question"> <div class="ex-question">
@@ -235,13 +237,13 @@ watch(
padding: 24px 0; padding: 24px 0;
text-align: center; text-align: center;
} }
.lesson { .tutorial {
border-top: 1px solid rgba(128, 128, 128, 0.15); border-top: 1px solid rgba(128, 128, 128, 0.15);
} }
.lesson:first-child { .tutorial:first-child {
border-top: 0; border-top: 0;
} }
.lesson-head { .tutorial-head {
width: 100%; width: 100%;
padding: 12px 0; padding: 12px 0;
border: 0; border: 0;
@@ -254,13 +256,13 @@ watch(
gap: 4px; gap: 4px;
cursor: pointer; cursor: pointer;
} }
.lesson-line { .tutorial-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-width: 0; min-width: 0;
} }
.lesson-title { .tutorial-title {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
min-width: 0; min-width: 0;
+44 -44
View File
@@ -7,7 +7,7 @@ import { parseTime, readableDuration } from 'utils/functions'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import UserName from 'shared/components/UserName.vue' import UserName from 'shared/components/UserName.vue'
import { groupClassOptions } from 'utils/classes' import { groupClassOptions } from 'utils/classes'
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson' import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
import 'oj/learn/learn.css' import 'oj/learn/learn.css'
import ExerciseAttempts from './ExerciseAttempts.vue' import ExerciseAttempts from './ExerciseAttempts.vue'
import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue' import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue'
@@ -30,7 +30,7 @@ const type = ref<TutorialType>('python')
const className = ref<string | null>(null) const className = ref<string | null>(null)
const board = ref<LearnBoard | null>(null) const board = ref<LearnBoard | null>(null)
const exercises = ref<LearnExerciseProgress[]>([]) const exercises = ref<LearnExerciseProgress[]>([])
const lessonId = ref<number | null>(null) const selectedTutorialId = ref<number | null>(null)
const expanded = ref<number | null>(null) const expanded = ref<number | null>(null)
const side = ref<'exercise' | 'problem'>('exercise') const side = ref<'exercise' | 'problem'>('exercise')
const problemOpen = ref<number | null>(null) const problemOpen = ref<number | null>(null)
@@ -58,32 +58,32 @@ const openedCount = computed(() => {
} }
return counts return counts
}) })
const lessonProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems]))) const tutorialProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])))
/** 学生 → 题 → 交过的情况 */ /** 学生 → 题 → 交过的情况 */
const problemStates = computed( const problemStates = computed(
() => new Map((board.value?.students ?? []).map((row) => [row.userId, new Map(row.problems.map((problem) => [problem.problemId, problem]))])), () => new Map((board.value?.students ?? []).map((row) => [row.userId, new Map(row.problems.map((problem) => [problem.problemId, problem]))])),
) )
const exerciseTotal = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal]))) const exerciseTotal = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])))
const steps = computed(() => new Map(groups.value.flatMap((group) => group.lessons.map((lesson) => [lesson.id, lesson.step])))) const steps = computed(() => new Map(groups.value.flatMap((group) => group.tutorials.map((tutorial) => [tutorial.id, tutorial.step]))))
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values())) const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
const lessonOptions = computed(() => const tutorialOptions = computed(() =>
groups.value.flatMap((group) => groups.value.flatMap((group) =>
group.lessons.map((lesson) => ({ group.tutorials.map((tutorial) => ({
label: `第 ${lesson.step} 课 ${splitTitle(lesson.title).kp}`, label: `第 ${tutorial.step} 课 ${splitTitle(tutorial.title).kp}`,
value: lesson.id, value: tutorial.id,
})), })),
), ),
) )
const lessonExercises = computed(() => exercises.value.filter((row) => row.tutorialId === lessonId.value)) const tutorialExercises = computed(() => exercises.value.filter((row) => row.tutorialId === selectedTutorialId.value))
/** 读过(或做过练一练)这一课的人 */ /** 读过(或做过练一练)这一课的人 */
function readersOf(id: number) { function readersOf(id: number) {
return started.value.filter((row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id)) return started.value.filter((row) => row.opened.includes(id) || row.tutorials.some((tutorial) => tutorial.tutorialId === id))
} }
const sideProblems = computed(() => { const sideProblems = computed(() => {
const id = lessonId.value const id = selectedTutorialId.value
if (id === null) return [] if (id === null) return []
const readers = readersOf(id) const readers = readersOf(id)
return (lessonProblems.value.get(id) ?? []).map((problem) => { return (tutorialProblems.value.get(id) ?? []).map((problem) => {
const people: ProblemReader[] = readers.map((row) => ({ const people: ProblemReader[] = readers.map((row) => ({
userId: row.userId, userId: row.userId,
username: row.username, username: row.username,
@@ -98,43 +98,43 @@ const sideProblems = computed(() => {
}) })
const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value)) const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value))
function lessonOf(row: LearnBoardStudent, id: number) { function tutorialOf(row: LearnBoardStudent, id: number) {
return row.lessons.find((lesson) => lesson.tutorialId === id) return row.tutorials.find((tutorial) => tutorial.tutorialId === id)
} }
function cellOf(row: LearnBoardStudent, id: number) { function cellOf(row: LearnBoardStudent, id: number) {
if (row.done.includes(id)) return 'done' if (row.done.includes(id)) return 'done'
const lesson = lessonOf(row, id) const tutorial = tutorialOf(row, id)
if (lesson) return lesson.solved ? 'partial' : 'partial stuck' if (tutorial) return tutorial.solved ? 'partial' : 'partial stuck'
if (row.opened.includes(id)) return 'opened' if (row.opened.includes(id)) return 'opened'
return '' return ''
} }
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */ /** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
function cellStyle(row: LearnBoardStudent, id: number) { function cellStyle(row: LearnBoardStudent, id: number) {
const lesson = lessonOf(row, id) const tutorial = tutorialOf(row, id)
const total = exerciseTotal.value.get(id) ?? 0 const total = exerciseTotal.value.get(id) ?? 0
if (!lesson || !total || row.done.includes(id)) return undefined if (!tutorial || !total || row.done.includes(id)) return undefined
return { '--fill': `${Math.round((lesson.solved / total) * 100)}%` } return { '--fill': `${Math.round((tutorial.solved / total) * 100)}%` }
} }
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) { function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
const head = `第 ${step} 课 ${splitTitle(title).kp}` const head = `第 ${step} 课 ${splitTitle(title).kp}`
const total = exerciseTotal.value.get(id) ?? 0 const total = exerciseTotal.value.get(id) ?? 0
const lesson = lessonOf(row, id) const tutorial = tutorialOf(row, id)
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : '' const wrong = tutorial?.wrongAttempts ? `,错了 ${tutorial.wrongAttempts} 次` : ''
const problems = lessonProblems.value.get(id) ?? [] const problems = tutorialProblems.value.get(id) ?? []
const states = problemStates.value.get(row.userId) const states = problemStates.value.get(row.userId)
const tail = problems.length ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` : '' const tail = problems.length ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` : ''
if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}` if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}`
if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}${tail}` if (tutorial) return `${head}:练一练 ${tutorial.solved}/${total}${wrong}${tail}`
if (row.opened.includes(id)) return `${head}:${total ? '打开过,练一练没做' : '打开过'}${tail}` if (row.opened.includes(id)) return `${head}:${total ? '打开过,练一练没做' : '打开过'}${tail}`
return `${head}:没打开` return `${head}:没打开`
} }
/** 他打开过或做过练一练的那些课 */ /** 他打开过或做过练一练的那些课 */
function touched(row: LearnBoardStudent) { function touched(row: LearnBoardStudent) {
return new Set([...row.opened, ...row.lessons.map((lesson) => lesson.tutorialId)]) return new Set([...row.opened, ...row.tutorials.map((tutorial) => tutorial.tutorialId)])
} }
/** 应做 = 他碰过的那些课,练一练加起来几道 */ /** 应做 = 他碰过的那些课,练一练加起来几道 */
@@ -148,7 +148,7 @@ function exerciseDue(row: LearnBoardStudent) {
function problemCount(row: LearnBoardStudent) { function problemCount(row: LearnBoardStudent) {
const ids = new Set<number>() const ids = new Set<number>()
for (const id of touched(row)) { for (const id of touched(row)) {
for (const problem of lessonProblems.value.get(id) ?? []) ids.add(problem.id) for (const problem of tutorialProblems.value.get(id) ?? []) ids.add(problem.id)
} }
const states = problemStates.value.get(row.userId) const states = problemStates.value.get(row.userId)
let solved = 0 let solved = 0
@@ -180,7 +180,7 @@ async function load() {
exercises.value = ex.results exercises.value = ex.results
// 练一练默认看大家打开得最多的那课 // 练一练默认看大家打开得最多的那课
const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0] const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0]
lessonId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null selectedTutorialId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -235,11 +235,11 @@ onMounted(load)
<div class="line head"> <div class="line head">
<span class="c-name strong">学到哪了</span> <span class="c-name strong">学到哪了</span>
<div class="cells-head"> <div class="cells-head">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-head"> <div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-head">
<span class="group-no" :title="group.project">{{ group.no || group.name }}</span> <span class="group-no" :title="group.project">{{ group.no || group.name }}</span>
<span class="group-steps"> <span class="group-steps">
<span v-for="lesson in group.lessons" :key="lesson.id" class="step" :title="splitTitle(lesson.title).kp"> <span v-for="tutorial in group.tutorials" :key="tutorial.id" class="step" :title="splitTitle(tutorial.title).kp">
{{ lesson.step }} {{ tutorial.step }}
</span> </span>
</span> </span>
</div> </div>
@@ -254,14 +254,14 @@ onMounted(load)
<UserName :username="row.username" /> <UserName :username="row.username" />
</button> </button>
<div class="cells"> <div class="cells">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells"> <div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
<span <span
v-for="lesson in group.lessons" v-for="tutorial in group.tutorials"
:key="lesson.id" :key="tutorial.id"
class="cell" class="cell"
:class="cellOf(row, lesson.id)" :class="cellOf(row, tutorial.id)"
:style="cellStyle(row, lesson.id)" :style="cellStyle(row, tutorial.id)"
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)" :title="cellTitle(row, tutorial.step, tutorial.title, tutorial.id)"
/> />
</div> </div>
</div> </div>
@@ -284,14 +284,14 @@ onMounted(load)
<div v-else class="line foot"> <div v-else class="line foot">
<span class="c-name muted small">每课打开过</span> <span class="c-name muted small">每课打开过</span>
<div class="cells bars"> <div class="cells bars">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells"> <div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
<span v-for="lesson in group.lessons" :key="lesson.id" class="bar-col"> <span v-for="tutorial in group.tutorials" :key="tutorial.id" class="bar-col">
<span class="bar-num num">{{ openedCount.get(lesson.id) || '' }}</span> <span class="bar-num num">{{ openedCount.get(tutorial.id) || '' }}</span>
<span <span
class="bar" class="bar"
:class="{ zero: !openedCount.get(lesson.id) }" :class="{ zero: !openedCount.get(tutorial.id) }"
:style="{ :style="{
height: `${Math.max(2, ((openedCount.get(lesson.id) ?? 0) / maxOpened) * 40)}px`, height: `${Math.max(2, ((openedCount.get(tutorial.id) ?? 0) / maxOpened) * 40)}px`,
}" }"
/> />
</span> </span>
@@ -316,7 +316,7 @@ onMounted(load)
<button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button> <button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button>
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button> <button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button>
</div> </div>
<n-select v-model:value="lessonId" :options="lessonOptions" size="small" class="lesson-select" /> <n-select v-model:value="selectedTutorialId" :options="tutorialOptions" size="small" class="tutorial-select" />
</div> </div>
<template v-if="side === 'problem'"> <template v-if="side === 'problem'">
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div> <div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
@@ -333,8 +333,8 @@ onMounted(load)
</button> </button>
</div> </div>
</template> </template>
<div v-else-if="!lessonExercises.length" class="muted none">这课没有练一练</div> <div v-else-if="!tutorialExercises.length" class="muted none">这课没有练一练</div>
<div v-for="row in side === 'exercise' ? lessonExercises : []" :key="row.exerciseId" class="ex"> <div v-for="row in side === 'exercise' ? tutorialExercises : []" :key="row.exerciseId" class="ex">
<button type="button" class="ex-main" @click="expanded = row.exerciseId"> <button type="button" class="ex-main" @click="expanded = row.exerciseId">
<span class="ex-line"> <span class="ex-line">
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span> <span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
@@ -711,7 +711,7 @@ onMounted(load)
.class-select { .class-select {
width: 170px; width: 170px;
} }
.lesson-select { .tutorial-select {
flex: 1 0 100%; flex: 1 0 100%;
min-width: 0; min-width: 0;
} }
@@ -12,11 +12,11 @@ import { errorCode, errorMessage, isApiError } from 'utils/api'
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants' import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
import { createZipBlob, getTagColor, unique } from 'utils/functions' import { createZipBlob, getTagColor, unique } from 'utils/functions'
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types' import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
import PreviewStrip from './components/PreviewStrip.vue' import PreviewStrip from './PreviewStrip.vue'
import { usePreviewCollapsed } from './components/previewCollapse' import { usePreviewCollapsed } from './previewCollapse'
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../api' import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../../api'
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './components/problemForm' import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './problemForm'
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './components/sqlScript' import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './sqlScript'
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue')) const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */ /** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
+1 -1
View File
@@ -3,7 +3,7 @@ import { isSqlProblem } from '@oj2/contract'
import type { AdminProblem } from 'utils/types' import type { AdminProblem } from 'utils/types'
import { getProblem } from '../api' import { getProblem } from '../api'
import ProblemDetail from './detail.vue' import ProblemDetail from './detail.vue'
import SqlDetail from './SqlDetail.vue' import SqlDetail from './components/SqlDetail.vue'
/** /**
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。 * 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract' import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract'
import ProblemPicker from 'oj/classroom/components/ProblemPicker.vue' import ProblemPicker from 'oj/task/components/ProblemPicker.vue'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import { groupClassOptions } from 'utils/classes' import { groupClassOptions } from 'utils/classes'
@@ -10,8 +10,8 @@ import { parseTime } from 'utils/functions'
import { createAdminLesson, updateAdminLesson } from '../../api' import { createAdminLesson, updateAdminLesson } from '../../api'
/** /**
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 看板上点错了班、隔天才想起来布置, * 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 任务页上点错了班、隔天才想起来布置,
* 都在这里纠正;补的是看板外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑 * 都在这里纠正;补的是任务页外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
*/ */
const props = defineProps<{ const props = defineProps<{
/** null = 补一节 */ /** null = 补一节 */
@@ -54,7 +54,7 @@ function inputIds() {
return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean) return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean)
} }
// ---- 选题弹框,和看板上那个是同一个 ---- // ---- 选题弹框,和任务页上那个是同一个 ----
const picking = ref(false) const picking = ref(false)
const pickerInitial = ref<{ id: string; title?: string }[]>([]) const pickerInitial = ref<{ id: string; title?: string }[]>([])
function openPicker() { function openPicker() {
@@ -17,8 +17,8 @@ import { deleteAdminLesson, getAdminLessons } from '../api'
import LessonEditModal from './components/LessonEditModal.vue' import LessonEditModal from './components/LessonEditModal.vue'
/** /**
* 后台「课堂作业」:布置过的每一节课一行,新的在前。课堂看板上当场布置的都在这里, * 后台「课堂任务」:布置过的每一节课一行,新的在前。课堂任务上当场布置的都在这里,
* 布置错了能改、删;看板外上的课能补一节 —— 数据统计按布置切课,补了才认得到 * 布置错了能改、删;任务页外上的课能补一节 —— 数据统计按布置切课,补了才认得到
*/ */
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
@@ -26,7 +26,7 @@ const theme = useThemeVars()
const configStore = useConfigStore() const configStore = useConfigStore()
const { isMobile } = useBreakpoints() const { isMobile } = useBreakpoints()
// 从看板「布置过的课」进来带着 ?className= // 从任务页「布置过的课」进来带着 ?className=
const { query } = usePagination<{ className: string; teacher: string }>({ const { query } = usePagination<{ className: string; teacher: string }>({
className: useRouteQuery('className', '').value, className: useRouteQuery('className', '').value,
teacher: useRouteQuery('teacher', '').value, teacher: useRouteQuery('teacher', '').value,
@@ -163,7 +163,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
</script> </script>
<template> <template>
<PageHeader title="课堂作业"> <PageHeader title="课堂任务">
<template #extra> <template #extra>
<n-text v-if="data" depth="3">{{ data.total }} 节</n-text> <n-text v-if="data" depth="3">{{ data.total }} 节</n-text>
</template> </template>
+6 -6
View File
@@ -4,9 +4,9 @@ import type { AdminTutorialProblem } from '@oj2/contract'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import MarkdownEditor from 'shared/components/MarkdownEditor.vue' import MarkdownEditor from 'shared/components/MarkdownEditor.vue'
import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types' import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types'
import { parseLesson } from 'oj/learn/composables/useLessonParse' import { parseTutorial } from 'oj/learn/composables/useTutorialParse'
import { joinTitle, lessonPath, splitTitle } from 'oj/learn/lesson' import { joinTitle, tutorialPath, splitTitle } from 'oj/learn/utils'
import LessonBody from 'oj/learn/components/LessonBody.vue' import TutorialBody from 'oj/learn/components/TutorialBody.vue'
import 'oj/learn/learn.css' import 'oj/learn/learn.css'
import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api' import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api'
import ExerciseManager from './components/ExerciseManager.vue' import ExerciseManager from './components/ExerciseManager.vue'
@@ -69,7 +69,7 @@ const heading = computed(() => {
const label = tutorial.type === 'c' ? 'C 语言' : 'Python' const label = tutorial.type === 'c' ? 'C 语言' : 'Python'
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开` return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
}) })
const preview = computed(() => parseLesson(tutorial.content, exercises.value)) const preview = computed(() => parseTutorial(tutorial.content, exercises.value))
async function init() { async function init() {
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] })) allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
@@ -131,7 +131,7 @@ function insertExercise(id: number) {
} }
function openStudentView() { function openStudentView() {
if (step.value) window.open(router.resolve(lessonPath(tutorial.type, step.value)).href, '_blank') if (step.value) window.open(router.resolve(tutorialPath(tutorial.type, step.value)).href, '_blank')
} }
watch(() => props.tutorialID, init) watch(() => props.tutorialID, init)
@@ -199,7 +199,7 @@ onMounted(init)
<MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" /> <MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" />
</div> </div>
<div class="preview"> <div class="preview">
<LessonBody <TutorialBody
:segments="preview.segments" :segments="preview.segments"
:lang="tutorial.type" :lang="tutorial.type"
:tutorial-id="tutorial.id" :tutorial-id="tutorial.id"
+6 -6
View File
@@ -2,7 +2,7 @@
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import type { TutorialListItem } from 'utils/types' import type { TutorialListItem } from 'utils/types'
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson' import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
import ProjectTile from 'oj/learn/components/ProjectTile.vue' import ProjectTile from 'oj/learn/components/ProjectTile.vue'
import 'oj/learn/learn.css' import 'oj/learn/learn.css'
import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api' import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api'
@@ -28,11 +28,11 @@ const list = computed(() => all.value[type.value] ?? [])
const groups = computed(() => const groups = computed(() =>
groupByProject(list.value) groupByProject(list.value)
.map((group) => { .map((group) => {
const items = group.lessons.map((lesson) => list.value.find((item) => item.id === lesson.id)!) const items = group.tutorials.map((entry) => list.value.find((item) => item.id === entry.id)!)
return { return {
...group, ...group,
items, items,
key: `${group.project}:${group.lessons[0]!.id}`, key: `${group.project}:${group.tutorials[0]!.id}`,
hidden: items.every((item) => !item.isPublic), hidden: items.every((item) => !item.isPublic),
} }
}) })
@@ -189,7 +189,7 @@ onMounted(load)
<div <div
v-for="item in group.items" v-for="item in group.items"
:key="item.id" :key="item.id"
class="row lesson" class="row tutorial"
:class="{ off: !item.isPublic, dragging: dragId === item.id }" :class="{ off: !item.isPublic, dragging: dragId === item.id }"
draggable="true" draggable="true"
@dragstart="dragId = item.id" @dragstart="dragId = item.id"
@@ -330,10 +330,10 @@ onMounted(load)
.group-name { .group-name {
font-weight: 700; font-weight: 700;
} }
.lesson.off { .tutorial.off {
opacity: 0.6; opacity: 0.6;
} }
.lesson.dragging { .tutorial.dragging {
opacity: 0.35; opacity: 0.35;
} }
.handle { .handle {
+8 -8
View File
@@ -26,7 +26,7 @@ import {
type Tutorial, type Tutorial,
type ClassToday, type ClassToday,
type LastVisit, type LastVisit,
type ClassBoard, type TaskBoard,
type ClassLesson, type ClassLesson,
type KnowledgeMap, type KnowledgeMap,
type ClassBattleItem, type ClassBattleItem,
@@ -251,14 +251,14 @@ export function getWeeklyRank(scope: 'global' | 'class') {
return api.get<WeeklyRank>('rankings/weekly', { params: { scope } }) return api.get<WeeklyRank>('rankings/weekly', { params: { scope } })
} }
/** 课堂看板(老师)。不给班级时后端猜最近两小时在交题的那个班 */ /** 课堂任务(老师)。不给班级时后端猜最近两小时在交题的那个班 */
export function getClassBoard(className?: string) { export function getTaskBoard(className?: string) {
return api.get<ClassBoard>('classroom/board', { params: { className } }) return api.get<TaskBoard>('task/board', { params: { className } })
} }
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和看板一样 */ /** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和任务页一样 */
export function getClassLesson(className?: string) { 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) { 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, className,
problemDisplayIds, problemDisplayIds,
language, language,
@@ -282,7 +282,7 @@ export function getKnowledgeMap() {
/** 老师首页「今天的课」:今天在上课的每个班 */ /** 老师首页「今天的课」:今天在上课的每个班 */
export function getClassToday() { export function getClassToday() {
return api.get<ClassToday>('classroom/today') return api.get<ClassToday>('task/today')
} }
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */ /** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
+2 -2
View File
@@ -18,7 +18,7 @@ import { useVisibleInterval } from 'shared/composables/visibleInterval'
* *
* 名字和排行榜一样用用户名(ks241XXX),不用真名。 * 名字和排行榜一样用用户名(ks241XXX),不用真名。
* 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面, * 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面,
* 没进来的人在最下面(看板里标了请假的写「请假」)。 * 没进来的人在最下面(课堂任务里标了请假的写「请假」)。
* 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。 * 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。
* 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 buildClassView) * 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 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 v-for="p in problems" :key="p.id"></span>
<span></span> <span></span>
<span class="flag-wrap"> <span class="flag-wrap">
<span class="flag grey">{{ user.leave ? '请假(看板里标的)' : '没进来' }}</span> <span class="flag grey">{{ user.leave ? '请假(课堂任务里标的)' : '没进来' }}</span>
</span> </span>
</div> </div>
</template> </template>
+5 -5
View File
@@ -4,23 +4,23 @@ import storage from 'utils/storage'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份 // 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
const Dashboard = defineAsyncComponent(() => import('./dashboard.vue')) const StudentHome = defineAsyncComponent(() => import('./student.vue'))
// 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课 // 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课
const TeacherHome = defineAsyncComponent(() => import('./TeacherHome.vue')) const TeacherHome = defineAsyncComponent(() => import('./teacher.vue'))
const ProblemList = defineAsyncComponent(() => import('oj/problem/list.vue')) const ProblemList = defineAsyncComponent(() => import('oj/problem/list.vue'))
const userStore = useUserStore() const userStore = useUserStore()
// profile 回来之前先按上次的登录标记猜一个:干等的话 /profile 一失败首页就是 // profile 回来之前先按上次的登录标记猜一个:干等的话 /profile 一失败首页就是
// 白的,而先按「没登录」渲染又会让每个学生都先闪一下题目列表 // 白的,而先按「没登录」渲染又会让每个学生都先闪一下题目列表
const showDashboard = computed(() => (userStore.isFinished ? userStore.isAuthed : !!storage.get(STORAGE_KEY.AUTHED))) const showHome = computed(() => (userStore.isFinished ? userStore.isAuthed : !!storage.get(STORAGE_KEY.AUTHED)))
</script> </script>
<template> <template>
<template v-if="showDashboard"> <template v-if="showHome">
<!-- 资料回来之前分不出是不是老师,先空着,免得老师先闪一下学生的首页 --> <!-- 资料回来之前分不出是不是老师,先空着,免得老师先闪一下学生的首页 -->
<TeacherHome v-if="userStore.isFinished && userStore.isTeacherOrAbove" /> <TeacherHome v-if="userStore.isFinished && userStore.isTeacherOrAbove" />
<Dashboard v-else-if="userStore.isFinished" /> <StudentHome v-else-if="userStore.isFinished" />
</template> </template>
<ProblemList v-else /> <ProblemList v-else />
</template> </template>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } from 'oj/learn/lesson' import { continueStep, doneCount, tutorialPath, splitTitle, type TutorialType } from 'oj/learn/utils'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api' import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
import { getClassActivity } from 'shared/api' import { getClassActivity } from 'shared/api'
@@ -74,7 +74,7 @@ const weeklyText = computed(() => {
}) })
/** /**
* 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/lesson.ts),不是本机记的: * 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/utils.ts),不是本机记的:
* 机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。 * 机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
*/ */
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> { async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
@@ -187,7 +187,7 @@ const solvedText = computed(() => (visit.value?.solved ?? []).map((item) => `${i
const classActivityTitle = computed(() => { const classActivityTitle = computed(() => {
const day = classActivity.value?.day const day = classActivity.value?.day
if (!day) return '' if (!day) return ''
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道 // 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
if (classActivity.value?.source === 'teacher') { if (classActivity.value?.source === 'teacher') {
const language = classActivity.value.language const language = classActivity.value.language
const mode = classActivity.value.mode const mode = classActivity.value.mode
@@ -243,7 +243,7 @@ watch(
) )
function learnLink(track: Track) { function learnLink(track: Track) {
return lessonPath(track.type, track.step) return tutorialPath(track.type, track.step)
} }
function openSubmission(row: SubmissionListItem) { function openSubmission(row: SubmissionListItem) {
@@ -10,7 +10,7 @@ import { parseTime, zonedParts, zonedYear } from 'utils/functions'
/** /**
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交), * 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、 * 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。 * 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂任务」进那个班。
* 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前) * 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前)
*/ */
const theme = useThemeVars() const theme = useThemeVars()
@@ -61,13 +61,13 @@ function metaOf(row: ClassRow) {
return parts.join(' · ') return parts.join(' · ')
} }
function openBoard(row: ClassRow) { function openTask(row: ClassRow) {
router.push({ name: 'classroom board', query: { className: row.className } }) router.push({ name: 'task', query: { className: row.className } })
} }
const shortcuts = computed(() => const shortcuts = computed(() =>
[ [
{ label: '课堂看板', to: '/classroom', show: true }, { label: '课堂任务', to: '/task', show: true },
{ label: '数据统计', to: '/statistics', show: true }, { label: '数据统计', to: '/statistics', show: true },
{ label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission }, { label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission },
{ label: '后台', to: userStore.isSuperAdmin ? '/admin' : '/admin/problem/list', show: true }, { 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="state" :class="{ live: stateOf(row).live }">{{ stateOf(row).text }}</span>
<span class="muted">{{ metaOf(row) }}</span> <span class="muted">{{ metaOf(row) }}</span>
<span class="spacer"></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>
<div v-if="row.problems.length" class="minis"> <div v-if="row.problems.length" class="minis">
<router-link <router-link
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import type { ProjectGroup } from '../lesson' import type { ProjectGroup } from '../utils'
/** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */ /** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */
defineProps<{ icon: string; tone: ProjectGroup['tone']; size?: number }>() defineProps<{ icon: string; tone: ProjectGroup['tone']; size?: number }>()
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import type { TutorialProgress } from '@oj2/contract' import type { TutorialProgress } from '@oj2/contract'
import { lessonPath, lessonState, type ProjectGroup, type TutorialType } from '../lesson' import { tutorialPath, tutorialState, type ProjectGroup, type TutorialType } from '../utils'
import LessonRow from './LessonRow.vue' import TutorialRow from './TutorialRow.vue'
import ProjectTile from './ProjectTile.vue' import ProjectTile from './ProjectTile.vue'
/** /**
@@ -21,14 +21,14 @@ const emit = defineEmits<{ go: [] }>()
const open = ref(new Set<string>()) const open = ref(new Set<string>())
function keyOf(group: ProjectGroup) { function keyOf(group: ProjectGroup) {
return `${group.project}:${group.lessons[0]!.id}` return `${group.project}:${group.tutorials[0]!.id}`
} }
// 换课换到别的项目时,把那个项目也展开(别的已经展开的不收) // 换课换到别的项目时,把那个项目也展开(别的已经展开的不收)
watch( watch(
() => [props.groups, props.currentStep] as const, () => [props.groups, props.currentStep] as const,
([groups, step]) => { ([groups, step]) => {
const group = groups.find((item) => item.lessons.some((lesson) => lesson.step === step)) const group = groups.find((item) => item.tutorials.some((tutorial) => tutorial.step === step))
if (group) open.value = new Set([...open.value, keyOf(group)]) if (group) open.value = new Set([...open.value, keyOf(group)])
}, },
{ immediate: true }, { immediate: true },
@@ -43,7 +43,7 @@ function toggle(group: ProjectGroup) {
} }
function doneIn(group: ProjectGroup) { function doneIn(group: ProjectGroup) {
return group.lessons.filter((lesson) => lessonState(props.progress[lesson.id]) === 'done').length return group.tutorials.filter((tutorial) => tutorialState(props.progress[tutorial.id]) === 'done').length
} }
</script> </script>
@@ -62,23 +62,23 @@ function doneIn(group: ProjectGroup) {
{{ group.name }} {{ group.name }}
</span> </span>
</span> </span>
<span v-if="doneIn(group) === group.lessons.length" class="count all"> <span v-if="doneIn(group) === group.tutorials.length" class="count all">
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.lessons.length }} <Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.tutorials.length }}
</span> </span>
<span v-else class="count">{{ doneIn(group) }}/{{ group.lessons.length }}</span> <span v-else class="count">{{ doneIn(group) }}/{{ group.tutorials.length }}</span>
<span v-if="big" class="chev"> <span v-if="big" class="chev">
<Icon :icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :width="14" /> <Icon :icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :width="14" />
</span> </span>
</button> </button>
<div v-if="open.has(keyOf(group))" class="lessons"> <div v-if="open.has(keyOf(group))" class="tutorials">
<LessonRow <TutorialRow
v-for="lesson in group.lessons" v-for="tutorial in group.tutorials"
:key="lesson.id" :key="tutorial.id"
:title="lesson.title" :title="tutorial.title"
:step="lesson.step" :step="tutorial.step"
:to="lessonPath(type, lesson.step)" :to="tutorialPath(type, tutorial.step)"
:progress="progress[lesson.id]" :progress="progress[tutorial.id]"
:active="lesson.step === currentStep" :active="tutorial.step === currentStep"
:height="big ? 50 : 46" :height="big ? 50 : 46"
@go="emit('go')" @go="emit('go')"
/> />
@@ -160,13 +160,13 @@ function doneIn(group: ProjectGroup) {
color: var(--learn-green-text); color: var(--learn-green-text);
font-weight: 600; font-weight: 600;
} }
.lessons { .tutorials {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
padding-left: 6px; padding-left: 6px;
} }
.big .lessons { .big .tutorials {
padding: 0 0 8px; padding: 0 0 8px;
} }
</style> </style>
+6 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LessonState, ProjectGroup } from '../lesson' import type { TutorialState, ProjectGroup } from '../utils'
/** /**
* 一门课的进度条:一课一小段,按项目分组隔开。绿 = 学完,橙 = 在学,灰 = 没开始, * 一门课的进度条:一课一小段,按项目分组隔开。绿 = 学完,橙 = 在学,灰 = 没开始,
@@ -7,7 +7,7 @@ import type { LessonState, ProjectGroup } from '../lesson'
*/ */
defineProps<{ defineProps<{
groups: ProjectGroup[] groups: ProjectGroup[]
stateOf: (id: number) => LessonState stateOf: (id: number) => TutorialState
current?: number current?: number
/** 每段的宽、高 */ /** 每段的宽、高 */
width?: number width?: number
@@ -19,12 +19,12 @@ defineProps<{
<template> <template>
<div class="seg" :style="{ gap: `${groupGap ?? 10}px` }" aria-hidden="true"> <div class="seg" :style="{ gap: `${groupGap ?? 10}px` }" aria-hidden="true">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group" :style="{ gap: `${gap ?? 3}px` }"> <div v-for="group in groups" :key="group.tutorials[0]!.id" class="group" :style="{ gap: `${gap ?? 3}px` }">
<span <span
v-for="lesson in group.lessons" v-for="tutorial in group.tutorials"
:key="lesson.id" :key="tutorial.id"
class="cell" class="cell"
:class="[stateOf(lesson.id), { current: lesson.id === current }]" :class="[stateOf(tutorial.id), { current: tutorial.id === current }]"
:style="{ :style="{
width: `${width ?? 20}px`, width: `${width ?? 20}px`,
height: `${height ?? 6}px`, height: `${height ?? 6}px`,
@@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import Markdown from 'shared/components/Markdown.vue' import Markdown from 'shared/components/Markdown.vue'
import type { LessonSegment } from '../composables/useLessonParse' import type { TutorialSegment } from '../composables/useTutorialParse'
import CodeRunBlock from './CodeRunBlock.vue' import CodeRunBlock from './CodeRunBlock.vue'
const ExerciseWidget = defineAsyncComponent(() => import('./ExerciseWidget.vue')) const ExerciseWidget = defineAsyncComponent(() => import('./ExerciseWidget.vue'))
/** 一课的正文:按 useLessonParse 拆好的段落逐段渲染 */ /** 一课的正文:按 useTutorialParse 拆好的段落逐段渲染 */
defineProps<{ defineProps<{
segments: LessonSegment[] segments: TutorialSegment[]
lang: 'python' | 'c' lang: 'python' | 'c'
tutorialId: number tutorialId: number
exerciseCount: number exerciseCount: number
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import type { LessonState } from '../lesson' import type { TutorialState } from '../utils'
/** 目录里每课前面的圆:学完打勾、在学橙圈、当前绿圈、没开始灰圈 */ /** 目录里每课前面的圆:学完打勾、在学橙圈、当前绿圈、没开始灰圈 */
defineProps<{ step: number; state: LessonState; current?: boolean }>() defineProps<{ step: number; state: TutorialState; current?: boolean }>()
</script> </script>
<template> <template>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import type { TutorialProblem } from '@oj2/contract' import type { TutorialProblem } from '@oj2/contract'
import { splitTitle, type LessonState, type ProjectGroup } from '../lesson' import { splitTitle, type TutorialState, type ProjectGroup } from '../utils'
import SegBar from './SegBar.vue' import SegBar from './SegBar.vue'
/** /**
@@ -19,7 +19,7 @@ const props = defineProps<{
problems: TutorialProblem[] problems: TutorialProblem[]
next: { title: string; to: string } | null next: { title: string; to: string } | null
groups: ProjectGroup[] groups: ProjectGroup[]
stateOf: (id: number) => LessonState stateOf: (id: number) => TutorialState
doneCount: number doneCount: number
/** 窄屏:进度条短一点、题号挪到题目名下面 */ /** 窄屏:进度条短一点、题号挪到题目名下面 */
compact?: boolean compact?: boolean
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import type { OutlineItem } from '../composables/useLessonParse' import type { OutlineItem } from '../composables/useTutorialParse'
/** /**
* 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾), * 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾),
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TutorialProgress } from '@oj2/contract' import type { TutorialProgress } from '@oj2/contract'
import { lessonState, splitTitle } from '../lesson' import { tutorialState, splitTitle } from '../utils'
import LessonDot from './LessonDot.vue' import TutorialDot from './TutorialDot.vue'
/** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */ /** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */
const props = defineProps<{ const props = defineProps<{
@@ -16,12 +16,12 @@ const props = defineProps<{
const emit = defineEmits<{ go: [] }>() const emit = defineEmits<{ go: [] }>()
const parts = computed(() => splitTitle(props.title)) const parts = computed(() => splitTitle(props.title))
const state = computed(() => lessonState(props.progress)) const state = computed(() => tutorialState(props.progress))
</script> </script>
<template> <template>
<router-link :to="to" class="row" :class="{ active }" :style="{ height: `${height ?? 46}px` }" @click="emit('go')"> <router-link :to="to" class="row" :class="{ active }" :style="{ height: `${height ?? 46}px` }" @click="emit('go')">
<LessonDot :step="step" :state="state" :current="active" /> <TutorialDot :step="step" :state="state" :current="active" />
<span class="text"> <span class="text">
<span class="kp">{{ parts.kp }}</span> <span class="kp">{{ parts.kp }}</span>
<span v-if="parts.scene" class="scene">{{ parts.scene }}</span> <span v-if="parts.scene" class="scene">{{ parts.scene }}</span>
@@ -14,7 +14,7 @@ import type { Exercise } from 'utils/types'
* 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方 * 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。 * 已经有了,每课都重复一张同样的项目图只是把正文往下推。
*/ */
export type LessonSegment = export type TutorialSegment =
| { type: 'md'; content: string } | { type: 'md'; content: string }
| { type: 'heading'; id: string; text: string } | { type: 'heading'; id: string; text: string }
| { type: 'goals'; id: string; items: string[] } | { type: 'goals'; id: string; items: string[] }
@@ -57,9 +57,9 @@ function stripPreamble(lines: string[]) {
return lines.slice(i) return lines.slice(i)
} }
export function parseLesson(content: string, exercises: Exercise[]) { export function parseTutorial(content: string, exercises: Exercise[]) {
const byId = new Map(exercises.map((item) => [item.id, item])) const byId = new Map(exercises.map((item) => [item.id, item]))
const segments: LessonSegment[] = [] const segments: TutorialSegment[] = []
const outline: OutlineItem[] = [] const outline: OutlineItem[] = []
let md: string[] = [] let md: string[] = []
let codeIndex = 0 let codeIndex = 0
-516
View File
@@ -1,516 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { TutorialProgress, TutorialSummary } from '@oj2/contract'
import { getLearnProgress, getTutorials } from '../api'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { COURSES, continueStep, doneCount, groupByProject, lessonPath, lessonState, splitProject, splitTitle, type TutorialType } from './lesson'
import LessonRow from './components/LessonRow.vue'
import ProjectTile from './components/ProjectTile.vue'
import ProjectTree from './components/ProjectTree.vue'
import SegBar from './components/SegBar.vue'
import './learn.css'
/**
* 自学首页 /learn(设计稿「自学首页」):最上面「接着学」,下面按项目分组的课程地图。
*
* 「接着学」看的是**服务端留痕**里最近打开的那一课,不是本机记的:机房一台电脑
* 轮着好几个班用,本地记的是上一个人学到哪。
*/
const userStore = useUserStore()
const authModal = useAuthModalStore()
const { isDesktop } = useBreakpoints()
const route = useRoute()
const router = useRouter()
interface Course {
type: TutorialType
label: string
titles: TutorialSummary[]
progress: Record<number, TutorialProgress>
}
const courses = ref<Course[]>([])
const loaded = ref(false)
const tab = ref<TutorialType>(route.query.course === 'c' ? 'c' : 'python')
async function load() {
const authed = userStore.isAuthed
courses.value = await Promise.all(
COURSES.map(async ({ type, label }) => {
const [titles, rows] = await Promise.all([getTutorials(type), authed ? getLearnProgress(type).catch(() => []) : Promise.resolve([])])
return {
type,
label,
titles,
progress: Object.fromEntries(rows.map((row) => [row.tutorialId, row])),
}
}),
)
// 没指定看哪门课时,打开最近在学的那门
if (!route.query.course && resume.value) tab.value = resume.value.course.type
loaded.value = true
}
function latestViewed(course: Course) {
return Object.values(course.progress).reduce<string>((acc, row) => (row.lastViewedAt && row.lastViewedAt > acc ? row.lastViewedAt : acc), '')
}
/** 接着学:最近打开过的那门课里该接着学的那课 */
const resume = computed(() => {
const started = courses.value.filter((course) => course.titles.length && latestViewed(course)).sort((a, b) => latestViewed(b).localeCompare(latestViewed(a)))
const course = started[0]
if (!course) return null
const step = continueStep(course.titles, course.progress)
if (!step) return null
const item = course.titles[step - 1]!
return { course, step, item, parts: splitTitle(item.title), project: splitProject(item.project) }
})
const others = computed(() => courses.value.filter((course) => course.titles.length && course.type !== resume.value?.course.type))
const current = computed(() => courses.value.find((course) => course.type === tab.value))
const groups = computed(() => groupByProject(current.value?.titles ?? []))
function stateOf(course: Course) {
return (id: number) => lessonState(course.progress[id])
}
function firstLesson(course: Course) {
return course.titles[0] ? splitTitle(course.titles[0].title) : null
}
function courseStep(course: Course) {
return continueStep(course.titles, course.progress)
}
function pickTab(type: TutorialType) {
tab.value = type
router.replace({ query: { ...route.query, course: type } })
}
function doneIn(course: Course, ids: number[]) {
return ids.filter((id) => lessonState(course.progress[id]) === 'done').length
}
watch(() => userStore.isAuthed, load)
onMounted(load)
</script>
<template>
<div class="home">
<template v-if="loaded">
<!-- 第一排:接着学 + 另一门课;一课没学过就是两张「开始学」 -->
<div class="top" :class="{ two: !resume }">
<section v-if="resume" class="resume">
<div class="resume-main">
<div class="resume-meta">
<span class="resume-label">接着学</span>
<span class="muted">
{{ resume.course.label }}
<template v-if="resume.item.project"> · {{ resume.item.project }}</template>
· 第 {{ resume.step }} 课
</span>
</div>
<div class="resume-title">
<span class="resume-kp">{{ resume.parts.kp }}</span>
<span v-if="resume.parts.scene" class="resume-scene">{{ resume.parts.scene }}</span>
</div>
<div class="resume-progress">
<SegBar
:groups="groupByProject(resume.course.titles)"
:state-of="stateOf(resume.course)"
:current="resume.item.id"
:width="isDesktop ? 20 : 11"
:gap="isDesktop ? 3 : 2"
:group-gap="isDesktop ? 10 : 6"
/>
<span class="num sec"> 学完 {{ doneCount(resume.course.titles, resume.course.progress) }} / {{ resume.course.titles.length }} 课 </span>
</div>
</div>
<n-button type="primary" size="large" class="resume-go" icon-placement="right" @click="router.push(lessonPath(resume.course.type, resume.step))">
<template #icon><Icon icon="ph:caret-right-bold" /></template>
接着学
</n-button>
</section>
<!-- 手机上有了「接着学」就不再摆另一门课的卡,下面的页签切得到 -->
<section v-for="course in resume ? (isDesktop ? others : []) : courses.filter((c) => c.titles.length)" :key="course.type" class="course card">
<div class="course-text">
<div class="course-head">
<span class="course-name">{{ course.label }}</span>
<span class="muted small">
{{ course.titles.length }} 课
<template v-if="resume">
·
{{ courseStep(course) ? `学完 ${doneCount(course.titles, course.progress)} 课` : '还没开始' }}</template
>
<template v-else> · {{ groupByProject(course.titles).length }} 个项目</template>
</span>
</div>
<div v-if="!resume && firstLesson(course)" class="first">
<span class="muted">第 1 课</span>
<span class="first-kp">{{ firstLesson(course)!.kp }}</span>
<span class="sec">{{ firstLesson(course)!.scene }}</span>
</div>
<SegBar :groups="groupByProject(course.titles)" :state-of="stateOf(course)" :width="resume ? 12 : 14" />
<router-link v-if="resume" :to="lessonPath(course.type, courseStep(course) ?? 1)" class="course-link">
{{ courseStep(course) ? `接着学第 ${courseStep(course)} 课` : '从第 1 课开始' }}
<Icon icon="ph:caret-right-bold" :width="13" />
</router-link>
</div>
<n-button v-if="!resume" type="primary" icon-placement="right" @click="router.push(lessonPath(course.type, 1))">
<template #icon><Icon icon="ph:caret-right-bold" /></template>
开始学
</n-button>
</section>
</div>
<div class="tabs" role="tablist">
<button
v-for="course in courses.filter((c) => c.titles.length)"
:key="course.type"
type="button"
role="tab"
class="tab"
:class="{ on: tab === course.type }"
:aria-selected="tab === course.type"
@click="pickTab(course.type)"
>
{{ course.label }}
<span class="tab-sub">
{{
userStore.isAuthed && doneCount(course.titles, course.progress)
? `学完 ${doneCount(course.titles, course.progress)}/${course.titles.length}`
: `${course.titles.length} 课`
}}
</span>
</button>
<span class="grow" />
<button v-if="!userStore.isAuthed" type="button" class="login" @click="authModal.openLoginModal()">登录后记下学到哪</button>
<div v-if="isDesktop && userStore.isTeacherOrAbove" class="admin-links">
<AdminLink to="/admin/learn" size="small">自学情况</AdminLink>
<AdminLink v-if="userStore.isSuperAdmin" :to="{ path: '/admin/tutorial/list', query: { type: tab } }" size="small"> 管理教程 </AdminLink>
</div>
</div>
<div v-if="current && isDesktop" class="map">
<section v-for="group in groups" :key="group.lessons[0]!.id" class="project card">
<div class="project-head">
<ProjectTile :icon="group.icon" :tone="group.tone" />
<span class="project-name">
<span v-if="group.no" class="muted small">{{ group.no }}</span>
<span class="project-title">{{ group.name }}</span>
</span>
<span
v-if="
userStore.isAuthed &&
doneIn(
current,
group.lessons.map((l) => l.id),
) === group.lessons.length
"
class="project-count all"
>
<Icon icon="ph:check-bold" :width="13" />{{ group.lessons.length }}/{{ group.lessons.length }}
</span>
<span
v-else-if="
userStore.isAuthed &&
doneIn(
current,
group.lessons.map((l) => l.id),
)
"
class="project-count"
>
{{
doneIn(
current,
group.lessons.map((l) => l.id),
)
}}/{{ group.lessons.length }}
</span>
<span v-else class="project-count">{{ group.lessons.length }} 课</span>
</div>
<LessonRow
v-for="lesson in group.lessons"
:key="lesson.id"
:title="lesson.title"
:step="lesson.step"
:to="lessonPath(current.type, lesson.step)"
:progress="current.progress[lesson.id]"
:active="resume?.course.type === current.type && resume.step === lesson.step"
:height="48"
/>
</section>
</div>
<ProjectTree
v-else-if="current"
:groups="groups"
:progress="current.progress"
:type="current.type"
:current-step="resume?.course.type === current.type ? resume.step : 0"
big
/>
<n-empty v-if="!courses.some((c) => c.titles.length)" description="还没有公开的教程" style="margin-top: 80px" />
</template>
<n-spin v-else class="spin" />
</div>
</template>
<style scoped>
.home {
max-width: 1286px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.muted {
opacity: 0.6;
}
.sec {
opacity: 0.8;
}
.small {
font-size: 13px;
}
.num {
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
.spin {
display: flex;
justify-content: center;
margin-top: 80px;
}
.card {
border: 1px solid var(--learn-line);
border-radius: 8px;
}
.top {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 20px;
}
.top.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.resume {
border: 1px solid var(--learn-green-line);
background: var(--learn-green-bg);
border-radius: 10px;
padding: 20px 26px;
display: flex;
align-items: center;
gap: 24px;
}
.resume-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.resume-meta {
display: flex;
gap: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.resume-label {
color: var(--learn-green-text);
font-weight: 700;
}
.resume-title {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.resume-kp {
font-size: 26px;
font-weight: 700;
}
.resume-scene {
font-size: 15px;
opacity: 0.8;
}
.resume-progress {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
margin-top: 6px;
font-size: 13px;
}
.course {
padding: 18px 22px;
display: flex;
align-items: center;
gap: 20px;
}
.course-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.course-head {
display: flex;
align-items: baseline;
gap: 8px;
}
.course-name {
font-size: 18px;
font-weight: 700;
}
.first {
display: flex;
gap: 6px;
font-size: 14px;
flex-wrap: wrap;
}
.first-kp {
font-weight: 600;
}
.course-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 600;
color: var(--learn-green-text);
text-decoration: none;
}
.tabs {
display: flex;
align-items: stretch;
gap: 24px;
height: 40px;
border-bottom: 1px solid var(--learn-line);
}
.tab {
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: inherit;
font: inherit;
font-size: 15px;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 0;
}
.tab.on {
color: var(--learn-green-text);
font-weight: 700;
border-bottom-color: var(--learn-green);
}
.tab-sub {
font-size: 13px;
font-weight: 400;
opacity: 0.6;
}
.admin-links {
display: flex;
align-items: center;
gap: 8px;
}
.login {
border: 0;
background: transparent;
color: var(--learn-green-text);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.map {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
@media (max-width: 1180px) {
.map {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.top {
grid-template-columns: minmax(0, 1fr);
}
}
.project {
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.project-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 10px;
}
.project-name {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.project-title {
font-size: 15px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-count {
font-size: 13px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
gap: 3px;
}
.project-count.all {
opacity: 1;
color: var(--learn-green-text);
font-weight: 600;
}
@media (max-width: 768px) {
.home {
gap: 14px;
}
.top,
.top.two {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.resume {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 14px 16px;
}
.resume-kp {
font-size: 22px;
}
.resume-go {
width: 100%;
}
.course {
padding: 14px 16px;
}
.tabs {
gap: 20px;
}
}
</style>
File diff suppressed because it is too large. Load diff
+608
View File
@@ -0,0 +1,608 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui'
import type { Tutorial, TutorialProgress, TutorialSummary } from '@oj2/contract'
import type { Exercise } from 'utils/types'
import { getExercises, getLearnProgress, getTutorial, getTutorials } from '../api'
import { parseTutorial } from './composables/useTutorialParse'
import { useLearnTrace } from './composables/useLearnTrace'
import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils'
import TutorialBody from './components/TutorialBody.vue'
import TutorialEnd from './components/TutorialEnd.vue'
import TutorialOutline from './components/TutorialOutline.vue'
import ProjectTree from './components/ProjectTree.vue'
import SegBar from './components/SegBar.vue'
import './learn.css'
/**
* 读一课。设计稿「自学重设计」页的阅读页 A(选定):左边目录按项目折叠、中间正文、
* 右边这一课的小节和练一练;手机上是顶部课程条 + 底部弹出的目录 + 底部上一课/下一课。
*/
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const userStore = useUserStore()
const theme = useThemeVars()
// 未登录也能看教程,只是不留痕、不记进度
const traced = computed(() => userStore.isAuthed)
const type = computed<TutorialType>(() => (route.params.type === 'c' ? 'c' : 'python'))
const courseLabel = computed(() => COURSES.find((item) => item.type === type.value)!.label)
const step = computed(() => {
const value = parseInt((route.params.step as string | undefined) ?? '1')
return Number.isFinite(value) && value > 0 ? value : 1
})
const titles = ref<TutorialSummary[]>([])
const progress = ref<Record<number, TutorialProgress>>({})
const tutorial = ref<Tutorial | null>(null)
const exercises = ref<Exercise[]>([])
const solvedIds = ref(new Set<number>())
const loading = ref(true)
const isEmpty = ref(false)
const menuOpen = ref(false)
// 手机上的目录是大半屏的底部抽屉:按返回键收起它,不是退出这一课
useBackClose(menuOpen, () => !isDesktop.value)
const groups = computed(() => groupByProject(titles.value))
const parsed = computed(() => (tutorial.value ? parseTutorial(tutorial.value.content, exercises.value) : { segments: [], outline: [], exerciseCount: 0 }))
const parts = computed(() => splitTitle(tutorial.value?.title ?? ''))
const solvedCount = computed(() => exercises.value.filter((item) => solvedIds.value.has(item.id)).length)
const firstUnsolved = computed(() => {
for (const seg of parsed.value.segments) {
if (seg.type === 'exercise' && !solvedIds.value.has(seg.exercise.id)) {
return { id: seg.exercise.id, index: seg.index + 1 }
}
}
return null
})
const prev = computed(() => tutorialAt(step.value - 1))
const next = computed(() => tutorialAt(step.value + 1))
const done = computed(() => doneCount(titles.value, progress.value))
function tutorialAt(at: number) {
const item = titles.value[at - 1]
return item ? { title: item.title, to: tutorialPath(type.value, at) } : null
}
function stateOf(id: number) {
return tutorialState(progress.value[id])
}
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
useLearnTrace(
computed(() => tutorial.value?.id ?? 0),
traced,
)
/**
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响学,
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
*/
async function loadProgress() {
if (!traced.value) {
progress.value = {}
return
}
try {
const rows = await getLearnProgress(type.value)
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
} catch {
progress.value = {}
}
}
let generation = 0
async function init() {
const mine = ++generation
loading.value = true
menuOpen.value = false
const list = await getTutorials(type.value)
if (mine !== generation) return
titles.value = list
isEmpty.value = list.length === 0
if (isEmpty.value) {
loading.value = false
return
}
if (step.value > list.length) {
router.replace(tutorialPath(type.value, list.length))
return
}
const id = list[step.value - 1]!.id
const [loaded, exs] = await Promise.allSettled([getTutorial(id), getExercises(id), loadProgress()])
if (mine !== generation) return
tutorial.value = loaded.status === 'fulfilled' ? loaded.value : null
exercises.value = exs.status === 'fulfilled' ? exs.value : []
solvedIds.value = new Set(exercises.value.filter((item) => item.solved).map((item) => item.id))
loading.value = false
activeId.value = ''
await nextTick()
reader.value?.scrollTo({ top: 0 })
// 手机上滚的是外层 n-layout 的容器,不是 window —— 原来 window.scrollTo 什么都没滚,
// 点「下一课」落在下一课的末尾
if (!isDesktop.value) scroller()?.scrollTo({ top: 0 })
pagerHidden.value = false
atEnd.value = false
}
watch(
() => [route.params.type, route.params.step],
() => {
if (route.name === 'learn tutorial') init()
},
{ immediate: true },
)
// 在教程页上登录/退出时把勾重新拉一遍(学生多半是点「运行」被拦下才登录的)
watch(traced, async () => {
await loadProgress()
if (!tutorial.value) return
const exs = await getExercises(tutorial.value.id).catch(() => exercises.value)
exercises.value = exs
solvedIds.value = new Set([...solvedIds.value, ...exs.filter((item) => item.solved).map((item) => item.id)])
})
/** 刚做对一道:本课的进度就地加上,目录、进度条、课末跟着变,不用等刷新 */
function onSolved(exerciseId: number) {
if (solvedIds.value.has(exerciseId) || !tutorial.value) return
solvedIds.value = new Set([...solvedIds.value, exerciseId])
const id = tutorial.value.id
const row = progress.value[id] ?? {
tutorialId: id,
viewCount: 1,
totalSeconds: 0,
firstViewedAt: null,
lastViewedAt: null,
exerciseTotal: exercises.value.length,
exerciseSolved: 0,
}
progress.value = { ...progress.value, [id]: { ...row, exerciseSolved: solvedCount.value } }
}
// ------------------------------------------------------------------ 手机底栏
// 往下读时收起上一课 / 目录 / 下一课,往回翻再出来;读到课末也收着 ——
// 课末卡片里就有「下一课」,两个摆在一起是重复的
const mobileRoot = ref<HTMLElement | null>(null)
const endRef = ref<HTMLElement | null>(null)
const pagerHidden = ref(false)
const atEnd = ref(false)
/** 往上找真正在滚的那层。n-layout-content 自己也带 .n-layout-scroll-container 类却不滚,按类名找会找错 */
function scroller(): HTMLElement | null {
for (let el = mobileRoot.value?.parentElement; el; el = el.parentElement) {
const overflow = getComputedStyle(el).overflowY
if ((overflow === 'auto' || overflow === 'scroll') && el.scrollHeight > el.clientHeight) {
return el
}
}
return null
}
let lastTop = 0
function onMobileScroll(event: Event) {
const box = event.target as HTMLElement
const top = box.scrollTop
if (top < 60) pagerHidden.value = false
else if (top > lastTop + 6) pagerHidden.value = true
else if (top < lastTop - 6) pagerHidden.value = false
lastTop = top
const end = endRef.value?.getBoundingClientRect().top
atEnd.value = end !== undefined && end < box.getBoundingClientRect().bottom
}
// 滚动事件不冒泡,在 document 上用捕获拿,不用先找到容器再挂
useEventListener(
document,
'scroll',
(event: Event) => {
const box = event.target
if (isDesktop.value || !(box instanceof HTMLElement)) return
// 只认包着本页的那层(目录抽屉、代码块里横着滚的不算)
if (!mobileRoot.value || !box.contains(mobileRoot.value)) return
onMobileScroll(event)
},
{ capture: true, passive: true },
)
// ------------------------------------------------------------------ 读到哪一节
const reader = ref<HTMLElement | null>(null)
const activeId = ref('')
function onScroll() {
const box = reader.value
if (!box) return
const top = box.getBoundingClientRect().top + 160
let current = parsed.value.outline[0]?.id ?? ''
for (const item of parsed.value.outline) {
const el = document.getElementById(item.id)
if (el && el.getBoundingClientRect().top <= top) current = item.id
}
activeId.value = current
}
function jump(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function jumpExercise(id: number) {
jump(`exercise-${id}`)
}
</script>
<template>
<div class="learn">
<n-empty v-if="isEmpty" description="这门课还没有公开的课" style="margin-top: 80px" />
<!-- 桌面:目录 | 正文 | 这一课 -->
<div v-else-if="isDesktop" class="layout">
<aside class="rail">
<router-link to="/learn" class="back"> <Icon icon="ph:caret-left-bold" :width="13" />自学首页 </router-link>
<div class="course">
<div class="course-head">
<span class="course-name">{{ courseLabel }}</span>
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
</div>
<SegBar :groups="groups" :state-of="stateOf" :current="tutorial?.id" :width="9" :height="5" :gap="2" :group-gap="6" />
</div>
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" />
<div v-if="!traced" class="login-tip muted">登录后记下学到哪</div>
</aside>
<main class="main">
<div ref="reader" class="reader" @scroll.passive="onScroll">
<!-- 按课重建:复用的话正文里的编辑器是就地换内容,CodeMirror 会把光标滚进视野,
刚滚回顶上的页面又被拽到某个代码块那儿 -->
<article v-if="tutorial" :key="tutorial.id" class="article">
<div class="crumb muted">
<span v-if="tutorial.project">{{ tutorial.project }}</span>
<span v-if="tutorial.project">·</span>
<span class="num">第 {{ step }} / {{ titles.length }} 课</span>
<template v-if="userStore.isSuperAdmin">
<span class="grow" />
<AdminLink :to="{ name: 'admin tutorial edit', params: { tutorialID: tutorial.id } }" size="tiny"> 编辑这一课 </AdminLink>
</template>
</div>
<h1 class="h1">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
<TutorialBody
:segments="parsed.segments"
:lang="type"
:tutorial-id="tutorial.id"
:exercise-count="parsed.exerciseCount"
:solved-ids="solvedIds"
@solved="onSolved"
/>
<TutorialEnd
:step="step"
:total="titles.length"
:course-label="courseLabel"
:exercise-count="parsed.exerciseCount"
:solved-count="solvedCount"
:first-unsolved="firstUnsolved"
:problems="tutorial.problems"
:next="next"
:groups="groups"
:state-of="stateOf"
:done-count="done"
@jump="jumpExercise"
/>
</article>
<n-spin v-else-if="loading" class="spin" />
</div>
<nav class="pager">
<div class="pager-inner">
<n-button v-if="prev" tag="a" :href="prev.to" @click.prevent="router.push(prev.to)">
<template #icon><Icon icon="ph:caret-left-bold" /></template>
上一课<span class="pager-name muted">{{ splitTitle(prev.title).kp }}</span>
</n-button>
<span class="grow" />
<n-button v-if="next" type="primary" icon-placement="right" tag="a" :href="next.to" @click.prevent="router.push(next.to)">
<template #icon><Icon icon="ph:caret-right-bold" /></template>
下一课<span class="pager-name next-name">{{ splitTitle(next.title).kp }}</span>
</n-button>
</div>
</nav>
</main>
<aside class="side">
<TutorialOutline
v-if="tutorial"
:items="parsed.outline"
:active-id="activeId || parsed.outline[0]?.id || ''"
:solved-ids="solvedIds"
:exercise-count="parsed.exerciseCount"
@jump="jump"
/>
</aside>
</div>
<!-- 手机 -->
<div v-else ref="mobileRoot" class="mobile">
<div class="mbar">
<n-button @click="menuOpen = true">
<template #icon><Icon icon="ph:list" /></template>
目录
</n-button>
<span class="num muted-strong">第 {{ step }} / {{ titles.length }} 课</span>
<span class="grow" />
<span v-if="parsed.exerciseCount" class="mpractice">
<span>练一练</span>
<span class="mboxes">
<span v-for="n in parsed.exerciseCount" :key="n" class="mbox" :class="{ on: n <= solvedCount }" />
</span>
</span>
</div>
<article v-if="tutorial" :key="tutorial.id" class="marticle">
<div v-if="tutorial.project" class="crumb muted">{{ tutorial.project }}</div>
<h1 class="h1 small">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
<TutorialBody
:segments="parsed.segments"
:lang="type"
:tutorial-id="tutorial.id"
:exercise-count="parsed.exerciseCount"
:solved-ids="solvedIds"
@solved="onSolved"
/>
<div ref="endRef">
<TutorialEnd
:step="step"
:total="titles.length"
:course-label="courseLabel"
:exercise-count="parsed.exerciseCount"
:solved-count="solvedCount"
:first-unsolved="firstUnsolved"
:problems="tutorial.problems"
:next="next"
:groups="groups"
:state-of="stateOf"
:done-count="done"
compact
@jump="jumpExercise"
/>
</div>
</article>
<n-spin v-else-if="loading" class="spin" />
<nav class="mpager" :class="{ hidden: pagerHidden || atEnd }">
<n-button v-if="prev" size="large" @click="router.push(prev.to)">
<template #icon><Icon icon="ph:caret-left-bold" /></template>
上一课
</n-button>
<span class="grow" />
<n-button size="large" quaternary @click="menuOpen = true">
<template #icon><Icon icon="ph:list" /></template>
目录
</n-button>
<span class="grow" />
<n-button v-if="next" size="large" type="primary" icon-placement="right" @click="router.push(next.to)">
<template #icon><Icon icon="ph:caret-right-bold" /></template>
下一课
</n-button>
</nav>
<!-- 不自动聚焦:一打开焦点框套在「自学首页」上,手机上看着像出了错 -->
<n-drawer v-model:show="menuOpen" placement="bottom" height="85%" class="sheet" :auto-focus="false">
<n-drawer-content :native-scrollbar="false" closable>
<template #header>
<span class="sheet-head">
<span class="course-name">{{ courseLabel }}</span>
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
<router-link to="/learn" class="sheet-home">自学首页</router-link>
</span>
</template>
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" big @go="menuOpen = false" />
</n-drawer-content>
</n-drawer>
</div>
</div>
</template>
<style scoped>
.muted {
opacity: 0.6;
}
.num {
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
.spin {
display: flex;
justify-content: center;
margin-top: 80px;
}
/* 桌面端固定高度,三栏各自滚动。72 = 顶栏 56 + 外框上内边距 16;
底下的 16 内边距用负外边距吃掉,上一课 / 下一课那条贴着屏幕底 */
.layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 220px;
grid-template-rows: minmax(0, 1fr);
height: calc(100vh - 72px);
margin-bottom: -16px;
}
@media (max-width: 1180px) {
.layout {
grid-template-columns: 240px minmax(0, 1fr);
}
.side {
display: none;
}
}
.rail {
box-sizing: border-box;
border-right: 1px solid var(--learn-line);
padding: 0 12px 16px 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.back {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 13px;
padding: 0 8px;
color: var(--learn-green-text);
text-decoration: none;
}
.course {
padding: 10px 8px 12px;
}
.course-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
}
.course-name {
font-size: 17px;
font-weight: 700;
}
.login-tip {
font-size: 13px;
padding: 8px;
}
.main {
min-width: 0;
display: flex;
flex-direction: column;
}
.reader {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 40px 0;
}
.article {
max-width: 720px;
margin: 0 auto;
}
.crumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
margin-bottom: 6px;
}
.h1 {
margin: 0;
font-size: 28px;
line-height: 1.3;
}
.h1.small {
font-size: 24px;
}
.scene {
font-size: 15px;
opacity: 0.8;
margin: 4px 0 18px;
}
.pager {
flex-shrink: 0;
border-top: 1px solid var(--learn-line);
padding: 12px 40px;
}
.pager-inner {
max-width: 720px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
}
.pager-name {
margin-left: 6px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.next-name {
opacity: 0.85;
}
.side {
box-sizing: border-box;
padding: 24px 0 0 16px;
overflow-y: auto;
}
/* 手机 */
/* 外层 n-layout 是 overflow: hidden,sticky 粘不住:顶部的条跟着滚走,
上一课 / 下一课(带目录按钮)固定在屏幕底部 */
.mobile {
padding-bottom: 72px;
}
.mbar {
height: 48px;
margin: 0 -12px;
padding: 0 12px;
box-sizing: border-box;
border-bottom: 1px solid var(--learn-line);
background: v-bind('theme.bodyColor');
display: flex;
align-items: center;
gap: 8px;
}
.muted-strong {
font-size: 13px;
opacity: 0.8;
}
.mpractice {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.mboxes {
display: flex;
gap: 3px;
}
.mbox {
width: 14px;
height: 6px;
border-radius: 3px;
background: var(--learn-todo);
}
.mbox.on {
background: var(--learn-green);
}
.marticle {
padding-top: 16px;
}
.mpager {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
padding: 10px 12px;
border-top: 1px solid var(--learn-line);
display: flex;
align-items: center;
background: v-bind('theme.bodyColor');
transition: transform 0.2s ease;
}
.mpager.hidden {
transform: translateY(100%);
}
.sheet-head {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
}
.sheet-home {
margin-left: 12px;
color: var(--learn-green-text);
text-decoration: none;
}
</style>
@@ -1,4 +1,4 @@
import { isLessonDone, type TutorialProgress, type TutorialSummary } from '@oj2/contract' import { isTutorialDone, type TutorialProgress, type TutorialSummary } from '@oj2/contract'
export type TutorialType = 'python' | 'c' export type TutorialType = 'python' | 'c'
@@ -32,7 +32,7 @@ export interface ProjectGroup {
icon: string icon: string
tone: 'blue' | 'purple' | 'green' | 'orange' | 'gray' tone: 'blue' | 'purple' | 'green' | 'orange' | 'gray'
/** 这个项目里的课,`step` 是在整门课里的序号(1 起) */ /** 这个项目里的课,`step` 是在整门课里的序号(1 起) */
lessons: { id: number; title: string; step: number }[] tutorials: { id: number; title: string; step: number }[]
} }
/** 「项目三 · 智慧农业·蜜橘园」拆成「项目三」和「智慧农业·蜜橘园」 */ /** 「项目三 · 智慧农业·蜜橘园」拆成「项目三」和「智慧农业·蜜橘园」 */
@@ -57,7 +57,7 @@ export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
titles.forEach((item, index) => { titles.forEach((item, index) => {
const last = groups[groups.length - 1] const last = groups[groups.length - 1]
if (last && last.project === item.project) { if (last && last.project === item.project) {
last.lessons.push({ id: item.id, title: item.title, step: index + 1 }) last.tutorials.push({ id: item.id, title: item.title, step: index + 1 })
return return
} }
const { no, name } = splitProject(item.project) const { no, name } = splitProject(item.project)
@@ -66,21 +66,21 @@ export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
no, no,
name, name,
...projectLook(name), ...projectLook(name),
lessons: [{ id: item.id, title: item.title, step: index + 1 }], tutorials: [{ id: item.id, title: item.title, step: index + 1 }],
}) })
}) })
return groups return groups
} }
export type LessonState = 'done' | 'started' | 'todo' export type TutorialState = 'done' | 'started' | 'todo'
/** /**
* 目录上的三态。学完 = 练一练全对(契约的 `isLessonDone`);打开过或者做过练一练但没 * 目录上的三态。学完 = 练一练全对(契约的 `isTutorialDone`);打开过或者做过练一练但没
* 全对是「在学」;别的是没开始。 * 全对是「在学」;别的是没开始。
*/ */
export function lessonState(progress: TutorialProgress | undefined): LessonState { export function tutorialState(progress: TutorialProgress | undefined): TutorialState {
if (!progress) return 'todo' if (!progress) return 'todo'
if (isLessonDone(progress)) return 'done' if (isTutorialDone(progress)) return 'done'
if (progress.viewCount > 0 || progress.exerciseSolved > 0) return 'started' if (progress.viewCount > 0 || progress.exerciseSolved > 0) return 'started'
return 'todo' return 'todo'
} }
@@ -98,17 +98,17 @@ export function continueStep(titles: TutorialSummary[], progress: Record<number,
if (!latest) return null if (!latest) return null
const index = titles.findIndex((item) => item.id === latest!.tutorialId) const index = titles.findIndex((item) => item.id === latest!.tutorialId)
if (index < 0) return null if (index < 0) return null
if (lessonState(latest) !== 'done') return index + 1 if (tutorialState(latest) !== 'done') return index + 1
for (let i = index + 1; i < titles.length; i++) { for (let i = index + 1; i < titles.length; i++) {
if (lessonState(progress[titles[i]!.id]) !== 'done') return i + 1 if (tutorialState(progress[titles[i]!.id]) !== 'done') return i + 1
} }
return index + 1 return index + 1
} }
export function doneCount(titles: TutorialSummary[], progress: Record<number, TutorialProgress>) { export function doneCount(titles: TutorialSummary[], progress: Record<number, TutorialProgress>) {
return titles.filter((item) => lessonState(progress[item.id]) === 'done').length return titles.filter((item) => tutorialState(progress[item.id]) === 'done').length
} }
export function lessonPath(type: TutorialType, step: number) { export function tutorialPath(type: TutorialType, step: number) {
return `/learn/${type}/${step.toString().padStart(2, '0')}` return `/learn/${type}/${step.toString().padStart(2, '0')}`
} }
File renamed without changes.
File renamed without changes.
@@ -8,10 +8,10 @@ import UserName from 'shared/components/UserName.vue'
* 老师协作时右栏顶上的一条(设计稿「举手与协作」HcPageA):正在帮谁、举手多久了、 * 老师协作时右栏顶上的一条(设计稿「举手与协作」HcPageA):正在帮谁、举手多久了、
* 看他交过什么、帮完了、下一个。语言不写(用户说的):编辑器工具栏的语言下拉就摆在下面。 * 看他交过什么、帮完了、下一个。语言不写(用户说的):编辑器工具栏的语言下拉就摆在下面。
* *
* 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开看板, * 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开任务页,
* 所以「帮完了」自动回到这个班的看板,还有人举手就给「下一个」直接去下一个人的题。 * 所以「帮完了」自动回到这个班的任务页,还有人举手就给「下一个」直接去下一个人的题。
* *
* 老师在看板上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。 * 老师在任务页上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。
*/ */
const collabStore = useCollabStore() const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore()) const { problem } = storeToRefs(useProblemStore())
@@ -46,7 +46,7 @@ function openHisSubmissions() {
} }
/** /**
* 帮完了:结束这次(求助记录一并清掉),回到这个班的看板。 * 帮完了:结束这次(求助记录一并清掉),回到这个班的任务页。
* 等 room_closed 回来、room 清空了再跳 —— 房间还开着时离开题目页,题目页的拦截 * 等 room_closed 回来、room 清空了再跳 —— 房间还开着时离开题目页,题目页的拦截
* (detail.vue 的 confirmLeavingHelp)会把跳转改成开新标签 * (detail.vue 的 confirmLeavingHelp)会把跳转改成开新标签
*/ */
@@ -54,7 +54,7 @@ async function finish() {
const className = room.value?.peerClassName const className = room.value?.peerClassName
collabStore.leave('done') collabStore.leave('done')
await until(() => collabStore.room).toBe(null, { timeout: 3000 }) 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() const collabHere = useCollabHere()
/** /**
* 老师在看板上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 —— * 老师在任务页上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 ——
* 按钮就写「xuyue 在看你的代码」,和「在帮你」一样亮着、点不了 * 按钮就写「xuyue 在看你的代码」,和「在帮你」一样亮着、点不了
*/ */
const peeked = computed(() => collabHere.value && collabStore.room?.mode === 'peek' && collabStore.helpStatus === 'idle') const peeked = computed(() => collabHere.value && collabStore.room?.mode === 'peek' && collabStore.helpStatus === 'idle')
@@ -168,7 +168,7 @@ watch(
) )
/** /**
* 老师在课堂看板布置这节课时选了语言:打开这节课的题,编辑器默认就用它。学 C 的班, * 老师在课堂任务布置这节课时选了语言:打开这节课的题,编辑器默认就用它。学 C 的班,
* 编辑器停在默认的 Python(或者上一个用这台电脑的人留下的偏好)的话,学生交上去才发现 * 编辑器停在默认的 Python(或者上一个用这台电脑的人留下的偏好)的话,学生交上去才发现
* 不算数。课堂条只在题库入口出现,这里同一个范围。 * 不算数。课堂条只在题库入口出现,这里同一个范围。
* *
+1 -1
View File
@@ -174,7 +174,7 @@ function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNorm
window.open(router.resolve(to).href, '_blank') window.open(router.resolve(to).href, '_blank')
return false return false
} }
// 老师在看板上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在 // 老师在任务页上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在
const peeked = collabHere.value && collabStore.room?.mode === 'peek' const peeked = collabHere.value && collabStore.room?.mode === 'peek'
if (collabStore.isTeacher || (collabStore.helpStatus === 'idle' && !peeked)) return true if (collabStore.isTeacher || (collabStore.helpStatus === 'idle' && !peeked)) return true
if (approvedTarget === to.fullPath) { if (approvedTarget === to.fullPath) {
@@ -55,7 +55,7 @@ function shortLabel(className: string) {
<div class="battle" :class="{ bare }"> <div class="battle" :class="{ bare }">
<div class="title"> <div class="title">
<b v-if="!bare">班级对抗</b><span class="muted">人均做对</span> <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>
<div v-for="item in shown" :key="item.className" class="row" :class="{ mine: item.className === mine }"> <div v-for="item in shown" :key="item.className" class="row" :class="{ mine: item.className === mine }">
<span class="rank">{{ item.rank }}</span> <span class="rank">{{ item.rank }}</span>
@@ -213,7 +213,7 @@ function go(path: string, query: Record<string, string>) {
<Markdown v-if="report" :content="report" /> <Markdown v-if="report" :content="report" />
</section> </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>
<div v-else-if="!loading" class="muted">这个班的数据没取到</div> <div v-else-if="!loading" class="muted">这个班的数据没取到</div>
</n-spin> </n-spin>
+1 -1
View File
@@ -441,7 +441,7 @@ async function restore(userId: number) {
secondary secondary
@click=" @click="
router.push({ router.push({
path: '/class', path: '/pk',
query: classContext ? { classes: classContext } : {}, query: classContext ? { classes: classContext } : {},
}) })
" "
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Submission } from 'utils/types'
import type { Tone } from 'shared/composables/tone' 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 }"> <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>{{ liveLessons.map((item) => classLabel(item.className)).join('、') }}正在上课</span>
<span class="spacer"></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>
<div v-if="!lessonCount && !data.scattered.length" class="empty">这段时间没有人交</div> <div v-if="!lessonCount && !data.scattered.length" class="empty">这段时间没有人交</div>
@@ -17,7 +17,7 @@ import StatItem from './StatItem.vue'
/** /**
* 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框 * 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框
* (老师平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页之类从别处进来的, * (老师平时从这里看),和 /statistics 页面(课堂任务「回头看」、题目页之类从别处进来的,
* 条件放在地址栏里能刷新、能转发)。 * 条件放在地址栏里能刷新、能转发)。
* *
* 代码页签按条件分三种看法: * 代码页签按条件分三种看法:
@@ -27,8 +27,8 @@ import StatItem from './StatItem.vue'
* - 选了班或填了学生:左边题目、右边学生(CodeStats) * - 选了班或填了学生:左边题目、右边学生(CodeStats)
* 从前两种点进去时左上角有「← 全部」回来。 * 从前两种点进去时左上角有「← 全部」回来。
* *
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有 * 分工:**课堂任务管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「一节课」。 * 投影用的大字名单和请假隐藏 —— 那些在任务页上。课堂统计一般看一两节课,时间段默认「一节课」。
* *
* 查询条件都是 v-model:页面接地址栏,弹框接提交列表那边的一份本地状态。 * 查询条件都是 v-model:页面接地址栏,弹框接提交列表那边的一份本地状态。
*/ */
@@ -66,7 +66,7 @@ const custom = computed<[number, number] | null>(() => {
}) })
/** /**
* 「做完」只数所选时段之内做对的,和看板、后台「课堂作业」、一行一节课的「做完」不一样 * 「做完」只数所选时段之内做对的,和任务页、后台「课堂任务」、一行一节课的「做完」不一样
* (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的 * (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的
*/ */
const doneLabel = computed(() => { const doneLabel = computed(() => {
@@ -229,7 +229,7 @@ const judged = computed(() => (stats.value ? stats.value.submissionCount - stats
/** /**
* 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭, * 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭,
* 1366 的屏上整排放不下,所以弹框里宽一档才出字。 * 1366 的屏上整排放不下,所以弹框里宽一档才出字。
* 原来旁边还有一个「这节课去课堂看板」:顶栏已经有看板,统计里再放一个成了干扰项(用户说的), * 原来旁边还有一个「这节课去课堂任务」:顶栏已经有任务页,统计里再放一个成了干扰项(用户说的),
* 现在只在有班正在上课时,在课的列表上方引过去 * 现在只在有班正在上课时,在课的列表上方引过去
*/ */
const narrow = useMediaQuery(props.modal ? '(max-width: 1439px)' : '(max-width: 1365px)') const narrow = useMediaQuery(props.modal ? '(max-width: 1439px)' : '(max-width: 1365px)')
+1 -1
View File
@@ -4,7 +4,7 @@ import StatisticsView from './components/StatisticsView.vue'
import { DEFAULT_PERIOD } from './period' import { DEFAULT_PERIOD } from './period'
/** /**
* 统计的页面形态:课堂看板「回头看」、题目页统计页签这些从别处进来时用。 * 统计的页面形态:课堂任务「回头看」、题目页统计页签这些从别处进来时用。
* 提交列表里是弹框(用户定的),两边是同一个 StatisticsView。 * 提交列表里是弹框(用户定的),两边是同一个 StatisticsView。
* *
* 查询条件都在地址栏里:带过来、刷新、转发给别的老师都还是这一份。 * 查询条件都在地址栏里:带过来、刷新、转发给别的老师都还是这一份。
+1 -1
View File
@@ -9,7 +9,7 @@ const DAY = 86_400_000
* 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、 * 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、
* 默认「一节课」(用户定的);后面几档是回头看。名字后面不写「1 小时内」:选框旁边 * 默认「一节课」(用户定的);后面几档是回头看。名字后面不写「1 小时内」:选框旁边
* 本来就写着具体几点到几点。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的, * 本来就写着具体几点到几点。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的,
* 那件事现在归课堂看板。 * 那件事现在归课堂任务。
*/ */
export const PERIOD_OPTIONS = [ export const PERIOD_OPTIONS = [
{ label: '一节课', value: '1h' }, { 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 { ClassLesson } from 'utils/types'
import type { SubmissionQuery } from './listQuery' import type { SubmissionQuery } from './listQuery'
/** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂看板 */ /** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂任务 */
export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref<boolean>; info: (text: string) => void }) { export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref<boolean>; info: (text: string) => void }) {
const { query } = options const { query } = options
const lesson = ref<ClassLesson | null>(null) const lesson = ref<ClassLesson | null>(null)
@@ -36,13 +36,13 @@ export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref
} }
query.className = res.className query.className = res.className
if (!res.problems.length) { if (!res.problems.length) {
options.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂看板布置`) options.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂任务布置`)
return return
} }
lesson.value = res lesson.value = res
query.problem = lessonIds.value query.problem = lessonIds.value
query.today = '1' query.today = '1'
// 老师布置时选了语言:看板上只数这个语言交的,列表也只列它(流程图那边不动) // 老师布置时选了语言:任务页上只数这个语言交的,列表也只列它(流程图那边不动)
if (res.language && !options.flowMode.value) query.language = res.language if (res.language && !options.flowMode.value) query.language = res.language
} finally { } finally {
lessonLoading.value = false lessonLoading.value = false
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import SubmissionView from './components/SubmissionView.vue' import SubmissionView from './components/SubmissionView.vue'
/** 单独打开的一条提交,主体在 SubmissionView(课堂看板「讲错误」的弹层也用它) */ /** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */
defineProps<{ submissionID: string }>() defineProps<{ submissionID: string }>()
</script> </script>
+3 -3
View File
@@ -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> <span v-if="userStore.isAuthed" class="vsep"></span>
<!-- 「我的」时这节课 / 班级 / 学生都用不上:后端只看自己时本来就不认它们 --> <!-- 「我的」时这节课 / 班级 / 学生都用不上:后端只看自己时本来就不认它们 -->
<!-- 学生界面没有班级下拉:地址里带着班级(比如从统计页、看板跳过来)就摆一个能点掉的标签, <!-- 学生界面没有班级下拉:地址里带着班级(比如从统计页、任务页跳过来)就摆一个能点掉的标签,
不然一直被一个看不见的条件筛着 --> 不然一直被一个看不见的条件筛着 -->
<button v-if="!teacher && query.className" class="chip on" title="点一下去掉班级筛选" @click="query.className = ''"> <button v-if="!teacher && query.className" class="chip on" title="点一下去掉班级筛选" @click="query.className = ''">
{{ classLabel(query.className) }}<Icon icon="ph:x-bold" :width="12" /> {{ classLabel(query.className) }}<Icon icon="ph:x-bold" :width="12" />
@@ -605,7 +605,7 @@ function dayBreak(index: number) {
/> />
</template> </template>
<!-- 按整名只看一个人(「只看他」、协作条、看板点进来):用户名有八九个字,塞进 108px 的 <!-- 按整名只看一个人(「只看他」、协作条、任务页点进来):用户名有八九个字,塞进 108px 的
输入框只剩半截,还被清除按钮压着 —— 换成一个带名字的标签,点 × 取消 --> 输入框只剩半截,还被清除按钮压着 —— 换成一个带名字的标签,点 × 取消 -->
<button v-if="!mine && exactPerson" class="chip on person" :title="`只看 ${query.username}(点一下取消)`" @click="clearPerson"> <button v-if="!mine && exactPerson" class="chip on person" :title="`只看 ${query.username}(点一下取消)`" @click="clearPerson">
<Icon icon="ph:user-bold" :width="12" /> <Icon icon="ph:user-bold" :width="12" />
@@ -1,11 +1,11 @@
import type { LessonMode } from '@oj2/contract' import type { LessonMode } from '@oj2/contract'
import type { Tone } from 'shared/composables/tone' import type { Tone } from 'shared/composables/tone'
import { parseTime } from 'utils/functions' 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。先画再写的格子是一条进度: * `mode` 是这节课做什么(契约 lessonModeSchema),推断出来的课为 null。先画再写的格子是一条进度:
* 在画 → 图画对了(空心绿「图 ✓」)→ 在写 → 两样都对(实心绿 ✓)。先写了代码、还没画图的写「缺图」 * 在画 → 图画对了(空心绿「图 ✓」)→ 在写 → 两样都对(实心绿 ✓)。先写了代码、还没画图的写「缺图」
@@ -20,7 +20,7 @@ export const STUCK_ATTEMPTS = 3
*/ */
export type CellStage = 'draw' | 'code' | 'nodraw' 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 === 'flowchart') return 'draw'
if (mode !== 'both' || !cell.flowchart) return 'code' if (mode !== 'both' || !cell.flowchart) return 'code'
if (cell.flowchart.status === 'tried') return 'draw' 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 if (mode !== 'both') return cell.attempts
const stage = cellStage(cell, mode) const stage = cellStage(cell, mode)
return stage === 'draw' ? cell.flowchartAttempts : stage === 'code' ? cell.codeAttempts : 0 return stage === 'draw' ? cell.flowchartAttempts : stage === 'code' ? cell.codeAttempts : 0
} }
/** 卡住了:没做完,而且在卡着的那一步上今天交了 STUCK_ATTEMPTS 次以上 */ /** 卡住了:没做完,而且在卡着的那一步上今天交了 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 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' return mode === 'both' && cell.status === 'tried' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none'
} }
/** 今天之前就通过了的,颜色淡一档。`day` 是看板的「今天」(东八区日期) */ /** 今天之前就通过了的,颜色淡一档。`day` 是任务页的「今天」(东八区日期) */
export function acceptedToday(cell: ClassBoardCell, day: string | undefined) { export function acceptedToday(cell: TaskBoardCell, day: string | undefined) {
return !!cell.acceptedAt && parseTime(cell.acceptedAt, 'YYYY-MM-DD') === day 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 const latest = cell.flowchart?.latest
if (!latest) return '✗' if (!latest) return '✗'
if (latest.status === 0 || latest.status === 1) return '评分中' if (latest.status === 0 || latest.status === 1) return '评分中'
@@ -61,7 +61,7 @@ function drawText(cell: ClassBoardCell, prefix: string) {
} }
/** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */ /** 做对了的写第几次做对:「✓ 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 === 'accepted') return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : '✓'
if (cell.status === 'tried') { if (cell.status === 'tried') {
const stage = cellStage(cell, mode) const stage = cellStage(cell, mode)
@@ -75,7 +75,7 @@ export function cellText(cell: ClassBoardCell, mode: LessonMode | null = null) {
} }
export function cellStyle( export function cellStyle(
cell: ClassBoardCell, cell: TaskBoardCell,
day: string | undefined, day: string | undefined,
colors: { colors: {
tone: (kind: Tone) => { color: string; background: string } tone: (kind: Tone) => { color: string; background: string }
@@ -102,7 +102,7 @@ export function cellStyle(
return { background: colors.actionColor } 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') { if (cell.status === 'accepted') {
const when = acceptedToday(cell, day) ? `${parseTime(cell.acceptedAt!, 'HH:mm')} 做对` : '以前就做对了' const when = acceptedToday(cell, day) ? `${parseTime(cell.acceptedAt!, 'HH:mm')} 做对` : '以前就做对了'
if (cell.challenge === 'done') return `${when},挑战也做到了` 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 const total = cell.trail.length
if (cell.acceptedTry === 1) return total === 1 ? '一次就做对' : `交了 ${total} 次,第 1 次就做对` if (cell.acceptedTry === 1) return total === 1 ? '一次就做对' : `交了 ${total} 次,第 1 次就做对`
if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对` if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对`
@@ -19,23 +19,23 @@ import SubmissionView from 'oj/submission/components/SubmissionView.vue'
import UserName from 'shared/components/UserName.vue' import UserName from 'shared/components/UserName.vue'
import { useBackClose } from 'shared/composables/backClose' import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import type { ClassBoardProblem } from 'utils/types' import type { TaskBoardProblem } from 'utils/types'
import FlowchartExplain from './FlowchartExplain.vue' import FlowchartExplain from './FlowchartExplain.vue'
import ProblemBrief from './ProblemBrief.vue' import ProblemBrief from './ProblemBrief.vue'
/** /**
* 课堂看板「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上 * 课堂任务「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
* 一个个讲这道题错着的人,所以顶上是这几个人,← → 换人;下面和独立的提交详情页同一块 * 一个个讲这道题错着的人,所以顶上是这几个人,← → 换人;下面和独立的提交详情页同一块
* (代码占满宽、为什么错在后),只是不出名字那一行、点「第几次」就地换。 * (代码占满宽、为什么错在后),只是不出名字那一行、点「第几次」就地换。
* *
* 「题目」按钮:演示错误时题目和代码要并排(用户要的),点了左边出题目,再点收起。 * 「题目」按钮:演示错误时题目和代码要并排(用户要的),点了左边出题目,再点收起。
* 平时收着,代码照样占满宽。开没开记在这台电脑上,讲下一个人、下一道题还是那样 * 平时收着,代码照样占满宽。开没开记在这台电脑上,讲下一个人、下一道题还是那样
* *
* 手机上(老师拿手机看看板):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮; * 手机上(老师拿手机看任务页):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮;
* 没有键盘所以不写「← → 换人」;题目展开时叠在代码上面,不并排 * 没有键盘所以不写「← → 换人」;题目展开时叠在代码上面,不并排
*/ */
const props = defineProps<{ const props = defineProps<{
problem: ClassBoardProblem | null problem: TaskBoardProblem | null
people: ExplainPerson[] people: ExplainPerson[]
}>() }>()
const show = defineModel<boolean>('show', { required: true }) const show = defineModel<boolean>('show', { required: true })
@@ -43,7 +43,7 @@ const index = defineModel<number>('index', { required: true })
const theme = useThemeVars() const theme = useThemeVars()
const { isMobile } = useBreakpoints() const { isMobile } = useBreakpoints()
// 手机上是整屏的:按返回键关掉它回到看板,不是退出看板 // 手机上是整屏的:按返回键关掉它回到任务页,不是退出任务页
useBackClose(show, isMobile) useBackClose(show, isMobile)
/** /**
* 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面, * 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面,
@@ -7,7 +7,7 @@ import { caseReport, fitFlowchartGraph, flowchartVerdict, markFlowchartFocus } f
import type { FlowchartSubmission } from 'utils/types' import type { FlowchartSubmission } from 'utils/types'
/** /**
* 课堂看板「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。 * 课堂任务「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。
* 左边是他最后画的那张(要改的框标红),右边是 AI 写的哪里错、可以怎么改 —— 和提交列表右栏 * 左边是他最后画的那张(要改的框标红),右边是 AI 写的哪里错、可以怎么改 —— 和提交列表右栏
* (FlowchartPane)、题目页结果页签同一套点评(FlowchartNote)。 * (FlowchartPane)、题目页结果页签同一套点评(FlowchartNote)。
* *
@@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ClassBoardStudent } from 'utils/types' import type { TaskBoardStudent } from 'utils/types'
/** /**
* 看板的「请假」框:勾今天不在的人。规则在看板那边(只有今天还没交过的人能算请假、 * 任务页的「请假」框:勾今天不在的人。规则在任务页那边(只有今天还没交过的人能算请假、
* 交了题或举手就自动算到,见 board.vue 的 canLeave),这里只管勾。 * 交了题或举手就自动算到,见 board.vue 的 canLeave),这里只管勾。
*/ */
const props = defineProps<{ const props = defineProps<{
/** 能勾的人:今天还没交过的(刚上课时就是全班),按名字排好 */ /** 能勾的人:今天还没交过的(刚上课时就是全班),按名字排好 */
candidates: ClassBoardStudent[] candidates: TaskBoardStudent[]
/** 打开时已经标着请假的 */ /** 打开时已经标着请假的 */
current: string[] current: string[]
}>() }>()
@@ -23,7 +23,7 @@ watch(show, (open) => {
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」 * 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt) * 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
*/ */
const unseen = (student: ClassBoardStudent | undefined) => !!student && !student.seenAt const unseen = (student: TaskBoardStudent | undefined) => !!student && !student.seenAt
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */ /** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
const sections = computed(() => const sections = computed(() =>
@@ -36,7 +36,7 @@ const sections = computed(() =>
].filter((section) => section.students.length), ].filter((section) => section.students.length),
) )
function selectAll(list: ClassBoardStudent[]) { function selectAll(list: TaskBoardStudent[]) {
selected.value = [...new Set([...selected.value, ...list.map((student) => student.username)])] selected.value = [...new Set([...selected.value, ...list.map((student) => student.username)])]
} }
@@ -3,12 +3,12 @@ import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode }
import { setClassLesson } from 'oj/api' import { setClassLesson } from 'oj/api'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants' 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 { Icon } from '@iconify/vue'
import ProblemPicker from './ProblemPicker.vue' import ProblemPicker from './ProblemPicker.vue'
/** /**
* 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 —— * 任务页上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
* - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条; * - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条;
* - `start`:刚上课还没有题时,这页最大的那个框。 * - `start`:刚上课还没有题时,这页最大的那个框。
* *
@@ -16,7 +16,7 @@ import ProblemPicker from './ProblemPicker.vue'
* `carry`:新开的这一节带上这一节的题、改成写代码 —— 先布置画流程图、再让全班写代码时用 * `carry`:新开的这一节带上这一节的题、改成写代码 —— 先布置画流程图、再让全班写代码时用
*/ */
const props = defineProps<{ const props = defineProps<{
board: ClassBoard board: TaskBoard
className: string className: string
variant: 'inline' | 'start' variant: 'inline' | 'start'
lessonId: number | null lessonId: number | null
@@ -27,7 +27,7 @@ const message = useMessage()
/** /**
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班 * 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
* 上一次布置时选的;选了之后看板和学生那边的「做完」都只认这个语言交对的 * 上一次布置时选的;选了之后任务页和学生那边的「做完」都只认这个语言交对的
*/ */
const lessonLanguage = ref<LessonLanguage>('Python') const lessonLanguage = ref<LessonLanguage>('Python')
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL'] const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
@@ -109,7 +109,7 @@ function pickerConfirm(ids: string[]) {
</n-button> </n-button>
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button> <n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
<n-button @click="emit('cancel')">取消</n-button> <n-button @click="emit('cancel')">取消</n-button>
<!-- 弹层挂在 body 下,放在根节点里面:两个根会让看板传进来的 class 落空 --> <!-- 弹层挂在 body 下,放在根节点里面:两个根会让任务页传进来的 class 落空 -->
<ProblemPicker <ProblemPicker
v-model:show="picking" v-model:show="picking"
v-model:language="lessonLanguage" v-model:language="lessonLanguage"
@@ -9,7 +9,7 @@ import type { ProblemRow } from 'utils/types'
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue' import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
/** /**
* 看板布置作业的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。 * 任务页布置任务的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
* 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的, * 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的,
* 要画流程图的只列打开了画流程图的。不用再一个个记题号。 * 要画流程图的只列打开了画流程图的。不用再一个个记题号。
* 点「布置」直接存,和在输入框里敲题号回车是一回事 * 点「布置」直接存,和在输入框里敲题号回车是一回事
@@ -23,7 +23,7 @@ const props = defineProps<{
}>() }>()
const show = defineModel<boolean>('show', { required: true }) const show = defineModel<boolean>('show', { required: true })
const language = defineModel<LessonLanguage>('language', { required: true }) const language = defineModel<LessonLanguage>('language', { required: true })
/** 这节课做什么(写代码 / 画流程图 / 先画再写),和看板上那个下拉是同一个 */ /** 这节课做什么(写代码 / 画流程图 / 先画再写),和任务页上那个下拉是同一个 */
const lessonMode = defineModel<LessonMode>('lessonMode', { required: true }) const lessonMode = defineModel<LessonMode>('lessonMode', { required: true })
const needs = computed(() => lessonNeeds(lessonMode.value)) const needs = computed(() => lessonNeeds(lessonMode.value))
/** 只画流程图不看语言 */ /** 只画流程图不看语言 */
@@ -6,7 +6,7 @@ import UserName from 'shared/components/UserName.vue'
import { attemptTone, cachedSubmission } from 'oj/statistics/attempts' import { attemptTone, cachedSubmission } from 'oj/statistics/attempts'
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from 'utils/constants' import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions' 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<{ const props = defineProps<{
username: string username: string
problem: ClassBoardProblem problem: TaskBoardProblem
cell: ClassBoardCell cell: TaskBoardCell
summary: string summary: string
/** 「在提交列表看这 N 次」:和格子原来点进去的是同一个列表 */ /** 「在提交列表看这 N 次」:和格子原来点进去的是同一个列表 */
listHref: string listHref: string
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { getClassBoard } from 'oj/api' import { getTaskBoard } from 'oj/api'
import { useTone } from 'shared/composables/tone' import { useTone } from 'shared/composables/tone'
import { groupClassOptions } from 'utils/classes' import { groupClassOptions } from 'utils/classes'
import UserName from 'shared/components/UserName.vue' import UserName from 'shared/components/UserName.vue'
@@ -12,7 +12,7 @@ import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions' 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 ExplainModal, { type ExplainPerson } from './components/ExplainModal.vue'
import LeaveModal from './components/LeaveModal.vue' import LeaveModal from './components/LeaveModal.vue'
import AdminLink from 'shared/components/AdminLink.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 TrailPanel from './components/TrailPanel.vue'
import { errorKind, type ErrorKind } from 'utils/errorKind' import { errorKind, type ErrorKind } from 'utils/errorKind'
import { LESSON_MODE_LABEL } from '@oj2/contract' 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 route = useRoute()
const { help, peek } = useTeacherHelp() const { help, peek } = useTeacherHelp()
const board = ref<ClassBoard | null>(null) const board = ref<TaskBoard | null>(null)
/** /**
* 带着 ?className= 进来的(协作条「帮完了」回来、举手面板「讲这道题」)就看那个班, * 带着 ?className= 进来的(协作条「帮完了」回来、举手面板「讲这道题」)就看那个班,
* 否则第一次由后端猜 * 否则第一次由后端猜
@@ -67,7 +67,7 @@ const classOptions = computed(() => {
async function load() { async function load() {
if (document.hidden) return if (document.hidden) return
try { try {
const data = await getClassBoard(className.value ?? undefined) const data = await getTaskBoard(className.value ?? undefined)
board.value = data board.value = data
// 第一次由后端猜,之后就固定在这个班上,别每次刷新都换 // 第一次由后端猜,之后就固定在这个班上,别每次刷新都换
className.value = data.className className.value = data.className
@@ -90,7 +90,7 @@ function changeClass(value: string) {
} }
onMounted(load) onMounted(load)
// 已经停在看板上时又从举手面板点了「讲这道题」:同一条路由只换 query,组件不重建 // 已经停在任务页上时又从举手面板点了「讲这道题」:同一条路由只换 query,组件不重建
watch( watch(
() => route.query, () => route.query,
(query) => { (query) => {
@@ -125,9 +125,9 @@ const editing = ref<false | 'edit' | 'new' | 'carry'>(false)
const mode = computed(() => board.value?.mode ?? null) const mode = computed(() => board.value?.mode ?? null)
/** /**
* 一个班一天可以上几节内容不同的课。打开看板(或换到这个班)时,这一节已经布置了这么久, * 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久,
* 多半是上一节课的 —— 先问这节课做哪几道题,底下能点回去接着看上一节。 * 多半是上一节课的 —— 先问这节课做哪几道题,底下能点回去接着看上一节。
* 只在第一次看到这一节时判一次:老师一直开着看板时,15 秒一刷不会把表突然换掉 * 只在第一次看到这一节时判一次:老师一直开着任务页时,15 秒一刷不会把表突然换掉
*/ */
const STALE_LESSON_MINUTES = 100 const STALE_LESSON_MINUTES = 100
const staleLessonId = ref<number | null>(null) const staleLessonId = ref<number | null>(null)
@@ -159,10 +159,10 @@ type Group = 'help' | 'idle' | 'stuck' | 'working' | 'done' | 'leave'
/** /**
* 正在课堂求助的学生。列表是老师端 WebSocket 实时推的(collabStore,全局常驻连接), * 正在课堂求助的学生。列表是老师端 WebSocket 实时推的(collabStore,全局常驻连接),
* 不经看板接口 —— 求助只在 serve 进程的内存里,本来就不落库。所以也只看得到连着 * 不经任务页接口 —— 求助只在 serve 进程的内存里,本来就不落库。所以也只看得到连着
* **本站**的学生:服务器和机房各有各的求助队列,和课堂求助本身一样。 * **本站**的学生:服务器和机房各有各的求助队列,和课堂求助本身一样。
*/ */
function helpOf(student: ClassBoardStudent) { function helpOf(student: TaskBoardStudent) {
return collabStore.requests.find((request) => request.studentId === student.userId) ?? null return collabStore.requests.find((request) => request.studentId === student.userId) ?? null
} }
@@ -170,7 +170,7 @@ function helpOf(student: ClassBoardStudent) {
* 经常不动手:最近几节课里最多只交过一节。至少有 3 节课可比才下结论, * 经常不动手:最近几节课里最多只交过一节。至少有 3 节课可比才下结论,
* 开学头两节谁都一样。没交过的人里把他们排在前面,「最后交」那格写上来过几节 * 开学头两节谁都一样。没交过的人里把他们排在前面,「最后交」那格写上来过几节
*/ */
function oftenIdle(student: ClassBoardStudent) { function oftenIdle(student: TaskBoardStudent) {
const total = board.value?.recentLessons ?? 0 const total = board.value?.recentLessons ?? 0
return total >= 3 && student.recentAttended <= 1 return total >= 3 && student.recentAttended <= 1
} }
@@ -179,7 +179,7 @@ function oftenIdle(student: ClassBoardStudent) {
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」 * 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt) * 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
*/ */
function unseen(student: ClassBoardStudent) { function unseen(student: TaskBoardStudent) {
return !student.seenAt return !student.seenAt
} }
@@ -189,11 +189,11 @@ function seenText(seenAt: string) {
return minutes < 60 ? `${minutes} 分钟前在` : `${Math.floor(minutes / 60)} 小时前在` 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 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 (absent(student)) return 'leave'
if (helpOf(student)) return 'help' if (helpOf(student)) return 'help'
const cells = student.cells const cells = student.cells
@@ -230,10 +230,10 @@ const GROUP_LABEL: Record<Group, string> = {
* 就是来了(迟到),自动取消标记,回到正常的分组里 —— 老师得看得见他卡住、举手 * 就是来了(迟到),自动取消标记,回到正常的分组里 —— 老师得看得见他卡住、举手
*/ */
const { onLeave, setLeave, cancelLeave } = useLeaveStudents('oj_leave_students') const { onLeave, setLeave, cancelLeave } = useLeaveStudents('oj_leave_students')
function canLeave(student: ClassBoardStudent) { function canLeave(student: TaskBoardStudent) {
return !student.lastSubmitAt && !helpOf(student) return !student.lastSubmitAt && !helpOf(student)
} }
function absent(student: ClassBoardStudent) { function absent(student: TaskBoardStudent) {
return canLeave(student) && onLeave(student.username) return canLeave(student) && onLeave(student.username)
} }
const boardStudents = computed(() => board.value?.students ?? []) 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') return a.username.localeCompare(b.username, 'zh-CN')
} }
@@ -279,7 +279,7 @@ const groupCount = computed(() => {
return count return count
}) })
/** /**
* 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 classBoardCellSchema.challenge)。 * 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 taskBoardCellSchema.challenge)。
* 做完了又没在挑战的,就是空着的 * 做完了又没在挑战的,就是空着的
*/ */
const challengingCount = computed( const challengingCount = computed(
@@ -300,8 +300,8 @@ const groupStarts = computed(() => {
// ---------- 题卡片和「错着的人按错法归堆」 ---------- // ---------- 题卡片和「错着的人按错法归堆」 ----------
interface WrongPerson { interface WrongPerson {
student: ClassBoardStudent student: TaskBoardStudent
cell: ClassBoardCell cell: TaskBoardCell
kind: ErrorKind kind: ErrorKind
/** 点名字讲的是哪一份:代码(cell.latest)还是最后一张图(cell.flowchart.latest);null = 没有可讲的 */ /** 点名字讲的是哪一份:代码(cell.latest)还是最后一张图(cell.flowchart.latest);null = 没有可讲的 */
explain: 'code' | 'draw' | 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 const latest = cell.flowchart?.latest
if (!latest) return { key: 'draw-none', label: '还没画对', line: null } if (!latest) return { key: 'draw-none', label: '还没画对', line: null }
if (latest.status === 0 || latest.status === 1) return { key: 'draw-wait', label: 'AI 在评', line: null } if (latest.status === 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 explainShow.value = true
} }
// ---------- 格子(显示规则在 boardCells.ts) ---------- // ---------- 格子(显示规则在 cells.ts) ----------
const cellColors = computed(() => ({ const cellColors = computed(() => ({
tone, tone,
@@ -441,12 +441,12 @@ const cellColors = computed(() => ({
actionColor: theme.value.actionColor, actionColor: theme.value.actionColor,
successColor: theme.value.successColor, successColor: theme.value.successColor,
})) }))
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value) const cellStyle = (cell: TaskBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value)
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value) const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value)
const cellText = (cell: ClassBoardCell) => cellTextOf(cell, mode.value) const cellText = (cell: TaskBoardCell) => cellTextOf(cell, mode.value)
/** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */ /** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */
function explainable(cell: ClassBoardCell) { function explainable(cell: TaskBoardCell) {
if (cell.status !== 'tried') return false if (cell.status !== 'tried') return false
const stage = cellStage(cell, mode.value) const stage = cellStage(cell, mode.value)
if (stage === 'draw') { if (stage === 'draw') {
@@ -473,15 +473,15 @@ function trailOutside(event: MouseEvent) {
} }
/** /**
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。 * 点格子 / 名字到提交列表看他今天交的。新标签打开:任务页一般开着不关,别把它顶掉。
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。 * 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。
* *
* 今天只画了流程图、没写代码的,跳到列表的「流程图」那边 —— 不然这节课整班在画流程图时, * 今天只画了流程图、没写代码的,跳到列表的「流程图」那边 —— 不然这节课整班在画流程图时,
* 点进去是一片「没有符合条件的提交」 * 点进去是一片「没有符合条件的提交」
*/ */
function submissionsHref( function submissionsHref(
student: ClassBoardStudent, student: TaskBoardStudent,
problem: ClassBoardProblem | undefined, problem: TaskBoardProblem | undefined,
flowchart: boolean, flowchart: boolean,
/** 「以前交过、今天还没交」的人要看以前的,不能只列今天 */ /** 「以前交过、今天还没交」的人要看以前的,不能只列今天 */
today = true, today = true,
@@ -503,7 +503,7 @@ function submissionsHref(
}).href }).href
} }
function lastText(student: ClassBoardStudent) { function lastText(student: TaskBoardStudent) {
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm') if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
if (oftenIdle(student)) { if (oftenIdle(student)) {
return `近 ${board.value?.recentLessons} 节交过 ${student.recentAttended} 节` return `近 ${board.value?.recentLessons} 节交过 ${student.recentAttended} 节`
@@ -558,8 +558,8 @@ function lastText(student: ClassBoardStudent) {
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : '请假' }} {{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : '请假' }}
</n-button> </n-button>
<!-- 布置过的每一节课:改错了班、隔天想起来补,去后台 --> <!-- 布置过的每一节课:改错了班、隔天想起来补,去后台 -->
<AdminLink v-if="isDesktop" :to="{ name: 'admin lesson list', query: className ? { className } : {} }">布置过的课</AdminLink> <AdminLink v-if="isDesktop" :to="{ name: 'admin task list', query: className ? { className } : {} }">布置过的课</AdminLink>
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) --> <!-- 任务页只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
<n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })"> 回头看 → </n-button> <n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })"> 回头看 → </n-button>
</div> </div>
+20 -20
View File
@@ -78,9 +78,9 @@ export const ojs: RouteRecordRaw = {
component: () => import('oj/rank/list.vue'), component: () => import('oj/rank/list.vue'),
}, },
{ {
path: 'classroom', path: 'task',
name: 'classroom board', name: 'task',
component: () => import('oj/classroom/board.vue'), component: () => import('oj/task/index.vue'),
meta: { requiresAuth: true, requiresTeacherAdmin: true }, meta: { requiresAuth: true, requiresTeacherAdmin: true },
}, },
{ {
@@ -90,9 +90,9 @@ export const ojs: RouteRecordRaw = {
meta: { requiresAuth: true, requiresTeacherAdmin: true }, meta: { requiresAuth: true, requiresTeacherAdmin: true },
}, },
{ {
path: 'class', path: 'pk',
name: 'class', name: 'pk',
component: () => import('oj/class/pk.vue'), component: () => import('oj/pk/index.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
@@ -113,13 +113,13 @@ export const ojs: RouteRecordRaw = {
}, },
{ {
path: 'learn', path: 'learn',
name: 'learn home', name: 'learn',
component: () => import('oj/learn/home.vue'), component: () => import('oj/learn/index.vue'),
}, },
{ {
path: 'learn/:type/:step', path: 'learn/:type/:step',
name: 'learn', name: 'learn tutorial',
component: () => import('oj/learn/index.vue'), component: () => import('oj/learn/tutorial.vue'),
props: true, props: true,
}, },
{ {
@@ -164,13 +164,13 @@ export const admins: RouteRecordRaw = {
{ {
path: '', path: '',
name: 'admin home', name: 'admin home',
component: () => import('admin/setting/home.vue'), component: () => import('admin/home/index.vue'),
meta: { requiresSuperAdmin: true }, meta: { requiresSuperAdmin: true },
}, },
{ {
path: 'config', path: 'config',
name: 'admin config', name: 'admin config',
component: () => import('admin/setting/config.vue'), component: () => import('admin/config/index.vue'),
meta: { requiresSuperAdmin: true }, meta: { requiresSuperAdmin: true },
}, },
{ {
@@ -291,22 +291,22 @@ export const admins: RouteRecordRaw = {
meta: { requiresSuperAdmin: true }, meta: { requiresSuperAdmin: true },
}, },
{ {
path: 'problem/stuck', path: 'analytics/stuck',
name: 'admin stuck problems', name: 'admin stuck problems',
component: () => import('admin/problem/Stuck.vue'), component: () => import('admin/analytics/stuck.vue'),
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: 'problem/trend', path: 'analytics/trend',
name: 'admin grade trend', name: 'admin grade trend',
component: () => import('admin/problem/Trend.vue'), component: () => import('admin/analytics/trend.vue'),
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
// 课堂看板布置的每一节课:改、删、补(数据统计按布置切课) // 课堂任务布置的每一节课:改、删、补(数据统计按布置切课)
path: 'lesson', path: 'task',
name: 'admin lesson list', name: 'admin task list',
component: () => import('admin/lesson/list.vue'), component: () => import('admin/task/list.vue'),
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
// 题单管理路由 // 题单管理路由
+1 -1
View File
@@ -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) return contractDeferred('GET /profiles/:username', () => import('./profileSchema').then((m) => m.userProfileSchema), response)
} }
/** 带 language 只数能用这个语言交的题(看板「选题」弹框用) */ /** 带 language 只数能用这个语言交的题(任务页「选题」弹框用) */
/** 课堂布置选题用:language 只数能用它交的题,flowchart 只数打开了画流程图的题 */ /** 课堂布置选题用:language 只数能用它交的题,flowchart 只数打开了画流程图的题 */
export function getProblemTagList(language = '', flowchart = false) { export function getProblemTagList(language = '', flowchart = false) {
return api.get<Tag[]>('problem-tags', { params: { language, ...(flowchart ? { flowchart: '1' } : {}) } }) return api.get<Tag[]>('problem-tags', { params: { language, ...(flowchart ? { flowchart: '1' } : {}) } })
@@ -39,7 +39,7 @@ watch(
) )
/** /**
* 老师这边房间开好了(「帮他」、看板上点「在写」、协作条的「下一个」):跳到那道题。 * 老师这边房间开好了(「帮他」、任务页上点「在写」、协作条的「下一个」):跳到那道题。
* 等 room_open 到了才跳,不在点的那一刻跳 —— 服务端可能拒了(别的老师先接走、学生刚走), * 等 room_open 到了才跳,不在点的那一刻跳 —— 服务端可能拒了(别的老师先接走、学生刚走),
* 那时候跳过去就是对着一个空编辑器。已经在这道题上就不跳(同一道题换下一个人) * 那时候跳过去就是对着一个空编辑器。已经在这道题上就不跳(同一道题换下一个人)
*/ */
+7 -7
View File
@@ -23,7 +23,7 @@ const { isDark, toggleDark } = useDarkTransition()
const theme = useThemeVars() const theme = useThemeVars()
/** /**
* 1280 以下(老师的笔记本)收一收:题号框变窄、「课堂看板」只留图标。 * 1280 以下(老师的笔记本)收一收:题号框变窄、「课堂任务」只留图标。
* 机房的两种屏是 1280 和 1366,都走完整那版 —— 老师也会用机房的电脑。 * 机房的两种屏是 1280 和 1366,都走完整那版 —— 老师也会用机房的电脑。
*/ */
const compact = useMediaQuery('(max-width: 1279px)') const compact = useMediaQuery('(max-width: 1279px)')
@@ -142,7 +142,7 @@ function renderThemeRow() {
/** /**
* 个人菜单只放「自己的东西」+ 后台入口 + 暗色开关。 * 个人菜单只放「自己的东西」+ 后台入口 + 暗色开关。
* 课堂求助、课堂看板挪到了顶栏上(HelpButton、下面的课堂看板链接),不在这里。 * 课堂求助、课堂任务挪到了顶栏上(HelpButton、下面的课堂任务链接),不在这里。
*/ */
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => { const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
const work = userStore.isAdminRole || userStore.canToggleDemoMode const work = userStore.isAdminRole || userStore.canToggleDemoMode
@@ -220,13 +220,13 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
<n-button <n-button
quaternary quaternary
class="tool-link" class="tool-link"
:type="active === 'classroom' ? 'primary' : 'default'" :type="active === 'task' ? 'primary' : 'default'"
title="课堂看板" title="课堂任务"
aria-label="课堂看板" aria-label="课堂任务"
@click="router.push('/classroom')" @click="router.push('/task')"
> >
<template #icon><Icon icon="ph:monitor" /></template> <template #icon><Icon icon="ph:monitor" /></template>
<template v-if="!compact">课堂看板</template> <template v-if="!compact">课堂任务</template>
</n-button> </n-button>
<span class="divider"></span> <span class="divider"></span>
</template> </template>
@@ -10,8 +10,8 @@ import HelpRequestList from './HelpRequestList.vue'
* 按钮闪一下(约 1 秒、没声音、不挡内容),代替原来每来一个就弹一条的 toast: * 按钮闪一下(约 1 秒、没声音、不挡内容),代替原来每来一个就弹一条的 toast:
* 那条 toast 一节课能弹十几次,讲台电脑投着屏的时候还弹在全班面前。 * 那条 toast 一节课能弹十几次,讲台电脑投着屏的时候还弹在全班面前。
* *
* 点开是挂在按钮下面的举手面板(HelpRequestList),不是弹框。课堂看板的表里举手的人 * 点开是挂在按钮下面的举手面板(HelpRequestList),不是弹框。课堂任务的表里举手的人
* 排最前、行里就有「帮他」,在看板上不用点这里。 * 排最前、行里就有「帮他」,在任务页上不用点这里。
*/ */
const props = withDefaults(defineProps<{ size?: 'medium' | 'small' }>(), { size: 'medium' }) const props = withDefaults(defineProps<{ size?: 'medium' | 'small' }>(), { size: 'medium' })
@@ -11,8 +11,8 @@ import UserName from './UserName.vue'
/** /**
* 顶栏「N 人举手」点开的面板(设计稿「举手与协作」HcPanel),挂在按钮下面,不是弹框。 * 顶栏「N 人举手」点开的面板(设计稿「举手与协作」HcPanel),挂在按钮下面,不是弹框。
* *
* 按题分组,每人写错法(和课堂看板同一套 errorKind)和等了多久。同一道题两人以上举手, * 按题分组,每人写错法(和课堂任务同一套 errorKind)和等了多久。同一道题两人以上举手,
* 用户的做法是**停下来全班讲**:组头给「讲这道题」(去看板、选中这道题看错法)和 * 用户的做法是**停下来全班讲**:组头给「讲这道题」(去任务页、选中这道题看错法)和
* 「讲完了,都放下」(这道题还在排队的手一次放下)。 * 「讲完了,都放下」(这道题还在排队的手一次放下)。
*/ */
const emit = defineEmits<{ close: [] }>() const emit = defineEmits<{ close: [] }>()
@@ -48,12 +48,12 @@ function onHelp(item: CollabRequestItem) {
if (help(item.studentId)) emit('close') if (help(item.studentId)) emit('close')
} }
/** 「讲这道题」:去这个班的看板、选中这道题,下面展开错着的人按错法归堆,点人就是讲错误弹层 */ /** 「讲这道题」:去这个班的任务页、选中这道题,下面展开错着的人按错法归堆,点人就是讲错误弹层 */
function explain(items: CollabRequestItem[], problemId: string) { function explain(items: CollabRequestItem[], problemId: string) {
const className = items.find((item) => item.className)?.className const className = items.find((item) => item.className)?.className
emit('close') emit('close')
router.push({ router.push({
name: 'classroom board', name: 'task',
query: { ...(className ? { className } : {}), problem: problemId }, query: { ...(className ? { className } : {}), problem: problemId },
}) })
} }
@@ -191,7 +191,7 @@ watch(
) )
/** /**
* 学生开着这道题的编辑器:报给服务端,老师看板「在写」那一列就是它,点了能直接打开他的 * 学生开着这道题的编辑器:报给服务端,老师任务页「在写」那一列就是它,点了能直接打开他的
* 代码(不用等他举手)。换题、换语言重报一次,编辑器卸载(切走、切成流程图)报 null。 * 代码(不用等他举手)。换题、换语言重报一次,编辑器卸载(切走、切成流程图)报 null。
* 比赛入口不报(collab = false):比赛题号和公开题撞号,老师打开的会是另一道题 * 比赛入口不报(collab = false):比赛题号和公开题撞号,老师打开的会是另一道题
*/ */
@@ -2,7 +2,7 @@ import { onMounted, ref } from 'vue'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
/** /**
* 课堂看板的「请假」:老师手动标记今天不在的学生,看板的人数、进度、「还没交过」都不算他们。 * 课堂任务的「请假」:老师手动标记今天不在的学生,任务页的人数、进度、「还没交过」都不算他们。
* *
* **今天有效**(按东八区的日子),第二天自动清掉 —— 请假一般是请一整天,原来按两小时算, * **今天有效**(按东八区的日子),第二天自动清掉 —— 请假一般是请一整天,原来按两小时算,
* 连上两节课的第二节中途人又冒出来,得重标一遍。 * 连上两节课的第二节中途人又冒出来,得重标一遍。
@@ -11,7 +11,7 @@ import { parseTime } from 'utils/functions'
* 换个人、换台机器都不该继承(2026-10 用户定的,不上服务器)。 * 换个人、换台机器都不该继承(2026-10 用户定的,不上服务器)。
* *
* 原来叫「请假隐藏」(开关 → 名字变成带 × 的标签 → 点 ×),用户说不会用,2026-10 改成 * 原来叫「请假隐藏」(开关 → 名字变成带 × 的标签 → 点 ×),用户说不会用,2026-10 改成
* 看板上一个「请假」按钮勾名单。旧键 `oj_hidden_students` 存的是两小时的过期时刻, * 任务页上一个「请假」按钮勾名单。旧键 `oj_hidden_students` 存的是两小时的过期时刻,
* 口径不同,不迁移,直接删掉。 * 口径不同,不迁移,直接删掉。
* *
* @param storageKey localStorage 的键 * @param storageKey localStorage 的键
@@ -36,7 +36,7 @@ export function useLeaveStudents(storageKey: string) {
try { try {
localStorage.setItem(storageKey, JSON.stringify(data)) localStorage.setItem(storageKey, JSON.stringify(data))
} catch { } catch {
// 存不下就只在这次打开里有效,不影响看板 // 存不下就只在这次打开里有效,不影响任务页
} }
} }
@@ -48,7 +48,7 @@ export function useLeaveStudents(storageKey: string) {
/** /**
* 把 `usernames` 这批人的标记换成 `selected`:在里面但没选的取消,选了的标上。 * 把 `usernames` 这批人的标记换成 `selected`:在里面但没选的取消,选了的标上。
* 只动这一批(看板上这个班),别的班标着的人不受影响 * 只动这一批(任务页上这个班),别的班标着的人不受影响
*/ */
function setLeave(usernames: string[], selected: string[]) { function setLeave(usernames: string[], selected: string[]) {
const scope = new Set(usernames) const scope = new Set(usernames)
@@ -1,7 +1,7 @@
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
/** /**
* 老师接单 / 主动打开学生的编辑器。两处入口(顶栏举手面板、课堂看板)共用。 * 老师接单 / 主动打开学生的编辑器。两处入口(顶栏举手面板、课堂任务)共用。
* *
* 只发消息,不跳页面:房间开好(room_open 到了)才跳到那道题,跳转统一在 CollabHost 里做 * 只发消息,不跳页面:房间开好(room_open 到了)才跳到那道题,跳转统一在 CollabHost 里做
* —— 原来是点完就跳,服务端拒了(别的老师先接走、学生刚走)前端也已经跳过去了。 * —— 原来是点完就跳,服务端拒了(别的老师先接走、学生刚走)前端也已经跳过去了。
+3 -3
View File
@@ -7,9 +7,9 @@ import { useUserStore } from 'shared/store/user'
import { getAccessToken } from 'utils/auth' import { getAccessToken } from 'utils/auth'
import { STORAGE_KEY } from 'utils/constants' import { STORAGE_KEY } from 'utils/constants'
import storage from 'utils/storage' 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 也能弹 —— 强制登出跟当前挂着哪个组件无关。 // 全站唯一一处,脱离 n-message-provider 也能弹 —— 强制登出跟当前挂着哪个组件无关。
// utils/api.ts 里也是这么做的 // utils/api.ts 里也是这么做的
@@ -580,7 +580,7 @@ export interface CollabRequestItem {
className: string | null className: string | null
problemId: string problemId: string
problemTitle: string problemTitle: string
/** 他这道题最后一次代码提交(和看板格子的 latest 同形),面板按它写错法;没交过为 null */ /** 他这道题最后一次代码提交(和任务页格子的 latest 同形),面板按它写错法;没交过为 null */
latest: BoardLatest | null latest: BoardLatest | null
createdAt: number createdAt: number
status: 'pending' | 'active' status: 'pending' | 'active'
+4 -4
View File
@@ -21,7 +21,7 @@ const { isDark, toggleDark } = useDarkTransition()
* 菜单的唯一一张表。`role` 必须和 routes.ts 里对应路由的 meta 对齐: * 菜单的唯一一张表。`role` 必须和 routes.ts 里对应路由的 meta 对齐:
* 菜单里露出一个守卫不放行的入口,点下去就是被静默踢回首页。 * 菜单里露出一个守卫不放行的入口,点下去就是被静默踢回首页。
* `path` 同时用来判定高亮:取当前路径能匹配上的**最长**那一条,所以 * `path` 同时用来判定高亮:取当前路径能匹配上的**最长**那一条,所以
* /admin/problem/stuck 亮「卡点分析」而不是「题目」。 * /admin/analytics/stuck 亮「卡点分析」而不是「首页」。
*/ */
type Role = 'problem' | 'teacher' | 'super' type Role = 'problem' | 'teacher' | 'super'
interface NavItem { interface NavItem {
@@ -67,7 +67,7 @@ const NAV: NavGroup[] = [
to: '/admin/problemset/list', to: '/admin/problemset/list',
role: 'teacher', role: 'teacher',
}, },
{ label: '课堂作业', icon: 'ph:chalkboard-teacher', path: '/admin/lesson', role: 'teacher' }, { label: '课堂任务', icon: 'ph:chalkboard-teacher', path: '/admin/task', role: 'teacher' },
{ {
label: '教程', label: '教程',
icon: 'ph:book-open', icon: 'ph:book-open',
@@ -83,13 +83,13 @@ const NAV: NavGroup[] = [
{ {
label: '卡点分析', label: '卡点分析',
icon: 'ph:warning-diamond', icon: 'ph:warning-diamond',
path: '/admin/problem/stuck', path: '/admin/analytics/stuck',
role: 'teacher', role: 'teacher',
}, },
{ {
label: '年度趋势', label: '年度趋势',
icon: 'ph:chart-line-up', icon: 'ph:chart-line-up',
path: '/admin/problem/trend', path: '/admin/analytics/trend',
role: 'teacher', role: 'teacher',
}, },
{ label: '自学情况', icon: 'ph:student', path: '/admin/learn', role: 'teacher' }, { label: '自学情况', icon: 'ph:student', path: '/admin/learn', role: 'teacher' },
+5 -5
View File
@@ -6,13 +6,13 @@ export type HelpStatus = 'idle' | 'pending' | 'active'
export interface RoomInfo { export interface RoomInfo {
/** /**
* help = 学生举手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。 * help = 学生举手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。
* 学生那边按钮写「在帮你」还是「在看你的代码」、结束时说「帮完了」还是「看完了」看它 * 学生那边按钮写「在帮你」还是「在看你的代码」、结束时说「帮完了」还是「看完了」看它
*/ */
mode: 'help' | 'peek' mode: 'help' | 'peek'
peerName: string peerName: string
peerRole: 'student' | 'teacher' peerRole: 'student' | 'teacher'
/** 教师端才有:学生的 id(协作条按它在列表里找他举手多久了)和班级(「帮完了」回哪个班的看板) */ /** 教师端才有:学生的 id(协作条按它在列表里找他举手多久了)和班级(「帮完了」回哪个班的任务页) */
peerId: number | null peerId: number | null
peerClassName: string | null peerClassName: string | null
problemId: string problemId: string
@@ -107,7 +107,7 @@ export const useCollabStore = defineStore('collab', () => {
// 自己离开了这道题(跳走页面、把语言切成流程图) // 自己离开了这道题(跳走页面、把语言切成流程图)
case 'self_left': case 'self_left':
return teacher ? '你已离开这道题,求助退回队列了' : '你已离开这道题,协作结束' return teacher ? '你已离开这道题,求助退回队列了' : '你已离开这道题,协作结束'
// done:老师自己点的「帮完了」,页面跟着回看板或者去下一个,不用再说一句 // done:老师自己点的「帮完了」,页面跟着回任务页或者去下一个,不用再说一句
default: default:
return teacher ? '' : '老师已结束这次帮忙' return teacher ? '' : '老师已结束这次帮忙'
} }
@@ -231,7 +231,7 @@ export const useCollabStore = defineStore('collab', () => {
ws.send({ type: 'accept', studentId }) ws.send({ type: 'accept', studentId })
} }
/** 老师在看板上主动打开一个学生正在写的代码(他没举手也行;举着手就等于接单) */ /** 老师在任务页上主动打开一个学生正在写的代码(他没举手也行;举着手就等于接单) */
function peek(studentId: number) { function peek(studentId: number) {
ws.send({ type: 'peek', studentId }) ws.send({ type: 'peek', studentId })
} }
@@ -242,7 +242,7 @@ export const useCollabStore = defineStore('collab', () => {
} }
/** /**
* 学生报告他开着哪道题的编辑器(null = 离开了),老师看板「在写」那一列用。 * 学生报告他开着哪道题的编辑器(null = 离开了),老师任务页「在写」那一列用。
* 老师不报:老师的编辑器不会被别人打开 * 老师不报:老师的编辑器不会被别人打开
*/ */
function reportPresence(problemId: string | null, language: LANGUAGE = 'Python') { function reportPresence(problemId: string | null, language: LANGUAGE = 'Python') {
+1 -1
View File
@@ -8,7 +8,7 @@ import type { ClassActivity, ClassActivityProblem } from 'utils/types'
const STALE_MS = 2 * 60 * 1000 const STALE_MS = 2 * 60 * 1000
/** /**
* 「这节课的题」:首页「班里在做」那几道(老师在课堂看板布置的,或者从同班提交推断的)。 * 「这节课的题」:首页「班里在做」那几道(老师在课堂任务布置的,或者从同班提交推断的)。
* *
* 题目页有两处要用:顶部的课堂条(做完几道、每道的状态),和通过之后的「下一题」。 * 题目页有两处要用:顶部的课堂条(做完几道、每道的状态),和通过之后的「下一题」。
* 原来「下一题」每次通过都自己拉一遍,现在两处读同一份,通过之后由判题那边 * 原来「下一题」每次通过都自己拉一遍,现在两处读同一份,通过之后由判题那边
+1 -1
View File
@@ -19,7 +19,7 @@ export const useUserStore = defineStore('user', () => {
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏。 // 演示模式:超管临时把界面伪装成普通学生,方便上课投屏。
// 开关存在 storage 里、只有主动退登才清,会话过期后换人登录它还在 —— 所以只对超管 // 开关存在 storage 里、只有主动退登才清,会话过期后换人登录它还在 —— 所以只对超管
// 生效。不然老师在超管用过的机房电脑上一登录,后台、看板、求助全没了, // 生效。不然老师在超管用过的机房电脑上一登录,后台、任务页、求助全没了,
// 而「退出演示」只给超管,他自己关不掉 // 而「退出演示」只给超管,他自己关不掉
const demoSwitch = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false) const demoSwitch = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
const demoMode = computed(() => demoSwitch.value && realIsSuperAdmin.value) const demoMode = computed(() => demoSwitch.value && realIsSuperAdmin.value)
+3 -3
View File
@@ -1,15 +1,15 @@
import { classifyDiff, type DiffKind } from 'utils/wrongAnswer' import { classifyDiff, type DiffKind } from 'utils/wrongAnswer'
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants' 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(判题机写的),和提交详情、题目页结果区同源。 * 依据是提交里存的 runtime_error / sample_check(判题机写的),和提交详情、题目页结果区同源。
*/ */
export type BoardLatest = NonNullable<ClassBoardCell['latest']> export type BoardLatest = NonNullable<TaskBoardCell['latest']>
export interface ErrorKind { export interface ErrorKind {
/** 归堆用:同一个 key 是同一种错 */ /** 归堆用:同一个 key 是同一种错 */
+2 -2
View File
@@ -251,8 +251,8 @@ export type { WeeklyRank, WeeklyRankItem } from '@oj2/contract'
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */ /** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem, LastVisit } from '@oj2/contract' 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' export type { KnowledgeLevel, KnowledgeMap } from '@oj2/contract'
+5 -5
View File
@@ -377,9 +377,9 @@ export const dashboardInfoSchema = z.object({
todaySystemErrorCount: z.number().int(), todaySystemErrorCount: z.number().int(),
/** 现在在线的人数(5 分钟内有动静,auth/presence.ts) */ /** 现在在线的人数(5 分钟内有动静,auth/presence.ts) */
onlineCount: z.number().int(), onlineCount: z.number().int(),
/** 正在上课的班(最近两小时同班在交题,和课堂看板猜班同一个办法);没有为 null */ /** 正在上课的班(最近两小时同班在交题,和课堂任务猜班同一个办法);没有为 null */
activeClass: z.string().nullable(), activeClass: z.string().nullable(),
/** 全站的数(看板那一排)。学生 = 普通用户 + 学生管理员,老师 = 教师管理员 + 超管 */ /** 全站的数(后台首页看板那一排)。学生 = 普通用户 + 学生管理员,老师 = 教师管理员 + 超管 */
site: z.object({ site: z.object({
students: z.number().int(), students: z.number().int(),
teachers: z.number().int(), teachers: z.number().int(),
@@ -675,7 +675,7 @@ const learnProblemStateSchema = z.object({
* 「自学情况」的方块格:一个班的学生 × 这门课的每一课。 * 「自学情况」的方块格:一个班的学生 × 这门课的每一课。
* *
* **没打开过任何一课的学生也要有一行**(`opened` 为空)—— 页面上把他们收成一串名单, * **没打开过任何一课的学生也要有一行**(`opened` 为空)—— 页面上把他们收成一串名单,
* 这张表首要回答的就是「谁还没开始」。`done` 是学完的课(练一练全对,见 `isLessonDone`), * 这张表首要回答的就是「谁还没开始」。`done` 是学完的课(练一练全对,见 `isTutorialDone`),
* 一定也在 `opened` 里或者是没打开却把练一练做完了的(老链接直达练习的情况)。 * 一定也在 `opened` 里或者是没打开却把练一练做完了的(老链接直达练习的情况)。
*/ */
export const learnBoardStudentSchema = z.object({ export const learnBoardStudentSchema = z.object({
@@ -690,7 +690,7 @@ export const learnBoardStudentSchema = z.object({
* 碰过练一练的课,一课一项(一道没做的课不在里面)。方块格靠它分出「做了一部分」, * 碰过练一练的课,一课一项(一道没做的课不在里面)。方块格靠它分出「做了一部分」,
* `wrongAttempts` 是这课所有练习错的次数加起来 * `wrongAttempts` 是这课所有练习错的次数加起来
*/ */
lessons: z.array( tutorials: z.array(
z.object({ z.object({
tutorialId: z.number().int(), tutorialId: z.number().int(),
tried: z.number().int(), tried: z.number().int(),
@@ -774,7 +774,7 @@ export const learnStudentDetailSchema = z.object({
userId: z.number().int(), userId: z.number().int(),
username: z.string(), username: z.string(),
className: z.string().nullable(), className: z.string().nullable(),
lessons: z.array( tutorials: z.array(
z.object({ z.object({
tutorialId: z.number().int(), tutorialId: z.number().int(),
title: z.string(), title: z.string(),
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more