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