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 { Hono } from 'hono'
@@ -24,7 +24,12 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
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 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([
db
@@ -156,10 +161,14 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
const classes = await affectedClasses(id, result.values.className)
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
// 时刻只填到分:没改时刻就留着原来带秒的那个。不然只改了做什么,这一节也被挪到这一分钟的开头,
// 排到同一分钟里后布置的那节前面去 —— 「今天最后一节」就换了人(先画流程图、紧接着再开一节写代码,常在同一分钟里)
const [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
.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))
.returning({ id: schema.classLesson.id })
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 { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
import { type ClassActivity, type ClassActivityProblem, type LastVisit } from '@oj2/contract'
import { and, asc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
import { Hono } from 'hono'
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 { accepted } from '../services/learning-stats'
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 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 [mine, drawn] = await Promise.all([
@@ -193,7 +192,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
problems: lesson.problems.map((problem) => {
const code = stepOf(codeById.get(problem.id))
const draw = stepOf(drawById.get(problem.id))
const needs = steps?.needs.get(problem.id)
const needs = steps?.needsOf(problem.id)
let myStatus: Step
if (needs) {
// 老师布置的:要哪几步就看哪几步,全做到才算做完
@@ -215,39 +214,3 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
}),
} 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[] = []
for (let k = 1; k <= CHAMPION_WEEKS; k++) {
const from = thisWeek - k * WEEK_MS
const inWeek = entrants.map((entrant) => {
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 inWeek = entrants.map((entrant) => ({ ...entrant, times: entrant.times.filter((time) => time >= from && time < from + WEEK_MS) }))
const [top] = standings(inWeek, null, false)
if (!top) continue
champions.push({
+9 -4
View File
@@ -527,6 +527,11 @@ const LESSON_LIVE_MINUTES = 15
/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */
const LESSONS_DEFAULT = 50
const LESSONS_MAX = 500
/** 课只算学生(STUDENT_ROLES,学生管理员也算),老师试题的提交不算 */
const studentRoles = sql.join(
STUDENT_ROLES.map((role) => sql`${role}`),
sql`, `,
)
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'
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
and u.admin_type in (${studentRoles}) and u.is_disabled = false
union all
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}`),
@@ -612,7 +617,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
where p.mode <> 'code'
and f.create_time <= ${range.end}
${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,
count(distinct user_id)::int as users,
@@ -721,8 +726,8 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
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
-- 只算学生(学生管理员也是学生):老师试题的提交不该把一段拼成「一节课」
and u.admin_type in (${studentRoles}) and u.is_disabled = false
),
g as (
select *,
+27 -12
View File
@@ -5,7 +5,6 @@ import {
type TaskBoard,
type TaskBoardStudent,
type ClassLesson,
lessonNeeds,
type StatisticInfo,
} from '@oj2/contract'
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(
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 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([
ids.length && userIds.length
? db
@@ -145,7 +149,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids),
lessonLanguageFilter(lesson.language),
lessonLanguageFilter(codeLanguage),
),
)
.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.problemId, ids),
gte(schema.submission.createTime, start),
lessonLanguageFilter(lesson.language),
lessonLanguageFilter(codeLanguage),
),
)
.orderBy(schema.submission.createTime)
@@ -234,7 +238,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
* 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数,
* 老评法和还没评完的为 null
*/
needs?.draw && ids.length && userIds.length
anyDraw && ids.length && userIds.length
? db
.selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], {
userId: schema.flowchartSubmission.userId,
@@ -281,6 +285,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
title: problem.title,
isSql: problem.isSql,
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
mode: steps?.modeOf(problem.id) ?? null,
})),
students: roster.map(
(student) =>
@@ -289,6 +294,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
username: student.username,
realName: student.realName ?? null,
cells: ids.map((id) => {
const needs = needsOf(id)
const key = `${student.userId}:${id}`
const cell = cellByKey.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)
} 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,
)
acceptedAt = steps.every((step) => step.at) ? steps.map((step) => step.at!).reduce(latestOf) : null
status = acceptedAt ? 'accepted' : steps.some((step) => step.status !== 'none') ? 'tried' : 'none'
acceptedAt = wanted.every((step) => step.at) ? wanted.map((step) => step.at!).reduce(latestOf) : null
status = acceptedAt ? 'accepted' : wanted.some((step) => step.status !== 'none') ? 'tried' : 'none'
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,
// 整道题做完了才谈挑战:先画再写只交对了代码、图还没画的,格子是「缺图」,不挂挑战的圈
challenge:
!codeWanted || !codeDone || !liveChallenges.get(id)?.size
!codeWanted || !codeDone || !acceptedAt || !liveChallenges.get(id)?.size
? null
: challengeDoneKeys.has(key)
? 'done'
@@ -410,9 +417,17 @@ taskRoutes.get('/task/today', requireTeacher, async (c) => {
const ids = lesson.problems.map((problem) => problem.id)
let allDoneCount = 0
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
// 老师布置的:和任务页、后台课堂任务同一个口径(要画的看图、要写的看代码)
// 老师布置的:和任务页同一个口径(每道题按今天几节合起来的要求,要画的看图、要写的看代码)
const counts = await lessonDoneCounts([
{ id: lesson.lessonId, className, day: calendarDay(), problemIds: ids, language: lesson.language, mode: lesson.mode },
{
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
} else if (ids.length) {
+60 -5
View File
@@ -59,7 +59,16 @@ export function lessonDayEnd(day: string) {
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
*/
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) =>
lesson.problemIds.map((problemId) => ({
@@ -67,7 +76,7 @@ export async function lessonDoneCounts(
class_name: lesson.className,
problem_id: problemId,
language: lesson.language,
mode: lesson.mode,
mode: lesson.modeOf?.(problemId) ?? lesson.mode,
until: lessonDayEnd(lesson.day),
})),
)
@@ -281,6 +290,45 @@ async function lessonPlan(className: string, day: string) {
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 = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
@@ -294,17 +342,23 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
const plan = await lessonPlan(className, today)
if (plan) {
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),
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 {
source: 'teacher' as const,
lessonId: plan.id,
day: today,
language: plan.language ?? null,
mode: plan.mode,
steps,
assignedAt: plan.createdAt,
assignedBy: plan.createdBy,
problems: planned.flatMap((id) => {
@@ -337,6 +391,7 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
language: null,
mode: null,
steps: null,
assignedAt: null,
assignedBy: null,
problems: picked.flatMap((row) => {
+31 -29
View File
@@ -36,9 +36,8 @@ export interface Entrant {
className: string | null
avatar: string | null
mood: string | null
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
/** 每道题第一次做对的时刻(毫秒),升序 */
times: number[]
stamps: string[]
}
export interface Standing {
@@ -64,10 +63,26 @@ export function avatarOf(avatar: string | null) {
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
* 走 `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[]> {
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([
db
.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))
.where(where),
db
.select({ userId: schema.submission.userId, at: firstSolved })
.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),
.select({ userId: firsts.userId, times: sql<number[]>`array_agg(${firsts.at} order by ${firsts.at})` })
.from(firsts)
.groupBy(firsts.userId),
])
const byUser = new Map<number, string[]>()
for (const row of solves) {
if (!row.at) continue
const list = byUser.get(row.userId)
if (list) list.push(row.at)
else byUser.set(row.userId, [row.at])
}
return people.map((person) => {
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),
}
})
const byUser = new Map(solves.map((row) => [row.userId, row.times]))
return people.map((person) => ({
id: person.id,
username: person.username,
className: person.className,
avatar: avatarOf(person.avatar),
mood: person.mood?.trim() || null,
times: byUser.get(person.id) ?? [],
}))
}
/**
@@ -127,7 +129,7 @@ export function standings(entrants: Entrant[], cutoff: number | null, includeZer
entrant,
solved,
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)
+2 -2
View File
@@ -786,9 +786,9 @@ export function deleteAchievement(id: number) {
// ---------- 课堂任务(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', {
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">
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 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 Pagination from 'shared/components/Pagination.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
@@ -27,13 +27,16 @@ const configStore = useConfigStore()
const { isMobile } = useBreakpoints()
// 从任务页「布置过的课」进来带着 ?className=
const { query } = usePagination<{ className: string; teacher: string }>({
const { query } = usePagination<{ className: string; teacher: string; mode: string }>({
className: useRouteQuery('className', '').value,
teacher: useRouteQuery('teacher', '').value,
mode: useRouteQuery('mode', '').value,
})
const data = ref<AdminLessonList | null>(null)
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(() => [
{ label: '全部老师', value: '' },
...(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, {
className: query.className,
teacher: query.teacher ? Number(query.teacher) : null,
mode: query.mode,
}),
)
if (res) data.value = res
}
onMounted(list)
// 换了班级、老师 usePagination 会把页码拨回 1,同一拍里只拉一次
watch(() => [query.page, query.limit, query.className, query.teacher], list)
// 换了班级、老师、做法 usePagination 会把页码拨回 1,同一拍里只拉一次
watch(() => [query.page, query.limit, query.className, query.teacher, query.mode], list)
// ---- 改 / 补 / 删 ----
const editing = ref(false)
@@ -133,17 +137,24 @@ function renderActions(lesson: AdminLesson) {
])
}
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */
function wayText(lesson: AdminLesson) {
return [lesson.mode !== 'code' ? LESSON_MODE_LABEL[lesson.mode] : '', lesson.language ? LANGUAGE_SHOW_VALUE[lesson.language] : ''].filter(Boolean).join(' · ')
/** 三种做法各一个颜色,一眼分得开;写代码也写出来,不当默认值省掉 */
const MODE_TAG_TYPE: Record<LessonMode, 'default' | 'info' | 'warning'> = { code: 'default', both: 'info', flowchart: 'warning' }
/** 「写代码 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) {
return h('div', { class: 'm-card' }, [
h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`),
renderWay(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),
])
}
@@ -153,7 +164,7 @@ const desktopColumns: DataTableColumn<AdminLesson>[] = [
{ 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: 120, render: (row) => wayText(row) || '—' },
{ title: '做什么', key: 'mode', width: 150, render: renderWay },
{ title: '题目', key: 'problems', width: 300, render: renderProblems },
{ title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone },
{ title: '', key: 'actions', render: renderActions },
@@ -173,6 +184,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
<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' }" />
<n-select v-model:value="query.mode" :options="modeOptions" aria-label="做什么" :style="{ width: isMobile ? '100%' : '120px' }" />
</template>
</PageHeader>
<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;
}
.lesson-table :deep(.way) {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.lesson-table :deep(.actions) {
display: flex;
gap: 8px;
+2 -1
View File
@@ -7,6 +7,7 @@ import { getClassActivity } from 'shared/api'
import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue'
import { pickHeadline, type Headline } from 'oj/user/knowledge'
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 { duration, parseTime, zonedParts, zonedYear } from 'utils/functions'
import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types'
@@ -191,7 +192,7 @@ const classActivityTitle = computed(() => {
// 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
if (classActivity.value?.source === 'teacher') {
const language = classActivity.value.language
const mode = classActivity.value.mode
const mode = activityMode(classActivity.value)
if (mode === 'flowchart') return '这节课 · 画流程图'
if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码`
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}`
@@ -73,12 +73,15 @@ watch(
() => (myFlowchartOpen.value = true),
)
/** 流程图画到 A / S 了:标题下给个「流程图 A 级」 */
/**
* 标题下的「流程图 X 级」:评完过的里面最好的那一次。画到 A / S 是绿的(算做完了),
* 没画到是橙的 —— 原来只在 A / S 时才写,画到 C 的学生标题下是「还没交过」,像是什么都没交
*/
const flowchartGrade = computed(() => {
const passed = flowchartStore.scores.filter((row) => isFlowchartPass(row.grade))
if (!passed.length) return ''
return passed.some((row) => row.grade === 'S') ? 'S' : 'A'
const best = flowchartStore.scores.reduce<(typeof flowchartStore.scores)[number] | null>((acc, row) => (!acc || row.score > acc.score ? row : acc), null)
return best?.grade ?? ''
})
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
// 例子旁的「复制」:点完变成「已复制」一秒
const message = useMessage()
@@ -192,12 +195,13 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-tag>
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag>
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
「做过,还没对」—— 那两个说的是代码 -->
<template v-else-if="!flowchartGrade">
「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
「还没交过」,旁边的「流程图 C 级」已经说了交过什么 -->
<template v-else-if="!flowchartPassed">
<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>
<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>
</header>
@@ -182,16 +182,25 @@ watch(
* 没有画这一步的课,画着流程图时不动他
*/
const lessonStore = useLessonStore()
let lessonLanguageFor: string | null = null
/**
* 换过的(题 + 这一节 + 该做的那一步)都记下:换到别的题再回来,同一步不再换第二次。
* 编辑器在换题时不重新挂载(detail.vue 只重新 init),只记最后一个的话一回来又被拽回去
*/
const lessonSwitched = new Set<string>()
let lessonViewing: string | null = null
watch(
() => [problem.value?._id, lessonStore.activity] as const,
([id, activity]) => {
if (!id || !activity) return
const key = [id, activity.day, activity.mode, activity.language].join('|')
if (lessonLanguageFor === key) return
lessonLanguageFor = key
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
const lesson = [id, activity.day, activity.mode, activity.language].join('|')
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.draw && item.draw !== 'accepted') {
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="top">
<h2>专题</h2>
<!-- 没有布置中的,就只有一组:数量写在标题旁边,不再另起一行「专题 14 个」把标题再说一遍 -->
<span v-if="!assigning.length && others.length" class="muted count">{{ others.length }} 个</span>
<div class="spacer"></div>
<n-input v-model:value="keyword" placeholder="搜专题" clearable class="search" />
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
@@ -133,8 +135,8 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
</div>
</template>
<div v-if="others.length" class="sect">
{{ assigning.length ? '其他专题' : '专题' }}
<div v-if="assigning.length && others.length" class="sect">
其他专题
<span class="muted">{{ others.length }} 个</span>
</div>
<div class="small-grid">
@@ -191,6 +193,12 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
font-size: var(--oj-fs-title);
}
/* 和分组标题旁的「N 个」同一档:小字、不加粗 */
.top .count {
font-size: var(--oj-fs-sec);
font-weight: 400;
}
.search {
width: 220px;
}
+48 -40
View File
@@ -122,8 +122,11 @@ onUnmounted(pause)
*/
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
if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done'
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'
}
@@ -327,23 +330,24 @@ function drawKind(cell: TaskBoardCell): ErrorKind {
* 要画流程图的课,卡在画图上的按最后一张图归堆(drawKind);先写了代码、没画图的单独一堆
*/
function wrongOf(index: number): WrongPerson[] {
const mode = modeAt(index)
return present.value.flatMap((student): WrongPerson[] => {
const cell = student.cells[index]
if (!cell || cell.status !== 'tried') return []
const stage = cellStage(cell, mode.value)
const stage = cellStage(cell, mode)
if (stage === 'draw') {
const latest = cell.flowchart?.latest
const evaluated = !!latest && latest.status !== 0 && latest.status !== 1
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 (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 }]
}
// 推断出来的课不分步:今天没代码、却画了流程图的单独一堆
const kind: ErrorKind = cell.latest
? errorKind(cell.latest)
: !mode.value && cell.attempts
: !mode && cell.attempts
? { key: 'flowchart', label: '今天只画了流程图', line: null }
: { key: 'before', label: '以前交过,今天还没交', line: 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 steps =
mode.value === 'both'
problem.mode === 'both'
? {
drawn: present.value.filter((student) => student.cells[i]?.flowchart?.status === 'accepted').length,
coded: present.value.filter((student) => student.cells[i]?.codeStatus === 'accepted').length,
}
: 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 selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value))
function toggleProblem(problemId: number) {
@@ -408,10 +409,14 @@ const explainShow = ref(false)
const explainIndex = ref(0)
const explainProblemIndex = ref(-1)
const explainProblem = computed(() => board.value?.problems[explainProblemIndex.value] ?? null)
/** 弹层顶上那排人:这道题错着的人,顺序和错法堆一样 */
const explainPeople = computed<ExplainPerson[]>(() => {
if (explainProblemIndex.value < 0) return []
const people = wrongOf(explainProblemIndex.value).filter((person) => person.explain)
/**
* 弹层顶上那排人:这道题错着的人,顺序和错法堆一样。打开那一刻定下来,开着时不跟 15 秒的刷新变:
* 弹层记的是第几个人,名单一变(有人改对了、图评完了回到名单里)就成了讲着讲着换了个人
*/
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>()
for (const person of people) {
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, submissionId: person.cell.latest!.submissionId, label: person.kind.label },
)
})
}
function openExplain(problemIndex: number, userId: number) {
explainProblemIndex.value = problemIndex
explainPeople.value = explainPeopleOf(problemIndex)
explainIndex.value = Math.max(
0,
explainPeople.value.findIndex((person) => person.userId === userId),
@@ -442,14 +448,15 @@ const cellColors = computed(() => ({
actionColor: theme.value.actionColor,
successColor: theme.value.successColor,
}))
const cellStyle = (cell: TaskBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value)
const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value)
const cellText = (cell: TaskBoardCell) => cellTextOf(cell, mode.value)
/** `index`:第几道题,格子按那道题今天做什么来画(modeAt) */
const cellStyle = (cell: TaskBoardCell, index: number) => cellStyleOf(cell, board.value?.day, cellColors.value, modeAt(index))
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
const stage = cellStage(cell, mode.value)
const stage = cellStage(cell, modeAt(index))
if (stage === 'draw') {
const latest = cell.flowchart?.latest
return !!latest && latest.status !== 0 && latest.status !== 1
@@ -490,7 +497,7 @@ function submissionsHref(
// SQL 题不受布置的语言限制,格子数的是 SQL 交的
const language = problem?.isSql ? 'SQL' : board.value?.language
// 只画流程图的课,格子数的全是流程图
const flowSide = flowchart || mode.value === 'flowchart'
const flowSide = flowchart || problem?.mode === 'flowchart'
return router.resolve({
name: 'submissions',
query: {
@@ -532,7 +539,8 @@ function lastText(student: TaskBoardStudent) {
/>
<div v-else-if="board?.problems.length && !needLesson" class="lesson">
<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-for="problem in board.problems" :key="problem.problemId" class="chip">
{{ problem.problemDisplayId }}
@@ -601,7 +609,7 @@ function lastText(student: TaskBoardStudent) {
</span>
<span v-if="stat.steps" class="card-steps">画对 {{ stat.steps.drawn }} · 写对 {{ stat.steps.coded }}</span>
<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 v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span>
</span>
@@ -624,7 +632,7 @@ function lastText(student: TaskBoardStudent) {
{{ board.problems[selectedIndex]!.title }}
</a>
<span>
{{ wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
{{ problemStats[selectedIndex]!.wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
</span>
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
<div class="spacer"></div>
@@ -643,7 +651,7 @@ function lastText(student: TaskBoardStudent) {
<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)">
<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>
<!-- 没有今天的代码可讲:去提交列表看他交过的 -->
<a
@@ -747,15 +755,15 @@ function lastText(student: TaskBoardStudent) {
</td>
<td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }">
<button
v-if="explainable(cell)"
v-if="explainable(cell, i)"
type="button"
class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
:style="cellStyle(cell, i)"
:title="cellTitle(cell, student, i)"
@click="openExplain(i, student.userId)"
>
{{ cellText(cell) }}
{{ cellText(cell, i) }}
</button>
<n-popover
v-else-if="cell.trail.length"
@@ -771,11 +779,11 @@ function lastText(student: TaskBoardStudent) {
class="cell"
:data-challenge="cell.challenge ?? undefined"
:class="{ picked: trailKey === `${student.userId}:${i}` }"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
:style="cellStyle(cell, i)"
:title="cellTitle(cell, student, i)"
@click="toggleTrail(`${student.userId}:${i}`)"
>
{{ cellText(cell) }}
{{ cellText(cell, i) }}
</button>
</template>
<TrailPanel
@@ -791,15 +799,15 @@ function lastText(student: TaskBoardStudent) {
v-else-if="cell.attempts"
class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)"
:style="cellStyle(cell, i)"
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
target="_blank"
:title="cellTitle(cell, student)"
:title="cellTitle(cell, student, i)"
>
{{ cellText(cell) }}
{{ cellText(cell, i) }}
</a>
<span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell)" :title="cellTitle(cell, student)">
{{ cellText(cell) }}
<span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell, i)" :title="cellTitle(cell, student, i)">
{{ cellText(cell, i) }}
</span>
</td>
<td class="num">{{ doneCount(student) }}/{{ board.problems.length }}</td>
+5 -3
View File
@@ -53,8 +53,8 @@ const envVersion = computed(() => {
})
// 一级路径就是菜单 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() {
await userStore.signOut()
@@ -80,7 +80,8 @@ function renderIcon(icon: string) {
* 顶栏导航的唯一一张表:桌面端渲染成一排纯文字,窄屏收进「菜单」下拉。
* `key` 就是一级路径,用来判定高亮(见上面的 active)。
*
* 顺序是 自学 / 题目 / 提交 / 专题 / 比赛 / 排名 / 公告。「自学」进自学首页(/learn),
* 顺序是 首页 / 自学 / 题目 / 提交 / 专题 / 比赛 / 排名 / 公告。「首页」只给登录了的:没登录时根路径
* 就是题目列表,和「题目」是同一页(2026-11-03 用户要的,原来只能点左上角的站名回首页)。「自学」进自学首页(/learn),
* 两门课、接着学哪一课都在那儿挑,不再是下拉。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。
* 公告 2026-10-13 回到顶栏(用户要的):首页只放置顶那条了,别的公告得有个地方点得到。
* 不用 n-menu:它横排每项固定占 100px,1280 宽的机房屏上会把顶栏挤成两行。
@@ -94,6 +95,7 @@ interface NavLink {
const navLinks = computed<NavLink[]>(() =>
[
{ key: 'home', label: '首页', to: '/', show: userStore.isAuthed },
{ key: 'learn', label: '自学', to: '/learn' },
{ key: 'problem', label: '题目', to: '/problem' },
{ 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('、')
}
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */
function languageText(value: LessonUpdate) {
const mode = value.mode && value.mode !== 'code' ? LESSON_MODE_LABEL[value.mode] : ''
/**
* 「画流程图」「先画再写 · C语言」「C语言」。新布置的一节写代码是默认的,不写出来(前面配「用」);
* 改了做法时 `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(' · ')
}
@@ -44,8 +47,9 @@ const lines = computed(() => {
if (value.added.length) result.push({ label: '加了', text: names(value.added) })
if (value.removed.length) result.push({ label: '去掉了', text: names(value.removed) })
if (value.languageChanged) {
const text = languageText(value)
result.push({ label: value.mode && value.mode !== 'code' ? '做法' : '语言', text: text ? `改成 ${text}` : value.mode === 'code' ? '改成写代码' : '不限了' })
// languageChanged 连做法一起算,分不出改的是哪样:一律写「做法」,连写代码带语言一起说
const text = languageText(value, true)
result.push({ label: '做法', text: text ? `改成 ${text}` : '不限了' })
}
if (value.reordered) result.push({ label: '', text: '换了题目的顺序' })
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 { getClassActivity } from 'shared/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
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
@@ -89,8 +102,8 @@ export const useLessonStore = defineStore('lesson', () => {
* 以前布置的)为空。写代码是默认的,不写出来
*/
const languageText = computed(() => {
const mode = activity.value?.mode
const language = activity.value?.language
const mode = activityMode(activity.value)
const language = mode === 'flowchart' ? null : activity.value?.language
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(),
/** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */
hasChallenges: z.boolean(),
/**
* 这道题今天要做什么:这个班今天布置过的几节里有这道题的,要求合起来(先布置画流程图、再用同样的题
* 写代码 = both)。格子按它画,和学生那边的「做完」一致。推断出来的课为 null
*/
mode: lessonModeSchema.nullable(),
})
export const taskBoardCellSchema = z.object({