- 课堂看板「选题」弹框:左边知识点(加必会题),右边搜题号/题名、点选,按点的先后排; 只列这次作业的语言能交的题(/problems、/problem-tags 加 ?language=) - 一天多节课:0040 拿掉 class_lesson (class_name, day) 唯一约束,每次布置一行; 看板「改」只改当前一节,加「新的一节」;这一节布置超过 100 分钟时先问这节课做哪几道题。 不带 lessonId/newLesson 的老请求照旧改今天最后一节 - 数据统计「一行一节课」:布置过的课以布置为准(题目和顺序照布置、做完按全班算、标「布置」), 这些提交不再参与推断切段,也不算零散;详情页「做完」注明是这一天/这节课/这段时间 - 后台「课堂作业」:一节一行,改(班级/日子/时刻/语言/题目)、看统计、删除、补一节; 看板加「布置过的课」入口。题号校验和「做完」口径抽到 services/lesson.ts,三处共用 ⚠️ 机房站点要和服务器一起升级:旧代码布置时 ON CONFLICT (class_name, day) 找不到约束会报错 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0916015405
commit
b344bd1dd6
26 files changed
+5685
-99
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
-- 一个班一天可以上几节内容不同的课:拿掉一天一行的唯一约束,每次布置一行。
|
||||||
|
-- 共库的机房站点要一起升级:旧代码布置时 ON CONFLICT (class_name, day) 会因为找不到这条约束报错。
|
||||||
|
ALTER TABLE "class_lesson" DROP CONSTRAINT "class_lesson_class_day_unique";--> statement-breakpoint
|
||||||
|
CREATE INDEX "class_lesson_class_day_idx" ON "class_lesson" USING btree ("class_name","day");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -281,6 +281,13 @@
|
|||||||
"when": 1792930018504,
|
"when": 1792930018504,
|
||||||
"tag": "0039_seed_challenges",
|
"tag": "0039_seed_challenges",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 40,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793622303180,
|
||||||
|
"tag": "0040_class_lesson_many_per_day",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1292,7 +1292,8 @@ export const exerciseAttempt = pgTable(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 老师给某个班「这节课」布置的题(课堂看板里输入的题号),一个班一天一行。
|
* 老师给某个班布置的一节课的题(课堂看板里布置的),一次布置一行。一个班一天可以有几节
|
||||||
|
* 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。
|
||||||
*
|
*
|
||||||
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。
|
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。
|
||||||
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
|
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
|
||||||
@@ -1323,10 +1324,11 @@ export const classLesson = pgTable(
|
|||||||
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过
|
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过
|
||||||
*/
|
*/
|
||||||
language: text().$type<LessonLanguage>(),
|
language: text().$type<LessonLanguage>(),
|
||||||
/** 这天第一次布置的老师,别的老师「改」也不换(和 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', {
|
||||||
@@ -1341,7 +1343,8 @@ export const classLesson = pgTable(
|
|||||||
}).notNull(),
|
}).notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
unique('class_lesson_class_day_unique').on(table.className, table.day),
|
// 一个班一天可以上几节内容不同的课,每次布置一行(0040 拿掉了原来的一天一行唯一约束)
|
||||||
|
index('class_lesson_class_day_idx').on(table.className, table.day),
|
||||||
index('class_lesson_created_by_idx').on(table.createdBy),
|
index('class_lesson_created_by_idx').on(table.createdBy),
|
||||||
foreignKey({
|
foreignKey({
|
||||||
columns: [table.createdBy],
|
columns: [table.createdBy],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { adminAnalyticsRoutes } from './analytics'
|
|||||||
import { adminConfRoutes } from './conf'
|
import { adminConfRoutes } from './conf'
|
||||||
import { adminContestRoutes } from './contest'
|
import { adminContestRoutes } from './contest'
|
||||||
import { adminLearnRoutes } from './learn'
|
import { adminLearnRoutes } from './learn'
|
||||||
|
import { adminLessonRoutes } from './lesson'
|
||||||
import { adminProblemRoutes } from './problem'
|
import { adminProblemRoutes } from './problem'
|
||||||
import { adminProblemSetRoutes } from './problemset'
|
import { adminProblemSetRoutes } from './problemset'
|
||||||
import { adminTagRoutes } from './tag'
|
import { adminTagRoutes } from './tag'
|
||||||
@@ -31,6 +32,7 @@ adminRoutes.route('/', adminAnalyticsRoutes)
|
|||||||
adminRoutes.route('/', adminConfRoutes)
|
adminRoutes.route('/', adminConfRoutes)
|
||||||
adminRoutes.route('/', adminContestRoutes)
|
adminRoutes.route('/', adminContestRoutes)
|
||||||
adminRoutes.route('/', adminLearnRoutes)
|
adminRoutes.route('/', adminLearnRoutes)
|
||||||
|
adminRoutes.route('/', adminLessonRoutes)
|
||||||
adminRoutes.route('/', adminProblemRoutes)
|
adminRoutes.route('/', adminProblemRoutes)
|
||||||
adminRoutes.route('/', adminProblemSetRoutes)
|
adminRoutes.route('/', adminProblemSetRoutes)
|
||||||
adminRoutes.route('/', adminTagRoutes)
|
adminRoutes.route('/', adminTagRoutes)
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { adminLessonRequestSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract'
|
||||||
|
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
|
import { requireTeacher, type AppEnv } from '../../auth/middleware'
|
||||||
|
import { db, schema } from '../../db'
|
||||||
|
import { failure, parseBody, success } from '../../http'
|
||||||
|
import { lessonDoneCounts, resolveLessonProblems } from '../../services/lesson'
|
||||||
|
import { localDateTime } from '../../time'
|
||||||
|
import { queryInteger } from '../helpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台「课堂作业」:布置过的每一节课(class_lesson)。看板上布置错了班、错了题可以在这里改,
|
||||||
|
* 看板外上的课(以前的、没在看板布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
|
||||||
|
*
|
||||||
|
* 权限和看板一样是老师:看板上任何老师都能改任何班的这节课,这里不另设门槛。
|
||||||
|
*/
|
||||||
|
export const adminLessonRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
const teacherName = sql<string>`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`
|
||||||
|
|
||||||
|
adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
|
||||||
|
const limit = queryInteger(c.req.query('limit'), 20, { min: 1, max: 100 })
|
||||||
|
const offset = queryInteger(c.req.query('offset'), 0, { min: 0 })
|
||||||
|
const className = c.req.query('className')?.trim()
|
||||||
|
const teacherId = queryInteger(c.req.query('teacher'), 0, { min: 0 })
|
||||||
|
const where = and(className ? eq(schema.classLesson.className, className) : undefined, teacherId ? eq(schema.classLesson.createdBy, teacherId) : undefined)
|
||||||
|
|
||||||
|
const [rows, [totalRow], teachers] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: schema.classLesson.id,
|
||||||
|
className: schema.classLesson.className,
|
||||||
|
day: schema.classLesson.day,
|
||||||
|
problemIds: schema.classLesson.problemIds,
|
||||||
|
language: schema.classLesson.language,
|
||||||
|
createdAt: schema.classLesson.createdAt,
|
||||||
|
updatedAt: schema.classLesson.updatedAt,
|
||||||
|
createdBy: teacherName,
|
||||||
|
})
|
||||||
|
.from(schema.classLesson)
|
||||||
|
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
|
||||||
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset),
|
||||||
|
db.select({ value: count() }).from(schema.classLesson).where(where),
|
||||||
|
db
|
||||||
|
.select({ id: schema.classLesson.createdBy, name: teacherName })
|
||||||
|
.from(schema.classLesson)
|
||||||
|
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
|
||||||
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
|
||||||
|
.groupBy(schema.classLesson.createdBy, schema.user.username, schema.userProfile.realName)
|
||||||
|
.orderBy(desc(count())),
|
||||||
|
])
|
||||||
|
|
||||||
|
const problemIds = [...new Set(rows.flatMap((row) => row.problemIds))]
|
||||||
|
const classNames = [...new Set(rows.map((row) => row.className))]
|
||||||
|
const [problems, sizes, done] = await Promise.all([
|
||||||
|
problemIds.length
|
||||||
|
? db
|
||||||
|
.select({ id: schema.problem.id, displayId: schema.problem.displayId, title: schema.problem.title, visible: schema.problem.visible })
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(inArray(schema.problem.id, problemIds))
|
||||||
|
: [],
|
||||||
|
classNames.length
|
||||||
|
? db
|
||||||
|
.select({ className: schema.user.className, value: count() })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(and(inArray(schema.user.className, classNames), inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false)))
|
||||||
|
.groupBy(schema.user.className)
|
||||||
|
: [],
|
||||||
|
lessonDoneCounts(rows),
|
||||||
|
])
|
||||||
|
const problemById = new Map(problems.map((row) => [row.id, row]))
|
||||||
|
const sizeByClass = new Map(sizes.map((row) => [row.className, row.value]))
|
||||||
|
|
||||||
|
return success(c, {
|
||||||
|
results: rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
className: row.className,
|
||||||
|
day: row.day,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
language: row.language ?? null,
|
||||||
|
// 题被删掉的(problem_ids 不挂外键)直接不列
|
||||||
|
problems: row.problemIds.flatMap((id) => {
|
||||||
|
const problem = problemById.get(id)
|
||||||
|
return problem ? [{ problemDisplayId: problem.displayId, title: problem.title, visible: problem.visible }] : []
|
||||||
|
}),
|
||||||
|
classSize: sizeByClass.get(row.className) ?? 0,
|
||||||
|
doneCount: done.get(row.id) ?? 0,
|
||||||
|
})),
|
||||||
|
total: totalRow?.value ?? 0,
|
||||||
|
teachers,
|
||||||
|
} satisfies AdminLessonList)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 新补一节、改一节共用的校验:班得有人、题号都对得上、语言每道都能交 */
|
||||||
|
async function lessonValues(
|
||||||
|
body: AdminLessonRequest,
|
||||||
|
): Promise<
|
||||||
|
| { code: string; message: string }
|
||||||
|
| { values: { className: string; day: string; problemIds: number[]; language: AdminLessonRequest['language']; createdAt: string } }
|
||||||
|
> {
|
||||||
|
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, body.className)).limit(1)
|
||||||
|
if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` }
|
||||||
|
const resolved = await resolveLessonProblems(body.problemDisplayIds, body.language)
|
||||||
|
if ('code' in resolved) return resolved
|
||||||
|
return {
|
||||||
|
values: {
|
||||||
|
className: body.className,
|
||||||
|
day: body.day,
|
||||||
|
problemIds: resolved.problemIds,
|
||||||
|
language: body.language,
|
||||||
|
createdAt: localDateTime(body.day, body.time),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adminLessonRoutes.post('/lessons', requireTeacher, async (c) => {
|
||||||
|
const parsed = await parseBody(c, adminLessonRequestSchema, '班级、日子、时刻或题号不对')
|
||||||
|
if (!parsed.success) return parsed.response
|
||||||
|
const result = await lessonValues(parsed.data)
|
||||||
|
if ('code' in result) return failure(c, 400, result.code, result.message)
|
||||||
|
const [row] = await db
|
||||||
|
.insert(schema.classLesson)
|
||||||
|
.values({ ...result.values, createdBy: c.get('user')!.id, updatedAt: new Date().toISOString() })
|
||||||
|
.returning({ id: schema.classLesson.id })
|
||||||
|
return success(c, { id: row!.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
|
||||||
|
const id = queryInteger(c.req.param('id'), 0, { min: 1 })
|
||||||
|
const parsed = await parseBody(c, adminLessonRequestSchema, '班级、日子、时刻或题号不对')
|
||||||
|
if (!parsed.success) return parsed.response
|
||||||
|
const result = await lessonValues(parsed.data)
|
||||||
|
if ('code' in result) return failure(c, 400, result.code, result.message)
|
||||||
|
// 布置的老师不换,和看板上「改」一样认第一次
|
||||||
|
const [row] = await db
|
||||||
|
.update(schema.classLesson)
|
||||||
|
.set({ ...result.values, updatedAt: new Date().toISOString() })
|
||||||
|
.where(eq(schema.classLesson.id, id))
|
||||||
|
.returning({ id: schema.classLesson.id })
|
||||||
|
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
|
||||||
|
return success(c, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
adminLessonRoutes.delete('/lessons/:id', requireTeacher, async (c) => {
|
||||||
|
const id = queryInteger(c.req.param('id'), 0, { min: 1 })
|
||||||
|
await db.delete(schema.classLesson).where(eq(schema.classLesson.id, id))
|
||||||
|
return success(c, null)
|
||||||
|
})
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
isSqlProblem,
|
|
||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
classLessonRequestSchema,
|
classLessonRequestSchema,
|
||||||
type ClassActivity,
|
type ClassActivity,
|
||||||
@@ -25,6 +24,7 @@ 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 { accepted } from '../services/learning-stats'
|
||||||
|
import { resolveLessonProblems } from '../services/lesson'
|
||||||
import { calendarDay, dayStart, localTime } from '../time'
|
import { calendarDay, dayStart, localTime } from '../time'
|
||||||
|
|
||||||
export const classroomRoutes = new Hono<AppEnv>()
|
export const classroomRoutes = new Hono<AppEnv>()
|
||||||
@@ -94,11 +94,12 @@ async function visibleProblems(ids: number[]) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言、布置时刻和布置的老师;
|
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言、布置时刻和布置的老师;
|
||||||
* 没布置为 null。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
|
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
|
||||||
*/
|
*/
|
||||||
async function lessonPlan(className: string, day: string) {
|
async function lessonPlan(className: string, day: string) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
|
id: schema.classLesson.id,
|
||||||
problemIds: schema.classLesson.problemIds,
|
problemIds: schema.classLesson.problemIds,
|
||||||
language: schema.classLesson.language,
|
language: schema.classLesson.language,
|
||||||
createdAt: schema.classLesson.createdAt,
|
createdAt: schema.classLesson.createdAt,
|
||||||
@@ -108,6 +109,7 @@ async function lessonPlan(className: string, day: string) {
|
|||||||
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
|
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, 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)))
|
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
|
||||||
|
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
return row?.problemIds.length ? row : null
|
return row?.problemIds.length ? row : null
|
||||||
}
|
}
|
||||||
@@ -132,6 +134,7 @@ async function classLessonProblems(className: string, lookbackDays: number) {
|
|||||||
const groupById = new Map(groups.map((row) => [row.problemId, row]))
|
const groupById = new Map(groups.map((row) => [row.problemId, row]))
|
||||||
return {
|
return {
|
||||||
source: 'teacher' as const,
|
source: 'teacher' as const,
|
||||||
|
lessonId: plan.id,
|
||||||
day: today,
|
day: today,
|
||||||
language: plan.language ?? null,
|
language: plan.language ?? null,
|
||||||
assignedAt: plan.createdAt,
|
assignedAt: plan.createdAt,
|
||||||
@@ -161,6 +164,7 @@ async function classLessonProblems(className: string, lookbackDays: number) {
|
|||||||
const problems = await visibleProblems(picked.map((row) => row.problemId))
|
const problems = await visibleProblems(picked.map((row) => row.problemId))
|
||||||
return {
|
return {
|
||||||
source: picked.length ? ('inferred' as const) : null,
|
source: picked.length ? ('inferred' as const) : null,
|
||||||
|
lessonId: null,
|
||||||
day: picked.length ? latest : null,
|
day: picked.length ? latest : null,
|
||||||
// 推断出来的课不知道老师要的是哪种语言,不限
|
// 推断出来的课不知道老师要的是哪种语言,不限
|
||||||
language: null,
|
language: null,
|
||||||
@@ -379,6 +383,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
className: null,
|
className: null,
|
||||||
day,
|
day,
|
||||||
source: null,
|
source: null,
|
||||||
|
lessonId: null,
|
||||||
language: null,
|
language: null,
|
||||||
assignedAt: null,
|
assignedAt: null,
|
||||||
assignedBy: null,
|
assignedBy: null,
|
||||||
@@ -397,7 +402,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
.select({ language: schema.classLesson.language })
|
.select({ language: schema.classLesson.language })
|
||||||
.from(schema.classLesson)
|
.from(schema.classLesson)
|
||||||
.where(and(eq(schema.classLesson.className, className), isNotNull(schema.classLesson.language)))
|
.where(and(eq(schema.classLesson.className, className), isNotNull(schema.classLesson.language)))
|
||||||
.orderBy(desc(schema.classLesson.day))
|
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.createdAt))
|
||||||
.limit(1),
|
.limit(1),
|
||||||
])
|
])
|
||||||
const ids = lesson.problems.map((problem) => problem.id)
|
const ids = lesson.problems.map((problem) => problem.id)
|
||||||
@@ -550,6 +555,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
className,
|
className,
|
||||||
day,
|
day,
|
||||||
source: lesson.source,
|
source: lesson.source,
|
||||||
|
lessonId: lesson.lessonId,
|
||||||
language: lesson.language,
|
language: lesson.language,
|
||||||
assignedAt: lesson.assignedAt,
|
assignedAt: lesson.assignedAt,
|
||||||
assignedBy: lesson.assignedBy,
|
assignedBy: lesson.assignedBy,
|
||||||
@@ -725,54 +731,51 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 老师给这个班布置今天的题。按展示题号给、不分大小写,存 problem.id 并保留输入顺序;
|
* 老师给这个班布置今天的一节课。按展示题号给、不分大小写,存 problem.id 并保留输入顺序;
|
||||||
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉,
|
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节,
|
||||||
* 学生那边退回推断。
|
* 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。
|
||||||
*/
|
*/
|
||||||
classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
|
classroomRoutes.put('/classroom/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, language } = parsed.data
|
const { className, problemDisplayIds, language, lessonId, newLesson } = parsed.data
|
||||||
const day = calendarDay()
|
const day = calendarDay()
|
||||||
|
|
||||||
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, className)).limit(1)
|
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, className)).limit(1)
|
||||||
if (!exists) return failure(c, 400, 'class-not-found', `没有 ${className} 这个班`)
|
if (!exists) return failure(c, 400, 'class-not-found', `没有 ${className} 这个班`)
|
||||||
|
|
||||||
|
// 要改的是哪一行:点名的那一节(只认这个班今天的),新开一节就没有,老写法是今天最后布置的那一节
|
||||||
|
const [target] = newLesson
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({ id: schema.classLesson.id })
|
||||||
|
.from(schema.classLesson)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.classLesson.className, className),
|
||||||
|
eq(schema.classLesson.day, day),
|
||||||
|
lessonId === undefined ? undefined : eq(schema.classLesson.id, lessonId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||||
|
.limit(1)
|
||||||
if (!problemDisplayIds.length) {
|
if (!problemDisplayIds.length) {
|
||||||
await db.delete(schema.classLesson).where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
|
if (target) await db.delete(schema.classLesson).where(eq(schema.classLesson.id, target.id))
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const wanted = [...new Set(problemDisplayIds.map((id) => id.toLowerCase()))]
|
const resolved = await resolveLessonProblems(problemDisplayIds, language)
|
||||||
const found = await db
|
if ('code' in resolved) return failure(c, 400, resolved.code, resolved.message)
|
||||||
.select({
|
const { problemIds } = resolved
|
||||||
id: schema.problem.id,
|
|
||||||
displayId: schema.problem.displayId,
|
|
||||||
languages: schema.problem.languages,
|
|
||||||
})
|
|
||||||
.from(schema.problem)
|
|
||||||
.where(and(inArray(sql<string>`lower(${schema.problem.displayId})`, wanted), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
|
||||||
const idByDisplay = new Map(found.map((row) => [row.displayId.toLowerCase(), row.id]))
|
|
||||||
const missing = wanted.filter((id) => !idByDisplay.has(id))
|
|
||||||
if (missing.length) {
|
|
||||||
return failure(c, 400, 'problem-not-found', `这些题号不存在或没有公开:${missing.join('、')}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选了语言就得每道都能用它交,不然学生点开才发现交不了(提交接口会拒:language-not-allowed)。
|
|
||||||
// SQL 题除外:它只收 SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL
|
|
||||||
if (language) {
|
|
||||||
const unsupported = found.filter((row) => !row.languages.includes(language) && !isSqlProblem(row)).map((row) => row.displayId)
|
|
||||||
if (unsupported.length) {
|
|
||||||
return failure(c, 400, 'language-not-allowed', `这些题不能用 ${language} 交:${unsupported.join('、')}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const problemIds = wanted.map((id) => idByDisplay.get(id)!)
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
await db
|
if (target) {
|
||||||
.insert(schema.classLesson)
|
// 「改」不换布置的人,和 createdAt 一样认第一次
|
||||||
.values({
|
await db.update(schema.classLesson).set({ problemIds, language, updatedAt: now }).where(eq(schema.classLesson.id, target.id))
|
||||||
|
} else {
|
||||||
|
// 新开一节、今天还没有,或者点名的那一节已经没了(别的老师清掉了)—— 最后这种也照新开处理,
|
||||||
|
// 老师手上的题号不至于白填
|
||||||
|
await db.insert(schema.classLesson).values({
|
||||||
className,
|
className,
|
||||||
day,
|
day,
|
||||||
problemIds,
|
problemIds,
|
||||||
@@ -781,10 +784,6 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
}
|
||||||
target: [schema.classLesson.className, schema.classLesson.day],
|
|
||||||
// 「改」不换布置的人,和 createdAt 一样认第一次
|
|
||||||
set: { problemIds, language, updatedAt: now },
|
|
||||||
})
|
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AST_SUPPORTED_LANGUAGES,
|
AST_SUPPORTED_LANGUAGES,
|
||||||
isSqlProblem,
|
isSqlProblem,
|
||||||
|
lessonLanguageSchema,
|
||||||
parseLanguage,
|
parseLanguage,
|
||||||
problemListSortSchema,
|
problemListSortSchema,
|
||||||
problemTypeFilterSchema,
|
problemTypeFilterSchema,
|
||||||
@@ -71,6 +72,15 @@ function typeFilter(type: ProblemTypeFilter | undefined) {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课堂看板「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
|
||||||
|
* 学生的题目列表不带这个参数
|
||||||
|
*/
|
||||||
|
function lessonLanguageFilter(raw: string | undefined) {
|
||||||
|
const language = lessonLanguageSchema.safeParse(raw).data
|
||||||
|
return language ? sql`${schema.problem.languages} @> ${JSON.stringify([language])}::jsonb` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
type ProblemStates = Awaited<ReturnType<typeof problemStates>> | null
|
type ProblemStates = Awaited<ReturnType<typeof problemStates>> | null
|
||||||
type UserCounts = Awaited<ReturnType<typeof problemUserCounts>>
|
type UserCounts = Awaited<ReturnType<typeof problemUserCounts>>
|
||||||
|
|
||||||
@@ -137,6 +147,8 @@ problemRoutes.get('/problems', optionalAuth, async (c) => {
|
|||||||
}
|
}
|
||||||
const byType = typeFilter(type)
|
const byType = typeFilter(type)
|
||||||
if (byType) filters.push(byType)
|
if (byType) filters.push(byType)
|
||||||
|
const byLanguage = lessonLanguageFilter(c.req.query('language'))
|
||||||
|
if (byLanguage) filters.push(byLanguage)
|
||||||
|
|
||||||
const states = user ? await problemStates(user.id) : null
|
const states = user ? await problemStates(user.id) : null
|
||||||
if (undone && states && states.solved.size > 0) filters.push(notInArray(schema.problem.id, [...states.solved]))
|
if (undone && states && states.solved.size > 0) filters.push(notInArray(schema.problem.id, [...states.solved]))
|
||||||
@@ -207,7 +219,15 @@ problemRoutes.get('/problem-tags', async (c) => {
|
|||||||
})
|
})
|
||||||
.from(schema.problemTag)
|
.from(schema.problemTag)
|
||||||
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||||
.innerJoin(schema.problem, and(eq(schema.problem.id, schema.problemTags.problemId), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
.innerJoin(
|
||||||
|
schema.problem,
|
||||||
|
and(
|
||||||
|
eq(schema.problem.id, schema.problemTags.problemId),
|
||||||
|
eq(schema.problem.visible, true),
|
||||||
|
isNull(schema.problem.contestId),
|
||||||
|
lessonLanguageFilter(c.req.query('language')),
|
||||||
|
),
|
||||||
|
)
|
||||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||||
.groupBy(schema.problemTag.id, schema.problemTag.name, schema.problemTag.category)
|
.groupBy(schema.problemTag.id, schema.problemTag.name, schema.problemTag.category)
|
||||||
.having(sql`count(${schema.problemTags.problemId}) > 0`)
|
.having(sql`count(${schema.problemTags.problemId}) > 0`)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type SubmissionStatisticsGrid,
|
type SubmissionStatisticsGrid,
|
||||||
type TodaySubmissionStatistics,
|
type TodaySubmissionStatistics,
|
||||||
} from '@oj2/contract'
|
} from '@oj2/contract'
|
||||||
import { and, count, desc, eq, inArray, isNull, sql, type SQL } from 'drizzle-orm'
|
import { and, count, desc, eq, gte, inArray, isNull, lte, sql, type SQL } from 'drizzle-orm'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
import { optionalAuth, requireTeacher } from '../auth/middleware'
|
import { optionalAuth, requireTeacher } from '../auth/middleware'
|
||||||
@@ -24,7 +24,8 @@ import { failure, success } from '../http'
|
|||||||
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from '../judge/status'
|
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from '../judge/status'
|
||||||
import { type ContestEnv } from '../services/contest'
|
import { type ContestEnv } from '../services/contest'
|
||||||
import { getBooleanOption } from '../services/options'
|
import { getBooleanOption } from '../services/options'
|
||||||
import { dayStart, localTime } from '../time'
|
import { lessonDoneCounts } from '../services/lesson'
|
||||||
|
import { calendarDay, dayStart, localTime } from '../time'
|
||||||
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from './helpers'
|
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from './helpers'
|
||||||
|
|
||||||
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
||||||
@@ -525,6 +526,122 @@ const LESSON_LIVE_MINUTES = 15
|
|||||||
const LESSONS_DEFAULT = 50
|
const LESSONS_DEFAULT = 50
|
||||||
const LESSONS_MAX = 500
|
const LESSONS_MAX = 500
|
||||||
|
|
||||||
|
type StatisticsRange = { start: string | null; end: string }
|
||||||
|
|
||||||
|
/** 这段时间里(按东八区日子)老师在课堂看板布置过的课,同一天的按布置先后 */
|
||||||
|
async function lessonPlans(range: StatisticsRange) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: schema.classLesson.id,
|
||||||
|
className: schema.classLesson.className,
|
||||||
|
day: schema.classLesson.day,
|
||||||
|
problemIds: schema.classLesson.problemIds,
|
||||||
|
language: schema.classLesson.language,
|
||||||
|
createdAt: schema.classLesson.createdAt,
|
||||||
|
})
|
||||||
|
.from(schema.classLesson)
|
||||||
|
.where(and(range.start ? gte(schema.classLesson.day, calendarDay(range.start)) : undefined, lte(schema.classLesson.day, calendarDay(range.end))))
|
||||||
|
.orderBy(schema.classLesson.createdAt, schema.classLesson.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LessonPlan = Awaited<ReturnType<typeof lessonPlans>>[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布置的课各自的数:那天这个班交布置的这几道题的提交(不论隔了多久、几个人;和推断的课
|
||||||
|
* 一样只算普通学生、不含比赛),加上全做对的人数(口径见 lessonDoneCounts)。时间窗之内一条提交都没有、布置时刻也不在
|
||||||
|
* 窗里的不列 —— 「那节课」那种按时段筛的,别把同一天另一节课带出来。
|
||||||
|
*/
|
||||||
|
async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
|
||||||
|
if (!plans.length) return []
|
||||||
|
const planRows = JSON.stringify(
|
||||||
|
plans.flatMap((plan) =>
|
||||||
|
plan.problemIds.map((problemId) => ({
|
||||||
|
k: plan.id,
|
||||||
|
class_name: plan.className,
|
||||||
|
day: plan.day,
|
||||||
|
problem_id: problemId,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const accepted = sql.join(
|
||||||
|
ACCEPTED_RESULTS.map((value) => sql`${value}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
const judging = sql.join(
|
||||||
|
UNJUDGED_RESULTS.map((value) => sql`${value}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
const plan = sql`(select * from jsonb_to_recordset(${planRows}::jsonb) as p(k int, class_name text, day text, problem_id int))`
|
||||||
|
const [counts, doneByLesson] = await Promise.all([
|
||||||
|
db.execute<{
|
||||||
|
k: number
|
||||||
|
problem_id: number | null
|
||||||
|
users: number
|
||||||
|
total: number
|
||||||
|
accepted: number
|
||||||
|
judging: number
|
||||||
|
first: string
|
||||||
|
last: string
|
||||||
|
}>(sql`
|
||||||
|
with p as ${plan},
|
||||||
|
s as (
|
||||||
|
select p.k, s.user_id, s.problem_id, s.result, s.create_time
|
||||||
|
from ${schema.submission} s
|
||||||
|
join ${schema.user} u on u.id = s.user_id
|
||||||
|
join p on p.class_name = u.class_name and p.problem_id = s.problem_id
|
||||||
|
and p.day = to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD')
|
||||||
|
where s.contest_id is null
|
||||||
|
and s.create_time <= ${range.end}
|
||||||
|
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
|
||||||
|
and u.admin_type = 'Regular User' and u.is_disabled = false
|
||||||
|
)
|
||||||
|
select k, problem_id,
|
||||||
|
count(distinct user_id)::int as users,
|
||||||
|
count(*)::int as total,
|
||||||
|
(count(*) filter (where result in (${accepted})))::int as accepted,
|
||||||
|
(count(*) filter (where result in (${judging})))::int as judging,
|
||||||
|
min(create_time) as first,
|
||||||
|
max(create_time) as last
|
||||||
|
from s
|
||||||
|
group by grouping sets ((k), (k, problem_id))
|
||||||
|
`),
|
||||||
|
lessonDoneCounts(plans),
|
||||||
|
])
|
||||||
|
const totalByLesson = new Map([...counts].filter((row) => row.problem_id === null).map((row) => [row.k, row]))
|
||||||
|
const byProblem = Map.groupBy(
|
||||||
|
[...counts].filter((row) => row.problem_id !== null),
|
||||||
|
(row) => row.k,
|
||||||
|
)
|
||||||
|
const inRange = (time: string) => Date.parse(time) <= Date.parse(range.end) && (!range.start || Date.parse(time) >= Date.parse(range.start))
|
||||||
|
return plans.flatMap((lesson) => {
|
||||||
|
const total = totalByLesson.get(lesson.id)
|
||||||
|
if (!total && !inRange(lesson.createdAt)) return []
|
||||||
|
// 刚布置、还没人交:时段就是布置那一刻
|
||||||
|
const first = total ? new Date(total.first).toISOString() : lesson.createdAt
|
||||||
|
const last = total ? new Date(total.last).toISOString() : lesson.createdAt
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
className: lesson.className,
|
||||||
|
day: lesson.day,
|
||||||
|
problemIds: lesson.problemIds,
|
||||||
|
start: first,
|
||||||
|
end: last,
|
||||||
|
users: total?.users ?? 0,
|
||||||
|
total: total?.total ?? 0,
|
||||||
|
accepted: total?.accepted ?? 0,
|
||||||
|
judging: total?.judging ?? 0,
|
||||||
|
byProblem: (byProblem.get(lesson.id) ?? []).map((row) => ({
|
||||||
|
problemId: row.problem_id!,
|
||||||
|
total: row.total,
|
||||||
|
accepted: row.accepted,
|
||||||
|
judging: row.judging,
|
||||||
|
})),
|
||||||
|
doneCount: doneByLesson.get(lesson.id) ?? 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type LessonAggRow = {
|
type LessonAggRow = {
|
||||||
level: number
|
level: number
|
||||||
class_name: string | null
|
class_name: string | null
|
||||||
@@ -532,7 +649,8 @@ type LessonAggRow = {
|
|||||||
sid: number | null
|
sid: number | null
|
||||||
problem_id: number | null
|
problem_id: number | null
|
||||||
result: JudgeStatusValue | null
|
result: JudgeStatusValue | null
|
||||||
lesson: boolean | null
|
/** plan = 布置的课的题(另算,见 assignedLessons);lesson = 推断出来的课;scattered = 零散 */
|
||||||
|
kind: 'plan' | 'lesson' | 'scattered' | null
|
||||||
users: number
|
users: number
|
||||||
total: number
|
total: number
|
||||||
accepted: number
|
accepted: number
|
||||||
@@ -563,10 +681,19 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
|||||||
sql`, `,
|
sql`, `,
|
||||||
)
|
)
|
||||||
|
|
||||||
// grouping() 的位:class_name 32、day 16、sid 8、problem_id 4、result 2、lesson 1。
|
// 这段时间里老师布置过的课。它们的题那天交的提交另算(assignedLessons),不参与切段
|
||||||
|
const plans = await lessonPlans(range)
|
||||||
|
const planRows = JSON.stringify(
|
||||||
|
plans.flatMap((plan) => plan.problemIds.map((problemId) => ({ class_name: plan.className, day: plan.day, problem_id: problemId }))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// grouping() 的位:class_name 32、day 16、sid 8、problem_id 4、result 2、kind 1。
|
||||||
// 没参与分组的那几列记 1,所以每种粒度对应一个固定的 level
|
// 没参与分组的那几列记 1,所以每种粒度对应一个固定的 level
|
||||||
const rows = await db.execute<LessonAggRow>(sql`
|
const rowsPromise = db.execute<LessonAggRow>(sql`
|
||||||
with s as (
|
with plan as (
|
||||||
|
select * from jsonb_to_recordset(${planRows}::jsonb) as p(class_name text, day text, problem_id int)
|
||||||
|
),
|
||||||
|
s as (
|
||||||
select u.class_name, s.user_id, s.problem_id, s.result, s.problemset_id, s.create_time,
|
select u.class_name, s.user_id, s.problem_id, s.result, s.problemset_id, s.create_time,
|
||||||
to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD') as day
|
to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD') as day
|
||||||
from ${schema.submission} s
|
from ${schema.submission} s
|
||||||
@@ -580,7 +707,11 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
|||||||
g as (
|
g as (
|
||||||
select *,
|
select *,
|
||||||
case when create_time - lag(create_time) over w > ${`${LESSON_GAP_MINUTES} minutes`}::interval
|
case when create_time - lag(create_time) over w > ${`${LESSON_GAP_MINUTES} minutes`}::interval
|
||||||
then 1 else 0 end as brk
|
then 1 else 0 end as brk,
|
||||||
|
exists (
|
||||||
|
select 1 from plan p
|
||||||
|
where p.class_name is not distinct from s.class_name and p.day = s.day and p.problem_id = s.problem_id
|
||||||
|
) as planned
|
||||||
from s
|
from s
|
||||||
window w as (partition by class_name, day order by create_time)
|
window w as (partition by class_name, day order by create_time)
|
||||||
),
|
),
|
||||||
@@ -594,20 +725,22 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
|||||||
from (
|
from (
|
||||||
select class_name, day, sid, problem_id, count(distinct user_id) as n
|
select class_name, day, sid, problem_id, count(distinct user_id) as n
|
||||||
from seg
|
from seg
|
||||||
|
where not planned
|
||||||
group by class_name, day, sid, problem_id
|
group by class_name, day, sid, problem_id
|
||||||
) p
|
) p
|
||||||
group by class_name, day, sid
|
group by class_name, day, sid
|
||||||
having max(n) >= ${LESSON_MIN_USERS}
|
having max(n) >= ${LESSON_MIN_USERS}
|
||||||
),
|
),
|
||||||
marked as (
|
marked as (
|
||||||
select seg.*, (l.sid is not null) as lesson
|
select seg.*,
|
||||||
|
case when seg.planned then 'plan' when l.sid is not null then 'lesson' else 'scattered' end as kind
|
||||||
from seg
|
from seg
|
||||||
left join lesson_seg l
|
left join lesson_seg l
|
||||||
-- 没填班级的号(class_name 为 null)合在一起当一个班切,= 会把它们全漏掉
|
-- 没填班级的号(class_name 为 null)合在一起当一个班切,= 会把它们全漏掉
|
||||||
on l.class_name is not distinct from seg.class_name and l.day = seg.day and l.sid = seg.sid
|
on l.class_name is not distinct from seg.class_name and l.day = seg.day and l.sid = seg.sid
|
||||||
)
|
)
|
||||||
select grouping(class_name, day, sid, problem_id, result, lesson) as level,
|
select grouping(class_name, day, sid, problem_id, result, kind) as level,
|
||||||
class_name, day, sid::int as sid, problem_id, result, lesson,
|
class_name, day, sid::int as sid, problem_id, result, kind,
|
||||||
count(distinct user_id)::int as users,
|
count(distinct user_id)::int as users,
|
||||||
count(*)::int as total,
|
count(*)::int as total,
|
||||||
(count(*) filter (where result in (${accepted})))::int as accepted,
|
(count(*) filter (where result in (${accepted})))::int as accepted,
|
||||||
@@ -618,26 +751,33 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
|||||||
(count(distinct user_id) filter (where result in (${accepted})))::int as accepted_users
|
(count(distinct user_id) filter (where result in (${accepted})))::int as accepted_users
|
||||||
from marked
|
from marked
|
||||||
group by grouping sets (
|
group by grouping sets (
|
||||||
(class_name, day, sid, problem_id),
|
(class_name, day, sid, problem_id, kind),
|
||||||
(class_name, day, sid, lesson),
|
(class_name, day, sid, kind),
|
||||||
(problem_id, class_name),
|
(problem_id, class_name),
|
||||||
(problem_id, result),
|
(problem_id, result),
|
||||||
(lesson, class_name, day)
|
(kind, class_name, day)
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
|
const [rows, assigned] = await Promise.all([rowsPromise, assignedLessons(plans, range)])
|
||||||
|
|
||||||
const LEVEL = { segProblem: 3, segment: 6, problemClass: 27, problemResult: 57, dayClass: 14 }
|
const LEVEL = { segProblem: 2, segment: 6, problemClass: 27, problemResult: 57, dayClass: 14 }
|
||||||
const segKey = (row: LessonAggRow) => `${row.class_name}|${row.day}|${row.sid}`
|
const segKey = (row: LessonAggRow) => `${row.class_name}|${row.day}|${row.sid}`
|
||||||
const failedOf = (row: { total: number; accepted: number; judging: number }) => row.total - row.accepted - row.judging
|
const failedOf = (row: { total: number; accepted: number; judging: number }) => row.total - row.accepted - row.judging
|
||||||
|
|
||||||
const problemsOfSeg = new Map<string, LessonAggRow[]>()
|
const problemsOfSeg = new Map<string, LessonAggRow[]>()
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.level !== LEVEL.segProblem) continue
|
if (row.level !== LEVEL.segProblem || row.kind !== 'lesson') continue
|
||||||
const key = segKey(row)
|
const key = segKey(row)
|
||||||
problemsOfSeg.set(key, [...(problemsOfSeg.get(key) ?? []), row])
|
problemsOfSeg.set(key, [...(problemsOfSeg.get(key) ?? []), row])
|
||||||
}
|
}
|
||||||
const allLessons = rows.filter((row) => row.level === LEVEL.segment && row.lesson).sort((a, b) => b.first.localeCompare(a.first))
|
// 推断的课和布置的课排在一起,按开始时间从近到远取前 limit 节
|
||||||
const picked = allLessons.slice(0, limit)
|
const allLessons = [
|
||||||
|
...rows.filter((row) => row.level === LEVEL.segment && row.kind === 'lesson').map((row) => ({ inferred: row, assigned: null, start: row.first })),
|
||||||
|
...assigned.map((lesson) => ({ inferred: null, assigned: lesson, start: lesson.start })),
|
||||||
|
].sort((a, b) => b.start.localeCompare(a.start))
|
||||||
|
const pickedAll = allLessons.slice(0, limit)
|
||||||
|
const picked = pickedAll.flatMap((item) => (item.inferred ? [item.inferred] : []))
|
||||||
|
const pickedAssigned = pickedAll.flatMap((item) => (item.assigned ? [item.assigned] : []))
|
||||||
const hasMore = allLessons.length > limit
|
const hasMore = allLessons.length > limit
|
||||||
|
|
||||||
// 题号、标题一次查齐:课的题、每节课错得最多的那道、整段时间错得最多的几道
|
// 题号、标题一次查齐:课的题、每节课错得最多的那道、整段时间错得最多的几道
|
||||||
@@ -675,7 +815,13 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
|||||||
const lessonProblemRows = picked.map((lesson) =>
|
const lessonProblemRows = picked.map((lesson) =>
|
||||||
(problemsOfSeg.get(segKey(lesson)) ?? []).filter((row) => row.users >= LESSON_MIN_USERS).sort((a, b) => b.users - a.users),
|
(problemsOfSeg.get(segKey(lesson)) ?? []).filter((row) => row.users >= LESSON_MIN_USERS).sort((a, b) => b.users - a.users),
|
||||||
)
|
)
|
||||||
const problemIds = [...new Set([...lessonProblemRows.flat().map((row) => row.problem_id!), ...hardRanked.map((row) => row.problemId)])]
|
const problemIds = [
|
||||||
|
...new Set([
|
||||||
|
...lessonProblemRows.flat().map((row) => row.problem_id!),
|
||||||
|
...hardRanked.map((row) => row.problemId),
|
||||||
|
...pickedAssigned.flatMap((lesson) => lesson.problemIds),
|
||||||
|
]),
|
||||||
|
]
|
||||||
const problems = problemIds.length
|
const problems = problemIds.length
|
||||||
? await db
|
? await db
|
||||||
.select({
|
.select({
|
||||||
@@ -689,7 +835,9 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
|||||||
: []
|
: []
|
||||||
const problemById = new Map(problems.map((row) => [row.id, row]))
|
const problemById = new Map(problems.map((row) => [row.id, row]))
|
||||||
|
|
||||||
const lessonClasses = [...new Set(picked.flatMap((row) => (row.class_name === null ? [] : [row.class_name])))]
|
const lessonClasses = [
|
||||||
|
...new Set([...picked.flatMap((row) => (row.class_name === null ? [] : [row.class_name])), ...pickedAssigned.map((lesson) => lesson.className)]),
|
||||||
|
]
|
||||||
const sizes = lessonClasses.length
|
const sizes = lessonClasses.length
|
||||||
? await db
|
? await db
|
||||||
.select({ className: schema.user.className, value: count() })
|
.select({ className: schema.user.className, value: count() })
|
||||||
@@ -700,7 +848,8 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
|||||||
|
|
||||||
const sum = (list: LessonAggRow[], key: 'total' | 'accepted' | 'judging') => list.reduce((acc, row) => acc + row[key], 0)
|
const sum = (list: LessonAggRow[], key: 'total' | 'accepted' | 'judging') => list.reduce((acc, row) => acc + row[key], 0)
|
||||||
const liveSince = new Date(Date.now() - LESSON_LIVE_MINUTES * 60_000).toISOString()
|
const liveSince = new Date(Date.now() - LESSON_LIVE_MINUTES * 60_000).toISOString()
|
||||||
const lessons = picked.map((lesson, index) => {
|
const sizeOf = (className: string | null) => (className === null ? 0 : (sizes.find((row) => row.className === className)?.value ?? 0))
|
||||||
|
const inferredLessons = picked.map((lesson, index) => {
|
||||||
const own = lessonProblemRows[index]!
|
const own = lessonProblemRows[index]!
|
||||||
const hardest = [...own].sort((a, b) => failedOf(b) - failedOf(a))[0]
|
const hardest = [...own].sort((a, b) => failedOf(b) - failedOf(a))[0]
|
||||||
const hardestProblem = hardest ? problemById.get(hardest.problem_id!) : undefined
|
const hardestProblem = hardest ? problemById.get(hardest.problem_id!) : undefined
|
||||||
@@ -711,13 +860,15 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
|||||||
end: lesson.last,
|
end: lesson.last,
|
||||||
userCount: lesson.users,
|
userCount: lesson.users,
|
||||||
// 没填班级的谈不上花名册,给 0(界面上就只写「几人」)
|
// 没填班级的谈不上花名册,给 0(界面上就只写「几人」)
|
||||||
classSize: lesson.class_name === null ? 0 : (sizes.find((row) => row.className === lesson.class_name)?.value ?? 0),
|
classSize: sizeOf(lesson.class_name),
|
||||||
// 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道
|
// 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道
|
||||||
problems: own
|
problems: own
|
||||||
.map((row) => problemById.get(row.problem_id!))
|
.map((row) => problemById.get(row.problem_id!))
|
||||||
.filter((row) => row?.visible)
|
.filter((row) => row?.visible)
|
||||||
.map((row) => row!.displayId)
|
.map((row) => row!.displayId)
|
||||||
.slice(0, STATISTICS_MAX_PROBLEMS),
|
.slice(0, STATISTICS_MAX_PROBLEMS),
|
||||||
|
assigned: false,
|
||||||
|
doneCount: null,
|
||||||
fromProblemSet: lesson.from_ps * 2 > lesson.total,
|
fromProblemSet: lesson.from_ps * 2 > lesson.total,
|
||||||
// 次数和正确率只算这节课的题:点进去筛的就是这几道,两边的数才对得上
|
// 次数和正确率只算这节课的题:点进去筛的就是这几道,两边的数才对得上
|
||||||
// (一段里零星一两个人顺手交的别的题不算)
|
// (一段里零星一两个人顺手交的别的题不算)
|
||||||
@@ -734,11 +885,44 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
|||||||
: null,
|
: null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const assignedOut = pickedAssigned.map((lesson) => {
|
||||||
|
const hardest = [...lesson.byProblem].sort((a, b) => failedOf(b) - failedOf(a))[0]
|
||||||
|
const hardestProblem = hardest ? problemById.get(hardest.problemId) : undefined
|
||||||
|
return {
|
||||||
|
className: lesson.className,
|
||||||
|
day: lesson.day,
|
||||||
|
start: lesson.start,
|
||||||
|
end: lesson.end,
|
||||||
|
userCount: lesson.users,
|
||||||
|
classSize: sizeOf(lesson.className),
|
||||||
|
problems: lesson.problemIds
|
||||||
|
.map((id) => problemById.get(id))
|
||||||
|
.filter((row) => row?.visible)
|
||||||
|
.map((row) => row!.displayId)
|
||||||
|
.slice(0, STATISTICS_MAX_PROBLEMS),
|
||||||
|
assigned: true,
|
||||||
|
doneCount: lesson.doneCount,
|
||||||
|
fromProblemSet: false,
|
||||||
|
total: lesson.total,
|
||||||
|
correctRate: judgedRate(lesson.accepted, lesson.total - lesson.judging),
|
||||||
|
// 刚布置还没人交的也算在上课(没人交时 end 就是布置时刻)
|
||||||
|
live: Date.parse(lesson.end) >= Date.parse(liveSince),
|
||||||
|
hardest:
|
||||||
|
hardest && hardestProblem && failedOf(hardest) > 0
|
||||||
|
? {
|
||||||
|
problemDisplayId: hardestProblem.displayId,
|
||||||
|
problemTitle: hardestProblem.title,
|
||||||
|
failed: failedOf(hardest),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const lessons = [...inferredLessons, ...assignedOut].sort((a, b) => b.start.localeCompare(a.start))
|
||||||
|
|
||||||
// 零散提交只给列出来的这些课覆盖到的日子(「这学期」只列了最近 50 节时,更早的零散不给)
|
// 零散提交只给列出来的这些课覆盖到的日子(「这学期」只列了最近 50 节时,更早的零散不给)
|
||||||
const earliestDay = hasMore ? (picked.at(-1)?.day ?? null) : null
|
const earliestDay = hasMore ? (lessons.at(-1)?.day ?? null) : null
|
||||||
const scattered = rows
|
const scattered = rows
|
||||||
.filter((row) => row.level === LEVEL.dayClass && row.lesson === false && (earliestDay === null || row.day! >= earliestDay))
|
.filter((row) => row.level === LEVEL.dayClass && row.kind === 'scattered' && (earliestDay === null || row.day! >= earliestDay))
|
||||||
.sort((a, b) => (a.day !== b.day ? b.day!.localeCompare(a.day!) : a.class_name === null ? 1 : b.class_name === null ? -1 : b.total - a.total))
|
.sort((a, b) => (a.day !== b.day ? b.day!.localeCompare(a.day!) : a.class_name === null ? 1 : b.class_name === null ? -1 : b.total - a.total))
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
day: row.day!,
|
day: row.day!,
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ export async function buildReport(
|
|||||||
.select({ day: schema.classLesson.day, problemIds: schema.classLesson.problemIds })
|
.select({ day: schema.classLesson.day, problemIds: schema.classLesson.problemIds })
|
||||||
.from(schema.classLesson)
|
.from(schema.classLesson)
|
||||||
.where(and(eq(schema.classLesson.className, target.className), start ? gte(schema.classLesson.day, calendarDay(start)) : undefined))
|
.where(and(eq(schema.classLesson.className, target.className), start ? gte(schema.classLesson.day, calendarDay(start)) : undefined))
|
||||||
|
// 一天布置过几节的,按布置先后接着排(下面的 planOrder 跨节累加)
|
||||||
|
.orderBy(asc(schema.classLesson.createdAt), asc(schema.classLesson.id))
|
||||||
: [],
|
: [],
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -149,9 +151,12 @@ export async function buildReport(
|
|||||||
// 否则在 ≥ 5 人的那些天里挑人最多的,打平取晚的
|
// 否则在 ≥ 5 人的那些天里挑人最多的,打平取晚的
|
||||||
const lessonDay = new Map<number, string>()
|
const lessonDay = new Map<number, string>()
|
||||||
const planOrder = new Map<string, number>()
|
const planOrder = new Map<string, number>()
|
||||||
|
const dayOffset = new Map<string, number>()
|
||||||
for (const plan of plans) {
|
for (const plan of plans) {
|
||||||
|
const offset = dayOffset.get(plan.day) ?? 0
|
||||||
|
dayOffset.set(plan.day, offset + plan.problemIds.length)
|
||||||
plan.problemIds.forEach((problemId, index) => {
|
plan.problemIds.forEach((problemId, index) => {
|
||||||
planOrder.set(`${plan.day}|${problemId}`, index)
|
if (!planOrder.has(`${plan.day}|${problemId}`)) planOrder.set(`${plan.day}|${problemId}`, offset + index)
|
||||||
const current = lessonDay.get(problemId)
|
const current = lessonDay.get(problemId)
|
||||||
if (!current || usersOn(plan.day, problemId) >= usersOn(current, problemId)) lessonDay.set(problemId, plan.day)
|
if (!current || usersOn(plan.day, problemId) >= usersOn(current, problemId)) lessonDay.set(problemId, plan.day)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { isSqlProblem, STUDENT_ROLES, type LessonLanguage } from '@oj2/contract'
|
||||||
|
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
|
import { db, schema } from '../db'
|
||||||
|
import { dayNumber, dayStart } from '../time'
|
||||||
|
import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布置的课(class_lesson)几处共用的口径:课堂看板布置、后台「课堂作业」增改、
|
||||||
|
* 数据统计按布置切课。放在一起,免得看板能布置的题后台布置不上,或者两边「做完」数得不一样。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题号 → problem.id,保留输入顺序、去重、不分大小写。只认题库里公开的题;有对不上的整个拒掉并
|
||||||
|
* 点名是哪几个,免得存下半张单子老师还不知道。选了语言就得每道都能用它交(SQL 题除外:它只收
|
||||||
|
* SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL)
|
||||||
|
*/
|
||||||
|
export async function resolveLessonProblems(
|
||||||
|
problemDisplayIds: string[],
|
||||||
|
language: LessonLanguage | null,
|
||||||
|
): Promise<{ problemIds: number[] } | { code: 'problem-not-found' | 'language-not-allowed'; message: string }> {
|
||||||
|
const wanted = [...new Set(problemDisplayIds.map((id) => id.toLowerCase()))]
|
||||||
|
const found = await db
|
||||||
|
.select({
|
||||||
|
id: schema.problem.id,
|
||||||
|
displayId: schema.problem.displayId,
|
||||||
|
languages: schema.problem.languages,
|
||||||
|
})
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(and(inArray(sql<string>`lower(${schema.problem.displayId})`, wanted), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
||||||
|
const idByDisplay = new Map(found.map((row) => [row.displayId.toLowerCase(), row.id]))
|
||||||
|
const missing = wanted.filter((id) => !idByDisplay.has(id))
|
||||||
|
if (missing.length) return { code: 'problem-not-found', message: `这些题号不存在或没有公开:${missing.join('、')}` }
|
||||||
|
if (language) {
|
||||||
|
const unsupported = found.filter((row) => !row.languages.includes(language) && !isSqlProblem(row)).map((row) => row.displayId)
|
||||||
|
if (unsupported.length) return { code: 'language-not-allowed', message: `这些题不能用 ${language} 交:${unsupported.join('、')}` }
|
||||||
|
}
|
||||||
|
return { problemIds: wanted.map((id) => idByDisplay.get(id)!) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 东八区某一天结束(第二天零点)的时刻 */
|
||||||
|
export function lessonDayEnd(day: string) {
|
||||||
|
return dayStart(new Date((dayNumber(day) + 1) * 86_400_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算),
|
||||||
|
* 代码只认布置的语言(SQL 题放行),流程图评到 A / S 也算 —— 和看板的格子同一个口径。
|
||||||
|
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
|
||||||
|
*/
|
||||||
|
export async function lessonDoneCounts(lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null }[]) {
|
||||||
|
const rows = lessons.flatMap((lesson) =>
|
||||||
|
lesson.problemIds.map((problemId) => ({
|
||||||
|
k: lesson.id,
|
||||||
|
class_name: lesson.className,
|
||||||
|
problem_id: problemId,
|
||||||
|
language: lesson.language,
|
||||||
|
until: lessonDayEnd(lesson.day),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
if (!rows.length) return new Map<number, number>()
|
||||||
|
const solved = sql.join(
|
||||||
|
SOLVED_RESULTS.map((value) => sql`${value}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
const result = await db.execute<{ k: number; done: number }>(sql`
|
||||||
|
with p as (
|
||||||
|
select * from jsonb_to_recordset(${JSON.stringify(rows)}::jsonb) as p(k int, class_name text, problem_id int, language text, until timestamptz)
|
||||||
|
),
|
||||||
|
members as (
|
||||||
|
select p.k, p.problem_id, p.language, p.until, u.id as user_id
|
||||||
|
from p join ${schema.user} u on u.class_name = p.class_name
|
||||||
|
where u.is_disabled = false and u.admin_type in (${sql.join(
|
||||||
|
STUDENT_ROLES.map((role) => sql`${role}`),
|
||||||
|
sql`, `,
|
||||||
|
)})
|
||||||
|
),
|
||||||
|
solved as (
|
||||||
|
select m.k, m.user_id, m.problem_id
|
||||||
|
from members m
|
||||||
|
where exists (
|
||||||
|
select 1 from ${schema.submission} s
|
||||||
|
where s.user_id = m.user_id and s.problem_id = m.problem_id and s.contest_id is null
|
||||||
|
and s.result in (${solved}) and s.create_time < m.until
|
||||||
|
and (m.language is null or s.language in (m.language, 'SQL'))
|
||||||
|
) or exists (
|
||||||
|
select 1 from ${schema.flowchartSubmission}
|
||||||
|
where ${schema.flowchartSubmission.userId} = m.user_id and ${schema.flowchartSubmission.problemId} = m.problem_id
|
||||||
|
and ${FLOWCHART_PASSED} and ${schema.flowchartSubmission.createTime} < m.until
|
||||||
|
)
|
||||||
|
),
|
||||||
|
per_user as (
|
||||||
|
select k, user_id from solved group by k, user_id
|
||||||
|
having count(distinct problem_id) >= (select count(*) from p p2 where p2.k = solved.k)
|
||||||
|
)
|
||||||
|
select k, count(*)::int as done from per_user group by k
|
||||||
|
`)
|
||||||
|
return new Map([...result].map((row) => [row.k, row.done]))
|
||||||
|
}
|
||||||
@@ -52,6 +52,12 @@ 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 字符串。后台补登课堂作业用 */
|
||||||
|
export function localDateTime(day: string, time: string): string {
|
||||||
|
const [hour, minute] = time.split(':').map(Number)
|
||||||
|
return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
/** 日历日序号 → `YYYY-MM-DD` */
|
/** 日历日序号 → `YYYY-MM-DD` */
|
||||||
export function dayText(day: number): string {
|
export function dayText(day: number): string {
|
||||||
return new Date(day * DAY_MS).toISOString().slice(0, 10)
|
return new Date(day * DAY_MS).toISOString().slice(0, 10)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { toAdminType, toProblemPermission } from '@oj2/contract'
|
import { toAdminType, toProblemPermission, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract'
|
||||||
import type {
|
import type {
|
||||||
AddContestProblemRequest,
|
AddContestProblemRequest,
|
||||||
ChallengeInput,
|
ChallengeInput,
|
||||||
@@ -779,3 +779,23 @@ export function updateAchievement(data: Partial<AdminAchievement>) {
|
|||||||
export function deleteAchievement(id: number) {
|
export function deleteAchievement(id: number) {
|
||||||
return api.delete(`admin/achievements/${id}`)
|
return api.delete(`admin/achievements/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 课堂作业(class_lesson) ----------
|
||||||
|
|
||||||
|
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) {
|
||||||
|
return api.get<AdminLessonList>('admin/lessons', {
|
||||||
|
params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminLesson(body: AdminLessonRequest) {
|
||||||
|
return api.post<{ id: number }>('admin/lessons', body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminLesson(id: number, body: AdminLessonRequest) {
|
||||||
|
return api.put<null>(`admin/lessons/${id}`, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAdminLesson(id: number) {
|
||||||
|
return api.delete<null>(`admin/lessons/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import type { AdminLesson, LessonLanguage } from '@oj2/contract'
|
||||||
|
import ProblemPicker from 'oj/classroom/components/ProblemPicker.vue'
|
||||||
|
import { useConfigStore } from 'shared/store/config'
|
||||||
|
import { errorMessage } from 'utils/api'
|
||||||
|
import { groupClassOptions } from 'utils/classes'
|
||||||
|
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||||
|
import { parseTime } from 'utils/functions'
|
||||||
|
import { createAdminLesson, updateAdminLesson } from '../../api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 看板上点错了班、隔天才想起来布置,
|
||||||
|
* 都在这里纠正;补的是看板外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
/** null = 补一节 */
|
||||||
|
lesson: AdminLesson | null
|
||||||
|
}>()
|
||||||
|
const show = defineModel<boolean>('show', { required: true })
|
||||||
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
const message = useMessage()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
|
||||||
|
const languageOptions = LESSON_LANGUAGES.map((value) => ({ label: LANGUAGE_SHOW_VALUE[value], value }))
|
||||||
|
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
|
||||||
|
|
||||||
|
const className = ref<string | null>(null)
|
||||||
|
const day = ref<string | null>(null)
|
||||||
|
const time = ref<string | null>(null)
|
||||||
|
const language = ref<LessonLanguage>('Python')
|
||||||
|
const idsInput = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
watch(show, (open) => {
|
||||||
|
if (!open) return
|
||||||
|
const lesson = props.lesson
|
||||||
|
const now = new Date()
|
||||||
|
className.value = lesson?.className ?? null
|
||||||
|
// 日子、时刻都按东八区:n-date-picker / n-time-picker 绑的是格式化后的文本,不经过浏览器时区
|
||||||
|
day.value = lesson?.day ?? parseTime(now, 'YYYY-MM-DD')
|
||||||
|
time.value = parseTime(lesson?.createdAt ?? now, 'HH:mm')
|
||||||
|
language.value = lesson?.language ?? 'Python'
|
||||||
|
idsInput.value = lesson?.problems.map((p) => p.problemDisplayId).join(' ') ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function inputIds() {
|
||||||
|
return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 选题弹框,和看板上那个是同一个 ----
|
||||||
|
const picking = ref(false)
|
||||||
|
const pickerInitial = ref<{ id: string; title?: string }[]>([])
|
||||||
|
function openPicker() {
|
||||||
|
const titles = new Map(props.lesson?.problems.map((p) => [p.problemDisplayId.toLowerCase(), p.title]) ?? [])
|
||||||
|
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
|
||||||
|
picking.value = true
|
||||||
|
}
|
||||||
|
function picked(ids: string[]) {
|
||||||
|
idsInput.value = ids.join(' ')
|
||||||
|
picking.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = computed(() => !!className.value && !!day.value && !!time.value && inputIds().length > 0)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!ready.value) return
|
||||||
|
saving.value = true
|
||||||
|
const body = {
|
||||||
|
className: className.value!,
|
||||||
|
day: day.value!,
|
||||||
|
time: time.value!,
|
||||||
|
language: language.value,
|
||||||
|
problemDisplayIds: inputIds(),
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (props.lesson) await updateAdminLesson(props.lesson.id, body)
|
||||||
|
else await createAdminLesson(body)
|
||||||
|
message.success(props.lesson ? '改好了' : '补上了')
|
||||||
|
show.value = false
|
||||||
|
emit('saved')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(errorMessage(error))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal v-model:show="show" preset="card" :title="lesson ? '改这一节' : '补一节'" :bordered="false" :style="{ width: 'min(520px, calc(100vw - 32px))' }">
|
||||||
|
<n-form label-placement="left" label-width="56">
|
||||||
|
<n-form-item label="班级">
|
||||||
|
<n-select v-model:value="className" :options="classOptions" filterable placeholder="选班级" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="上课">
|
||||||
|
<div class="when">
|
||||||
|
<n-date-picker v-model:formatted-value="day" value-format="yyyy-MM-dd" type="date" class="day" />
|
||||||
|
<n-time-picker v-model:formatted-value="time" value-format="HH:mm" format="HH:mm" class="time" />
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="语言">
|
||||||
|
<n-select v-model:value="language" :options="languageOptions" class="lang" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="题目">
|
||||||
|
<div class="ids">
|
||||||
|
<n-input v-model:value="idsInput" placeholder="题号,用空格隔开" @keyup.enter="save" />
|
||||||
|
<n-button @click="openPicker">
|
||||||
|
<template #icon><Icon icon="ph:list-checks" /></template>
|
||||||
|
选题
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="foot">
|
||||||
|
<n-button @click="show = false">取消</n-button>
|
||||||
|
<n-button type="primary" :disabled="!ready" :loading="saving" @click="save">{{ lesson ? '保存' : '补上' }}</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ProblemPicker v-model:show="picking" v-model:language="language" :initial="pickerInitial" :saving="false" mode="pick" @confirm="picked" />
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.when,
|
||||||
|
.ids {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ids .n-input {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AdminLesson, AdminLessonList } from '@oj2/contract'
|
||||||
|
import { useRouteQuery } from '@vueuse/router'
|
||||||
|
import PageHeader from 'admin/components/PageHeader.vue'
|
||||||
|
import { NButton, NPopconfirm, NTooltip, useThemeVars } from 'naive-ui'
|
||||||
|
import { dayStartOfDate } from 'oj/statistics/period'
|
||||||
|
import Pagination from 'shared/components/Pagination.vue'
|
||||||
|
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||||
|
import { useListLoader } from 'shared/composables/listLoader'
|
||||||
|
import { usePagination } from 'shared/composables/pagination'
|
||||||
|
import { useConfigStore } from 'shared/store/config'
|
||||||
|
import { errorMessage } from 'utils/api'
|
||||||
|
import { classLabel, groupClassOptions } from 'utils/classes'
|
||||||
|
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||||
|
import { parseTime } from 'utils/functions'
|
||||||
|
import { deleteAdminLesson, getAdminLessons } from '../api'
|
||||||
|
import LessonEditModal from './components/LessonEditModal.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台「课堂作业」:布置过的每一节课一行,新的在前。课堂看板上当场布置的都在这里,
|
||||||
|
* 布置错了能改、删;看板外上的课能补一节 —— 数据统计按布置切课,补了才认得到
|
||||||
|
*/
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
|
// 从看板「布置过的课」进来带着 ?className=
|
||||||
|
const { query } = usePagination<{ className: string; teacher: string }>({
|
||||||
|
className: useRouteQuery('className', '').value,
|
||||||
|
teacher: useRouteQuery('teacher', '').value,
|
||||||
|
})
|
||||||
|
const data = ref<AdminLessonList | null>(null)
|
||||||
|
|
||||||
|
const classOptions = computed(() => [{ label: '全部班级', value: '' }, ...groupClassOptions(configStore.config?.classList ?? [])])
|
||||||
|
const teacherOptions = computed(() => [
|
||||||
|
{ label: '全部老师', value: '' },
|
||||||
|
...(data.value?.teachers ?? []).map((teacher) => ({ label: teacher.name, value: String(teacher.id) })),
|
||||||
|
])
|
||||||
|
|
||||||
|
const { loading, track } = useListLoader()
|
||||||
|
async function list() {
|
||||||
|
if (query.page < 1) query.page = 1
|
||||||
|
const res = await track(
|
||||||
|
getAdminLessons((query.page - 1) * query.limit, query.limit, {
|
||||||
|
className: query.className,
|
||||||
|
teacher: query.teacher ? Number(query.teacher) : null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (res) data.value = res
|
||||||
|
}
|
||||||
|
onMounted(list)
|
||||||
|
// 换了班级、老师 usePagination 会把页码拨回 1,同一拍里只拉一次
|
||||||
|
watch(() => [query.page, query.limit, query.className, query.teacher], list)
|
||||||
|
|
||||||
|
// ---- 改 / 补 / 删 ----
|
||||||
|
const editing = ref(false)
|
||||||
|
const editingLesson = ref<AdminLesson | null>(null)
|
||||||
|
function edit(lesson: AdminLesson | null) {
|
||||||
|
editingLesson.value = lesson
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(lesson: AdminLesson) {
|
||||||
|
try {
|
||||||
|
await deleteAdminLesson(lesson.id)
|
||||||
|
message.success('删掉了')
|
||||||
|
list()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(errorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 看统计:这个班、这几道题、那一整天 */
|
||||||
|
function statistics(lesson: AdminLesson) {
|
||||||
|
const day = String(dayStartOfDate(lesson.day))
|
||||||
|
router.push({
|
||||||
|
name: 'statistics',
|
||||||
|
query: {
|
||||||
|
className: lesson.className,
|
||||||
|
problem: lesson.problems.map((p) => p.problemDisplayId).join(' '),
|
||||||
|
period: 'custom',
|
||||||
|
from: day,
|
||||||
|
to: day,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 列 ----
|
||||||
|
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
function dayText(day: string) {
|
||||||
|
const [y, m, d] = day.split('-').map(Number) as [number, number, number]
|
||||||
|
const weekday = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]
|
||||||
|
const thisYear = parseTime(new Date(), 'YYYY') === String(y)
|
||||||
|
return `${thisYear ? '' : `${y}年`}${m}月${d}日 ${weekday}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProblems(lesson: AdminLesson) {
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ class: 'probs' },
|
||||||
|
lesson.problems.map((p) =>
|
||||||
|
h(
|
||||||
|
NTooltip,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
trigger: () => h('span', { class: ['chip', { hidden: !p.visible }] }, p.problemDisplayId),
|
||||||
|
default: () => (p.visible ? p.title : `${p.title}(已藏起来,统计里不算)`),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDone(lesson: AdminLesson) {
|
||||||
|
return lesson.classSize ? `${lesson.doneCount} / ${lesson.classSize}` : `${lesson.doneCount}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActions(lesson: AdminLesson) {
|
||||||
|
const size = isMobile.value ? 'medium' : 'small'
|
||||||
|
return h('div', { class: 'actions' }, [
|
||||||
|
h(NButton, { size, secondary: true, onClick: () => edit(lesson) }, { default: () => '改' }),
|
||||||
|
h(NButton, { size, onClick: () => statistics(lesson) }, { default: () => '看统计' }),
|
||||||
|
h(
|
||||||
|
NPopconfirm,
|
||||||
|
{ onPositiveClick: () => remove(lesson), positiveText: '删掉', negativeText: '不删' },
|
||||||
|
{
|
||||||
|
trigger: () => h(NButton, { size, type: 'error', tertiary: true }, { default: () => '删除' }),
|
||||||
|
default: () => `删掉 ${classLabel(lesson.className)} ${dayText(lesson.day)} 这一节?学生的提交不受影响,数据统计里这节课改回按提交推断`,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手机上一节课一张卡:日子 · 时刻 · 班级一行,题号一行,老师 · 语言 · 做完一行小字,操作一行 */
|
||||||
|
function renderMobileCard(lesson: AdminLesson) {
|
||||||
|
return h('div', { class: 'm-card' }, [
|
||||||
|
h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`),
|
||||||
|
renderProblems(lesson),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'm-meta' },
|
||||||
|
[lesson.createdBy, lesson.language ? LANGUAGE_SHOW_VALUE[lesson.language] : null, `做完 ${renderDone(lesson)}`].filter(Boolean).join(' · '),
|
||||||
|
),
|
||||||
|
renderActions(lesson),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopColumns: DataTableColumn<AdminLesson>[] = [
|
||||||
|
{ title: '日子', key: 'day', width: 130, render: (row) => dayText(row.day) },
|
||||||
|
{ title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') },
|
||||||
|
{ title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) },
|
||||||
|
{ title: '老师', key: 'createdBy', width: 90 },
|
||||||
|
{ title: '语言', key: 'language', width: 80, render: (row) => (row.language ? LANGUAGE_SHOW_VALUE[row.language] : '—') },
|
||||||
|
{ title: '题目', key: 'problems', width: 300, render: renderProblems },
|
||||||
|
{ title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone },
|
||||||
|
{ title: '', key: 'actions', render: renderActions },
|
||||||
|
]
|
||||||
|
const mobileColumns: DataTableColumn<AdminLesson>[] = [{ title: '课', key: 'id', render: renderMobileCard }]
|
||||||
|
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="课堂作业">
|
||||||
|
<template #extra>
|
||||||
|
<n-text v-if="data" depth="3">{{ data.total }} 节</n-text>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<n-button type="primary" @click="edit(null)">补一节</n-button>
|
||||||
|
</template>
|
||||||
|
<template #filters>
|
||||||
|
<n-select v-model:value="query.className" :options="classOptions" filterable aria-label="班级" :style="{ width: isMobile ? '100%' : '160px' }" />
|
||||||
|
<n-select v-model:value="query.teacher" :options="teacherOptions" aria-label="老师" :style="{ width: isMobile ? '100%' : '140px' }" />
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
<n-data-table :loading="loading" striped class="lesson-table" :columns="columns" :data="data?.results ?? []" :row-key="(row: AdminLesson) => row.id" />
|
||||||
|
<Pagination v-if="data" :total="data.total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||||
|
<LessonEditModal v-model:show="editing" :lesson="editingLesson" @saved="list" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lesson-table :deep(.probs) {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-table :deep(.chip) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
/* 描边不填底:斑马纹的隔行底色和 actionColor 是同一档灰,填底的话那几行看不出块 */
|
||||||
|
border: 1px solid v-bind('theme.borderColor');
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-table :deep(.chip.hidden) {
|
||||||
|
color: v-bind('theme.textColor3');
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-table :deep(.actions) {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-table :deep(.m-card) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-table :deep(.m-head) {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-table :deep(.m-meta) {
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind('theme.textColor3');
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -260,12 +260,16 @@ export function getClassLesson(className?: string) {
|
|||||||
return api.get<ClassLesson>('classroom/lesson', { params: { className } })
|
return api.get<ClassLesson>('classroom/lesson', { params: { className } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 给这个班布置今天的题,空数组 = 清掉 */
|
/**
|
||||||
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null) {
|
* 给这个班布置今天的一节课,空数组 = 清掉这一节。`lessonId` 为 null 是今天新开一节
|
||||||
|
* (一个班一天可以上几节内容不同的课),否则改那一节
|
||||||
|
*/
|
||||||
|
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, lessonId: number | null) {
|
||||||
return api.put<null>('classroom/lesson', {
|
return api.put<null>('classroom/lesson', {
|
||||||
className,
|
className,
|
||||||
problemDisplayIds,
|
problemDisplayIds,
|
||||||
language,
|
language,
|
||||||
|
...(lessonId === null ? { newLesson: true } : { lessonId }),
|
||||||
} satisfies ClassLessonRequest)
|
} satisfies ClassLessonRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { parseTime } from 'utils/functions'
|
|||||||
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from 'utils/types'
|
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } 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 { useBreakpoints } from 'shared/composables/breakpoints'
|
||||||
import LessonForm from './components/LessonForm.vue'
|
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'
|
||||||
@@ -41,6 +43,7 @@ const collabStore = useCollabStore()
|
|||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const tone = useTone()
|
const tone = useTone()
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { help, peek } = useTeacherHelp()
|
const { help, peek } = useTeacherHelp()
|
||||||
@@ -111,13 +114,34 @@ onUnmounted(pause)
|
|||||||
|
|
||||||
// ---------- 这节课的题:上课时当场填 ----------
|
// ---------- 这节课的题:上课时当场填 ----------
|
||||||
|
|
||||||
const editing = ref(false)
|
/** 顶上那条布置框开着没有:edit = 改这一节,new = 今天再开一节 */
|
||||||
|
const editing = ref<false | 'edit' | 'new'>(false)
|
||||||
|
|
||||||
/** 还没有题(没布置、今天也推断不出来):填题号的框就是这页最大的东西 */
|
/**
|
||||||
const needLesson = computed(() => !!board.value?.className && !board.value.problems.length)
|
* 一个班一天可以上几节内容不同的课。打开看板(或换到这个班)时,这一节已经布置了这么久,
|
||||||
|
* 多半是上一节课的 —— 先问这节课做哪几道题,底下能点回去接着看上一节。
|
||||||
|
* 只在第一次看到这一节时判一次:老师一直开着看板时,15 秒一刷不会把表突然换掉
|
||||||
|
*/
|
||||||
|
const STALE_LESSON_MINUTES = 100
|
||||||
|
const staleLessonId = ref<number | null>(null)
|
||||||
|
const seenLessonIds = new Set<number>()
|
||||||
|
watch(
|
||||||
|
() => board.value?.lessonId ?? null,
|
||||||
|
(lessonId) => {
|
||||||
|
if (lessonId === null || seenLessonIds.has(lessonId)) return
|
||||||
|
seenLessonIds.add(lessonId)
|
||||||
|
const assignedAt = board.value?.assignedAt
|
||||||
|
if (assignedAt && Date.now() - Date.parse(assignedAt) > STALE_LESSON_MINUTES * 60_000) staleLessonId.value = lessonId
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const staleLesson = computed(() => board.value?.lessonId != null && board.value.lessonId === staleLessonId.value)
|
||||||
|
|
||||||
|
/** 还没有题(没布置、今天也推断不出来),或者布置的是上一节课的:填题号的框就是这页最大的东西 */
|
||||||
|
const needLesson = computed(() => !!board.value?.className && (!board.value.problems.length || staleLesson.value))
|
||||||
|
|
||||||
async function lessonSaved() {
|
async function lessonSaved() {
|
||||||
editing.value = false
|
editing.value = false
|
||||||
|
staleLessonId.value = null
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
@@ -434,28 +458,36 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
v-if="editing && board && className"
|
v-if="editing && board && className"
|
||||||
:board="board"
|
:board="board"
|
||||||
:class-name="className"
|
:class-name="className"
|
||||||
|
:lesson-id="editing === 'edit' ? board.lessonId : null"
|
||||||
variant="inline"
|
variant="inline"
|
||||||
class="lesson-edit"
|
class="lesson-edit"
|
||||||
@saved="lessonSaved"
|
@saved="lessonSaved"
|
||||||
@cancel="editing = false"
|
@cancel="editing = false"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="board?.problems.length" class="lesson">
|
<div v-else-if="board?.problems.length && !needLesson" class="lesson">
|
||||||
<span class="lesson-label">{{ board.source === 'teacher' ? '这节课' : '今天在做' }}</span>
|
<span class="lesson-label">{{ board.source === 'teacher' ? '这节课' : '今天在做' }}</span>
|
||||||
<span v-if="board.language" class="chip lang">{{ LANGUAGE_SHOW_VALUE[board.language] }}</span>
|
<span v-if="board.language" class="chip lang">{{ LANGUAGE_SHOW_VALUE[board.language] }}</span>
|
||||||
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">
|
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">
|
||||||
{{ problem.problemDisplayId }}
|
{{ problem.problemDisplayId }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="board.assignedAt" class="assigned"> {{ parseTime(board.assignedAt, 'HH:mm') }} {{ board.assignedBy }} 布置 </span>
|
<span v-if="board.assignedAt" class="assigned"> {{ parseTime(board.assignedAt, 'HH:mm') }} {{ board.assignedBy }} 布置 </span>
|
||||||
<n-button text type="primary" class="edit" @click="editing = true">
|
<n-button text type="primary" class="edit" @click="editing = 'edit'">
|
||||||
<template #icon><Icon icon="ph:pencil-simple" /></template>
|
<template #icon><Icon icon="ph:pencil-simple" /></template>
|
||||||
{{ board.source === 'teacher' ? '改' : '布置' }}
|
{{ board.source === 'teacher' ? '改' : '布置' }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<!-- 一天上几节内容不同的课:下一节另开一份,别把上一节的改掉(统计按布置切课) -->
|
||||||
|
<n-button v-if="board.source === 'teacher'" text type="primary" class="edit" @click="editing = 'new'">
|
||||||
|
<template #icon><Icon icon="ph:plus" /></template>
|
||||||
|
新的一节
|
||||||
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<n-button v-if="board?.className" :type="leaveStudents.length ? 'warning' : 'default'" :secondary="!!leaveStudents.length" @click="leaveDialog = true">
|
<n-button v-if="board?.className" :type="leaveStudents.length ? 'warning' : 'default'" :secondary="!!leaveStudents.length" @click="leaveDialog = true">
|
||||||
{{ 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>
|
||||||
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
|
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
|
||||||
<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>
|
||||||
@@ -466,7 +498,12 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
|
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
|
||||||
<section v-if="needLesson" class="start">
|
<section v-if="needLesson" class="start">
|
||||||
<div class="start-title">这节课做哪几道题?</div>
|
<div class="start-title">这节课做哪几道题?</div>
|
||||||
<LessonForm v-if="className" :board="board" :class-name="className" variant="start" class="start-row" @saved="lessonSaved" />
|
<LessonForm v-if="className" :board="board" :class-name="className" :lesson-id="null" variant="start" class="start-row" @saved="lessonSaved" />
|
||||||
|
<div v-if="staleLesson" class="earlier">
|
||||||
|
<span>上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置</span>
|
||||||
|
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">{{ problem.problemDisplayId }}</span>
|
||||||
|
<n-button text type="primary" class="edit" @click="staleLessonId = null">接着看这一节</n-button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -557,7 +594,8 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<!-- 上一节的表先不摆:一整列都是上一节的题,和上面「这节课做哪几道题」对不上 -->
|
||||||
|
<div v-if="!staleLesson" class="table-wrap">
|
||||||
<table class="grid">
|
<table class="grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -815,6 +853,15 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.earlier {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: v-bind('theme.textColor3');
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 题卡片 ---------- */
|
/* ---------- 题卡片 ---------- */
|
||||||
|
|
||||||
.cards {
|
.cards {
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ 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 { ClassBoard } from 'utils/types'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import ProblemPicker from './ProblemPicker.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
|
* 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
|
||||||
* - `inline`:顶上一行里点「改 / 布置」展开的那一小条;
|
* - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条;
|
||||||
* - `start`:刚上课还没有题时,这页最大的那个框。
|
* - `start`:刚上课还没有题时,这页最大的那个框。
|
||||||
|
*
|
||||||
|
* 一个班一天可以上几节内容不同的课:`lessonId` 是要改的那一节,null = 今天新开一节
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
board: ClassBoard
|
board: ClassBoard
|
||||||
className: string
|
className: string
|
||||||
variant: 'inline' | 'start'
|
variant: 'inline' | 'start'
|
||||||
|
lessonId: number | null
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ saved: []; cancel: [] }>()
|
const emit = defineEmits<{ saved: []; cancel: [] }>()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -31,27 +36,35 @@ const languageOptions = LESSON_LANGUAGES.map((value) => ({
|
|||||||
const lessonInput = ref('')
|
const lessonInput = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
|
/** 改的是老师布置的这一节:框里是它的题;新开一节是空的 */
|
||||||
|
const editingPlanned = computed(() => props.lessonId !== null && props.lessonId === props.board.lessonId)
|
||||||
|
|
||||||
function resetLessonInput() {
|
function resetLessonInput() {
|
||||||
lessonInput.value = props.board.source === 'teacher' ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : ''
|
lessonInput.value = editingPlanned.value ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : ''
|
||||||
lessonLanguage.value = props.board.language ?? props.board.lastLanguage ?? 'Python'
|
lessonLanguage.value = (editingPlanned.value ? props.board.language : null) ?? props.board.lastLanguage ?? 'Python'
|
||||||
}
|
}
|
||||||
|
|
||||||
resetLessonInput()
|
resetLessonInput()
|
||||||
// 推断出来的题也带进框里,老师多半只是确认一下
|
// 推断出来的题也带进框里,老师多半只是确认一下
|
||||||
if (props.variant === 'inline' && !lessonInput.value && props.board.problems.length) {
|
if (props.variant === 'inline' && props.board.source === 'inferred' && !lessonInput.value && props.board.problems.length) {
|
||||||
lessonInput.value = props.board.problems.map((problem) => problem.problemDisplayId).join(' ')
|
lessonInput.value = props.board.problems.map((problem) => problem.problemDisplayId).join(' ')
|
||||||
}
|
}
|
||||||
// 换了班要把输入框换成那个班今天布置的题、布置时选的语言(15 秒一次的刷新不动它们)。
|
// 换了班、换了一节要把输入框换成那一节的题、布置时选的语言(15 秒一次的刷新不动它们)。
|
||||||
// 必须写成两个来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的,
|
// 必须写成几个单值来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的,
|
||||||
// 回调次次都跑,老师打到一半的题号每 15 秒被清一次
|
// 回调次次都跑,老师打到一半的题号每 15 秒被清一次
|
||||||
watch([() => props.board.className, () => props.board.source], resetLessonInput)
|
watch([() => props.board.className, () => props.board.source, () => props.lessonId], resetLessonInput)
|
||||||
|
|
||||||
|
function inputIds() {
|
||||||
|
return lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
async function saveLesson() {
|
async function saveLesson() {
|
||||||
const ids = lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
|
const ids = inputIds()
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null)
|
await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null, props.lessonId)
|
||||||
message.success(ids.length ? '已布置,学生首页会显示这几道题' : '已清掉')
|
message.success(ids.length ? '已布置,学生首页会显示这几道题' : '已清掉')
|
||||||
|
picking.value = false
|
||||||
emit('saved')
|
emit('saved')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(errorMessage(error))
|
message.error(errorMessage(error))
|
||||||
@@ -59,19 +72,43 @@ async function saveLesson() {
|
|||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 选题弹框:不用记题号,按知识点挑。框里已经敲了的题号带进去,题名能对上的带上题名 ----
|
||||||
|
const picking = ref(false)
|
||||||
|
const pickerInitial = ref<{ id: string; title?: string }[]>([])
|
||||||
|
function openPicker() {
|
||||||
|
const titles = new Map(props.board.problems.map((p) => [p.problemDisplayId.toLowerCase(), p.title]))
|
||||||
|
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
|
||||||
|
picking.value = true
|
||||||
|
}
|
||||||
|
function pickerConfirm(ids: string[]) {
|
||||||
|
lessonInput.value = ids.join(' ')
|
||||||
|
saveLesson()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="variant === 'inline'">
|
<div v-if="variant === 'inline'">
|
||||||
<n-select v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言做" class="lang-select" />
|
<n-select v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言做" class="lang-select" />
|
||||||
<n-input v-model:value="lessonInput" placeholder="这节课的题号,用空格隔开" clearable autofocus @keyup.enter="saveLesson" />
|
<n-input v-model:value="lessonInput" placeholder="这节课的题号,用空格隔开" clearable autofocus @keyup.enter="saveLesson" />
|
||||||
|
<n-button @click="openPicker">
|
||||||
|
<template #icon><Icon icon="ph:list-checks" /></template>
|
||||||
|
选题
|
||||||
|
</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 落空 -->
|
||||||
|
<ProblemPicker v-model:show="picking" v-model:language="lessonLanguage" :initial="pickerInitial" :saving="saving" @confirm="pickerConfirm" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<n-select v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言做" size="large" class="lang-select" />
|
<n-select v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言做" size="large" class="lang-select" />
|
||||||
<n-input v-model:value="lessonInput" size="large" placeholder="输入题号,用空格隔开" class="start-input" @keyup.enter="saveLesson" />
|
<n-input v-model:value="lessonInput" size="large" placeholder="输入题号,用空格隔开" class="start-input" @keyup.enter="saveLesson" />
|
||||||
|
<n-button size="large" @click="openPicker">
|
||||||
|
<template #icon><Icon icon="ph:list-checks" /></template>
|
||||||
|
选题
|
||||||
|
</n-button>
|
||||||
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">布置</n-button>
|
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">布置</n-button>
|
||||||
|
<ProblemPicker v-model:show="picking" v-model:language="lessonLanguage" :initial="pickerInitial" :saving="saving" @confirm="pickerConfirm" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import type { LessonLanguage, Tag } from '@oj2/contract'
|
||||||
|
import { useThemeVars } from 'naive-ui'
|
||||||
|
import { getProblemList, getProblemTagList } from 'shared/api'
|
||||||
|
import { useTone } from 'shared/composables/tone'
|
||||||
|
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||||
|
import type { ProblemRow } from 'utils/types'
|
||||||
|
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 看板布置作业的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
|
||||||
|
* 只列能用这次作业的语言交的题(左栏题数也按这个语言数),不用再一个个记题号。
|
||||||
|
* 点「布置」直接存,和在输入框里敲题号回车是一回事
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 打开时已经在框里的题(题号 + 知道的话带上题名) */
|
||||||
|
initial: { id: string; title?: string }[]
|
||||||
|
saving: boolean
|
||||||
|
/** pick = 只把选好的题交回去(后台改一节时用),不直接布置 */
|
||||||
|
mode?: 'assign' | 'pick'
|
||||||
|
}>()
|
||||||
|
const show = defineModel<boolean>('show', { required: true })
|
||||||
|
const language = defineModel<LessonLanguage>('language', { required: true })
|
||||||
|
const emit = defineEmits<{ confirm: [ids: string[]] }>()
|
||||||
|
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
|
||||||
|
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
|
||||||
|
const languageOptions = LESSON_LANGUAGES.map((value) => ({ label: LANGUAGE_SHOW_VALUE[value], value }))
|
||||||
|
const SORTS = [
|
||||||
|
{ label: '最新出的', value: '' },
|
||||||
|
{ label: '做过的人最多', value: '-submission_number' },
|
||||||
|
]
|
||||||
|
const PAGE_SIZE = 100
|
||||||
|
/** 老师挑的题,必会题是按课堂用得最多挑出来的,放在知识点前面 */
|
||||||
|
const MUST_DO = '必会题'
|
||||||
|
|
||||||
|
const tags = ref<Tag[]>([])
|
||||||
|
const tag = ref('')
|
||||||
|
const keyword = ref('')
|
||||||
|
const sort = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const rows = ref<ProblemRow[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
/** 勾的先后就是这节课的顺序 */
|
||||||
|
const picked = ref<{ id: string; title?: string }[]>([])
|
||||||
|
const pickedIndex = computed(() => new Map(picked.value.map((p, i) => [p.id.toLowerCase(), i])))
|
||||||
|
|
||||||
|
const knowledge = computed(() => tags.value.filter((t) => t.category === 'knowledge').sort((a, b) => b.problemCount - a.problemCount))
|
||||||
|
const mustDo = computed(() => tags.value.find((t) => t.name === MUST_DO) ?? null)
|
||||||
|
const allCount = ref(0)
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
const list = await getProblemTagList(language.value)
|
||||||
|
tags.value = list
|
||||||
|
// 换了语言,原来选的知识点在这个语言下可能一道都没有
|
||||||
|
if (tag.value && !list.some((t) => t.name === tag.value)) tag.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
async function loadRows() {
|
||||||
|
const mine = ++seq
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getProblemList((page.value - 1) * PAGE_SIZE, PAGE_SIZE, {
|
||||||
|
tag: tag.value,
|
||||||
|
keyword: keyword.value.trim(),
|
||||||
|
sort: sort.value,
|
||||||
|
language: language.value,
|
||||||
|
})
|
||||||
|
if (mine !== seq) return
|
||||||
|
rows.value = res.results
|
||||||
|
total.value = res.total
|
||||||
|
if (!tag.value && !keyword.value.trim()) allCount.value = res.total
|
||||||
|
} finally {
|
||||||
|
if (mine === seq) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listRef = ref<HTMLElement | null>(null)
|
||||||
|
function reload() {
|
||||||
|
page.value = 1
|
||||||
|
listRef.value?.scrollTo({ top: 0 })
|
||||||
|
loadRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(show, (open) => {
|
||||||
|
if (!open) return
|
||||||
|
picked.value = props.initial.map((p) => ({ ...p }))
|
||||||
|
keyword.value = ''
|
||||||
|
loadTags()
|
||||||
|
reload()
|
||||||
|
})
|
||||||
|
watch(language, () => {
|
||||||
|
if (!show.value) return
|
||||||
|
loadTags()
|
||||||
|
reload()
|
||||||
|
})
|
||||||
|
// 搜题号多半是记得那道题、不知道它在哪个知识点下,所以搜的是整个题库;点了知识点就不再搜
|
||||||
|
function pickTag(name: string) {
|
||||||
|
keyword.value = ''
|
||||||
|
tag.value = name
|
||||||
|
}
|
||||||
|
watch(keyword, (text) => {
|
||||||
|
if (text.trim()) tag.value = ''
|
||||||
|
})
|
||||||
|
watch([tag, sort], reload)
|
||||||
|
watchDebounced(keyword, reload, { debounce: 300 })
|
||||||
|
watch(page, () => {
|
||||||
|
listRef.value?.scrollTo({ top: 0 })
|
||||||
|
loadRows()
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggle(row: ProblemRow) {
|
||||||
|
const key = row._id.toLowerCase()
|
||||||
|
if (pickedIndex.value.has(key)) picked.value = picked.value.filter((p) => p.id.toLowerCase() !== key)
|
||||||
|
else picked.value = [...picked.value, { id: row._id, title: row.title }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string) {
|
||||||
|
picked.value = picked.value.filter((p) => p.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
|
||||||
|
function difficultyStyle(row: ProblemRow) {
|
||||||
|
if (!row.difficulty) return {}
|
||||||
|
const t = tone(DIFFICULTY_TONE[row.difficulty])
|
||||||
|
return { color: t.color, background: t.background }
|
||||||
|
}
|
||||||
|
|
||||||
|
// n-modal 把内容挂到 body 下,<style> 里的 v-bind() 传不进去,颜色走根节点的内联变量
|
||||||
|
const vars = computed(() => ({
|
||||||
|
// 宽度也写在这里:n-modal 的 preset card 上 scoped 的 class 宽度不生效
|
||||||
|
width: 'min(1040px, calc(100vw - 32px))',
|
||||||
|
'--text2': theme.value.textColor2,
|
||||||
|
'--text3': theme.value.textColor3,
|
||||||
|
'--divider': theme.value.dividerColor,
|
||||||
|
'--hover': theme.value.hoverColor,
|
||||||
|
'--action': theme.value.actionColor,
|
||||||
|
'--primary': theme.value.primaryColor,
|
||||||
|
'--on-bg': tone('success').background,
|
||||||
|
'--on-text': tone('success').color,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal v-model:show="show" preset="card" :style="vars" :bordered="false" :auto-focus="false" title="选题">
|
||||||
|
<template #header-extra>
|
||||||
|
<div class="head-extra">
|
||||||
|
<span class="muted">用</span>
|
||||||
|
<n-select v-model:value="language" :options="languageOptions" aria-label="用什么语言做" size="small" class="lang" />
|
||||||
|
<span class="muted">做</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<nav class="tags">
|
||||||
|
<button class="tag" :class="{ on: tag === '' && !keyword.trim() }" @click="pickTag('')">
|
||||||
|
<span>全部</span><span class="count">{{ allCount || '' }}</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="mustDo" class="tag" :class="{ on: tag === MUST_DO }" @click="pickTag(MUST_DO)">
|
||||||
|
<span>{{ MUST_DO }}</span
|
||||||
|
><span class="count">{{ mustDo.problemCount }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="tag-head">知识点</div>
|
||||||
|
<button v-for="t in knowledge" :key="t.id" class="tag" :class="{ on: tag === t.name }" @click="pickTag(t.name)">
|
||||||
|
<span>{{ t.name }}</span
|
||||||
|
><span class="count">{{ t.problemCount }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="main">
|
||||||
|
<div class="filters">
|
||||||
|
<n-input v-model:value="keyword" placeholder="搜题号或题目名" clearable class="search">
|
||||||
|
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
|
||||||
|
</n-input>
|
||||||
|
<n-select v-model:value="sort" :options="SORTS" class="sort" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="listRef" class="list">
|
||||||
|
<n-spin :show="loading" class="spin">
|
||||||
|
<n-empty v-if="!loading && !rows.length" :description="`没有能用 ${LANGUAGE_SHOW_VALUE[language]} 做的题`" class="empty" />
|
||||||
|
<button v-for="row in rows" :key="row.id" type="button" class="row" :class="{ on: pickedIndex.has(row._id.toLowerCase()) }" @click="toggle(row)">
|
||||||
|
<span class="check">
|
||||||
|
<template v-if="pickedIndex.has(row._id.toLowerCase())">{{ pickedIndex.get(row._id.toLowerCase())! + 1 }}</template>
|
||||||
|
</span>
|
||||||
|
<span class="pid">{{ row._id }}</span>
|
||||||
|
<span class="title">
|
||||||
|
<span class="title-text">{{ row.title }}</span>
|
||||||
|
<ProblemTypeTag v-if="row.allowFlowchart" kind="flowchart" />
|
||||||
|
<ProblemTypeTag v-if="row.hasAstRules" kind="ast" />
|
||||||
|
</span>
|
||||||
|
<span class="diff" :style="difficultyStyle(row)">{{ row.difficulty ?? '' }}</span>
|
||||||
|
<span class="people">{{ row.solvedUsers }}/{{ row.triedUsers }}</span>
|
||||||
|
</button>
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
<div v-if="total > PAGE_SIZE" class="pager">
|
||||||
|
<n-pagination v-model:page="page" :page-size="PAGE_SIZE" :item-count="total" simple />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="foot">
|
||||||
|
<div class="picked">
|
||||||
|
<span v-if="!picked.length" class="muted">还没选题</span>
|
||||||
|
<span v-for="(p, i) in picked" :key="p.id" class="pick">
|
||||||
|
<b>{{ i + 1 }}</b>
|
||||||
|
<span>{{ p.id }}</span>
|
||||||
|
<span v-if="p.title" class="pick-title">{{ p.title }}</span>
|
||||||
|
<button type="button" class="x" :aria-label="`去掉 ${p.id}`" @click="remove(p.id)"><Icon icon="ph:x-bold" /></button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<n-button @click="show = false">取消</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!picked.length && (mode === 'pick' || !initial.length)"
|
||||||
|
:loading="saving"
|
||||||
|
@click="
|
||||||
|
emit(
|
||||||
|
'confirm',
|
||||||
|
picked.map((p) => p.id),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template v-if="mode === 'pick'">{{ picked.length ? `用这 ${picked.length} 道` : '选好了' }}</template>
|
||||||
|
<template v-else>{{ picked.length ? `布置这 ${picked.length} 道` : initial.length ? '清掉' : '布置' }}</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head-extra {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
height: min(560px, calc(100vh - 260px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 左栏:知识点 ---- */
|
||||||
|
.tags {
|
||||||
|
flex: 0 0 168px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding-right: 12px;
|
||||||
|
border-right: 1px solid var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-head {
|
||||||
|
margin: 10px 10px 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.on {
|
||||||
|
background: var(--on-bg);
|
||||||
|
color: var(--on-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.on .count {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 右边:题 ---- */
|
||||||
|
.main {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1 1 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.on {
|
||||||
|
background: var(--on-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
flex: 0 0 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 1.5px solid var(--text3);
|
||||||
|
border-radius: 5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.on .check {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pid {
|
||||||
|
flex: 0 0 56px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people {
|
||||||
|
flex: 0 0 76px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 底下:选好的,按先后 ---- */
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 4px 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--action);
|
||||||
|
font-size: 14px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick b {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text3);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.x:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.body {
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 280px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0 0 8px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -79,12 +79,17 @@ function clock(lesson: Lesson) {
|
|||||||
return `${parseTime(lesson.start, 'HH:mm')}–${parseTime(lesson.end, 'HH:mm')}`
|
return `${parseTime(lesson.start, 'HH:mm')}–${parseTime(lesson.end, 'HH:mm')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」 */
|
/**
|
||||||
|
* 布置的课知道要做什么,写全班几人做完;推断的课只知道谁交了,写来了几人。
|
||||||
|
* 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」
|
||||||
|
*/
|
||||||
function attendance(lesson: Lesson) {
|
function attendance(lesson: Lesson) {
|
||||||
|
if (lesson.doneCount !== null) return lesson.classSize ? `做完 ${lesson.doneCount} / ${lesson.classSize}` : `做完 ${lesson.doneCount} 人`
|
||||||
return lesson.classSize >= lesson.userCount ? `${lesson.userCount} / ${lesson.classSize} 人` : `${lesson.userCount} 人`
|
return lesson.classSize >= lesson.userCount ? `${lesson.userCount} / ${lesson.classSize} 人` : `${lesson.userCount} 人`
|
||||||
}
|
}
|
||||||
|
|
||||||
function problemText(lesson: Lesson) {
|
function problemText(lesson: Lesson) {
|
||||||
|
if (lesson.assigned) return `布置 ${lesson.problems.length} 道`
|
||||||
return `${lesson.fromProblemSet ? '题单 ' : ''}${lesson.problems.length} 道`
|
return `${lesson.fromProblemSet ? '题单 ' : ''}${lesson.problems.length} 道`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +157,7 @@ function percent(value: number, total: number) {
|
|||||||
<span v-if="nowHour !== null" class="now" :style="{ left: axisLeft(nowHour) }"></span>
|
<span v-if="nowHour !== null" class="now" :style="{ left: axisLeft(nowHour) }"></span>
|
||||||
</span>
|
</span>
|
||||||
<b class="c-class">{{ classLabel(lesson.className) }}</b>
|
<b class="c-class">{{ classLabel(lesson.className) }}</b>
|
||||||
<span class="c-came">{{ attendance(lesson) }}</span>
|
<span class="c-came" :title="lesson.assigned ? `交过的 ${lesson.userCount} 人` : undefined">{{ attendance(lesson) }}</span>
|
||||||
<span class="c-probs">{{ problemText(lesson) }}</span>
|
<span class="c-probs">{{ problemText(lesson) }}</span>
|
||||||
<span class="c-rate">
|
<span class="c-rate">
|
||||||
{{ lesson.total }} 次 ·
|
{{ lesson.total }} 次 ·
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ const custom = computed<[number, number] | null>(() => {
|
|||||||
return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null
|
return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「做完」只数所选时段之内做对的,和看板、后台「课堂作业」、一行一节课的「做完」不一样
|
||||||
|
* (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的
|
||||||
|
*/
|
||||||
|
const doneLabel = computed(() => {
|
||||||
|
const value = periodValue.value
|
||||||
|
if (value === 'today' || (value === 'custom' && custom.value && custom.value[0] === custom.value[1])) return '这一天做完'
|
||||||
|
if (value === 'lesson' || value === '1h' || value === '2h') return '这节课做完'
|
||||||
|
return '这段时间做完'
|
||||||
|
})
|
||||||
|
|
||||||
const periodValue = computed<Period>(() => {
|
const periodValue = computed<Period>(() => {
|
||||||
// 「那节课」要带着精确的起止时刻,地址栏里缺了就退回默认
|
// 「那节课」要带着精确的起止时刻,地址栏里缺了就退回默认
|
||||||
if (period.value === 'lesson') return custom.value ? 'lesson' : DEFAULT_PERIOD
|
if (period.value === 'lesson') return custom.value ? 'lesson' : DEFAULT_PERIOD
|
||||||
@@ -377,7 +388,7 @@ function resultWidth(value: number) {
|
|||||||
<StatItem label="上了几节课" :value="`${lessons.lessons.length}${lessons.hasMore ? '+' : ''}`" unit="节" />
|
<StatItem label="上了几节课" :value="`${lessons.lessons.length}${lessons.hasMore ? '+' : ''}`" unit="节" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="personCount">
|
<template v-if="personCount">
|
||||||
<StatItem :label="hasProblems ? '做完' : '做对过题'" :value="`${doneCount} / ${personCount}`" unit="人" tone="success" />
|
<StatItem :label="hasProblems ? doneLabel : '做对过题'" :value="`${doneCount} / ${personCount}`" unit="人" tone="success" />
|
||||||
<StatItem v-if="hasProblems" label="完成度" :value="`${personCount ? Math.round((doneCount / personCount) * 100) : 0}%`" />
|
<StatItem v-if="hasProblems" label="完成度" :value="`${personCount ? Math.round((doneCount / personCount) * 100) : 0}%`" />
|
||||||
<StatItem label="一道没交" :value="stats.dataUnaccepted.length" unit="人" tone="error" />
|
<StatItem label="一道没交" :value="stats.dataUnaccepted.length" unit="人" tone="error" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -302,6 +302,13 @@ export const admins: RouteRecordRaw = {
|
|||||||
component: () => import('admin/problem/Trend.vue'),
|
component: () => import('admin/problem/Trend.vue'),
|
||||||
meta: { requiresTeacherAdmin: true },
|
meta: { requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 课堂看板布置的每一节课:改、删、补(数据统计按布置切课)
|
||||||
|
path: 'lesson',
|
||||||
|
name: 'admin lesson list',
|
||||||
|
component: () => import('admin/lesson/list.vue'),
|
||||||
|
meta: { requiresTeacherAdmin: true },
|
||||||
|
},
|
||||||
// 题单管理路由
|
// 题单管理路由
|
||||||
{
|
{
|
||||||
path: 'problemset/list',
|
path: 'problemset/list',
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProblemTagList() {
|
/** 带 language 只数能用这个语言交的题(看板「选题」弹框用) */
|
||||||
return api.get<Tag[]>('problem-tags')
|
export function getProblemTagList(language = '') {
|
||||||
|
return api.get<Tag[]>('problem-tags', { params: { language } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHitokoto() {
|
export function getHitokoto() {
|
||||||
|
|||||||
@@ -67,6 +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: '教程',
|
label: '教程',
|
||||||
icon: 'ph:book-open',
|
icon: 'ph:book-open',
|
||||||
|
|||||||
@@ -74,12 +74,20 @@ export const lastVisitSchema = z.object({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 课堂看板里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉。
|
* 课堂看板里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉。
|
||||||
* `language` 是这份作业用什么语言做,null = 没选
|
* `language` 是这份作业用什么语言做,null = 没选。
|
||||||
|
*
|
||||||
|
* 一个班一天可以上几节内容不同的课,每次布置是一行(class_lesson.id):
|
||||||
|
* - `lessonId`:改 / 清掉这一节(看板上的「改」);
|
||||||
|
* - `newLesson`:今天再开一节(看板上的「新的一节」);
|
||||||
|
* - 两个都不带:改今天最后布置的那一节,今天还没有就新开 —— 一天只能一份时的老写法,
|
||||||
|
* 开着旧页面的老师、没跟上的机房站点发的都是这种,别让它们每改一次就多出一节课
|
||||||
*/
|
*/
|
||||||
export const classLessonRequestSchema = z.object({
|
export const classLessonRequestSchema = z.object({
|
||||||
className: z.string().trim().min(1),
|
className: z.string().trim().min(1),
|
||||||
problemDisplayIds: z.array(z.string().trim().min(1)).max(20),
|
problemDisplayIds: z.array(z.string().trim().min(1)).max(20),
|
||||||
language: lessonLanguageSchema.nullable(),
|
language: lessonLanguageSchema.nullable(),
|
||||||
|
lessonId: z.number().int().positive().optional(),
|
||||||
|
newLesson: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const classBoardProblemSchema = z.object({
|
export const classBoardProblemSchema = z.object({
|
||||||
@@ -168,16 +176,19 @@ export const classBoardStudentSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课堂看板:一个班、今天、这节课的几道题 × 全班学生。
|
* 课堂看板:一个班、今天、这节课的几道题 × 全班学生。「这节课」是今天最后布置的那一节,
|
||||||
|
* 今天没布置过就按今天同班的提交推断。
|
||||||
* className 为 null = 没指定班级、最近两小时也没有哪个班在交(没法猜)。
|
* className 为 null = 没指定班级、最近两小时也没有哪个班在交(没法猜)。
|
||||||
*/
|
*/
|
||||||
export const classBoardSchema = z.object({
|
export const classBoardSchema = z.object({
|
||||||
className: z.string().nullable(),
|
className: z.string().nullable(),
|
||||||
day: z.string(),
|
day: z.string(),
|
||||||
source: z.enum(['teacher', 'inferred']).nullable(),
|
source: z.enum(['teacher', 'inferred']).nullable(),
|
||||||
/** 今天布置的语言(见 lessonLanguageSchema) */
|
/** 老师布置的这一节的 id(「改」带着它);推断出来的课为 null */
|
||||||
|
lessonId: z.number().int().nullable(),
|
||||||
|
/** 这一节布置的语言(见 lessonLanguageSchema) */
|
||||||
language: lessonLanguageSchema,
|
language: lessonLanguageSchema,
|
||||||
/** 今天第一次布置的时刻,之后改题不变;推断出来的课为 null */
|
/** 这一节第一次布置的时刻,之后改题不变;推断出来的课为 null */
|
||||||
assignedAt: z.string().nullable(),
|
assignedAt: z.string().nullable(),
|
||||||
/** 第一次布置的老师(真名,没有真名用用户名);推断出来的课为 null */
|
/** 第一次布置的老师(真名,没有真名用用户名);推断出来的课为 null */
|
||||||
assignedBy: z.string().nullable(),
|
assignedBy: z.string().nullable(),
|
||||||
@@ -250,3 +261,48 @@ export type ClassBoardProblem = z.infer<typeof classBoardProblemSchema>
|
|||||||
export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
|
export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
|
||||||
export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
|
export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
|
||||||
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
|
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台「课堂作业」:布置过的每一节课一行(class_lesson),新的在前。看板上当场布置的、
|
||||||
|
* 后台补的都在这里,数据统计按它们切课。
|
||||||
|
*/
|
||||||
|
export const adminLessonSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
className: z.string(),
|
||||||
|
/** 东八区的日子,`2026-10-08` */
|
||||||
|
day: z.string(),
|
||||||
|
/** 布置的时刻(看板上第一次布置那一刻;后台补的是填的时刻) */
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
/** 布置的老师:真名,没有真名用用户名(和看板「谁 布置」一样) */
|
||||||
|
createdBy: z.string(),
|
||||||
|
language: lessonLanguageSchema,
|
||||||
|
/** 按布置的顺序;题后来被藏起来的 visible 为 false(统计、学生那边都不再出现) */
|
||||||
|
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string(), visible: z.boolean() })),
|
||||||
|
/** 班里的学生数(STUDENT_ROLES、没禁用),和 doneCount 同一个花名册 */
|
||||||
|
classSize: z.number().int(),
|
||||||
|
/** 把布置的题全做对了的人数(那天结束前;口径同看板) */
|
||||||
|
doneCount: z.number().int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const adminLessonListSchema = z.object({
|
||||||
|
results: z.array(adminLessonSchema),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
/** 布置过课的老师(筛选下拉用),按布置的节数从多到少 */
|
||||||
|
teachers: z.array(z.object({ id: z.number().int(), name: z.string() })),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台新补一节 / 改一节。日子和时刻按东八区填:补的是看板外上的课(没在看板布置、
|
||||||
|
* 以前的课),时刻决定同一天几节课的先后。题目至少一道 —— 删掉一节走 DELETE
|
||||||
|
*/
|
||||||
|
export const adminLessonRequestSchema = z.object({
|
||||||
|
className: z.string().trim().min(1),
|
||||||
|
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||||
|
language: lessonLanguageSchema,
|
||||||
|
problemDisplayIds: z.array(z.string().trim().min(1)).min(1).max(20),
|
||||||
|
})
|
||||||
|
export type AdminLesson = z.infer<typeof adminLessonSchema>
|
||||||
|
export type AdminLessonList = z.infer<typeof adminLessonListSchema>
|
||||||
|
export type AdminLessonRequest = z.infer<typeof adminLessonRequestSchema>
|
||||||
@@ -450,6 +450,10 @@ export const NO_CLASS = 'none'
|
|||||||
* 没填班级的号合在一起当一个班切(`className` 为 null)。
|
* 没填班级的号合在一起当一个班切(`className` 为 null)。
|
||||||
* 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38,
|
* 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38,
|
||||||
* 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。
|
* 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。
|
||||||
|
*
|
||||||
|
* **老师在课堂看板布置过的课以布置为准**(`assigned`):题目和顺序照布置的,那天这个班
|
||||||
|
* 交这几道题的提交全算这节课(不论隔了多久、几个人),完成率按全班算。这些提交不再参与上面
|
||||||
|
* 的切段 —— 同一天没布置的另一节课照旧推断,也不会被算成零散提交。
|
||||||
*/
|
*/
|
||||||
const lessonFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
|
const lessonFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
|
||||||
|
|
||||||
@@ -467,8 +471,18 @@ export const submissionLessonsSchema = z.object({
|
|||||||
userCount: z.number().int(),
|
userCount: z.number().int(),
|
||||||
/** 班里的学生数(未禁用的普通用户)。没填班级的谈不上花名册,给 0 */
|
/** 班里的学生数(未禁用的普通用户)。没填班级的谈不上花名册,给 0 */
|
||||||
classSize: z.number().int(),
|
classSize: z.number().int(),
|
||||||
/** 这节课做的题(这一段里 5 人以上做过的),做的人多的在前,最多 20 道 —— 点进去就筛这几道 */
|
/**
|
||||||
|
* 这节课做的题,最多 20 道 —— 点进去就筛这几道。布置的课是布置的那几道、按布置的顺序;
|
||||||
|
* 推断的课是这一段里 5 人以上做过的,做的人多的在前
|
||||||
|
*/
|
||||||
problems: z.array(z.string()),
|
problems: z.array(z.string()),
|
||||||
|
/** 老师在课堂看板布置的课(不是从提交推断的) */
|
||||||
|
assigned: z.boolean(),
|
||||||
|
/**
|
||||||
|
* 布置的课:班里把布置的题全做对了的人数(那天结束前,以前做对的也算;布置时选了语言的
|
||||||
|
* 只认那个语言,流程图评到 A / S 也算 —— 和看板同一个口径)。推断的课为 null
|
||||||
|
*/
|
||||||
|
doneCount: z.number().int().nullable(),
|
||||||
/** 多半是从题单里交的 —— 「题单 7 道」 */
|
/** 多半是从题单里交的 —— 「题单 7 道」 */
|
||||||
fromProblemSet: z.boolean(),
|
fromProblemSet: z.boolean(),
|
||||||
/** 这节课的题(上面 problems)交了几次 —— 和点进去看到的对得上 */
|
/** 这节课的题(上面 problems)交了几次 —— 和点进去看到的对得上 */
|
||||||
|
|||||||
Reference in new issue
Block a user