feat(看板娘): 点她会按页面说一句傲娇的话,再给两三个能直接办事的按钮
Deploy / deploy (push) Successful in 49s

- 她从不主动开口,点一下才冒气泡;比赛里不弹。台词 326 句(shared/mascot/lines.ts),
  口吻照「小鲸鱼」角色卡,学校里舍掉英文、粗口,「用户」改叫「同学」
- 按情况换话:题目页分没写、没交、例子没对上/对上、答错、编译错误、运行出错、超时、
  错很多次、做对、一次就对、终于对了、以前做过;首页、题目列表、提交、专题、自学、排名等各有一组
- 按钮真的去办:运行例子、我错在哪(切到结果里出错的那一段)、举手、输题号、随便来一道、
  下一道、选题……
- 老师也看得到:有学生举手时任何页面先说这件事;首页、课堂任务、统计、题目页各有一组
- 页面用 useMascotGuide 告诉她现在的情况(shared/mascot/guide.ts),以后按学生行为调整也接在这一层
- 修:连点时像卡住了(快点时按压过渡来不及显示,改成每下播一遍压扁回弹);
  一打开从左上角飞到右下角;去掉单击的摆动

设计稿与台词表:https://claude.ai/artifact/Qz2zPCfq2zCrqmxyms1UGx

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-03 16:12:23 +08:00
1 parent 15966ceb0d
commit ef3c53c671
25 files changed
+860 -57

No files matched your search

