Compare commits
6
Commits
1b703d13ae
...
41d2e46aa9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41d2e46aa9 | ||
|
|
f06bb063b4 | ||
|
|
4a4cede99f | ||
|
|
91057c1096 | ||
|
|
c3f1413545 | ||
|
|
b09d479893 |
No files matched your search
@@ -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', '这一节已经被删掉了')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(' · ')
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user