diff --git a/apps/api/src/routes/admin/lesson.ts b/apps/api/src/routes/admin/lesson.ts index fe853ef..b7c84d9 100644 --- a/apps/api/src/routes/admin/lesson.ts +++ b/apps/api/src/routes/admin/lesson.ts @@ -161,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', '这一节已经被删掉了') diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index e098cb7..67aa1e3 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -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 *, diff --git a/apps/web/src/oj/home/student.vue b/apps/web/src/oj/home/student.vue index 2a98e55..3c4bfc3 100644 --- a/apps/web/src/oj/home/student.vue +++ b/apps/web/src/oj/home/student.vue @@ -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]}做` : ''}` diff --git a/apps/web/src/oj/problem/components/ProblemEditor.vue b/apps/web/src/oj/problem/components/ProblemEditor.vue index 570c517..ecbdac7 100644 --- a/apps/web/src/oj/problem/components/ProblemEditor.vue +++ b/apps/web/src/oj/problem/components/ProblemEditor.vue @@ -182,16 +182,25 @@ watch( * 没有画这一步的课,画着流程图时不动他 */ const lessonStore = useLessonStore() -let lessonLanguageFor: string | null = null +/** + * 换过的(题 + 这一节 + 该做的那一步)都记下:换到别的题再回来,同一步不再换第二次。 + * 编辑器在换题时不重新挂载(detail.vue 只重新 init),只记最后一个的话一回来又被拽回去 + */ +const lessonSwitched = new Set() +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') diff --git a/apps/web/src/oj/task/index.vue b/apps/web/src/oj/task/index.vue index 1e1ca50..fa7d94e 100644 --- a/apps/web/src/oj/task/index.vue +++ b/apps/web/src/oj/task/index.vue @@ -408,10 +408,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(() => { - if (explainProblemIndex.value < 0) return [] - const people = wrongOf(explainProblemIndex.value).filter((person) => person.explain) +/** + * 弹层顶上那排人:这道题错着的人,顺序和错法堆一样。打开那一刻定下来,开着时不跟 15 秒的刷新变: + * 弹层记的是第几个人,名单一变(有人改对了、图评完了回到名单里)就成了讲着讲着换了个人 + */ +const explainPeople = ref([]) +function explainPeopleOf(problemIndex: number): ExplainPerson[] { + if (problemIndex < 0) return [] + const people = wrongOf(problemIndex).filter((person) => person.explain) const order = new Map() for (const person of people) { order.set(person.kind.key, (order.get(person.kind.key) ?? 0) + 1) @@ -423,10 +427,11 @@ const explainPeople = computed(() => { ? { 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), @@ -532,7 +537,8 @@ function lastText(student: TaskBoardStudent) { />
{{ board.source === 'teacher' ? '这节课' : '今天在做' }} - {{ LESSON_MODE_LABEL[board.mode] }} + + {{ LESSON_MODE_LABEL[board.mode] }} {{ LANGUAGE_SHOW_VALUE[board.language] }} {{ problem.problemDisplayId }} diff --git a/apps/web/src/shared/components/LessonUpdateNotice.vue b/apps/web/src/shared/components/LessonUpdateNotice.vue index 3952aa0..264ea8e 100644 --- a/apps/web/src/shared/components/LessonUpdateNotice.vue +++ b/apps/web/src/shared/components/LessonUpdateNotice.vue @@ -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 diff --git a/apps/web/src/shared/store/lesson.ts b/apps/web/src/shared/store/lesson.ts index 4db8d9c..7c451ea 100644 --- a/apps/web/src/shared/store/lesson.ts +++ b/apps/web/src/shared/store/lesson.ts @@ -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(' · ') })