feat(看板娘): 课堂任务页上她会告诉老师谁卡住了、哪种错扎堆、谁在线没动,卡住的人多起来和全班做完时自己开口
Deploy / deploy (push) Successful in 1m0s

- 点她按全班这一刻挑最要紧的一件:全班做完(「改题」)> 同一种错 ≥3 人(「看这堆」「讲第一个」)
  > 有人卡住(「看 xxx 的」直接打开讲错误)> 布置 10 分钟后在线却一道没交 > 做完的在做挑战。
  卡住、错法归堆和任务页同一套口径(cells.ts 的 isStuck、页面上的按错法归堆)。
- 主动开口:某道题卡住的人涨到 3 个、全班刚做完,每件事一节课说一次;刚打开页面那次只记数不说。
  老师电脑不投影,和学生一样至少隔 3 分钟。
- 老师首页说这节课做完几个人,不再是固定的「这节课的提交,筛好了」。
- 这几句也配了图:扎堆拿放大镜、卡住冒汗、做完拉礼花……

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-04 19:43:01 +08:00
1 parent b99f619c0a
commit 95049b6e4c
5 files changed
+177 -6

No files matched your search

+8 -1
View File
@@ -77,8 +77,15 @@ useMascotGuide(() => {
const row = assigned.find((it) => stateOf(it).live) ?? assigned[0] const row = assigned.find((it) => stateOf(it).live) ?? assigned[0]
if (!row) return { situation: 'teacher.home.unassigned', actions: [goAction(router, '布置题目', '/task')] } 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 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 { return {
situation: 'teacher.home.assigned', ...progress,
actions: [ actions: [
{ label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) }, { label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) },
{ label: '课堂任务', run: () => openTask(row) }, { label: '课堂任务', run: () => openTask(row) },
+137 -4
View File
@@ -10,7 +10,8 @@ import { useLeaveStudents } from 'shared/composables/leaveStudents'
import { useTeacherHelp } from 'shared/composables/teacherHelp' import { useTeacherHelp } from 'shared/composables/teacherHelp'
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
import { useConfigStore } from 'shared/store/config' 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 { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
@@ -518,7 +519,62 @@ function submissionsHref(
* 还没布置时填题号的框就是这页最大的东西,不给按钮 * 还没布置时填题号的框就是这页最大的东西,不给按钮
*/ */
const startForm = useTemplateRef<{ openPicker: () => void }>('startForm') 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<string, WrongPerson[]>()
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 const b = board.value
if (!b?.className) return null if (!b?.className) return null
if (needLesson.value) if (needLesson.value)
@@ -531,8 +587,85 @@ useMascotGuide(() => {
...(language ? { language } : {}), ...(language ? { language } : {}),
} }
// 新标签打开,和点格子一样:任务页一般开着不关 // 新标签打开,和点格子一样:任务页一般开着不关
const open = () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank') const submissions: MascotAction = { label: '这节课的提交', run: () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank') }
return { situation: 'teacher.task.assigned', actions: [{ label: '这节课的提交', run: open }] }
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<number, number>; 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<number, number>()
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) { function lastText(student: TaskBoardStudent) {
+1 -1
View File
@@ -457,7 +457,7 @@ async function runAction(action: MascotAction) {
let lastSpokeAt = -Infinity let lastSpokeAt = -Infinity
const spokenKeys = new Set<string>() const spokenKeys = new Set<string>()
watch(speechRequest, (speech) => { 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 (document.visibilityState !== 'visible' || spokenKeys.has(speech.key)) return
if (performance.now() - lastSpokeAt < SPEAK_GAP_MS) return if (performance.now() - lastSpokeAt < SPEAK_GAP_MS) return
lastSpokeAt = performance.now() lastSpokeAt = performance.now()
+22
View File
@@ -123,6 +123,28 @@ export const MASCOT_LINES = {
'teacher.home.assigned': ['这节课的提交,筛好了', '看看谁在摸鱼', '交上来不少了', '去看看', '有人卡住了吧'], 'teacher.home.assigned': ['这节课的提交,筛好了', '看看谁在摸鱼', '交上来不少了', '去看看', '有人卡住了吧'],
'teacher.task.unassigned': ['空的。题自己选', '选几道吧', '五道以内,别太狠', '还没布置呢', '挑简单的先热身?'], 'teacher.task.unassigned': ['空的。题自己选', '选几道吧', '五道以内,别太狠', '还没布置呢', '挑简单的先热身?'],
'teacher.task.assigned': ['进度都在这', '谁在摸鱼,一目了然', '差不多该讲题了吧', '卡在同一道的有点多', '做完的可以加一道'], '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.statistics': ['看完记得夸夸学生', '数字好看吗', '别只看平均数', '有进步的,夸一句', '这节课还行吧'],
'teacher.submissions': ['筛这节课的?', '想看谁的', '错的多的先看', '一条条看,辛苦了', '重判之前想清楚'], 'teacher.submissions': ['筛这节课的?', '想看谁的', '错的多的先看', '一条条看,辛苦了', '重判之前想清楚'],
'teacher.problem': ['全班做得怎样,不看看?', '难不难,数据说了算', '又在看题?', '要改题?', '这题错的人不少'], 'teacher.problem': ['全班做得怎样,不看看?', '难不难,数据说了算', '又在看题?', '要改题?', '这题错的人不少'],
+9
View File
@@ -223,6 +223,15 @@ const SITUATION_POSE: Partial<Record<MascotSituation, readonly MascotPose[]>> =
'teacher.task.assigned': ['waiting', 'lecture'], 'teacher.task.assigned': ['waiting', 'lecture'],
'teacher.task.unassigned': ['empty'], 'teacher.task.unassigned': ['empty'],
'teacher.problems': ['tool'], '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.submissions': ['review', 'sweat'],
'teacher.statistics': ['review'], 'teacher.statistics': ['review'],
// 软下来的时候 // 软下来的时候