Compare commits

..
6 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 41d2e46aa9 perf(排名): 打开排名页快一些,看「全服 · 全部」从 0.27 秒降到 0.08 秒
Deploy / deploy (push) Canceled after 0s
做对时刻在库里换成毫秒、按人聚成数组再取回,不再一行一个 timestamptz
逐条解析(全服全部历史六万多行,光解析就占 150ms)。名次与原来完全一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:32:32 +08:00
xuyueandClaude Opus 5.5 f06bb063b4 feat(顶栏): 导航最左边加「首页」,和点站名一样回到首页
没登录时不显示:那时首页就是题目列表,和「题目」是同一页。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:36:41 +08:00
xuyueandClaude Opus 5.5 4a4cede99f style(专题): 列表没有「布置中」时不再另起一行「专题 14 个」,数量写在标题旁边
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:34:59 +08:00
xuyueandClaude Opus 5.5 91057c1096 fix(课堂任务): 先画一节、再用这几道题写代码时,老师这边也认没画图的还没做完
- 任务页格子、老师首页「全做对」和每道题做对几人,按今天几节里同一道题合起来的要求算
  (和学生那边同一份口径,挪进 services/lesson.ts 的 todayNeeds):写代码那节只交对了代码、
  没画图的,格子是「缺图」,不再打 ✓。后台和数据统计还是一节一行、各数各的
- 学生课堂条上「几人做对」也按合起来的要求数,不再把自己没做完的算进去
- 先画再写只交对了代码、图还没画的格子,不再挂挑战的圈
- 题目页标题下:流程图没画到 A 也写「流程图 C 级」(橙色),不再写「还没交过」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:32:57 +08:00
xuyueandClaude Opus 5.5 c3f1413545 fix(课堂任务): 后台改一节不再把它排到同一分钟布置的另一节前面;学生管理员算进统计的课
- 后台「改」时刻只填到分,原来一保存就把布置时刻截成整分:先画流程图、紧接着再开一节写代码,
  只改了做什么,「今天最后一节」就换回了画流程图那节。没改时刻就留着原来的
- 数据统计「一行一节课」只数 Regular User,学生管理员的提交不算,和「做完 / 全班」的花名册对不上
- 学生「老师改了这节课」:画流程图改成写 C、先画再写改成写代码,原来写「语言 改成 C语言」,
  看不出不用画了;现在写「做法 改成 写代码 · C语言」
- 学生课堂标题按每道题合起来的要求写:先画流程图、再用这几道题写代码时,不再只写「用 C语言做」
- 先画再写的题,切到别的题再回来不再被拽回画布(原来只记最后一道题)
- 课堂任务顶上写代码的课也写「写代码」;讲错误的弹层开着时不再因为 15 秒刷新换了个人

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:26:39 +08:00
xuyueandClaude Opus 5.5 b09d479893 style(课堂任务): 后台列表分清写代码、先画再写、画流程图,能按做法筛
原来写代码当默认值不写出来,只剩「C语言」「Python」,看着和别的分不开。
现在三种都用不同颜色的标签写出来,筛选多一个「做什么」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:05:23 +08:00
18 changed files with 297 additions and 182 deletions

No files matched your search

