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>