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