+12 -3
View File
@@ -1,4 +1,4 @@
import { adminLessonRequestSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract' import { adminLessonRequestSchema, lessonModeSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract'
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm' import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
@@ -24,7 +24,12 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
const offset = queryInteger(c.req.query('offset'), 0, { min: 0 }) const offset = queryInteger(c.req.query('offset'), 0, { min: 0 })
const className = c.req.query('className')?.trim() const className = c.req.query('className')?.trim()
const teacherId = queryInteger(c.req.query('teacher'), 0, { min: 0 }) 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 mode = lessonModeSchema.safeParse(c.req.query('mode')).data
const where = and(
className ? eq(schema.classLesson.className, className) : undefined,
teacherId ? eq(schema.classLesson.createdBy, teacherId) : undefined,
mode ? eq(schema.classLesson.mode, mode) : undefined,
)
const [rows, [totalRow], teachers] = await Promise.all([ const [rows, [totalRow], teachers] = await Promise.all([
db db
@@ -156,10 +161,14 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次 // 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
const classes = await affectedClasses(id, result.values.className) const classes = await affectedClasses(id, result.values.className)
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className))) const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
// 时刻只填到分:没改时刻就留着原来带秒的那个。不然只改了做什么,这一节也被挪到这一分钟的开头,
// 排到同一分钟里后布置的那节前面去 —— 「今天最后一节」就换了人(先画流程图、紧接着再开一节写代码,常在同一分钟里)
const [current] = await db.select({ createdAt: schema.classLesson.createdAt }).from(schema.classLesson).where(eq(schema.classLesson.id, id))
const sameMinute = current && Math.floor(Date.parse(current.createdAt) / 60_000) * 60_000 === Date.parse(result.values.createdAt)
// 布置的老师不换,和任务页上「改」一样认第一次 // 布置的老师不换,和任务页上「改」一样认第一次
const [row] = await db const [row] = await db
.update(schema.classLesson) .update(schema.classLesson)
.set({ ...result.values, updatedAt: new Date().toISOString() }) .set({ ...result.values, createdAt: sameMinute ? current.createdAt : result.values.createdAt, updatedAt: new Date().toISOString() })
.where(eq(schema.classLesson.id, id)) .where(eq(schema.classLesson.id, id))
.returning({ id: schema.classLesson.id }) .returning({ id: schema.classLesson.id })
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了') if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
+4 -41
View File
@@ -1,5 +1,5 @@
import { type ClassActivity, type ClassActivityProblem, type LastVisit, type LessonLanguage, type LessonMode, lessonNeeds } from '@oj2/contract' import { type ClassActivity, type ClassActivityProblem, type LastVisit } from '@oj2/contract'
import { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm' import { and, asc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
import { requireAuth, type AppEnv } from '../auth/middleware' import { requireAuth, type AppEnv } from '../auth/middleware'
@@ -9,7 +9,6 @@ import { success } from '../http'
import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity' import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity'
import { accepted } from '../services/learning-stats' import { accepted } from '../services/learning-stats'
import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson' import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson'
import { calendarDay } from '../time'
/** /**
* 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。 * 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。
@@ -145,7 +144,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS) const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id) const ids = lesson.problems.map((problem) => problem.id)
const steps = lesson.source === 'teacher' && lesson.mode ? await todaySteps(user.className, ids, lesson) : null const steps = lesson.steps
const codeLanguage = steps ? steps.language : lesson.language const codeLanguage = steps ? steps.language : lesson.language
// 代码提交和流程图提交两张表,互不依赖,一起查 // 代码提交和流程图提交两张表,互不依赖,一起查
const [mine, drawn] = await Promise.all([ const [mine, drawn] = await Promise.all([
@@ -193,7 +192,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
problems: lesson.problems.map((problem) => { problems: lesson.problems.map((problem) => {
const code = stepOf(codeById.get(problem.id)) const code = stepOf(codeById.get(problem.id))
const draw = stepOf(drawById.get(problem.id)) const draw = stepOf(drawById.get(problem.id))
const needs = steps?.needs.get(problem.id) const needs = steps?.needsOf(problem.id)
let myStatus: Step let myStatus: Step
if (needs) { if (needs) {
// 老师布置的:要哪几步就看哪几步,全做到才算做完 // 老师布置的:要哪几步就看哪几步,全做到才算做完
@@ -215,39 +214,3 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
}), }),
} satisfies ClassActivity) } satisfies ClassActivity)
}) })
/**
* 学生那边一道题要画还是要写:今天这个班布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再布置一节同样的题写代码时,还没画完的人照样看得到要画 ——
* 学生只看得到今天最后布置的那一节,不合起来的话,画流程图那节一布置写代码就没了。
*
* 写代码用的语言:今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
async function todaySteps(
className: string,
problemIds: number[],
latest: { lessonId: number | null; language: LessonLanguage | null; mode: LessonMode | null },
) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = latest.mode && lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = !!latest.mode && lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.lessonId ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
return { needs, language }
}
+1 -8
View File
@@ -252,14 +252,7 @@ rankingRoutes.get('/rankings/champions', optionalAuth, async (c) => {
const champions: WeeklyChampion[] = [] const champions: WeeklyChampion[] = []
for (let k = 1; k <= CHAMPION_WEEKS; k++) { for (let k = 1; k <= CHAMPION_WEEKS; k++) {
const from = thisWeek - k * WEEK_MS const from = thisWeek - k * WEEK_MS
const inWeek = entrants.map((entrant) => { const inWeek = entrants.map((entrant) => ({ ...entrant, times: entrant.times.filter((time) => time >= from && time < from + WEEK_MS) }))
const keep = entrant.times.map((time, index) => [time, entrant.stamps[index]!] as const).filter(([time]) => time >= from && time < from + WEEK_MS)
return {
...entrant,
times: keep.map(([time]) => time),
stamps: keep.map(([, stamp]) => stamp),
}
})
const [top] = standings(inWeek, null, false) const [top] = standings(inWeek, null, false)
if (!top) continue if (!top) continue
champions.push({ champions.push({
+9 -4
View File
@@ -527,6 +527,11 @@ const LESSON_LIVE_MINUTES = 15
/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */ /** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */
const LESSONS_DEFAULT = 50 const LESSONS_DEFAULT = 50
const LESSONS_MAX = 500 const LESSONS_MAX = 500
/** 课只算学生(STUDENT_ROLES,学生管理员也算),老师试题的提交不算 */
const studentRoles = sql.join(
STUDENT_ROLES.map((role) => sql`${role}`),
sql`, `,
)
type StatisticsRange = { start: string | null; end: string } type StatisticsRange = { start: string | null; end: string }
@@ -599,7 +604,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
where s.contest_id is null and p.mode <> 'flowchart' where s.contest_id is null and p.mode <> 'flowchart'
and s.create_time <= ${range.end} and s.create_time <= ${range.end}
${range.start ? sql`and s.create_time >= ${range.start}` : sql``} ${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
and u.admin_type = 'Regular User' and u.is_disabled = false and u.admin_type in (${studentRoles}) and u.is_disabled = false
union all union all
select p.k, f.user_id, f.problem_id, (f.status = 2 and coalesce(f.ai_grade in (${sql.join( select p.k, f.user_id, f.problem_id, (f.status = 2 and coalesce(f.ai_grade in (${sql.join(
FLOWCHART_PASS_GRADES.map((grade) => sql`${grade}`), FLOWCHART_PASS_GRADES.map((grade) => sql`${grade}`),
@@ -612,7 +617,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
where p.mode <> 'code' where p.mode <> 'code'
and f.create_time <= ${range.end} and f.create_time <= ${range.end}
${range.start ? sql`and f.create_time >= ${range.start}` : sql``} ${range.start ? sql`and f.create_time >= ${range.start}` : sql``}
and u.admin_type = 'Regular User' and u.is_disabled = false and u.admin_type in (${studentRoles}) and u.is_disabled = false
) )
select k, problem_id, select k, problem_id,
count(distinct user_id)::int as users, count(distinct user_id)::int as users,
@@ -721,8 +726,8 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
where s.contest_id is null where s.contest_id is null
and s.create_time <= ${range.end} and s.create_time <= ${range.end}
${range.start ? sql`and s.create_time >= ${range.start}` : sql``} ${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
-- 只算普通学生:老师试题的提交不该把一段拼成「一节课」 -- 只算学生(学生管理员也是学生):老师试题的提交不该把一段拼成「一节课」
and u.admin_type = 'Regular User' and u.is_disabled = false and u.admin_type in (${studentRoles}) and u.is_disabled = false
), ),
g as ( g as (
select *, select *,
+27 -12
View File
@@ -5,7 +5,6 @@ import {
type TaskBoard, type TaskBoard,
type TaskBoardStudent, type TaskBoardStudent,
type ClassLesson, type ClassLesson,
lessonNeeds,
type StatisticInfo, type StatisticInfo,
} from '@oj2/contract' } from '@oj2/contract'
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
@@ -127,9 +126,14 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
const challengeDoneKeys = new Set( const challengeDoneKeys = new Set(
challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`), challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`),
) )
// 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样) // 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样)。
// 格子按每道题合起来的要求算(todayNeeds):先画流程图一节、再写代码一节,没画图的照样标「缺图」,
// 和学生那边的「做完」对得上。代码按的语言也是合起来那份:最后一节只画流程图时,用前面写代码那节的
const mode = lesson.source === 'teacher' ? lesson.mode : null const mode = lesson.source === 'teacher' ? lesson.mode : null
const needs = mode ? lessonNeeds(mode) : null const steps = lesson.steps
const needsOf = (problemId: number) => steps?.needsOf(problemId) ?? null
const anyDraw = ids.some((id) => needsOf(id)?.draw)
const codeLanguage = steps ? steps.language : lesson.language
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([ const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([
ids.length && userIds.length ids.length && userIds.length
? db ? db
@@ -145,7 +149,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds), inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids), inArray(schema.submission.problemId, ids),
lessonLanguageFilter(lesson.language), lessonLanguageFilter(codeLanguage),
), ),
) )
.groupBy(schema.submission.userId, schema.submission.problemId) .groupBy(schema.submission.userId, schema.submission.problemId)
@@ -223,7 +227,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
inArray(schema.submission.userId, userIds), inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids), inArray(schema.submission.problemId, ids),
gte(schema.submission.createTime, start), gte(schema.submission.createTime, start),
lessonLanguageFilter(lesson.language), lessonLanguageFilter(codeLanguage),
), ),
) )
.orderBy(schema.submission.createTime) .orderBy(schema.submission.createTime)
@@ -234,7 +238,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
* 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数, * 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数,
* 老评法和还没评完的为 null * 老评法和还没评完的为 null
*/ */
needs?.draw && ids.length && userIds.length anyDraw && ids.length && userIds.length
? db ? db
.selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], { .selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], {
userId: schema.flowchartSubmission.userId, userId: schema.flowchartSubmission.userId,
@@ -281,6 +285,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
title: problem.title, title: problem.title,
isSql: problem.isSql, isSql: problem.isSql,
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0, hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
mode: steps?.modeOf(problem.id) ?? null,
})), })),
students: roster.map( students: roster.map(
(student) => (student) =>
@@ -289,6 +294,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
username: student.username, username: student.username,
realName: student.realName ?? null, realName: student.realName ?? null,
cells: ids.map((id) => { cells: ids.map((id) => {
const needs = needsOf(id)
const key = `${student.userId}:${id}` const key = `${student.userId}:${id}`
const cell = cellByKey.get(key) const cell = cellByKey.get(key)
const flow = drawnByKey.get(key) const flow = drawnByKey.get(key)
@@ -305,11 +311,11 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0) attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0)
} else { } else {
// 要哪几步看哪几步:两步都做到的时刻是晚的那一步 // 要哪几步看哪几步:两步都做到的时刻是晚的那一步
const steps = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter( const wanted = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter(
(step) => step !== null, (step) => step !== null,
) )
acceptedAt = steps.every((step) => step.at) ? steps.map((step) => step.at!).reduce(latestOf) : null acceptedAt = wanted.every((step) => step.at) ? wanted.map((step) => step.at!).reduce(latestOf) : null
status = acceptedAt ? 'accepted' : steps.some((step) => step.status !== 'none') ? 'tried' : 'none' status = acceptedAt ? 'accepted' : wanted.some((step) => step.status !== 'none') ? 'tried' : 'none'
attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0) attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0)
} }
// 下面几样都是代码那一步的:只画流程图的课没有 // 下面几样都是代码那一步的:只画流程图的课没有
@@ -352,8 +358,9 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
})) }))
: [], : [],
acceptedTry: firstTry >= 0 ? firstTry + 1 : null, acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
// 整道题做完了才谈挑战:先画再写只交对了代码、图还没画的,格子是「缺图」,不挂挑战的圈
challenge: challenge:
!codeWanted || !codeDone || !liveChallenges.get(id)?.size !codeWanted || !codeDone || !acceptedAt || !liveChallenges.get(id)?.size
? null ? null
: challengeDoneKeys.has(key) : challengeDoneKeys.has(key)
? 'done' ? 'done'
@@ -410,9 +417,17 @@ taskRoutes.get('/task/today', requireTeacher, async (c) => {
const ids = lesson.problems.map((problem) => problem.id) const ids = lesson.problems.map((problem) => problem.id)
let allDoneCount = 0 let allDoneCount = 0
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) { if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
// 老师布置的:和任务页、后台课堂任务同一个口径(要画的看图、要写的看代码) // 老师布置的:和任务页同一个口径(每道题按今天几节合起来的要求,要画的看图、要写的看代码)
const counts = await lessonDoneCounts([ const counts = await lessonDoneCounts([
{ id: lesson.lessonId, className, day: calendarDay(), problemIds: ids, language: lesson.language, mode: lesson.mode }, {
id: lesson.lessonId,
className,
day: calendarDay(),
problemIds: ids,
language: lesson.steps?.language ?? lesson.language,
mode: lesson.mode,
modeOf: lesson.steps?.modeOf,
},
]) ])
allDoneCount = counts.get(lesson.lessonId) ?? 0 allDoneCount = counts.get(lesson.lessonId) ?? 0
} else if (ids.length) { } else if (ids.length) {
+60 -5
View File
@@ -59,7 +59,16 @@ export function lessonDayEnd(day: string) {
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上 * 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
*/ */
export async function lessonDoneCounts( export async function lessonDoneCounts(
lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null; mode: LessonMode }[], lessons: {
id: number
className: string
day: string
problemIds: number[]
language: LessonLanguage | null
mode: LessonMode
/** 每道题各自做什么(老师首页按今天几节合起来的要求算,见 todayNeeds);不给就都按 mode */
modeOf?: (problemId: number) => LessonMode
}[],
) { ) {
const rows = lessons.flatMap((lesson) => const rows = lessons.flatMap((lesson) =>
lesson.problemIds.map((problemId) => ({ lesson.problemIds.map((problemId) => ({
@@ -67,7 +76,7 @@ export async function lessonDoneCounts(
class_name: lesson.className, class_name: lesson.className,
problem_id: problemId, problem_id: problemId,
language: lesson.language, language: lesson.language,
mode: lesson.mode, mode: lesson.modeOf?.(problemId) ?? lesson.mode,
until: lessonDayEnd(lesson.day), until: lessonDayEnd(lesson.day),
})), })),
) )
@@ -281,6 +290,45 @@ async function lessonPlan(className: string, day: string) {
return row?.problemIds.length ? row : null return row?.problemIds.length ? row : null
} }
/**
* 一道题今天要画还是要写:这个班今天布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再「用这几道题写代码」开一节时,还没画完的照样要画 ——
* 只看今天最后布置的那一节的话,一布置写代码,画图那一步就没了。学生的课堂条、任务页的格子、
* 老师首页的「全做对」都按它算,三处说的「做完」才一样(后台和数据统计还是一节一行、各数各的)。
*
* `language`:代码按什么语言数 —— 今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
export async function todayNeeds(className: string, problemIds: number[], latest: { id: number; language: LessonLanguage | null; mode: LessonMode }) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.id ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
const latestNeeds = lessonNeeds(latest.mode)
const needsOf = (id: number) => needs.get(id) ?? latestNeeds
const modeOf = (id: number): LessonMode => {
const { draw, code } = needsOf(id)
return draw && code ? 'both' : draw ? 'flowchart' : 'code'
}
return { needsOf, modeOf, language }
}
/** /**
* 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher), * 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher),
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的 * 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
@@ -294,17 +342,23 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
const plan = await lessonPlan(className, today) const plan = await lessonPlan(className, today)
if (plan) { if (plan) {
const planned = plan.problemIds const planned = plan.problemIds
const [problems, groups] = await Promise.all([ const steps = await todayNeeds(className, planned, plan)
// 每道题按它合起来的要求数「做过 / 做对」:要求一样的几道一起数
const byMode = Map.groupBy(planned, (id) => steps.modeOf(id))
const [problems, ...groupLists] = await Promise.all([
visibleProblems(planned), visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }), ...[...byMode].map(([mode, ids]) =>
classDayGroups(className, dayStart(), { problemIds: ids, language: lessonNeeds(mode).code ? steps.language : null, mode }),
),
]) ])
const groupById = new Map(groups.map((row) => [row.problemId, row])) const groupById = new Map(groupLists.flat().map((row) => [row.problemId, row]))
return { return {
source: 'teacher' as const, source: 'teacher' as const,
lessonId: plan.id, lessonId: plan.id,
day: today, day: today,
language: plan.language ?? null, language: plan.language ?? null,
mode: plan.mode, mode: plan.mode,
steps,
assignedAt: plan.createdAt, assignedAt: plan.createdAt,
assignedBy: plan.createdBy, assignedBy: plan.createdBy,
problems: planned.flatMap((id) => { problems: planned.flatMap((id) => {
@@ -337,6 +391,7 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限 // 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
language: null, language: null,
mode: null, mode: null,
steps: null,
assignedAt: null, assignedAt: null,
assignedBy: null, assignedBy: null,
problems: picked.flatMap((row) => { problems: picked.flatMap((row) => {
+31 -29
View File
@@ -36,9 +36,8 @@ export interface Entrant {
className: string | null className: string | null
avatar: string | null avatar: string | null
mood: string | null mood: string | null
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */ /** 每道题第一次做对的时刻(毫秒),升序 */
times: number[] times: number[]
stamps: string[]
} }
export interface Standing { export interface Standing {
@@ -64,10 +63,26 @@ export function avatarOf(avatar: string | null) {
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是 * 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。 * 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE * 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
* contest_id IS NULL),全服全部历史实测 24ms。 * contest_id IS NULL)。
*
* 时刻在库里就换成毫秒数、按人排好聚成数组再拿回来:全服全部历史有六万多个 (人, 题),
* 一行一个 timestamptz 的话,光 db/index.ts 那个保留微秒的 parser 加 `Date.parse` 就
* 要 150ms(两核上),库里的查询本身才 40ms。截到毫秒和原来 `Date.parse` 比较的精度一样,
* 名次不变;`reachedAt` 只拿来显示「比你早多久」,用不着微秒。
*/ */
export async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> { export async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
const firstSolved = min(schema.submission.createTime) const firstSolved = min(schema.submission.createTime)
const firsts = db
.select({
userId: schema.submission.userId,
at: sql<number>`floor(extract(epoch from ${firstSolved}) * 1000)::float8`.as('at'),
})
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(where, isNull(schema.submission.contestId), inArray(schema.submission.result, SOLVED_RESULTS)))
.groupBy(schema.submission.userId, schema.submission.problemId)
.having(since ? gte(firstSolved, since) : undefined)
.as('firsts')
const [people, solves] = await Promise.all([ const [people, solves] = await Promise.all([
db db
.select({ .select({
@@ -81,33 +96,20 @@ export async function loadEntrants(where: SQL, since: string | null): Promise<En
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where), .where(where),
db db
.select({ userId: schema.submission.userId, at: firstSolved }) .select({ userId: firsts.userId, times: sql<number[]>`array_agg(${firsts.at} order by ${firsts.at})` })
.from(schema.submission) .from(firsts)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .groupBy(firsts.userId),
.where(and(where, isNull(schema.submission.contestId), inArray(schema.submission.result, SOLVED_RESULTS)))
.groupBy(schema.submission.userId, schema.submission.problemId)
.having(since ? gte(firstSolved, since) : undefined),
]) ])
const byUser = new Map<number, string[]>() const byUser = new Map(solves.map((row) => [row.userId, row.times]))
for (const row of solves) { return people.map((person) => ({
if (!row.at) continue id: person.id,
const list = byUser.get(row.userId) username: person.username,
if (list) list.push(row.at) className: person.className,
else byUser.set(row.userId, [row.at]) avatar: avatarOf(person.avatar),
} mood: person.mood?.trim() || null,
return people.map((person) => { times: byUser.get(person.id) ?? [],
const pairs = (byUser.get(person.id) ?? []).map((stamp) => [Date.parse(stamp), stamp] as const).sort((a, b) => a[0] - b[0]) }))
return {
id: person.id,
username: person.username,
className: person.className,
avatar: avatarOf(person.avatar),
mood: person.mood?.trim() || null,
times: pairs.map(([time]) => time),
stamps: pairs.map(([, stamp]) => stamp),
}
})
} }
/** /**
@@ -127,7 +129,7 @@ export function standings(entrants: Entrant[], cutoff: number | null, includeZer
entrant, entrant,
solved, solved,
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY, reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
reachedAt: solved ? entrant.stamps[solved - 1]! : null, reachedAt: solved ? new Date(entrant.times[solved - 1]!).toISOString() : null,
} }
}) })
.filter((row) => includeZero || row.solved > 0) .filter((row) => includeZero || row.solved > 0)
+2 -2
View File
@@ -786,9 +786,9 @@ export function deleteAchievement(id: number) {
// ---------- 课堂任务(class_lesson) ---------- // ---------- 课堂任务(class_lesson) ----------
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) { export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null; mode: string }) {
return api.get<AdminLessonList>('admin/lessons', { return api.get<AdminLessonList>('admin/lessons', {
params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '' }, params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '', mode: filters.mode },
}) })
} }
+30 -11
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { LESSON_MODE_LABEL, type AdminLesson, type AdminLessonList } from '@oj2/contract' import { LESSON_MODE_LABEL, type AdminLesson, type AdminLessonList, type LessonMode } from '@oj2/contract'
import { useRouteQuery } from '@vueuse/router' import { useRouteQuery } from '@vueuse/router'
import PageHeader from 'admin/components/PageHeader.vue' import PageHeader from 'admin/components/PageHeader.vue'
import { NButton, NPopconfirm, NTooltip, useThemeVars } from 'naive-ui' import { NButton, NPopconfirm, NTag, NTooltip, useThemeVars } from 'naive-ui'
import { dayStartOfDate } from 'oj/statistics/period' import { dayStartOfDate } from 'oj/statistics/period'
import Pagination from 'shared/components/Pagination.vue' import Pagination from 'shared/components/Pagination.vue'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
@@ -27,13 +27,16 @@ const configStore = useConfigStore()
const { isMobile } = useBreakpoints() const { isMobile } = useBreakpoints()
// 从任务页「布置过的课」进来带着 ?className= // 从任务页「布置过的课」进来带着 ?className=
const { query } = usePagination<{ className: string; teacher: string }>({ const { query } = usePagination<{ className: string; teacher: string; mode: string }>({
className: useRouteQuery('className', '').value, className: useRouteQuery('className', '').value,
teacher: useRouteQuery('teacher', '').value, teacher: useRouteQuery('teacher', '').value,
mode: useRouteQuery('mode', '').value,
}) })
const data = ref<AdminLessonList | null>(null) const data = ref<AdminLessonList | null>(null)
const classOptions = computed(() => [{ label: '全部班级', value: '' }, ...groupClassOptions(configStore.config?.classList ?? [])]) const classOptions = computed(() => [{ label: '全部班级', value: '' }, ...groupClassOptions(configStore.config?.classList ?? [])])
const MODES: LessonMode[] = ['code', 'both', 'flowchart']
const modeOptions = [{ label: '全部做法', value: '' }, ...MODES.map((value) => ({ label: LESSON_MODE_LABEL[value], value }))]
const teacherOptions = computed(() => [ const teacherOptions = computed(() => [
{ label: '全部老师', value: '' }, { label: '全部老师', value: '' },
...(data.value?.teachers ?? []).map((teacher) => ({ label: teacher.name, value: String(teacher.id) })), ...(data.value?.teachers ?? []).map((teacher) => ({ label: teacher.name, value: String(teacher.id) })),
@@ -46,13 +49,14 @@ async function list() {
getAdminLessons((query.page - 1) * query.limit, query.limit, { getAdminLessons((query.page - 1) * query.limit, query.limit, {
className: query.className, className: query.className,
teacher: query.teacher ? Number(query.teacher) : null, teacher: query.teacher ? Number(query.teacher) : null,
mode: query.mode,
}), }),
) )
if (res) data.value = res if (res) data.value = res
} }
onMounted(list) onMounted(list)
// 换了班级、老师 usePagination 会把页码拨回 1,同一拍里只拉一次 // 换了班级、老师、做法 usePagination 会把页码拨回 1,同一拍里只拉一次
watch(() => [query.page, query.limit, query.className, query.teacher], list) watch(() => [query.page, query.limit, query.className, query.teacher, query.mode], list)
// ---- 改 / 补 / 删 ---- // ---- 改 / 补 / 删 ----
const editing = ref(false) const editing = ref(false)
@@ -133,17 +137,24 @@ function renderActions(lesson: AdminLesson) {
]) ])
} }
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */ /** 三种做法各一个颜色,一眼分得开;写代码也写出来,不当默认值省掉 */
function wayText(lesson: AdminLesson) { const MODE_TAG_TYPE: Record<LessonMode, 'default' | 'info' | 'warning'> = { code: 'default', both: 'info', flowchart: 'warning' }
return [lesson.mode !== 'code' ? LESSON_MODE_LABEL[lesson.mode] : '', lesson.language ? LANGUAGE_SHOW_VALUE[lesson.language] : ''].filter(Boolean).join(' · ')
/** 「写代码 C语言」「先画再写 Python」「画流程图」:画流程图不用语言 */
function renderWay(lesson: AdminLesson) {
return h('div', { class: 'way' }, [
h(NTag, { size: 'small', bordered: false, type: MODE_TAG_TYPE[lesson.mode] }, { default: () => LESSON_MODE_LABEL[lesson.mode] }),
lesson.language ? h('span', { class: 'way-lang' }, LANGUAGE_SHOW_VALUE[lesson.language]) : null,
])
} }
/** 手机上一节课一张卡:日子 · 时刻 · 班级一行,题号一行,老师 · 语言 · 做完一行小字,操作一行 */ /** 手机上一节课一张卡:日子 · 时刻 · 班级一行,做什么一行,题号一行,老师 · 做完一行小字,操作一行 */
function renderMobileCard(lesson: AdminLesson) { function renderMobileCard(lesson: AdminLesson) {
return h('div', { class: 'm-card' }, [ return h('div', { class: 'm-card' }, [
h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`), h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`),
renderWay(lesson),
renderProblems(lesson), renderProblems(lesson),
h('div', { class: 'm-meta' }, [lesson.createdBy, wayText(lesson), `做完 ${renderDone(lesson)}`].filter(Boolean).join(' · ')), h('div', { class: 'm-meta' }, `${lesson.createdBy} · 做完 ${renderDone(lesson)}`),
renderActions(lesson), renderActions(lesson),
]) ])
} }
@@ -153,7 +164,7 @@ const desktopColumns: DataTableColumn<AdminLesson>[] = [
{ title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') }, { title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') },
{ title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) }, { title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) },
{ title: '老师', key: 'createdBy', width: 90 }, { title: '老师', key: 'createdBy', width: 90 },
{ title: '做什么', key: 'language', width: 120, render: (row) => wayText(row) || '—' }, { title: '做什么', key: 'mode', width: 150, render: renderWay },
{ title: '题目', key: 'problems', width: 300, render: renderProblems }, { title: '题目', key: 'problems', width: 300, render: renderProblems },
{ title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone }, { title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone },
{ title: '', key: 'actions', render: renderActions }, { title: '', key: 'actions', render: renderActions },
@@ -173,6 +184,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
<template #filters> <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.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' }" /> <n-select v-model:value="query.teacher" :options="teacherOptions" aria-label="老师" :style="{ width: isMobile ? '100%' : '140px' }" />
<n-select v-model:value="query.mode" :options="modeOptions" aria-label="做什么" :style="{ width: isMobile ? '100%' : '120px' }" />
</template> </template>
</PageHeader> </PageHeader>
<n-data-table :loading="loading" striped class="lesson-table" :columns="columns" :data="data?.results ?? []" :row-key="(row: AdminLesson) => row.id" /> <n-data-table :loading="loading" striped class="lesson-table" :columns="columns" :data="data?.results ?? []" :row-key="(row: AdminLesson) => row.id" />
@@ -205,6 +217,13 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
text-decoration: line-through; text-decoration: line-through;
} }
.lesson-table :deep(.way) {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.lesson-table :deep(.actions) { .lesson-table :deep(.actions) {
display: flex; display: flex;
gap: 8px; gap: 8px;
+2 -1
View File
@@ -7,6 +7,7 @@ import { getClassActivity } from 'shared/api'
import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue' import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue'
import { pickHeadline, type Headline } from 'oj/user/knowledge' import { pickHeadline, type Headline } from 'oj/user/knowledge'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { activityMode } from 'shared/store/lesson'
import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from 'utils/constants' import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { duration, parseTime, zonedParts, zonedYear } from 'utils/functions' import { duration, parseTime, zonedParts, zonedYear } from 'utils/functions'
import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types' import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types'
@@ -191,7 +192,7 @@ const classActivityTitle = computed(() => {
// 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道 // 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
if (classActivity.value?.source === 'teacher') { if (classActivity.value?.source === 'teacher') {
const language = classActivity.value.language const language = classActivity.value.language
const mode = classActivity.value.mode const mode = activityMode(classActivity.value)
if (mode === 'flowchart') return '这节课 · 画流程图' if (mode === 'flowchart') return '这节课 · 画流程图'
if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码` if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码`
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}` return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}`
@@ -73,12 +73,15 @@ watch(
() => (myFlowchartOpen.value = true), () => (myFlowchartOpen.value = true),
) )
/** 流程图画到 A / S 了:标题下给个「流程图 A 级」 */ /**
* 标题下的「流程图 X 级」:评完过的里面最好的那一次。画到 A / S 是绿的(算做完了),
* 没画到是橙的 —— 原来只在 A / S 时才写,画到 C 的学生标题下是「还没交过」,像是什么都没交
*/
const flowchartGrade = computed(() => { const flowchartGrade = computed(() => {
const passed = flowchartStore.scores.filter((row) => isFlowchartPass(row.grade)) const best = flowchartStore.scores.reduce<(typeof flowchartStore.scores)[number] | null>((acc, row) => (!acc || row.score > acc.score ? row : acc), null)
if (!passed.length) return '' return best?.grade ?? ''
return passed.some((row) => row.grade === 'S') ? 'S' : 'A'
}) })
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
// 例子旁的「复制」:点完变成「已复制」一秒 // 例子旁的「复制」:点完变成「已复制」一秒
const message = useMessage() const message = useMessage()
@@ -192,12 +195,13 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-tag> </n-tag>
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag> <n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag>
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」 <!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
「做过,还没对」—— 那两个说的是代码 --> 「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
<template v-else-if="!flowchartGrade"> 「还没交过」,旁边的「流程图 C 级」已经说了交过什么 -->
<template v-else-if="!flowchartPassed">
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag> <n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag>
<n-tag v-else-if="myStatus === 'none'" size="small" :bordered="false">还没交过</n-tag> <n-tag v-else-if="myStatus === 'none' && !flowchartGrade" size="small" :bordered="false">还没交过</n-tag>
</template> </template>
<n-tag v-if="flowchartGrade" size="small" type="success" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag> <n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
</div> </div>
</header> </header>
@@ -182,16 +182,25 @@ watch(
* 没有画这一步的课,画着流程图时不动他 * 没有画这一步的课,画着流程图时不动他
*/ */
const lessonStore = useLessonStore() const lessonStore = useLessonStore()
let lessonLanguageFor: string | null = null /**
* 换过的(题 + 这一节 + 该做的那一步)都记下:换到别的题再回来,同一步不再换第二次。
* 编辑器在换题时不重新挂载(detail.vue 只重新 init),只记最后一个的话一回来又被拽回去
*/
const lessonSwitched = new Set<string>()
let lessonViewing: string | null = null
watch( watch(
() => [problem.value?._id, lessonStore.activity] as const, () => [problem.value?._id, lessonStore.activity] as const,
([id, activity]) => { ([id, activity]) => {
if (!id || !activity) return if (!id || !activity) return
const key = [id, activity.day, activity.mode, activity.language].join('|') const lesson = [id, activity.day, activity.mode, activity.language].join('|')
if (lessonLanguageFor === key) return
lessonLanguageFor = key
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
const item = lessonStore.find(id) const item = lessonStore.find(id)
const key = [lesson, item?.draw && item.draw !== 'accepted' ? 'draw' : 'code'].join('|')
// 停在这道题上、只是课堂状态刷新了(比如刚画对图):不跟着换,也不记,下回打开这道题再换
const stay = lessonViewing === lesson
lessonViewing = lesson
if (lessonSwitched.has(key) || stay) return
lessonSwitched.add(key)
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
if (!item) return if (!item) return
if (item.draw && item.draw !== 'accepted') { if (item.draw && item.draw !== 'accepted') {
if (problemStore.canDraw && codeStore.code.language !== 'Flowchart') problemStore.switchLanguage('Flowchart') if (problemStore.canDraw && codeStore.code.language !== 'Flowchart') problemStore.switchLanguage('Flowchart')
+10 -2
View File
@@ -83,6 +83,8 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
<div class="page oj-page"> <div class="page oj-page">
<div class="top"> <div class="top">
<h2>专题</h2> <h2>专题</h2>
<!-- 没有布置中的,就只有一组:数量写在标题旁边,不再另起一行「专题 14 个」把标题再说一遍 -->
<span v-if="!assigning.length && others.length" class="muted count">{{ others.length }} 个</span>
<div class="spacer"></div> <div class="spacer"></div>
<n-input v-model:value="keyword" placeholder="搜专题" clearable class="search" /> <n-input v-model:value="keyword" placeholder="搜专题" clearable class="search" />
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 --> <!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
@@ -133,8 +135,8 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
</div> </div>
</template> </template>
<div v-if="others.length" class="sect"> <div v-if="assigning.length && others.length" class="sect">
{{ assigning.length ? '其他专题' : '专题' }} 其他专题
<span class="muted">{{ others.length }} 个</span> <span class="muted">{{ others.length }} 个</span>
</div> </div>
<div class="small-grid"> <div class="small-grid">
@@ -191,6 +193,12 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
font-size: var(--oj-fs-title); font-size: var(--oj-fs-title);
} }
/* 和分组标题旁的「N 个」同一档:小字、不加粗 */
.top .count {
font-size: var(--oj-fs-sec);
font-weight: 400;
}
.search { .search {
width: 220px; width: 220px;
} }
+48 -40
View File
@@ -122,8 +122,11 @@ onUnmounted(pause)
*/ */
const editing = ref<false | 'edit' | 'new' | 'carry'>(false) const editing = ref<false | 'edit' | 'new' | 'carry'>(false)
/** 这节课做什么;推断出来的课为 null(代码交对或流程图画对都算) */ /**
const mode = computed(() => board.value?.mode ?? null) * 第几道题今天做什么:今天几节里有这道题的要求合起来(先画流程图一节、再写代码一节 = 先画再写),
* 和学生那边的「做完」一致。推断出来的课为 null(代码交对或流程图画对都算)
*/
const modeAt = (index: number) => board.value?.problems[index]?.mode ?? null
/** /**
* 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久, * 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久,
@@ -200,7 +203,7 @@ function groupOf(student: TaskBoardStudent): Group {
const cells = student.cells const cells = student.cells
if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done' if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done'
if (!student.lastSubmitAt) return 'idle' if (!student.lastSubmitAt) return 'idle'
if (cells.some((cell) => isStuck(cell, mode.value))) return 'stuck' if (cells.some((cell, i) => isStuck(cell, modeAt(i)))) return 'stuck'
return 'working' return 'working'
} }
@@ -327,23 +330,24 @@ function drawKind(cell: TaskBoardCell): ErrorKind {
* 要画流程图的课,卡在画图上的按最后一张图归堆(drawKind);先写了代码、没画图的单独一堆 * 要画流程图的课,卡在画图上的按最后一张图归堆(drawKind);先写了代码、没画图的单独一堆
*/ */
function wrongOf(index: number): WrongPerson[] { function wrongOf(index: number): WrongPerson[] {
const mode = modeAt(index)
return present.value.flatMap((student): WrongPerson[] => { return present.value.flatMap((student): WrongPerson[] => {
const cell = student.cells[index] const cell = student.cells[index]
if (!cell || cell.status !== 'tried') return [] if (!cell || cell.status !== 'tried') return []
const stage = cellStage(cell, mode.value) const stage = cellStage(cell, mode)
if (stage === 'draw') { if (stage === 'draw') {
const latest = cell.flowchart?.latest const latest = cell.flowchart?.latest
const evaluated = !!latest && latest.status !== 0 && latest.status !== 1 const evaluated = !!latest && latest.status !== 0 && latest.status !== 1
return [{ student, cell, kind: drawKind(cell), explain: evaluated ? 'draw' : null }] return [{ student, cell, kind: drawKind(cell), explain: evaluated ? 'draw' : null }]
} }
if (stage === 'nodraw') return [{ student, cell, kind: { key: 'nodraw', label: '代码对了,还没画图', line: null }, explain: null }] if (stage === 'nodraw') return [{ student, cell, kind: { key: 'nodraw', label: '代码对了,还没画图', line: null }, explain: null }]
if (mode.value === 'both' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none') { if (mode === 'both' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none') {
return [{ student, cell, kind: { key: 'drawn', label: '图画对了,还没写代码', line: null }, explain: null }] return [{ student, cell, kind: { key: 'drawn', label: '图画对了,还没写代码', line: null }, explain: null }]
} }
// 推断出来的课不分步:今天没代码、却画了流程图的单独一堆 // 推断出来的课不分步:今天没代码、却画了流程图的单独一堆
const kind: ErrorKind = cell.latest const kind: ErrorKind = cell.latest
? errorKind(cell.latest) ? errorKind(cell.latest)
: !mode.value && cell.attempts : !mode && cell.attempts
? { key: 'flowchart', label: '今天只画了流程图', line: null } ? { key: 'flowchart', label: '今天只画了流程图', line: null }
: { key: 'before', label: '以前交过,今天还没交', line: null } : { key: 'before', label: '以前交过,今天还没交', line: null }
return [{ student, cell, kind, explain: cell.latest ? 'code' : null }] return [{ student, cell, kind, explain: cell.latest ? 'code' : null }]
@@ -362,21 +366,18 @@ const problemStats = computed(() =>
const challenged = problem.hasChallenges ? present.value.filter((student) => student.cells[i]?.challenge === 'done').length : null const challenged = problem.hasChallenges ? present.value.filter((student) => student.cells[i]?.challenge === 'done').length : null
// 先画再写:两步各几人做到,老师一眼看出全班卡在画图还是写代码 // 先画再写:两步各几人做到,老师一眼看出全班卡在画图还是写代码
const steps = const steps =
mode.value === 'both' problem.mode === 'both'
? { ? {
drawn: present.value.filter((student) => student.cells[i]?.flowchart?.status === 'accepted').length, drawn: present.value.filter((student) => student.cells[i]?.flowchart?.status === 'accepted').length,
coded: present.value.filter((student) => student.cells[i]?.codeStatus === 'accepted').length, coded: present.value.filter((student) => student.cells[i]?.codeStatus === 'accepted').length,
} }
: null : null
return { problem, done, wrong, none: total - done - wrong, total, challenged, steps } // 先画再写的题,没做完的里面有图画对了、还没写代码的,说「错着」不对 —— 改说「没做完」
const wrongWord = problem.mode === 'both' ? '没做完' : '错着'
return { problem, done, wrong, none: total - done - wrong, total, challenged, steps, wrongWord }
}), }),
) )
/**
* 先画再写的课,没做完的里面有图画对了、还没写代码的,说「错着」不对 —— 改说「没做完」
*/
const wrongWord = computed(() => (mode.value === 'both' ? '没做完' : '错着'))
const selectedId = ref<number | null>(null) const selectedId = ref<number | null>(null)
const selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value)) const selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value))
function toggleProblem(problemId: number) { function toggleProblem(problemId: number) {
@@ -408,10 +409,14 @@ const explainShow = ref(false)
const explainIndex = ref(0) const explainIndex = ref(0)
const explainProblemIndex = ref(-1) const explainProblemIndex = ref(-1)
const explainProblem = computed(() => board.value?.problems[explainProblemIndex.value] ?? null) const explainProblem = computed(() => board.value?.problems[explainProblemIndex.value] ?? null)
/** 弹层顶上那排人:这道题错着的人,顺序和错法堆一样 */ /**
const explainPeople = computed<ExplainPerson[]>(() => { * 弹层顶上那排人:这道题错着的人,顺序和错法堆一样。打开那一刻定下来,开着时不跟 15 秒的刷新变:
if (explainProblemIndex.value < 0) return [] * 弹层记的是第几个人,名单一变(有人改对了、图评完了回到名单里)就成了讲着讲着换了个人
const people = wrongOf(explainProblemIndex.value).filter((person) => person.explain) */
const explainPeople = ref<ExplainPerson[]>([])
function explainPeopleOf(problemIndex: number): ExplainPerson[] {
if (problemIndex < 0) return []
const people = wrongOf(problemIndex).filter((person) => person.explain)
const order = new Map<string, number>() const order = new Map<string, number>()
for (const person of people) { for (const person of people) {
order.set(person.kind.key, (order.get(person.kind.key) ?? 0) + 1) order.set(person.kind.key, (order.get(person.kind.key) ?? 0) + 1)
@@ -423,10 +428,11 @@ const explainPeople = computed<ExplainPerson[]>(() => {
? { userId: person.student.userId, username: person.student.username, flowchartId: person.cell.flowchart!.latest!.id, label: person.kind.label } ? { userId: person.student.userId, username: person.student.username, flowchartId: person.cell.flowchart!.latest!.id, label: person.kind.label }
: { userId: person.student.userId, username: person.student.username, submissionId: person.cell.latest!.submissionId, label: person.kind.label }, : { userId: person.student.userId, username: person.student.username, submissionId: person.cell.latest!.submissionId, label: person.kind.label },
) )
}) }
function openExplain(problemIndex: number, userId: number) { function openExplain(problemIndex: number, userId: number) {
explainProblemIndex.value = problemIndex explainProblemIndex.value = problemIndex
explainPeople.value = explainPeopleOf(problemIndex)
explainIndex.value = Math.max( explainIndex.value = Math.max(
0, 0,
explainPeople.value.findIndex((person) => person.userId === userId), explainPeople.value.findIndex((person) => person.userId === userId),
@@ -442,14 +448,15 @@ const cellColors = computed(() => ({
actionColor: theme.value.actionColor, actionColor: theme.value.actionColor,
successColor: theme.value.successColor, successColor: theme.value.successColor,
})) }))
const cellStyle = (cell: TaskBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value) /** `index`:第几道题,格子按那道题今天做什么来画(modeAt) */
const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value) const cellStyle = (cell: TaskBoardCell, index: number) => cellStyleOf(cell, board.value?.day, cellColors.value, modeAt(index))
const cellText = (cell: TaskBoardCell) => cellTextOf(cell, mode.value) const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent, index: number) => cellTitleOf(cell, student, board.value?.day, modeAt(index))
const cellText = (cell: TaskBoardCell, index: number) => cellTextOf(cell, modeAt(index))
/** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */ /** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */
function explainable(cell: TaskBoardCell) { function explainable(cell: TaskBoardCell, index: number) {
if (cell.status !== 'tried') return false if (cell.status !== 'tried') return false
const stage = cellStage(cell, mode.value) const stage = cellStage(cell, modeAt(index))
if (stage === 'draw') { if (stage === 'draw') {
const latest = cell.flowchart?.latest const latest = cell.flowchart?.latest
return !!latest && latest.status !== 0 && latest.status !== 1 return !!latest && latest.status !== 0 && latest.status !== 1
@@ -490,7 +497,7 @@ function submissionsHref(
// SQL 题不受布置的语言限制,格子数的是 SQL 交的 // SQL 题不受布置的语言限制,格子数的是 SQL 交的
const language = problem?.isSql ? 'SQL' : board.value?.language const language = problem?.isSql ? 'SQL' : board.value?.language
// 只画流程图的课,格子数的全是流程图 // 只画流程图的课,格子数的全是流程图
const flowSide = flowchart || mode.value === 'flowchart' const flowSide = flowchart || problem?.mode === 'flowchart'
return router.resolve({ return router.resolve({
name: 'submissions', name: 'submissions',
query: { query: {
@@ -532,7 +539,8 @@ function lastText(student: TaskBoardStudent) {
/> />
<div v-else-if="board?.problems.length && !needLesson" 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.mode && board.mode !== 'code'" class="chip lang">{{ LESSON_MODE_LABEL[board.mode] }}</span> <!-- 写代码也写出来:只写「Python」的话,看不出这节是写代码还是先画再写 -->
<span v-if="board.mode" class="chip lang">{{ LESSON_MODE_LABEL[board.mode] }}</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 }}
@@ -601,7 +609,7 @@ function lastText(student: TaskBoardStudent) {
</span> </span>
<span v-if="stat.steps" class="card-steps">画对 {{ stat.steps.drawn }} · 写对 {{ stat.steps.coded }}</span> <span v-if="stat.steps" class="card-steps">画对 {{ stat.steps.drawn }} · 写对 {{ stat.steps.coded }}</span>
<span class="card-foot"> <span class="card-foot">
<span :class="stat.wrong ? 'wrong' : 'muted'">{{ wrongWord }} {{ stat.wrong }}</span> <span :class="stat.wrong ? 'wrong' : 'muted'">{{ stat.wrongWord }} {{ stat.wrong }}</span>
<span class="muted">没做 {{ stat.none }}</span> <span class="muted">没做 {{ stat.none }}</span>
<span v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span> <span v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span>
</span> </span>
@@ -624,7 +632,7 @@ function lastText(student: TaskBoardStudent) {
{{ board.problems[selectedIndex]!.title }} {{ board.problems[selectedIndex]!.title }}
</a> </a>
<span> <span>
{{ wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人 {{ problemStats[selectedIndex]!.wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
</span> </span>
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span> <span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
<div class="spacer"></div> <div class="spacer"></div>
@@ -643,7 +651,7 @@ function lastText(student: TaskBoardStudent) {
<template v-for="person in group.people" :key="person.student.userId"> <template v-for="person in group.people" :key="person.student.userId">
<button v-if="person.explain" type="button" class="who" @click="openExplain(selectedIndex, person.student.userId)"> <button v-if="person.explain" type="button" class="who" @click="openExplain(selectedIndex, person.student.userId)">
<UserName :username="person.student.username" /> <UserName :username="person.student.username" />
<span class="mini" :style="cellStyle(person.cell)">{{ cellText(person.cell) }}</span> <span class="mini" :style="cellStyle(person.cell, selectedIndex)">{{ cellText(person.cell, selectedIndex) }}</span>
</button> </button>
<!-- 没有今天的代码可讲:去提交列表看他交过的 --> <!-- 没有今天的代码可讲:去提交列表看他交过的 -->
<a <a
@@ -747,15 +755,15 @@ function lastText(student: TaskBoardStudent) {
</td> </td>
<td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }"> <td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }">
<button <button
v-if="explainable(cell)" v-if="explainable(cell, i)"
type="button" type="button"
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined" :data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)" :style="cellStyle(cell, i)"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student, i)"
@click="openExplain(i, student.userId)" @click="openExplain(i, student.userId)"
> >
{{ cellText(cell) }} {{ cellText(cell, i) }}
</button> </button>
<n-popover <n-popover
v-else-if="cell.trail.length" v-else-if="cell.trail.length"
@@ -771,11 +779,11 @@ function lastText(student: TaskBoardStudent) {
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined" :data-challenge="cell.challenge ?? undefined"
:class="{ picked: trailKey === `${student.userId}:${i}` }" :class="{ picked: trailKey === `${student.userId}:${i}` }"
:style="cellStyle(cell)" :style="cellStyle(cell, i)"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student, i)"
@click="toggleTrail(`${student.userId}:${i}`)" @click="toggleTrail(`${student.userId}:${i}`)"
> >
{{ cellText(cell) }} {{ cellText(cell, i) }}
</button> </button>
</template> </template>
<TrailPanel <TrailPanel
@@ -791,15 +799,15 @@ function lastText(student: TaskBoardStudent) {
v-else-if="cell.attempts" v-else-if="cell.attempts"
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined" :data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)" :style="cellStyle(cell, i)"
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)" :href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
target="_blank" target="_blank"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student, i)"
> >
{{ cellText(cell) }} {{ cellText(cell, i) }}
</a> </a>
<span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell)" :title="cellTitle(cell, student)"> <span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell, i)" :title="cellTitle(cell, student, i)">
{{ cellText(cell) }} {{ cellText(cell, i) }}
</span> </span>
</td> </td>
<td class="num">{{ doneCount(student) }}/{{ board.problems.length }}</td> <td class="num">{{ doneCount(student) }}/{{ board.problems.length }}</td>
+5 -3
View File
@@ -53,8 +53,8 @@ const envVersion = computed(() => {
}) })
// 一级路径就是菜单 key,对不上的页面(/user、/achievement 等) // 一级路径就是菜单 key,对不上的页面(/user、/achievement 等)
// 自然没有一项亮着。根路径没登录时就是题目列表,登录了是个人首页,不亮任何一项 // 自然没有一项亮着。根路径没登录时就是题目列表(亮「题目」),登录了是个人首页(亮「首页」)
const active = computed(() => route.path.split('/')[1] || (userStore.isAuthed ? '' : 'problem')) const active = computed(() => route.path.split('/')[1] || (userStore.isAuthed ? 'home' : 'problem'))
async function handleLogout() { async function handleLogout() {
await userStore.signOut() await userStore.signOut()
@@ -80,7 +80,8 @@ function renderIcon(icon: string) {
* 顶栏导航的唯一一张表:桌面端渲染成一排纯文字,窄屏收进「菜单」下拉。 * 顶栏导航的唯一一张表:桌面端渲染成一排纯文字,窄屏收进「菜单」下拉。
* `key` 就是一级路径,用来判定高亮(见上面的 active)。 * `key` 就是一级路径,用来判定高亮(见上面的 active)。
* *
* 顺序是 自学 / 题目 / 提交 / 专题 / 比赛 / 排名 / 公告。「自学」进自学首页(/learn), * 顺序是 首页 / 自学 / 题目 / 提交 / 专题 / 比赛 / 排名 / 公告。「首页」只给登录了的:没登录时根路径
* 就是题目列表,和「题目」是同一页(2026-11-03 用户要的,原来只能点左上角的站名回首页)。「自学」进自学首页(/learn),
* 两门课、接着学哪一课都在那儿挑,不再是下拉。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。 * 两门课、接着学哪一课都在那儿挑,不再是下拉。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。
* 公告 2026-10-13 回到顶栏(用户要的):首页只放置顶那条了,别的公告得有个地方点得到。 * 公告 2026-10-13 回到顶栏(用户要的):首页只放置顶那条了,别的公告得有个地方点得到。
* 不用 n-menu:它横排每项固定占 100px,1280 宽的机房屏上会把顶栏挤成两行。 * 不用 n-menu:它横排每项固定占 100px,1280 宽的机房屏上会把顶栏挤成两行。
@@ -94,6 +95,7 @@ interface NavLink {
const navLinks = computed<NavLink[]>(() => const navLinks = computed<NavLink[]>(() =>
[ [
{ key: 'home', label: '首页', to: '/', show: userStore.isAuthed },
{ key: 'learn', label: '自学', to: '/learn' }, { key: 'learn', label: '自学', to: '/learn' },
{ key: 'problem', label: '题目', to: '/problem' }, { key: 'problem', label: '题目', to: '/problem' },
{ key: 'submission', label: '提交', to: '/submission', show: userStore.showSubmissions }, { key: 'submission', label: '提交', to: '/submission', show: userStore.showSubmissions },
@@ -17,9 +17,12 @@ function names(list: Item[]) {
return list.map((item) => `${item.problemDisplayId} ${item.title}`).join('、') return list.map((item) => `${item.problemDisplayId} ${item.title}`).join('、')
} }
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */ /**
function languageText(value: LessonUpdate) { * 「画流程图」「先画再写 · C语言」「C语言」。新布置的一节写代码是默认的,不写出来(前面配「用」);
const mode = value.mode && value.mode !== 'code' ? LESSON_MODE_LABEL[value.mode] : '' * 改了做法时 `spellCode` 把「写代码」也写出来 —— 从画流程图改成写 C,只说「C语言」学生看不出不用画了
*/
function languageText(value: LessonUpdate, spellCode = false) {
const mode = value.mode && (spellCode || value.mode !== 'code') ? LESSON_MODE_LABEL[value.mode] : ''
return [mode, value.language ? LANGUAGE_SHOW_VALUE[value.language] : ''].filter(Boolean).join(' · ') return [mode, value.language ? LANGUAGE_SHOW_VALUE[value.language] : ''].filter(Boolean).join(' · ')
} }
@@ -44,8 +47,9 @@ const lines = computed(() => {
if (value.added.length) result.push({ label: '加了', text: names(value.added) }) if (value.added.length) result.push({ label: '加了', text: names(value.added) })
if (value.removed.length) result.push({ label: '去掉了', text: names(value.removed) }) if (value.removed.length) result.push({ label: '去掉了', text: names(value.removed) })
if (value.languageChanged) { if (value.languageChanged) {
const text = languageText(value) // languageChanged 连做法一起算,分不出改的是哪样:一律写「做法」,连写代码带语言一起说
result.push({ label: value.mode && value.mode !== 'code' ? '做法' : '语言', text: text ? `改成 ${text}` : value.mode === 'code' ? '改成写代码' : '不限了' }) const text = languageText(value, true)
result.push({ label: '做法', text: text ? `改成 ${text}` : '不限了' })
} }
if (value.reordered) result.push({ label: '', text: '换了题目的顺序' }) if (value.reordered) result.push({ label: '', text: '换了题目的顺序' })
return result return result
+16 -3
View File
@@ -1,9 +1,22 @@
import { LESSON_MODE_LABEL } from '@oj2/contract' import { LESSON_MODE_LABEL, type LessonMode } from '@oj2/contract'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { getClassActivity } from 'shared/api' import { getClassActivity } from 'shared/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import type { ClassActivity, ClassActivityProblem } from 'utils/types' import type { ClassActivity, ClassActivityProblem } from 'utils/types'
/**
* 学生这边这节课做什么:按每道题合起来的要求算(后端 todaySteps 把今天几节里同一道题的要求并在一起),
* 不直接用 `mode` —— 那是今天最后布置的那一节的。先布置画流程图、再「用这几道题写代码」,
* 最后一节是写代码,但还没画完的照样要画,标题只写「用 C语言做」就漏了画图。
* 推断出来的课为 null
*/
export function activityMode(activity: ClassActivity | null | undefined): LessonMode | null {
if (!activity?.mode) return null
const draw = activity.problems.some((item) => item.draw !== null)
const code = activity.problems.some((item) => item.code !== null)
return draw && code ? 'both' : draw ? 'flowchart' : code ? 'code' : activity.mode
}
/** 同一节课里来回换题不用每次都重拉;过了这么久再进题目页就重拉一次 */ /** 同一节课里来回换题不用每次都重拉;过了这么久再进题目页就重拉一次 */
const STALE_MS = 2 * 60 * 1000 const STALE_MS = 2 * 60 * 1000
@@ -89,8 +102,8 @@ export const useLessonStore = defineStore('lesson', () => {
* 以前布置的)为空。写代码是默认的,不写出来 * 以前布置的)为空。写代码是默认的,不写出来
*/ */
const languageText = computed(() => { const languageText = computed(() => {
const mode = activity.value?.mode const mode = activityMode(activity.value)
const language = activity.value?.language const language = mode === 'flowchart' ? null : activity.value?.language
return [mode && mode !== 'code' ? LESSON_MODE_LABEL[mode] : '', language ? LANGUAGE_SHOW_VALUE[language] : ''].filter(Boolean).join(' · ') return [mode && mode !== 'code' ? LESSON_MODE_LABEL[mode] : '', language ? LANGUAGE_SHOW_VALUE[language] : ''].filter(Boolean).join(' · ')
}) })
+5
View File
@@ -42,6 +42,11 @@ export const taskBoardProblemSchema = z.object({
isSql: z.boolean(), isSql: z.boolean(),
/** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */ /** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */
hasChallenges: z.boolean(), hasChallenges: z.boolean(),
/**
* 这道题今天要做什么:这个班今天布置过的几节里有这道题的,要求合起来(先布置画流程图、再用同样的题
* 写代码 = both)。格子按它画,和学生那边的「做完」一致。推断出来的课为 null
*/
mode: lessonModeSchema.nullable(),
}) })
export const taskBoardCellSchema = z.object({ export const taskBoardCellSchema = z.object({