diff --git a/apps/web/src/oj/home/teacher.vue b/apps/web/src/oj/home/teacher.vue index e8b2a29..44abfad 100644 --- a/apps/web/src/oj/home/teacher.vue +++ b/apps/web/src/oj/home/teacher.vue @@ -77,8 +77,15 @@ useMascotGuide(() => { const row = assigned.find((it) => stateOf(it).live) ?? assigned[0] if (!row) return { situation: 'teacher.home.unassigned', actions: [goAction(router, '布置题目', '/task')] } const query = { className: row.className, problem: row.problems.map((problem) => problem.problemDisplayId).join(','), today: '1' } + // 有人交过了就说这节课做完几个,还没人交就照旧那几句 + const progress = row.activeCount + ? { + situation: row.allDoneCount >= row.rosterCount ? ('teacher.home.progressDone' as const) : ('teacher.home.progress' as const), + vars: { cls: classLabel(row.className), done: row.allDoneCount, total: row.rosterCount, left: row.rosterCount - row.allDoneCount }, + } + : { situation: 'teacher.home.assigned' as const } return { - situation: 'teacher.home.assigned', + ...progress, actions: [ { label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) }, { label: '课堂任务', run: () => openTask(row) }, diff --git a/apps/web/src/oj/task/index.vue b/apps/web/src/oj/task/index.vue index e075a84..b7deaf1 100644 --- a/apps/web/src/oj/task/index.vue +++ b/apps/web/src/oj/task/index.vue @@ -10,7 +10,8 @@ import { useLeaveStudents } from 'shared/composables/leaveStudents' import { useTeacherHelp } from 'shared/composables/teacherHelp' import { useCollabStore } from 'shared/store/collab' import { useConfigStore } from 'shared/store/config' -import { useMascotGuide } from 'shared/mascot/guide' +import { requestSpeech } from 'shared/mascot/behavior' +import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide' import { errorMessage } from 'utils/api' import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import { parseTime } from 'utils/functions' @@ -518,7 +519,62 @@ function submissionsHref( * 还没布置时填题号的框就是这页最大的东西,不给按钮 */ const startForm = useTemplateRef<{ openPicker: () => void }>('startForm') -useMascotGuide(() => { +// ---------- 看板娘:点她时挑全班这一刻最要紧的一件说;卡住的多起来、全班做完时她自己开口 ---------- + +/** 同一种错有这么多人才算「扎堆」 */ +const PILE_MIN = 3 +/** 布置这么久了,人在线却一道没交,才算「没动」 */ +const IDLE_AFTER_MS = 10 * 60_000 +/** 一道题卡住的人涨到这么多,她主动说一声 */ +const STUCK_RISE = 3 + +/** 人最多的那一堆错(灰底那几堆不算:不是今天的错,没得讲) */ +function biggestPile() { + let best: { index: number; kind: ErrorKind; people: WrongPerson[] } | null = null + const count = board.value?.problems.length ?? 0 + for (let index = 0; index < count; index++) { + const groups = new Map() + for (const person of wrongOf(index)) { + if (QUIET_KINDS.has(person.kind.key)) continue + groups.set(person.kind.key, [...(groups.get(person.kind.key) ?? []), person]) + } + for (const people of groups.values()) { + if (!best || people.length > best.people.length) best = { index, kind: people[0]!.kind, people } + } + } + return best && best.people.length >= PILE_MIN ? best : null +} + +/** 卡住的人(cells.ts 的 isStuck),交得多的在前 */ +function stuckPeople() { + return present.value + .flatMap((student) => student.cells.map((cell, index) => ({ student, cell, index }))) + .filter(({ cell, index }) => isStuck(cell, modeAt(index))) + .sort((a, b) => b.cell.attempts - a.cell.attempts) +} + +/** 在线、今天一道没交、也不是早就全做完了的人 */ +function idlePeople() { + const assignedAt = board.value?.assignedAt + if (!assignedAt || Date.now() - Date.parse(assignedAt) < IDLE_AFTER_MS) return [] + return present.value.filter( + (student) => student.online && student.cells.every((cell) => cell.attempts === 0) && student.cells.some((cell) => cell.status !== 'accepted'), + ) +} + +const allDone = () => present.value.length > 0 && present.value.every((student) => student.cells.every((cell) => cell.status === 'accepted')) + +/** 点名字:能讲的打开讲错误的弹层,不能讲的展开那道题的错法 */ +function lookAt(index: number, student: TaskBoardStudent): MascotAction { + const cell = student.cells[index]! + const problem = board.value!.problems[index]! + return { + label: `看 ${displayName(student.username)} 的`, + run: () => (explainable(cell, index) ? openExplain(index, student.userId) : (selectedId.value = problem.problemId)), + } +} + +useMascotGuide((): MascotGuide | null => { const b = board.value if (!b?.className) return null if (needLesson.value) @@ -531,8 +587,85 @@ useMascotGuide(() => { ...(language ? { language } : {}), } // 新标签打开,和点格子一样:任务页一般开着不关 - const open = () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank') - return { situation: 'teacher.task.assigned', actions: [{ label: '这节课的提交', run: open }] } + const submissions: MascotAction = { label: '这节课的提交', run: () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank') } + + if (allDone()) + return { situation: 'teacher.task.allDone', actions: [{ label: b.source === 'teacher' ? '改题' : '布置', run: () => (editing.value = 'edit') }] } + + const pile = biggestPile() + if (pile) { + const problem = b.problems[pile.index]! + const first = pile.people.find((person) => person.explain) + const actions: MascotAction[] = [{ label: '看这堆', run: () => (selectedId.value = problem.problemId) }] + if (first) actions.push({ label: '讲第一个', run: () => openExplain(pile.index, first.student.userId) }) + return { + situation: 'teacher.task.errorPile', + n: pile.people.length, + vars: { pid: problem.problemDisplayId, kind: pile.kind.label }, + actions: [...actions, submissions], + } + } + + const stuck = stuckPeople() + if (stuck.length) { + const top = stuck[0]! + return { + situation: 'teacher.task.stuck', + n: new Set(stuck.map((it) => it.student.userId)).size, + vars: { name: displayName(top.student.username), k: top.cell.attempts, pid: b.problems[top.index]!.problemDisplayId }, + actions: [lookAt(top.index, top.student), submissions], + } + } + + const idle = idlePeople() + if (idle.length) { + const name = displayName(idle[0]!.username) + return idle.length === 1 ? { situation: 'teacher.task.idleOne', vars: { name } } : { situation: 'teacher.task.idle', n: idle.length, vars: { name } } + } + + const challenging = present.value.filter((student) => student.cells.some((cell) => cell.challenge === 'doing')).length + if (challenging) return { situation: 'teacher.task.challenging', n: challenging, actions: [submissions] } + + return { situation: 'teacher.task.assigned', actions: [submissions] } +}) + +/** + * 主动开口:每 15 秒刷新一次,和上一次比 —— 某道题卡住的人从不到 3 个涨到 3 个、全班刚做完。 + * 刚打开页面(或换了班、换了一节)的那一次只记数不说,不然一刷新就把已经知道的事再说一遍 + */ +let seen: { key: string; stuck: Map; done: boolean } | null = null +watch(board, (b) => { + if (!b?.className || needLesson.value) return + const key = `${b.className}|${b.day}|${b.lessonId ?? ''}|${b.problems.map((problem) => problem.problemId).join(',')}` + const stuck = new Map() + for (const { index } of stuckPeople()) { + const id = b.problems[index]!.problemId + stuck.set(id, (stuck.get(id) ?? 0) + 1) + } + const done = allDone() + const before = seen?.key === key ? seen : null + seen = { key, stuck, done } + if (!before) return + if (done && !before.done) { + requestSpeech({ + key: `done:${key}`, + situation: 'teacher.task.allDone', + actions: [{ label: b.source === 'teacher' ? '改题' : '布置', run: () => (editing.value = 'edit') }], + }) + return + } + for (const [id, n] of stuck) { + if (n < STUCK_RISE || (before.stuck.get(id) ?? 0) >= STUCK_RISE) continue + const problem = b.problems.find((it) => it.problemId === id)! + requestSpeech({ + key: `stuck:${key}:${id}`, + situation: 'teacher.task.stuckRise', + n, + vars: { pid: problem.problemDisplayId }, + actions: [{ label: '看这道', run: () => (selectedId.value = id) }], + }) + return + } }) function lastText(student: TaskBoardStudent) { diff --git a/apps/web/src/shared/components/Mascot.vue b/apps/web/src/shared/components/Mascot.vue index c8bba51..274af55 100644 --- a/apps/web/src/shared/components/Mascot.vue +++ b/apps/web/src/shared/components/Mascot.vue @@ -457,7 +457,7 @@ async function runAction(action: MascotAction) { let lastSpokeAt = -Infinity const spokenKeys = new Set() watch(speechRequest, (speech) => { - if (!speech || silent.value || sulking.value || dragging.value || bubble.value || userStore.isTeacherOrAbove) return + if (!speech || silent.value || sulking.value || dragging.value || bubble.value) return if (document.visibilityState !== 'visible' || spokenKeys.has(speech.key)) return if (performance.now() - lastSpokeAt < SPEAK_GAP_MS) return lastSpokeAt = performance.now() diff --git a/apps/web/src/shared/mascot/lines.ts b/apps/web/src/shared/mascot/lines.ts index 467924c..8a46aa1 100644 --- a/apps/web/src/shared/mascot/lines.ts +++ b/apps/web/src/shared/mascot/lines.ts @@ -123,6 +123,28 @@ export const MASCOT_LINES = { 'teacher.home.assigned': ['这节课的提交,筛好了', '看看谁在摸鱼', '交上来不少了', '去看看', '有人卡住了吧'], 'teacher.task.unassigned': ['空的。题自己选', '选几道吧', '五道以内,别太狠', '还没布置呢', '挑简单的先热身?'], 'teacher.task.assigned': ['进度都在这', '谁在摸鱼,一目了然', '差不多该讲题了吧', '卡在同一道的有点多', '做完的可以加一道'], + // 课堂任务页:按全班这一刻的情况挑最要紧的一件(oj/task/index.vue)。名字是用户名(displayName) + 'teacher.task.errorPile': [ + '{pid} 上 {n} 个人都是「{kind}」', + '{n} 个人栽在同一个坑里:{pid},{kind}', + '{pid} 的「{kind}」有 {n} 个,讲一遍?', + '同一种错 {n} 个人,在 {pid}', + ], + 'teacher.task.stuck': [ + '{name} 在 {pid} 上交了 {k} 次了', + '{n} 个人卡住了,{name} 交了 {k} 次', + '有人卡住了:{name},{k} 次', + '去看看 {name} 吧,{pid} 交了 {k} 次', + ], + 'teacher.task.idle': ['{n} 个人在线,一道还没交', '{name} 等 {n} 个人还没动', '开着页面没交的有 {n} 个'], + 'teacher.task.idleOne': ['{name} 在线,一道还没交', '{name} 还没动呢', '{name} 开着页面,没交'], + 'teacher.task.allDone': ['全班都做完了,再加一道?', '都做完了……还挺快', '没题做了,再布置点?', '全做对了,可以收工了'], + 'teacher.task.challenging': ['{n} 个做完的在做挑战', '做完的 {n} 个人在挑战,没闲着', '{n} 个人做完了还在挑战'], + // 她主动说的:一道题卡住的人涨到 3 个 + 'teacher.task.stuckRise': ['{pid} 上卡住 {n} 个了', '{n} 个人卡在 {pid},要不要讲一下', '{pid} 卡住的人多起来了,{n} 个'], + // 老师首页:这节课的进度 + 'teacher.home.progress': ['{cls}:{done}/{total} 人做完了', '{cls} 做完 {done} 个,还差 {left} 个', '{cls} 这节课 {done}/{total}'], + 'teacher.home.progressDone': ['{cls} 全做完了', '{cls} 都做对了,还挺快'], 'teacher.statistics': ['看完记得夸夸学生', '数字好看吗', '别只看平均数', '有进步的,夸一句', '这节课还行吧'], 'teacher.submissions': ['筛这节课的?', '想看谁的', '错的多的先看', '一条条看,辛苦了', '重判之前想清楚'], 'teacher.problem': ['全班做得怎样,不看看?', '难不难,数据说了算', '又在看题?', '要改题?', '这题错的人不少'], diff --git a/apps/web/src/shared/mascot/poses.ts b/apps/web/src/shared/mascot/poses.ts index 669cfc3..b106226 100644 --- a/apps/web/src/shared/mascot/poses.ts +++ b/apps/web/src/shared/mascot/poses.ts @@ -223,6 +223,15 @@ const SITUATION_POSE: Partial> = 'teacher.task.assigned': ['waiting', 'lecture'], 'teacher.task.unassigned': ['empty'], 'teacher.problems': ['tool'], + 'teacher.task.errorPile': ['debug', 'review'], + 'teacher.task.stuck': ['sweat', 'thinking'], + 'teacher.task.stuckRise': ['shock', 'omg'], + 'teacher.task.idle': ['unimpressed', 'sleepy'], + 'teacher.task.idleOne': ['unimpressed', 'sleepy'], + 'teacher.task.allDone': ['party', 'cheer'], + 'teacher.task.challenging': ['bold', 'playful'], + 'teacher.home.progress': ['review'], + 'teacher.home.progressDone': ['done', 'party'], 'teacher.submissions': ['review', 'sweat'], 'teacher.statistics': ['review'], // 软下来的时候