- 她从不主动开口,点一下才冒气泡;比赛里不弹。台词 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:
1 parent
15966ceb0d
commit
ef3c53c671
25 files changed
+860
-57
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 : ''
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user