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