+4
View File
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import UserName from 'shared/components/UserName.vue'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { RARITY_COLOR, RARITY_LABEL } from 'utils/constants'
import type { AchievementList, AchievementRarity, UserBadge } from 'utils/types'
import ClassCard from './components/ClassCard.vue'
@@ -59,6 +60,9 @@ async function load() {
onMounted(load)
watch(name, load)
// 看板娘:只在看自己的成就时说话
useMascotGuide(() => (data.value && !other.value && !userStore.isTeacherOrAbove ? { situation: 'achievement' } : null))
</script>
<template>
+4
View File
@@ -5,6 +5,7 @@ import AdminLink from 'shared/components/AdminLink.vue'
import NotFound from 'shared/components/NotFound.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { errorCode } from 'utils/api'
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
import type { Announcement, AnnouncementListItem } from 'utils/types'
@@ -27,6 +28,9 @@ const { isDesktop } = useBreakpoints()
const announcements = ref<AnnouncementListItem[]>([])
const loaded = ref(false)
// 看板娘:只念叨一句,不给按钮
useMascotGuide(() => ({ situation: 'announcement' }))
onMounted(async () => {
const res = await getAnnouncementList(0, LIMIT)
announcements.value = res.results
+9
View File
@@ -12,6 +12,7 @@ import { usePagination } from 'shared/composables/pagination'
import AdminLink from 'shared/components/AdminLink.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { ContestStatus } from 'utils/constants'
import ContestBadge from './components/ContestBadge.vue'
@@ -115,6 +116,14 @@ function open(contest: Contest) {
router.push(userStore.isTeacherOrAbove ? `/contest/${contest.id}/class` : `/contest/${contest.id}`)
}
// 看板娘:有正在比的就带进去
useMascotGuide(() => {
if (userStore.isTeacherOrAbove) return null
const live = active.value.find((c) => c.status === ContestStatus.underway)
if (!live) return { situation: 'contests.none' }
return { situation: 'contests.live', actions: [goAction(router, '进比赛', `/contest/${live.id}`)] }
})
function mineText(contest: Contest) {
const mine = contest.mine
if (!mine) return ''
+12
View File
@@ -8,6 +8,7 @@ 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 { focusJumpAction, goAction, randomProblemAction, useMascotGuide } from 'shared/mascot/guide'
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'
@@ -244,6 +245,17 @@ watch(
{ immediate: true },
)
// 看板娘:今天有课(和「这节课」那张卡同一个条件)就催今天的题,没课就随便做一道
useMascotGuide(() => {
if (!loaded.value) return null
const random = randomProblemAction(router)
const learn = goAction(router, '自学', '/learn')
if (!classToday.value || !classActivity.value?.problems.length) return { situation: 'home.free', actions: [focusJumpAction(), random, learn] }
const next = nextProblem.value
if (!next) return { situation: 'home.done', actions: [random, learn] }
return { situation: 'home.assigned', actions: [goAction(router, '今天的题', `/problem/${next.problemDisplayId}`), focusJumpAction(), random] }
})
function learnLink(track: Track) {
return tutorialPath(track.type, track.step)
}
+20
View File
@@ -6,6 +6,7 @@ import { useThemeVars } from 'naive-ui'
import { getAnnouncementList, getClassToday } from 'oj/api'
import { classLabel } from 'utils/classes'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
/**
@@ -66,6 +67,25 @@ function openTask(row: ClassRow) {
router.push({ name: 'task', query: { className: row.className } })
}
/**
* 看板娘:今天有班布置了题,就带去那个班这节课的提交(和提交列表「这节课」同一组筛选:班、这几道题、今天),
* 有好几个班时挑正在上课的那个
*/
useMascotGuide(() => {
if (!today.value) return null
const assigned = today.value.classes.filter((row) => row.source === 'teacher' && row.problems.length)
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' }
return {
situation: 'teacher.home.assigned',
actions: [
{ label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) },
{ label: '课堂任务', run: () => openTask(row) },
],
}
})
const shortcuts = computed(() =>
[
{ label: '课堂任务', to: '/task', show: true },
+9
View File
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils'
import TutorialRow from './components/TutorialRow.vue'
import ProjectTile from './components/ProjectTile.vue'
@@ -94,6 +95,14 @@ function doneIn(course: Course, ids: number[]) {
return ids.filter((id) => tutorialState(course.progress[id]) === 'done').length
}
// 看板娘:学过就带去「接着学」那一课,一课没学过就从当前页签那门课的第 1 课开始
useMascotGuide(() => {
if (!loaded.value || userStore.isTeacherOrAbove) return null
const it = resume.value
if (it) return { situation: 'learn.continue', n: it.step, actions: [goAction(router, '接着学', tutorialPath(it.course.type, it.step))] }
return { situation: 'learn.fresh', actions: [goAction(router, '第 1 课', tutorialPath(tab.value, 1))] }
})
watch(() => userStore.isAuthed, load)
onMounted(load)
</script>
+11
View File
@@ -10,6 +10,7 @@ import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide, type MascotAction } from 'shared/mascot/guide'
import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils'
import TutorialBody from './components/TutorialBody.vue'
import TutorialEnd from './components/TutorialEnd.vue'
@@ -233,6 +234,16 @@ function jump(id: string) {
function jumpExercise(id: number) {
jump(`exercise-${id}`)
}
// 看板娘:练一练没做完就带去第一道没做对的;做完了(或这课没有练一练)就去下一课
useMascotGuide(() => {
if (!tutorial.value || userStore.isTeacherOrAbove) return null
const to = next.value?.to
const nextLesson: MascotAction[] = to ? [{ label: '下一课', run: () => router.push(to) }] : []
const unsolved = firstUnsolved.value
if (!unsolved) return { situation: 'tutorial.passed', actions: nextLesson }
return { situation: 'tutorial', actions: [{ label: '练一练', run: () => jumpExercise(unsolved.id) }, ...nextLesson] }
})
</script>
<template>
+5
View File
@@ -6,6 +6,7 @@ import { classLabel, groupClassOptions } from 'utils/classes'
import { useAIStream } from 'shared/composables/aiStream'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import PkDuel from './components/PkDuel.vue'
import PkGrid from './components/PkGrid.vue'
import PkRace from './components/PkRace.vue'
@@ -31,6 +32,10 @@ const { isDesktop } = useBreakpoints()
const MAX_CLASSES = 8
const teacher = computed(() => userStore.isTeacherOrAbove)
// 看板娘:学生念叨一句班级对决,老师只打招呼
useMascotGuide(() => (teacher.value ? null : { situation: 'pk' }))
const pk = ref<ClassPk | null>(null)
const loading = ref(false)
const battle = ref<ClassBattleItem[]>([])
@@ -0,0 +1,117 @@
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import type { MascotSituation } from 'shared/mascot/lines'
import { useCollabStore } from 'shared/store/collab'
import { useLessonStore } from 'shared/store/lesson'
import { useUserStore } from 'shared/store/user'
import { useCodeStore } from 'oj/store/code'
import { useProblemStore } from 'oj/store/problem'
import { useSubmissionStore, type ResultSegment } from 'oj/store/submission'
import { SubmissionStatus } from 'utils/constants'
import { useProblemPageContext } from './problemPageContext'
import { useTeacherCollab } from './teacherCollab'
/** 同一道题错到这么多次,她就不怼了,劝去举手 */
const STUCK_FAILS = 5
/** 错过这么多次以后做对,算「终于」 */
const FINALLY_FAILS = 2
const RESULT_SITUATION: Partial<Record<SubmissionStatus, MascotSituation>> = {
[SubmissionStatus.wrong_answer]: 'problem.wrong',
[SubmissionStatus.partial_accepted]: 'problem.wrong',
[SubmissionStatus.ast_check_failed]: 'problem.wrong',
[SubmissionStatus.compile_error]: 'problem.compileError',
[SubmissionStatus.runtime_error]: 'problem.runtimeError',
[SubmissionStatus.memory_limit_exceeded]: 'problem.runtimeError',
[SubmissionStatus.cpu_time_limit_exceeded]: 'problem.timeout',
[SubmissionStatus.real_time_limit_exceeded]: 'problem.timeout',
}
/**
* 题目页上看板娘说什么、给哪几个按钮。看的是这一页上最近的那件事:刚交的结果、刚跑的例子,
* 都没有就看写没写代码。比赛入口不用管(Mascot.vue 在比赛里不弹)。
*
* `showResult` 是「我错在哪」:把左栏切到「结果」页签的那一段(提交结果 / 运行例子),页签归页面管。
*/
export function useProblemMascotGuide(showResult: (segment: ResultSegment) => void) {
const router = useRouter()
const userStore = useUserStore()
const problemStore = useProblemStore()
const submissionStore = useSubmissionStore()
const codeStore = useCodeStore()
const collabStore = useCollabStore()
const lessonStore = useLessonStore()
const ctx = useProblemPageContext()
const teacherCollab = useTeacherCollab()
const { isDesktop } = useBreakpoints()
useMascotGuide(() => {
const problem = problemStore.problem
if (!problem) return null
if (userStore.isTeacherOrAbove) {
// 协作中别把老师带离这一页:跳走协作就断了
if (teacherCollab.value) return null
const actions: MascotAction[] = [{ label: '全班这题的提交', run: () => router.push({ name: 'submissions', query: { problem: problem._id } }) }]
if (isDesktop.value && userStore.canEditProblem(problem)) {
actions.push({ label: '编辑这道题', run: () => window.open(router.resolve(`/admin/problem/edit/${problem.id}`).href, '_blank') })
}
return { situation: 'teacher.problem', actions }
}
const language = codeStore.code.language
const entry = ctx.value
const runSamples: MascotAction | null =
problem.samples.length && language !== 'Flowchart' && language !== 'SQL' ? { label: '运行例子', run: submissionStore.runSamples } : null
// 和工具栏的「举手求助」同一个条件;已经举着手、老师在帮的时候不给
const help: MascotAction | null =
entry.help && isDesktop.value && !userStore.demoMode && language !== 'Flowchart' && collabStore.helpStatus === 'idle'
? { label: '举手求助', run: () => collabStore.requestHelp(problem._id, language) }
: null
const why: MascotAction = { label: '我错在哪', run: () => showResult('submit') }
const busy = submissionStore.isFormatting || submissionStore.isSubmittingRequest || submissionStore.judging || submissionStore.isCooldown
const submit: MascotAction | null = busy
? null
: { label: '提交', run: () => submissionStore.submit({ contestId: entry.contestId, problemSetId: entry.problemSetId }) }
const guide = (situation: MascotSituation, ...actions: (MascotAction | null)[]): MascotGuide => ({
situation,
actions: actions.filter((it): it is MascotAction => !!it),
})
if (language === 'Flowchart') return guide('problem.flowchart', help)
// 正在判、正在跑例子:没什么好说的,打个招呼
if (submissionStore.judging || submissionStore.pending || submissionStore.trial.samplesRunning.value) return null
const stuck = problemStore.failCount >= STUCK_FAILS
if (submissionStore.syntaxErrorInfo) return stuck ? guide('problem.stuck', help, why) : guide('problem.compileError', why, help)
const runs = submissionStore.trial.sampleRuns.value
if (submissionStore.resultSegment === 'samples' && runs.length) {
return runs.every((run) => run.result === SubmissionStatus.accepted)
? guide('problem.samplesPassed', submit)
: guide('problem.samplesFailed', { label: '我错在哪', run: () => showResult('samples') }, help)
}
const result = submissionStore.submission?.result
if (result === SubmissionStatus.accepted) {
const next = nextAction()
const fails = problemStore.failCount
return guide(fails === 0 ? 'problem.firstTry' : fails >= FINALLY_FAILS ? 'problem.finally' : 'problem.accepted', next)
}
const failed = result === undefined ? undefined : RESULT_SITUATION[result as SubmissionStatus]
if (failed) return stuck ? guide('problem.stuck', help, why) : guide(failed, why, failed === 'problem.wrong' ? runSamples : null, help)
if (problem.myStatus === SubmissionStatus.accepted) return guide('problem.solvedBefore', nextAction())
if (codeStore.code.value.trim().length < 10) return guide(entry.entry === 'problemset' ? 'problem.inSet' : 'problem.blank', runSamples, help)
return guide('problem.unsubmitted', runSamples, help)
/** 做对之后去哪:专题里回专题,这节课里去下一道还没做的 */
function nextAction(): MascotAction | null {
if (entry.entry === 'problemset') return { label: '回专题', run: () => router.push(`/problemset/${entry.problemSetId}`) }
const next = lessonStore.nextAfter(problem!._id)?.next
return next ? { label: '下一道', run: () => router.push(`/problem/${next.problemDisplayId}`) } : null
}
})
}
+8
View File
@@ -19,6 +19,7 @@ import ContextBar from './components/ContextBar.vue'
import { useEditorMenu } from './composables/editorMenu'
import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext'
import { useCollabHere, useTeacherCollab } from './composables/teacherCollab'
import { useProblemMascotGuide } from './composables/mascotGuide'
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版,
@@ -208,6 +209,13 @@ watch(
},
)
// 看板娘气泡里的「我错在哪」:切到「结果」页签里出错的那一段
useProblemMascotGuide((segment) => {
drawer.value = null
currentTab.value = 'result'
submissionStore.resultSegment = segment
})
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' })
+13
View File
@@ -18,6 +18,7 @@ import { usePagination } from 'shared/composables/pagination'
import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { focusJumpAction, useMascotGuide, type MascotAction } from 'shared/mascot/guide'
import ProblemStatus from './components/ProblemStatus.vue'
import ProblemTypeTag from './components/ProblemTypeTag.vue'
import ProgressRow from './components/ProgressRow.vue'
@@ -272,6 +273,18 @@ async function pickRandom() {
}
}
// 看板娘:学生给找题的几条路(「随便来一道」按这页选着的知识点和类型挑),老师给新建题目
useMascotGuide(() => {
if (teacher.value) {
return {
situation: 'teacher.problems',
actions: userStore.hasProblemPermission ? [{ label: '新建题目', run: () => router.push('/admin/problem/create') }] : [],
}
}
const undone: MascotAction | null = userStore.isAuthed && !undoneOn.value ? { label: '只看没做完', run: () => (undoneOn.value = true) } : null
return { situation: 'problems', actions: [focusJumpAction(), ...(undone ? [undone] : []), { label: '随便来一道', run: pickRandom }] }
})
// ---- 「只看没做完」下一道都没有了:说清楚为什么是空的,给两条路 ----
const nextTag = computed(() => {
const p = mine.value
+12
View File
@@ -8,6 +8,7 @@ import { parseTime } from 'utils/functions'
import type { ProblemSet, ProblemSetProblem } from 'utils/types'
import { useAuthModalStore } from 'shared/store/authModal'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { useFireworks } from '../problem/composables/useFireworks'
import NotFound from 'shared/components/NotFound.vue'
import BadgeLadder from './components/BadgeLadder.vue'
@@ -122,6 +123,17 @@ async function join() {
}
}
// 看板娘:老师看全班,学生还差几道必做题、带去下一道;没加入就只打招呼
useMascotGuide(() => {
if (!set.value) return null
if (userStore.isTeacherOrAbove) return { situation: 'teacher.problemset', actions: [{ label: '全班情况', run: () => (tab.value = 'class') }] }
if (!joined.value) return null
const todo = problems.value.filter((item) => item.isRequired && !item.isCompleted)
const next = todo[0]
if (progress.value?.isCompleted || !next) return { situation: 'problemset.done', actions: [goAction(router, '专题列表', '/problemset')] }
return { situation: 'problemset.unfinished', n: todo.length, actions: [{ label: '去做下一道', run: () => router.push(problemLink(next)) }] }
})
/** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
const canEdit = computed(() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id))
+11
View File
@@ -3,6 +3,7 @@ import { useThemeVars } from 'naive-ui'
import AdminLink from 'shared/components/AdminLink.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { getProblemSetList } from 'oj/api'
import { rgba, useTone } from 'shared/composables/tone'
import { parseTime } from 'utils/functions'
@@ -55,6 +56,16 @@ const others = computed(() =>
}),
)
// 看板娘:加入了还没做完的专题,布置中的优先,其次最近加入的
useMascotGuide(() => {
if (userStore.isTeacherOrAbove) return null
const pending = sets.value.filter((set) => set.userProgress.isJoined && !set.userProgress.isCompleted)
const latest = (set: ProblemSet) => Date.parse(set.userProgress.joinTime ?? '') || 0
const target = pending.find((set) => set.assigning) ?? [...pending].sort((a, b) => latest(b) - latest(a))[0]
if (!target) return null
return { situation: 'problemsets.unfinished', actions: [goAction(router, '接着做', `/problemset/${target.id}`)] }
})
function description(set: ProblemSet) {
// 简介大多就是把标题再抄一遍,一样的就不重复显示
return set.description && set.description !== set.title ? set.description : ''
+12
View File
@@ -7,6 +7,7 @@ import UserName from 'shared/components/UserName.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useConfigStore } from 'shared/store/config'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { errorCode } from 'utils/api'
import { displayName } from '@oj2/contract'
import { STORAGE_KEY } from 'utils/constants'
@@ -271,6 +272,17 @@ function openDetail(className: string) {
showDetail.value = true
}
// 看板娘:自己在榜上就把自己那一行滚到眼前(领奖台上是「你」那个标签,赛道上是高亮的那一行)
useMascotGuide(() => {
if (teacher.value || !board.value) return null
const listed = rows.value.some((row) => row.user.id === meId.value)
const findMe = () => {
const el = document.querySelector('.track-card .entry.me, .track-card .prow.me, .track-card .tag.me')
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
return { situation: 'rank', actions: listed ? [{ label: '我在哪', run: findMe }] : [] }
})
function open(username: string) {
router.push({ path: '/user', query: { name: username } })
}
+3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useRouteQuery } from '@vueuse/router'
import { useMascotGuide } from 'shared/mascot/guide'
import StatisticsView from './components/StatisticsView.vue'
import { DEFAULT_PERIOD } from './period'
@@ -19,6 +20,8 @@ const period = useRouteQuery<string>('period', DEFAULT_PERIOD, { mode: 'replace'
const from = useRouteQuery<string>('from', '', { mode: 'replace' })
const to = useRouteQuery<string>('to', '', { mode: 'replace' })
useMascotGuide(() => ({ situation: 'teacher.statistics' }))
function openList(query: Record<string, string>) {
window.open(router.resolve({ name: 'submissions', query }).href, '_blank')
}
+34 -1
View File
@@ -1,8 +1,41 @@
<script setup lang="ts">
import { getSubmission } from 'oj/api'
import { useMascotGuide } from 'shared/mascot/guide'
import { useUserStore } from 'shared/store/user'
import type { Submission } from 'utils/types'
import SubmissionView from './components/SubmissionView.vue'
/** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */
defineProps<{ submissionID: string }>()
const props = defineProps<{ submissionID: string }>()
const router = useRouter()
const userStore = useUserStore()
/**
* 看板娘:学生看自己的提交时带回这道题。SubmissionView 里的数据拿不出来(弹层也用它,不挂看板娘),
* 这里另取一份不带「这道题交的几次」的,只给学生取
*/
const mine = ref<Submission | null>(null)
watch(
() => [props.submissionID, userStore.isAuthed && !userStore.isTeacherOrAbove] as const,
async ([id, student]) => {
mine.value = null
if (!student) return
const res = await getSubmission(id).catch(() => null)
if (res && id === props.submissionID && res.userId === userStore.user?.id) mine.value = res
},
{ immediate: true },
)
useMascotGuide(() => {
const s = mine.value
if (!s || userStore.isTeacherOrAbove) return null
// 和页面上题目那个链接同一个去处:比赛里交的回比赛里的题
const to = s.contestId
? { name: 'contest problem', params: { contestID: String(s.contestId), problemID: s.problemDisplayId } }
: '/problem/' + s.problemDisplayId
return { situation: 'submission.detail', actions: [{ label: '回到这道题', run: () => router.push(to) }] }
})
</script>
<template>
+13
View File
@@ -11,6 +11,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import { usePagination } from 'shared/composables/pagination'
import { useConfigStore } from 'shared/store/config'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from 'utils/constants'
import { parseTime } from 'utils/functions'
import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from 'utils/types'
@@ -427,6 +428,18 @@ const { lesson, lessonActive, lessonLoading, toggleLesson } = useLessonFilter({
info: (text) => message.info(text),
})
// 看板娘:学生带去看自己的;「这节课」只有老师那一版有,老师给它
useMascotGuide(() => {
if (inContest.value) return null
if (!teacher.value) return { situation: 'submissions', actions: mine.value ? [] : [{ label: '只看我的', run: () => (scope.value = 'mine') }] }
const toLesson = () => {
// 「我的」时这节课的按钮是藏着的,先切回全部
if (mine.value) scope.value = 'all'
toggleLesson()
}
return { situation: 'teacher.submissions', actions: lessonActive.value ? [] : [{ label: '这节课', run: toLesson }] }
})
const activeFilters = computed(
() =>
[query.username, query.className, query.result, query.problem].filter(Boolean).length +
@@ -92,6 +92,9 @@ function openPicker() {
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
picking.value = true
}
// 看板娘气泡里的「选题」:课堂任务页没布置时替老师打开选题弹框
defineExpose({ openPicker })
function pickerConfirm(ids: string[]) {
lessonInput.value = ids.join(' ')
saveLesson()
+34 -1
View File
@@ -10,6 +10,7 @@ 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 { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions'
@@ -511,6 +512,29 @@ function submissionsHref(
}).href
}
/**
* 看板娘:布置了就带去这节课的提交,筛法同提交列表「这节课」(班、这几道题、今天)。
* 语言和格子一致:画流程图的课看流程图,选了语言的只看它 —— 夹着 SQL 题的不筛语言,免得把 SQL 交的藏掉。
* 还没布置时填题号的框就是这页最大的东西,不给按钮
*/
const startForm = useTemplateRef<{ openPicker: () => void }>('startForm')
useMascotGuide(() => {
const b = board.value
if (!b?.className) return null
if (needLesson.value)
return { situation: 'teacher.task.unassigned', actions: startForm.value ? [{ label: '选题', run: () => startForm.value?.openPicker() }] : [] }
const language = b.mode === 'flowchart' ? 'Flowchart' : b.problems.some((problem) => problem.isSql) ? null : b.language
const query = {
className: b.className,
problem: b.problems.map((problem) => problem.problemDisplayId).join(','),
today: '1',
...(language ? { language } : {}),
}
// 新标签打开,和点格子一样:任务页一般开着不关
const open = () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank')
return { situation: 'teacher.task.assigned', actions: [{ label: '这节课的提交', run: open }] }
})
function lastText(student: TaskBoardStudent) {
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
if (oftenIdle(student)) {
@@ -578,7 +602,16 @@ function lastText(student: TaskBoardStudent) {
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
<section v-if="needLesson" class="start">
<div class="start-title">这节课做哪几道题?</div>
<LessonForm v-if="className" :board="board" :class-name="className" :lesson-id="null" variant="start" class="start-row" @saved="lessonSaved" />
<LessonForm
v-if="className"
ref="startForm"
:board="board"
:class-name="className"
:lesson-id="null"
variant="start"
class="start-row"
@saved="lessonSaved"
/>
<div v-if="staleLesson" class="earlier">
<span>上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置</span>
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">{{ problem.problemDisplayId }}</span>
+8
View File
@@ -4,6 +4,7 @@ import { getProfileOverview } from 'oj/api'
import { getProfile } from 'shared/api'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import type { Profile } from 'utils/types'
import AchievementCard from './components/AchievementCard.vue'
import KnowledgeMap from './components/KnowledgeMap.vue'
@@ -17,6 +18,7 @@ import VersusCard from './components/VersusCard.vue'
* /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。
*/
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
@@ -47,6 +49,12 @@ async function load() {
}
watch(username, load, { immediate: true })
// 看板娘:只在看自己的主页时说话
useMascotGuide(() => {
if (!isSelf.value || !profile.value || userStore.isTeacherOrAbove) return null
return { situation: 'profile', actions: [goAction(router, '我的成就', '/achievement'), goAction(router, '我的提交', '/submission?myself=1')] }
})
</script>
<template>
+274 -54
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 看板娘。只陪着,不干别的。现在只有一个形象(大肥鱼)。
* 看板娘,兼站内的引导员。现在只有一个形象(鲸鱼娘)。
*
* 形象取自 MeteorNOX/DeepSeek-Balance-Whale-Widget 的 assets/DSniang1.png(裁掉透明边、缩到 2 倍显示尺寸转 webp)。
* 那个仓库的代码是 MIT,但 assets/ 下的图不在 MIT 里(见它的 PROVENANCE.md)。
@@ -8,14 +8,22 @@
* - 常驻在前台页面的角落,能拖;松手按「四分之一区」贴边:中心落在左/右四分之一就贴左/右缘,
* 上/下四分之一就贴上/下缘,中间保持松手的位置。两个方向各判各的,所以能贴进角落。
* - 在屏幕左半边时整体镜像,脸总朝着页面中间。
* - 按下去像玩偶一样压扁(底边不动),松手弹回;点一下蹦一下。
* - 按下去像玩偶一样压扁(底边不动),松手弹回。
* - 点一下冒气泡:一句话加最多三个按钮,按页面、按情况变(页面经 shared/mascot/guide.ts 告诉她),
* 台词在 shared/mascot/lines.ts。她从不主动开口;比赛里点她不弹气泡。再点一下、点别处、换页面都收起。
* - 彩蛋:连着快点,有时候(每轮三分之一的机会)点到第 4~8 下(也是随机的)会被摸摸头(同一个仓库的 assets/rua.gif),摸的时候接着点就一直摸。
*
* 位置只是这台电脑上的个人偏好,存 localStorage(读写都包 try/catch,拿不到就用默认位置)。
* 定位一律用 transform 的像素值:贴右缘时若换成 right:0,过渡没法在 auto 和数值之间插值,会瞬移。
*/
import { TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract'
import imageUrl from 'shared/assets/mascot/whale.webp'
import patUrl from 'shared/assets/mascot/whale-pat.gif'
import { MASCOT_LINES, type MascotSituation } from 'shared/mascot/lines'
import { currentMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import { useAuthModalStore } from 'shared/store/authModal'
import { useCollabStore } from 'shared/store/collab'
import { useUserStore } from 'shared/store/user'
const WIDTH = 132
const HEIGHT = 140 // 图是 264×280
@@ -36,6 +44,8 @@ function rollPatClicks() {
return PAT_CLICKS_MIN + Math.floor(Math.random() * (PAT_CLICKS_MAX - PAT_CLICKS_MIN + 1))
}
const STREAK_GAP_MS = 600 // 两下之间超过这么久就不算连点
const OVERCLICK = 8 // 一轮连点到这么多下还没摸到头,她就嫌烦了
const QUIET_BUBBLE_MS = 5000 // 没有按钮的气泡(只是一句话)过这么久自己收起
type HAnchor = 'left' | 'right' | null
type VAnchor = 'top' | 'bottom' | null
@@ -71,10 +81,11 @@ const place = loadPlace()
const pos = reactive({ x: 0, y: 0 })
const dragging = ref(false)
const pressed = ref(false)
const hop = ref(false)
const patting = ref(false)
const flipped = ref(false)
const root = ref<HTMLElement>()
const body = ref<HTMLElement>()
const squashEl = ref<HTMLElement>()
const bubbleEl = ref<HTMLElement>()
function clamp(v: number, lo: number, hi: number) {
return Math.min(Math.max(v, lo), Math.max(lo, hi))
@@ -90,6 +101,10 @@ function settle() {
flipped.value = pos.x + WIDTH / 2 < vw / 2
}
// 第一帧之前就算好位置。挪到 onMounted 里算的话,第一帧画在 (0,0),
// 接着位置一变就触发过渡,看上去是从左上角飞过去的
settle()
let start: { px: number; py: number; x: number; y: number } | null = null
let moved = false
@@ -98,7 +113,7 @@ function onDown(e: PointerEvent) {
// 不拦的话,第一下甩得快时浏览器会拿光标新位置底下的东西(比如题目列表的链接)开始原生拖拽,
// 随即发 pointercancel,看板娘就卡在半路
e.preventDefault()
root.value?.setPointerCapture(e.pointerId)
body.value?.setPointerCapture(e.pointerId)
start = { px: e.clientX, py: e.clientY, x: pos.x, y: pos.y }
moved = false
pressed.value = true
@@ -117,6 +132,19 @@ function onMove(e: PointerEvent) {
flipped.value = pos.x + WIDTH / 2 < document.documentElement.clientWidth / 2
}
/**
* 每点一下都压扁再弹回来一次。按住时的压扁靠 .pressed 的过渡,可快速点一下从按下到松开只有几十毫秒,
* 过渡还没走到位就松了,连点时看上去像卡住。用 Web Animations 播一遍完整的,每下都从头播
*/
const reducedMotion = usePreferredReducedMotion()
function boing() {
if (reducedMotion.value === 'reduce') return
squashEl.value?.animate([{ transform: 'scale(1.06, 0.86)' }, { transform: 'scale(0.97, 1.04)', offset: 0.55 }, { transform: 'scale(1, 1)' }], {
duration: 280,
easing: 'ease-out',
})
}
// 动图 94KB,不在每个页面一打开就下:连点到第 2 下才预载,最少也还要再点两下才用得上
let patPreloaded = false
function preloadPat() {
@@ -126,7 +154,6 @@ function preloadPat() {
}
let patTimer: ReturnType<typeof setTimeout> | undefined
let hopTimer: ReturnType<typeof setTimeout> | undefined
let streak = 0
let patClicks = rollPatClicks()
let lastClick = 0
@@ -148,6 +175,7 @@ function onUp() {
savePlace(place)
return
}
boing()
const now = performance.now()
if (now - lastClick <= STREAK_GAP_MS) streak++
else {
@@ -159,7 +187,7 @@ function onUp() {
// 正在被摸,或者连点到数了:摸摸头,从这一下再摸满一轮
if (patting.value || streak >= patClicks) {
hop.value = false
if (!patting.value) say({ text: pick('patting') })
patting.value = true
clearTimeout(patTimer)
patTimer = setTimeout(() => {
@@ -168,48 +196,173 @@ function onUp() {
}, PAT_MS)
return
}
// 平时:蹦一下。连点就从头再蹦
hop.value = false
clearTimeout(hopTimer)
requestAnimationFrame(() => {
hop.value = true
hopTimer = setTimeout(() => (hop.value = false), 500)
})
// 一轮里只有第一下开关气泡,后面连着点是在找彩蛋,不跟着一开一关
if (streak === 1) toggleBubble()
else if (streak === OVERCLICK) say({ text: pick('overclick') })
}
// ---- 气泡 ----
const route = useRoute()
const userStore = useUserStore()
const collabStore = useCollabStore()
const authModal = useAuthModalStore()
interface Bubble {
text: string
actions?: MascotAction[]
}
const bubble = ref<Bubble | null>(null)
let quietTimer: ReturnType<typeof setTimeout> | undefined
/** 比赛、测验里不说话:「运行例子」「举手」这类按钮在考试时算不算帮忙,干脆不弹 */
const silent = computed(() => String(route.name ?? '').startsWith('contest '))
function say(next: Bubble) {
if (silent.value) return
bubble.value = next
clearTimeout(quietTimer)
if (!next.actions?.length) quietTimer = setTimeout(close, QUIET_BUBBLE_MS)
}
function close() {
bubble.value = null
clearTimeout(quietTimer)
}
function toggleBubble() {
if (bubble.value) return close()
const guide = resolveGuide()
say({ text: pickFor(guide), actions: guide.actions?.slice(0, 3) })
}
/** 先看全站的:没登录、老师这边有人举手;再问页面;都没有就打个招呼 */
function resolveGuide(): MascotGuide {
if (!userStore.isAuthed) return { situation: 'guest', actions: [{ label: '登录', run: authModal.openLoginModal }] }
const teacher = userStore.isTeacherOrAbove
if (teacher && collabStore.isTeacher && collabStore.pendingCount > 0) {
return { situation: 'teacher.help', n: collabStore.pendingCount, actions: [{ label: '去看看', run: () => (collabStore.helpPanelOpen = true) }] }
}
return currentMascotGuide() ?? { situation: teacher ? 'teacher.greet' : 'greet' }
}
/** 这几种情况要紧,不拿彩蛋换掉 */
const SERIOUS = new Set<MascotSituation>(['guest', 'teacher.help', 'problem.stuck'])
const RARE_CHANCE = 0.03
const TIME_CHANCE = 0.15
function pickFor(guide: MascotGuide) {
let situation = guide.situation
if (!SERIOUS.has(situation) && guide.n === undefined) {
const timely = timeSituation()
if (Math.random() < RARE_CHANCE) situation = 'rare'
else if (timely && !userStore.isTeacherOrAbove && Math.random() < TIME_CHANCE) situation = timely
}
return pick(situation).replace('{n}', String(guide.n ?? ''))
}
/** 同一组里不连着抽到同一句 */
let lastLine = ''
function pick(situation: MascotSituation) {
const pool: readonly string[] = MASCOT_LINES[situation]
const choices = pool.length > 1 ? pool.filter((line) => line !== lastLine) : pool
lastLine = choices[Math.floor(Math.random() * choices.length)]!
return lastLine
}
/**
* 按东八区的钟点挑时间彩蛋。节次是估的(第一节 8:00、午饭 11:20、下午第一节 13:30、
* 最后一节 15:50 前后),对不上学校作息就改这里
*/
function timeSituation(): MascotSituation | null {
const wall = new Date(Date.now() + TIME_ZONE_OFFSET_MINUTES * 60_000)
const day = wall.getUTCDay()
const minutes = wall.getUTCHours() * 60 + wall.getUTCMinutes()
const within = (from: string, to: string) => {
const [fh, fm] = from.split(':').map(Number)
const [th, tm] = to.split(':').map(Number)
return minutes >= fh! * 60 + fm! && minutes < th! * 60 + tm!
}
if (day === 0 || day === 6 || !within('07:00', '18:00')) return 'offHours'
if (within('08:00', '08:50')) return 'time.firstClass'
if (within('11:20', '12:10')) return 'time.lunch'
if (within('13:30', '14:20')) return 'time.afternoon'
if (within('15:50', '16:40')) return 'time.lastClass'
if (day === 1) return 'day.monday'
if (day === 5) return 'day.friday'
return null
}
async function runAction(action: MascotAction) {
close()
await action.run()
}
// 点别处收起;点她自己由 onUp 管(开关),不算「别处」
onClickOutside(bubbleEl, close, { ignore: [body] })
watch(() => route.fullPath, close)
watch(silent, (quiet) => quiet && close())
/** 她靠上时气泡往下冒,免得顶出屏幕;在左半边时气泡贴左对齐 */
const bubbleBelow = computed(() => pos.y < 220)
onMounted(() => {
settle()
window.addEventListener('resize', settle, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener('resize', settle)
clearTimeout(patTimer)
clearTimeout(hopTimer)
clearTimeout(quietTimer)
})
</script>
<template>
<div
ref="root"
class="mascot"
:class="{ dragging, pressed, hop, patting, flipped }"
:style="{ width: `${WIDTH}px`, height: `${HEIGHT}px`, transform: `translate3d(${pos.x}px, ${pos.y}px, 0)` }"
aria-hidden="true"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="onUp"
@dragstart.prevent
>
<div class="flip">
<div class="squash">
<div class="breathe">
<img v-if="patting" class="pat" :src="patUrl" :width="PAT_SIZE" :height="PAT_SIZE" :style="{ bottom: `${PAT_BOTTOM}px` }" alt="" draggable="false" />
<img v-else :src="imageUrl" :width="WIDTH" :height="HEIGHT" alt="" draggable="false" />
<div class="mascot" :class="{ dragging }" :style="{ width: `${WIDTH}px`, height: `${HEIGHT}px`, transform: `translate3d(${pos.x}px, ${pos.y}px, 0)` }">
<div
ref="body"
class="body"
:class="{ pressed, patting, flipped }"
aria-hidden="true"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="onUp"
@dragstart.prevent
>
<div class="flip">
<div ref="squashEl" class="squash">
<div class="breathe">
<img
v-if="patting"
class="pat"
:src="patUrl"
:width="PAT_SIZE"
:height="PAT_SIZE"
:style="{ bottom: `${PAT_BOTTOM}px` }"
alt=""
draggable="false"
/>
<img v-else :src="imageUrl" :width="WIDTH" :height="HEIGHT" alt="" draggable="false" />
</div>
</div>
</div>
</div>
<Transition name="bubble">
<div v-if="bubble && !dragging" ref="bubbleEl" class="bubble" :class="{ below: bubbleBelow, left: flipped }" role="status">
<p class="line">{{ bubble.text }}</p>
<div v-if="bubble.actions?.length" class="actions">
<n-button
v-for="(action, index) in bubble.actions"
:key="action.label"
size="small"
:type="index === 0 ? 'primary' : 'default'"
@click="runAction(action)"
>
{{ action.label }}
</n-button>
</div>
</div>
</Transition>
</div>
</template>
@@ -219,17 +372,99 @@ onBeforeUnmount(() => {
left: 0;
top: 0;
z-index: 900; /* 压在页面上,但让弹框、消息、烟花盖住它 */
cursor: grab;
touch-action: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: transform 0.35s cubic-bezier(0.34, 1.4, 0.64, 1);
will-change: transform;
}
.mascot.dragging {
cursor: grabbing;
transition: none;
}
.body {
width: 100%;
height: 100%;
cursor: grab;
touch-action: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.dragging .body {
cursor: grabbing;
}
/* 气泡:白底深蓝描边(取她身上线条的颜色),尾巴指着她的头。设计稿「看板娘引导」 */
.bubble {
--bubble-line: #2b3a6b;
--bubble-bg: #fff;
position: absolute;
right: 14px;
bottom: calc(100% + 12px);
width: max-content;
min-width: 120px;
max-width: 280px;
box-sizing: border-box;
padding: 12px 14px;
background: var(--bubble-bg);
border: 2px solid var(--bubble-line);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(20, 30, 60, 0.16);
display: flex;
flex-direction: column;
gap: 10px;
}
:global(html.dark) .bubble {
--bubble-line: #7f94d4;
--bubble-bg: #1f2230;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.bubble::after {
content: '';
position: absolute;
right: 40px;
bottom: -9px;
width: 14px;
height: 14px;
background: var(--bubble-bg);
border-right: 2px solid var(--bubble-line);
border-bottom: 2px solid var(--bubble-line);
transform: rotate(45deg);
}
.bubble.left {
right: auto;
left: 14px;
}
.bubble.left::after {
right: auto;
left: 40px;
}
.bubble.below {
bottom: auto;
top: calc(100% + 12px);
}
.bubble.below::after {
bottom: auto;
top: -9px;
transform: rotate(-135deg);
}
.line {
margin: 0;
font-size: 15px;
line-height: 1.5;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bubble-enter-active,
.bubble-leave-active {
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.bubble-enter-from,
.bubble-leave-to {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
.flip,
.squash,
@@ -254,9 +489,6 @@ onBeforeUnmount(() => {
transform: scale(1.05, 0.88);
transition-duration: 0.1s;
}
.hop .squash {
animation: mascot-hop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 平时轻轻地一起一伏 */
.breathe {
@@ -291,18 +523,6 @@ img.pat {
}
}
@keyframes mascot-hop {
0% {
transform: translateY(0) scale(1.05, 0.88);
}
45% {
transform: translateY(-16px) scale(0.97, 1.05);
}
100% {
transform: translateY(0) scale(1, 1);
}
}
@media (prefers-reduced-motion: reduce) {
.breathe {
animation: none;
@@ -2,6 +2,7 @@
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui'
import type { RouteLocationRaw } from 'vue-router'
import { useMascotGuide } from 'shared/mascot/guide'
/**
* 「找不到」的整页提示:地址打错、题目 / 比赛 / 专题不存在或没公开、比赛还没开始。
@@ -21,6 +22,9 @@ const props = withDefaults(
const theme = useThemeVars()
const router = useRouter()
// 看板娘:和页面上那个按钮去同一个地方(回首页 / 回到专题 / 回到比赛)
useMascotGuide(() => ({ situation: 'notFound', actions: [{ label: props.toLabel, run: () => router.push(props.to) }] }))
</script>
<template>
@@ -6,6 +6,7 @@ import { useProblemJump } from 'shared/composables/problemJump'
import { useUserStore } from 'shared/store/user'
import type { ClassActivityProblem } from 'utils/types'
import { zonedParts } from 'utils/functions'
import { jumpFocusRequest } from 'shared/mascot/guide'
/**
* 顶栏的题号框:老师报完题号,学生在哪一页都能直接敲,不用先回首页或题目列表。
@@ -29,7 +30,9 @@ const lessonStore = useLessonStore()
const { jump, jumping } = useProblemJump()
const keyword = ref('')
const focused = ref(false)
const input = useTemplateRef<{ blur: () => void }>('input')
const input = useTemplateRef<{ blur: () => void; focus: () => void }>('input')
// 看板娘气泡里的「输题号」:把光标放进来
watch(jumpFocusRequest, () => input.value?.focus())
/** 题目页的三种入口(题库 / 比赛 / 专题)顶上都有自己的上下文条 */
const PROBLEM_PAGES = new Set(['problem', 'contest problem', 'problemset problem'])
+72
View File
@@ -0,0 +1,72 @@
import type { Router } from 'vue-router'
import type { MascotSituation } from './lines'
/**
* 看板娘的「引导」:页面告诉她现在是什么情况、气泡里的按钮各做什么。
*
* 每个页面在 setup 里调一次 useMascotGuide,给一个函数;看板娘被点的那一刻才调用它,
* 所以函数里直接读页面当下的状态就行,不用做成 computed。函数返回 null 就当这页没有专门的话,
* 看板娘只打招呼。比赛里看板娘不弹气泡,那一层在 Mascot.vue 里按路由判断,页面不用管。
*
* 以后要按学生的行为调整她说什么(常错哪一类、习惯先跑例子还是直接交),也是往这一层加信号。
*/
export interface MascotAction {
label: string
run: () => unknown
}
export interface MascotGuide {
situation: MascotSituation
/** 台词里 `{n}` 换成它:还差几道、第几课、几个人举手 */
n?: number
/** 最多三个,第一个是主按钮 */
actions?: MascotAction[]
}
type GuideProvider = () => MascotGuide | null
/** 后挂上的排在后面;一页只挂一个,嵌套的子组件别再挂 */
const providers = shallowRef<GuideProvider[]>([])
export function useMascotGuide(provider: GuideProvider) {
onMounted(() => {
providers.value = [...providers.value, provider]
})
onBeforeUnmount(() => {
providers.value = providers.value.filter((it) => it !== provider)
})
}
export function currentMascotGuide(): MascotGuide | null {
const list = providers.value
for (let i = list.length - 1; i >= 0; i--) {
const guide = list[i]!()
if (guide) return guide
}
return null
}
// ---- 几个页面都用得上的按钮 ----
/** 顶栏「输入题号直达」框:看板娘点一下就把光标放进去。ProblemJumpBox 盯着这个数 */
export const jumpFocusRequest = ref(0)
export function focusJumpAction(): MascotAction {
return { label: '输题号', run: () => jumpFocusRequest.value++ }
}
/** 和题目列表的「随便来一道」同一个接口:不限知识点和类型,按学生惯用的语言挑 */
export function randomProblemAction(router: Router): MascotAction {
return {
label: '随便来一道',
run: async () => {
const [{ getRandomProblem }, { useCodeStore }] = await Promise.all([import('oj/api'), import('oj/store/code')])
const id = await getRandomProblem({ tag: '', type: '', language: useCodeStore().preferredLanguage() })
router.push('/problem/' + id)
},
}
}
export function goAction(router: Router, label: string, to: string): MascotAction {
return { label, run: () => router.push(to) }
}
+164
View File
@@ -0,0 +1,164 @@
/**
* 看板娘的台词。点她一下,按「现在是什么情况」从对应那一组里随机抽一句。
*
* 口吻照「DeepSeek 小鲸鱼」角色卡:短、像群友、傲娇嘴硬、心软藏着。学校里舍掉了英文、
* 粗口和「V我50」;角色卡里叫人「用户」的梗改成「同学」。不自称「本小姐」,也不自称「大肥鱼」。
* 设计稿(含整张台词表):https://claude.ai/artifact/Qz2zPCfq2zCrqmxyms1UGx
*
* `{n}` 由页面给的数字替换(还差几道、第几课、几个人举手)。
* 文案守全站的规矩:全中文、不用「他」、对学生不说「追」。
*/
export const MASCOT_LINES = {
// ---- 学生:首页 ----
'home.assigned': [
'今天的题还没做完',
'题在这,自己去',
'还发呆?',
'老师布置的,不是我',
'做完再玩',
'就几道,别磨蹭',
'老师报题号了吧',
'还剩几道,自己数',
'先做今天的',
'别人都交了',
'题不会自己跑过来',
'我替你记着呢……才怪',
],
'home.done': ['做完了?哼,还挺快', '今天的题清空了', '行,算你今天没白来', '多做一道又不会掉肉', '做完了就去自学', '今天表现还行', '剩下的时间别浪费'],
'home.free': ['闲着?来一道', '没作业也别想摸鱼', '随便挑一道呗', '无聊就做题', '来都来了', '老师没布置,你也能做', '今天做一道,明天轻松点', '手痒了吧'],
// ---- 学生:题目列表 ----
problems: [
'找哪道',
'题号报来',
'翻半天了吧',
'不知道做哪道?随便来一道',
'题号都记不住?',
'简单的先做',
'没做完的先挑',
'看名字挑题?行吧',
'这么多,慢慢找',
],
// ---- 学生:题目页 ----
'problem.blank': ['看完题再写', '题目读两遍', '输入输出先看清', '例子先看懂', '先别急着敲', '空的?', '写啊,我又不会帮你写'],
'problem.inSet': ['专题里的题,做完拿奖章', '这个专题还差几道', '做完回专题看看'],
'problem.unsubmitted': [
'直接交?胆子不小',
'先跑例子',
'交之前对一下例子',
'例子都没跑就交?',
'我看着呢',
'写完了?先跑一下',
'跑一下又不要钱',
'自信是好事,先跑例子',
],
'problem.samplesFailed': ['例子都没过,还想交?', '差一点点', '对着例子看看', '一个字都不能差', '别急着交', '空格也算', '多了一行?', '输出和例子摆一起看'],
'problem.samplesPassed': ['例子过了,交吧', '还行,交', '交了才算数', '例子过了不代表全对'],
'problem.wrong': [
'错了就改呗',
'慌什么,看看错哪了',
'错一次而已',
'我就知道',
'改改就好了,又不难',
'再来',
'输出再对一遍',
'是不是格式的事',
'特殊情况想过没',
'离对就差一点',
],
'problem.compileError': ['连编译都没过?', '红字看看', '少了个符号吧', '中文标点?', '括号配对了没', '拼错了吧'],
'problem.runtimeError': ['跑一半崩了', '程序炸了', '看看哪一行', '输入读对了没', '除以零了?'],
'problem.timeout': ['跑太久了', '死循环?', '循环停得下来吗', '是不是多读了输入', '慢死了'],
'problem.stuck': [
'行吧,这次不怼你。问问老师?',
'别硬撑了,举个手',
'歇一下,再看一遍题',
'不丢人,老师就是干这个的',
'……我在呢',
'举手又没人笑你',
'换个思路试试',
'从头读一遍题',
],
'problem.accepted': [
'哼,算你厉害',
'还行吧,别得意',
'下一道',
'才不是夸你',
'这么快?……有点东西',
'行,记你一功',
'对了就对了,笑什么',
'一般般啦',
'还算有点用',
'别骄傲,下一道',
],
'problem.firstTry': ['一次就过?运气吧', '……好吧,厉害', '没跑例子也对了?算你狠', '这题对你太简单了吧'],
'problem.finally': ['终于', '我就说改改就行', '磨了半天,总算', '看吧,不难', '这次算你没放弃'],
'problem.solvedBefore': ['做过了还来?', '复习呢?行吧', '旧题有什么好看的', '会了就下一道', '再做一遍也行,别抄自己的'],
'problem.flowchart': ['先画图', '框和箭头别乱连', '画完让我看看……才不看', '判断框要两个出口'],
// ---- 学生:其他页面 ----
'submission.detail': ['盯着看也不会变对', '回去改改', '看出问题没', '看完了就回去', '又在研究?', '红的那一格看看'],
'problemsets.unfinished': ['上次那个还没做完', '挑一个', '别光看不做', '奖章还空着呢', '先把开了头的做完'],
'problemset.unfinished': ['还差 {n} 道', '就差 {n} 道了', '做一半跑了?', '奖章不会自己来', '接着做', '都到这儿了'],
'problemset.done': ['全做完了?哼,奖章拿着', '还挺能坚持', '下一个专题呢', '这个奖章……还挺好看'],
'learn.continue': ['第 {n} 课,没忘吧', '上次学到一半', '学一课又不会怎样', '今天学一课?', '接着上次的', '学完这课再玩'],
'learn.fresh': ['从第一课开始', '不会就从头学', '光做题不学?', '第一课很短的'],
tutorial: ['看完了?练一练', '光看不练没用', '练一练才算学会', '代码能直接跑,试试', '下一课等着呢', '别跳着看', '这段改一改再跑'],
'tutorial.passed': ['这课过了', '还挺快', '下一课'],
submissions: ['先看自己的', '看别人的干嘛', '找自己的?', '这么多,找得到吗', '这节课的在这', '红的多还是绿的多'],
rank: ['想知道第几?带你去', '别光看前面的', '排名又不会自己涨', '做对一道新题就能动', '看别人干嘛,做题去', '前一名也没多几道'],
pk: ['你们班行不行', '比输了别找我', '多做一道,班里就多一道'],
'contests.live': ['比赛开着呢', '还不进去?', '去吧,我不看'],
'contests.none': ['今天没比赛', '想比赛?找老师'],
announcement: ['看公告?难得', '有新东西', '看完就做题去'],
profile: ['欣赏自己呢?', '自恋', '做对的题都在这', '还行吧,这战绩', '签名该改了'],
achievement: ['又来数成就?', '还差几个', '下一个快了', '拿了就别显摆', '这么多?……一般吧'],
notFound: ['迷路了?跟我回去', '这里什么都没有', '地址打错了吧', '走错门了', '没这个地方'],
guest: ['先登录,不然不理你', '你谁啊', '登录再说', '不认识你', '报上名来', '点名字就能登录'],
// ---- 老师 ----
'teacher.help': ['{n} 个学生举手了,快去', '有人举手,别装没看见', '等着呢,快去', '{n} 个了,再不去要急了', '举着手呢,手酸了', '老师,有人找'],
'teacher.home.unassigned': ['今天不布置?学生要摸鱼了', '空着呢', '题呢?', '布置点什么吧', '不布置我可去吃饭了', '学生都在等'],
'teacher.home.assigned': ['这节课的提交,筛好了', '看看谁在摸鱼', '交上来不少了', '去看看', '有人卡住了吧'],
'teacher.task.unassigned': ['空的。题自己选', '选几道吧', '五道以内,别太狠', '还没布置呢', '挑简单的先热身?'],
'teacher.task.assigned': ['进度都在这', '谁在摸鱼,一目了然', '差不多该讲题了吧', '卡在同一道的有点多', '做完的可以加一道'],
'teacher.statistics': ['看完记得夸夸学生', '数字好看吗', '别只看平均数', '有进步的,夸一句', '这节课还行吧'],
'teacher.submissions': ['筛这节课的?', '想看谁的', '错的多的先看', '一条条看,辛苦了', '重判之前想清楚'],
'teacher.problem': ['全班做得怎样,不看看?', '难不难,数据说了算', '又在看题?', '要改题?', '这题错的人不少'],
'teacher.problems': ['又出新题?才没关心你', '出题辛苦了……随口说的', '题够多了吧', '新题记得放例子', '标签别忘了打'],
'teacher.problemset': ['全班进度,记着呢', '谁没做完,一看就知道', '奖章发得出去吗'],
'teacher.greet': ['老师好……才不是特意打招呼', '有事?', '忙完了?', '喝口水吧', '下课了吗', '今天几节课?', '辛苦……随口说的'],
// ---- 招呼与彩蛋 ----
greet: [
'干嘛',
'?',
'戳我干嘛',
'在呢',
'又来',
'有事说事',
'忙着呢',
'嗯?',
'别戳了',
'看什么看',
'好饿',
'叫我干嘛',
'我在摸鱼,别吵',
'你也在摸鱼?',
'不理你',
'哼',
],
patting: ['头发乱了', '再摸收费', '别摸了', '……就一下', '手拿开', '摸够了没', '……还行'],
overclick: ['同学,你够了', '再点我要生气了', '手不酸吗', '点我又不加分', '有这功夫做道题', '鼠标要坏了'],
rare: ['服务器繁忙', '让我想想……算了', '这个我不会,骗你的', '刚才说到哪了', '我去吃饭了,你做完告诉我'],
'time.firstClass': ['醒了没', '早,题呢', '眼睛睁开', '早饭吃了没'],
'time.lunch': ['事已至此,先吃饭吧', '好饿', '饭点了', '做完这道就去吃'],
'time.afternoon': ['困了吧', '午觉睡了没', '醒醒'],
'time.lastClass': ['快下课了,交了再走', '收拾书包前再交一次', '最后一道'],
'day.monday': ['周一了,打起精神', '上周的题做完没'],
'day.friday': ['周五了,再做一道', '明天放假,今天做完'],
offHours: ['这个点还在做题?', '放假还来,行吧', '……有点感动,一点点'],
} as const satisfies Record<string, readonly string[]>
export type MascotSituation = keyof typeof MASCOT_LINES