refactor(课堂任务): 「课堂看板」改名「课堂任务」,地址换成 /task;班级 PK 换成 /pk

- 界面上的「课堂看板」「课堂作业」统一叫「课堂任务」,顶栏、老师首页、后台菜单一起改
- 前端 oj/classroom → oj/task、oj/class → oj/pk,两个名字不再只差几个字母却毫不相干;旧地址不留跳转
- 后端 routes/classroom.ts 拆开:课堂任务的接口进 routes/task.ts(/task/board|lesson|today),
  首页的「上次来」「班里在做」进 routes/home.ts;共用的取题口径挪进 services/lesson.ts
- 契约 classroom.ts 同样拆成 task.ts / home.ts,ClassBoard* 改叫 TaskBoard*

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 22:53:07 +08:00
1 parent 46d3fe2dbe
commit 29c31640b5
82 files changed
+798 -766

No files matched your search

+1 -1
View File
@@ -64,7 +64,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
Shared logic lives in `shared/`:
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (登录框、注册框开没开), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render,渲染器是 pele 不是 mermaid), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity`
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render,渲染器是 pele 不是 mermaid), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂任务的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity`
- `layout/` — `default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha)
+1 -1
View File
@@ -780,7 +780,7 @@ export function deleteAchievement(id: number) {
return api.delete(`admin/achievements/${id}`)
}
// ---------- 课堂作业(class_lesson) ----------
// ---------- 课堂任务(class_lesson) ----------
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) {
return api.get<AdminLessonList>('admin/lessons', {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract'
import ProblemPicker from 'oj/classroom/components/ProblemPicker.vue'
import ProblemPicker from 'oj/task/components/ProblemPicker.vue'
import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api'
import { groupClassOptions } from 'utils/classes'
@@ -10,8 +10,8 @@ import { parseTime } from 'utils/functions'
import { createAdminLesson, updateAdminLesson } from '../../api'
/**
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 看板上点错了班、隔天才想起来布置,
* 都在这里纠正;补的是看板外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 任务页上点错了班、隔天才想起来布置,
* 都在这里纠正;补的是任务页外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
*/
const props = defineProps<{
/** null = 补一节 */
@@ -54,7 +54,7 @@ function inputIds() {
return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean)
}
// ---- 选题弹框,和看板上那个是同一个 ----
// ---- 选题弹框,和任务页上那个是同一个 ----
const picking = ref(false)
const pickerInitial = ref<{ id: string; title?: string }[]>([])
function openPicker() {
+4 -4
View File
@@ -17,8 +17,8 @@ import { deleteAdminLesson, getAdminLessons } from '../api'
import LessonEditModal from './components/LessonEditModal.vue'
/**
* 后台「课堂作业」:布置过的每一节课一行,新的在前。课堂看板上当场布置的都在这里,
* 布置错了能改、删;看板外上的课能补一节 —— 数据统计按布置切课,补了才认得到
* 后台「课堂任务」:布置过的每一节课一行,新的在前。课堂任务上当场布置的都在这里,
* 布置错了能改、删;任务页外上的课能补一节 —— 数据统计按布置切课,补了才认得到
*/
const router = useRouter()
const message = useMessage()
@@ -26,7 +26,7 @@ const theme = useThemeVars()
const configStore = useConfigStore()
const { isMobile } = useBreakpoints()
// 从看板「布置过的课」进来带着 ?className=
// 从任务页「布置过的课」进来带着 ?className=
const { query } = usePagination<{ className: string; teacher: string }>({
className: useRouteQuery('className', '').value,
teacher: useRouteQuery('teacher', '').value,
@@ -163,7 +163,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
</script>
<template>
<PageHeader title="课堂作业">
<PageHeader title="课堂任务">
<template #extra>
<n-text v-if="data" depth="3">{{ data.total }} 节</n-text>
</template>
+2 -2
View File
@@ -94,7 +94,7 @@ const shortcuts = [
{ label: '新建题目', to: '/admin/problem/create' },
{ label: '新建比赛', to: '/admin/contest/create' },
{ label: '批量建号', to: '/admin/user/generate' },
{ label: '课堂看板', to: '/classroom' },
{ label: '课堂任务', to: '/task' },
]
// ---------------------------------------------------------------- 抽一个人
@@ -170,7 +170,7 @@ onUnmounted(stopRolling)
<div class="tile">
<span class="label">正在上课</span>
<b>{{ info.activeClass ? classLabel(info.activeClass) : '没有' }}</b>
<router-link v-if="info.activeClass" class="link" :to="{ name: 'classroom board', query: { className: info.activeClass } }"> 去课堂看板 → </router-link>
<router-link v-if="info.activeClass" class="link" :to="{ name: 'task', query: { className: info.activeClass } }"> 去课堂任务 → </router-link>
<span v-else class="note">最近两小时没有哪个班在交题</span>
</div>
</div>
+8 -8
View File
@@ -26,7 +26,7 @@ import {
type Tutorial,
type ClassToday,
type LastVisit,
type ClassBoard,
type TaskBoard,
type ClassLesson,
type KnowledgeMap,
type ClassBattleItem,
@@ -251,14 +251,14 @@ export function getWeeklyRank(scope: 'global' | 'class') {
return api.get<WeeklyRank>('rankings/weekly', { params: { scope } })
}
/** 课堂看板(老师)。不给班级时后端猜最近两小时在交题的那个班 */
export function getClassBoard(className?: string) {
return api.get<ClassBoard>('classroom/board', { params: { className } })
/** 课堂任务(老师)。不给班级时后端猜最近两小时在交题的那个班 */
export function getTaskBoard(className?: string) {
return api.get<TaskBoard>('task/board', { params: { className } })
}
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和看板一样 */
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和任务页一样 */
export function getClassLesson(className?: string) {
return api.get<ClassLesson>('classroom/lesson', { params: { className } })
return api.get<ClassLesson>('task/lesson', { params: { className } })
}
/**
@@ -266,7 +266,7 @@ export function getClassLesson(className?: string) {
* (一个班一天可以上几节内容不同的课),否则改那一节
*/
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, mode: LessonMode, lessonId: number | null) {
return api.put<null>('classroom/lesson', {
return api.put<null>('task/lesson', {
className,
problemDisplayIds,
language,
@@ -282,7 +282,7 @@ export function getKnowledgeMap() {
/** 老师首页「今天的课」:今天在上课的每个班 */
export function getClassToday() {
return api.get<ClassToday>('classroom/today')
return api.get<ClassToday>('task/today')
}
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
+2 -2
View File
@@ -18,7 +18,7 @@ import { useVisibleInterval } from 'shared/composables/visibleInterval'
*
* 名字和排行榜一样用用户名(ks241XXX),不用真名。
* 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面,
* 没进来的人在最下面(看板里标了请假的写「请假」)。
* 没进来的人在最下面(课堂任务里标了请假的写「请假」)。
* 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。
* 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 buildClassView)
*/
@@ -357,7 +357,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
<span v-for="p in problems" :key="p.id"></span>
<span></span>
<span class="flag-wrap">
<span class="flag grey">{{ user.leave ? '请假(看板里标的)' : '没进来' }}</span>
<span class="flag grey">{{ user.leave ? '请假(课堂任务里标的)' : '没进来' }}</span>
</span>
</div>
</template>
+5 -5
View File
@@ -10,7 +10,7 @@ import { parseTime, zonedParts, zonedYear } from 'utils/functions'
/**
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂任务」进那个班。
* 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前)
*/
const theme = useThemeVars()
@@ -61,13 +61,13 @@ function metaOf(row: ClassRow) {
return parts.join(' · ')
}
function openBoard(row: ClassRow) {
router.push({ name: 'classroom board', query: { className: row.className } })
function openTask(row: ClassRow) {
router.push({ name: 'task', query: { className: row.className } })
}
const shortcuts = computed(() =>
[
{ label: '课堂看板', to: '/classroom', show: true },
{ label: '课堂任务', to: '/task', show: true },
{ label: '数据统计', to: '/statistics', show: true },
{ label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission },
{ label: '后台', to: userStore.isSuperAdmin ? '/admin' : '/admin/problem/list', show: true },
@@ -92,7 +92,7 @@ const shortcuts = computed(() =>
<span class="state" :class="{ live: stateOf(row).live }">{{ stateOf(row).text }}</span>
<span class="muted">{{ metaOf(row) }}</span>
<span class="spacer"></span>
<n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openBoard(row)"> 课堂看板 </n-button>
<n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openTask(row)"> 课堂任务 </n-button>
</div>
<div v-if="row.problems.length" class="minis">
<router-link
+1 -1
View File
@@ -187,7 +187,7 @@ const solvedText = computed(() => (visit.value?.solved ?? []).map((item) => `${i
const classActivityTitle = computed(() => {
const day = classActivity.value?.day
if (!day) return ''
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
// 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
if (classActivity.value?.source === 'teacher') {
const language = classActivity.value.language
const mode = classActivity.value.mode
File renamed without changes.
File renamed without changes.
@@ -8,10 +8,10 @@ import UserName from 'shared/components/UserName.vue'
* 老师协作时右栏顶上的一条(设计稿「举手与协作」HcPageA):正在帮谁、举手多久了、
* 看他交过什么、帮完了、下一个。语言不写(用户说的):编辑器工具栏的语言下拉就摆在下面。
*
* 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开看板,
* 所以「帮完了」自动回到这个班的看板,还有人举手就给「下一个」直接去下一个人的题。
* 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开任务页,
* 所以「帮完了」自动回到这个班的任务页,还有人举手就给「下一个」直接去下一个人的题。
*
* 老师在看板上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。
* 老师在任务页上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。
*/
const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore())
@@ -46,7 +46,7 @@ function openHisSubmissions() {
}
/**
* 帮完了:结束这次(求助记录一并清掉),回到这个班的看板。
* 帮完了:结束这次(求助记录一并清掉),回到这个班的任务页。
* 等 room_closed 回来、room 清空了再跳 —— 房间还开着时离开题目页,题目页的拦截
* (detail.vue 的 confirmLeavingHelp)会把跳转改成开新标签
*/
@@ -54,7 +54,7 @@ async function finish() {
const className = room.value?.peerClassName
collabStore.leave('done')
await until(() => collabStore.room).toBe(null, { timeout: 3000 })
router.push({ name: 'classroom board', query: className ? { className } : {} })
router.push({ name: 'task', query: className ? { className } : {} })
}
/**
@@ -86,7 +86,7 @@ const showCollabBar = useTeacherCollab()
*/
const collabHere = useCollabHere()
/**
* 老师在看板上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 ——
* 老师在任务页上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 ——
* 按钮就写「xuyue 在看你的代码」,和「在帮你」一样亮着、点不了
*/
const peeked = computed(() => collabHere.value && collabStore.room?.mode === 'peek' && collabStore.helpStatus === 'idle')
@@ -168,7 +168,7 @@ watch(
)
/**
* 老师在课堂看板布置这节课时选了语言:打开这节课的题,编辑器默认就用它。学 C 的班,
* 老师在课堂任务布置这节课时选了语言:打开这节课的题,编辑器默认就用它。学 C 的班,
* 编辑器停在默认的 Python(或者上一个用这台电脑的人留下的偏好)的话,学生交上去才发现
* 不算数。课堂条只在题库入口出现,这里同一个范围。
*
+1 -1
View File
@@ -174,7 +174,7 @@ function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNorm
window.open(router.resolve(to).href, '_blank')
return false
}
// 老师在看板上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在
// 老师在任务页上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在
const peeked = collabHere.value && collabStore.room?.mode === 'peek'
if (collabStore.isTeacher || (collabStore.helpStatus === 'idle' && !peeked)) return true
if (approvedTarget === to.fullPath) {
@@ -55,7 +55,7 @@ function shortLabel(className: string) {
<div class="battle" :class="{ bare }">
<div class="title">
<b v-if="!bare">班级对抗</b><span class="muted">人均做对</span>
<router-link v-if="pk" :to="{ path: '/class', query: mine ? { classes: mine } : {} }" class="pk">班级 PK ›</router-link>
<router-link v-if="pk" :to="{ path: '/pk', query: mine ? { classes: mine } : {} }" class="pk">班级 PK ›</router-link>
</div>
<div v-for="item in shown" :key="item.className" class="row" :class="{ mine: item.className === mine }">
<span class="rank">{{ item.rank }}</span>
@@ -213,7 +213,7 @@ function go(path: string, query: Record<string, string>) {
<Markdown v-if="report" :content="report" />
</section>
<a v-if="pk" href="#" class="pk" @click.prevent="go('/class', { classes: className! })">和别的班比 → 班级 PK ›</a>
<a v-if="pk" href="#" class="pk" @click.prevent="go('/pk', { classes: className! })">和别的班比 → 班级 PK ›</a>
</div>
<div v-else-if="!loading" class="muted">这个班的数据没取到</div>
</n-spin>
+1 -1
View File
@@ -441,7 +441,7 @@ async function restore(userId: number) {
secondary
@click="
router.push({
path: '/class',
path: '/pk',
query: classContext ? { classes: classContext } : {},
})
"
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Submission } from 'utils/types'
import type { Tone } from 'shared/composables/tone'
/**
* 一次提交一格的那串方块(统计页的 AttemptSquares、课堂看板做对格子的轨迹)共用的两样:
* 一次提交一格的那串方块(统计页的 AttemptSquares、课堂任务做对格子的轨迹)共用的两样:
* 方块的颜色,和停上去看代码时拉的提交。
*/
@@ -143,7 +143,7 @@ function percent(value: number, total: number) {
<div v-if="liveLessons.length" class="live" :style="{ background: tone('success').background, color: tone('success').color }">
<span>{{ liveLessons.map((item) => classLabel(item.className)).join('、') }}正在上课</span>
<span class="spacer"></span>
<a href="#" class="live-link" @click.prevent="router.push('/classroom')"> <Icon icon="ph:monitor" :width="14" />去课堂看板 </a>
<a href="#" class="live-link" @click.prevent="router.push('/task')"> <Icon icon="ph:monitor" :width="14" />去课堂任务 </a>
</div>
<div v-if="!lessonCount && !data.scattered.length" class="empty">这段时间没有人交</div>
@@ -17,7 +17,7 @@ import StatItem from './StatItem.vue'
/**
* 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框
* (老师平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页之类从别处进来的,
* (老师平时从这里看),和 /statistics 页面(课堂任务「回头看」、题目页之类从别处进来的,
* 条件放在地址栏里能刷新、能转发)。
*
* 代码页签按条件分三种看法:
@@ -27,8 +27,8 @@ import StatItem from './StatItem.vue'
* - 选了班或填了学生:左边题目、右边学生(CodeStats)
* 从前两种点进去时左上角有「← 全部」回来。
*
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「一节课」。
* 分工:**课堂任务管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有
* 投影用的大字名单和请假隐藏 —— 那些在任务页上。课堂统计一般看一两节课,时间段默认「一节课」。
*
* 查询条件都是 v-model:页面接地址栏,弹框接提交列表那边的一份本地状态。
*/
@@ -66,7 +66,7 @@ const custom = computed<[number, number] | null>(() => {
})
/**
* 「做完」只数所选时段之内做对的,和看板、后台「课堂作业」、一行一节课的「做完」不一样
* 「做完」只数所选时段之内做对的,和任务页、后台「课堂任务」、一行一节课的「做完」不一样
* (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的
*/
const doneLabel = computed(() => {
@@ -229,7 +229,7 @@ const judged = computed(() => (stats.value ? stats.value.submissionCount - stats
/**
* 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭,
* 1366 的屏上整排放不下,所以弹框里宽一档才出字。
* 原来旁边还有一个「这节课去课堂看板」:顶栏已经有看板,统计里再放一个成了干扰项(用户说的),
* 原来旁边还有一个「这节课去课堂任务」:顶栏已经有任务页,统计里再放一个成了干扰项(用户说的),
* 现在只在有班正在上课时,在课的列表上方引过去
*/
const narrow = useMediaQuery(props.modal ? '(max-width: 1439px)' : '(max-width: 1365px)')
+1 -1
View File
@@ -4,7 +4,7 @@ import StatisticsView from './components/StatisticsView.vue'
import { DEFAULT_PERIOD } from './period'
/**
* 统计的页面形态:课堂看板「回头看」、题目页统计页签这些从别处进来时用。
* 统计的页面形态:课堂任务「回头看」、题目页统计页签这些从别处进来时用。
* 提交列表里是弹框(用户定的),两边是同一个 StatisticsView。
*
* 查询条件都在地址栏里:带过来、刷新、转发给别的老师都还是这一份。
+1 -1
View File
@@ -9,7 +9,7 @@ const DAY = 86_400_000
* 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、
* 默认「一节课」(用户定的);后面几档是回头看。名字后面不写「1 小时内」:选框旁边
* 本来就写着具体几点到几点。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的,
* 那件事现在归课堂看板。
* 那件事现在归课堂任务。
*/
export const PERIOD_OPTIONS = [
{ label: '一节课', value: '1h' },
@@ -30,7 +30,7 @@ import StatusPill from './StatusPill.vue'
* 入口是提交列表右栏的「新页面打开」、统计页的方块、后台卡点分析 —— 多半是老师在看某个学生
* 怎么一次次改对的。
*
* 独立的提交详情页(detail.vue)和课堂看板「讲错误」的弹层共用这一个。`embedded`(弹层里):
* 独立的提交详情页(detail.vue)和课堂任务「讲错误」的弹层共用这一个。`embedded`(弹层里):
* 不出名字和题目那一行(弹层顶上有一排人名),不占 ← →(弹层拿它换人),
* 点「第几次」就地换、不改地址栏。
*/
@@ -4,7 +4,7 @@ import { classLabel } from 'utils/classes'
import type { ClassLesson } from 'utils/types'
import type { SubmissionQuery } from './listQuery'
/** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂看板 */
/** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂任务 */
export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref<boolean>; info: (text: string) => void }) {
const { query } = options
const lesson = ref<ClassLesson | null>(null)
@@ -36,13 +36,13 @@ export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref
}
query.className = res.className
if (!res.problems.length) {
options.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂看板布置`)
options.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂任务布置`)
return
}
lesson.value = res
query.problem = lessonIds.value
query.today = '1'
// 老师布置时选了语言:看板上只数这个语言交的,列表也只列它(流程图那边不动)
// 老师布置时选了语言:任务页上只数这个语言交的,列表也只列它(流程图那边不动)
if (res.language && !options.flowMode.value) query.language = res.language
} finally {
lessonLoading.value = false
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import SubmissionView from './components/SubmissionView.vue'
/** 单独打开的一条提交,主体在 SubmissionView(课堂看板「讲错误」的弹层也用它) */
/** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */
defineProps<{ submissionID: string }>()
</script>
+3 -3
View File
@@ -32,7 +32,7 @@ import { useStatsLink } from './composables/statsLink'
*
* 老师课上的三种用法 —— 一条条翻全班的代码、看某一个学生、看完成情况 —— 前两种在
* 这一页里做完:↑ ↓ 换条,右边跟着换,不再「点开弹框 → 关掉 → 点下一行」。
* 完成情况在课堂看板和「统计」里,这页不重复造一张表。
* 完成情况在课堂任务和「统计」里,这页不重复造一张表。
*/
// 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着
@@ -579,7 +579,7 @@ function dayBreak(index: number) {
<span v-if="userStore.isAuthed" class="vsep"></span>
<!-- 「我的」时这节课 / 班级 / 学生都用不上:后端只看自己时本来就不认它们 -->
<!-- 学生界面没有班级下拉:地址里带着班级(比如从统计页、看板跳过来)就摆一个能点掉的标签,
<!-- 学生界面没有班级下拉:地址里带着班级(比如从统计页、任务页跳过来)就摆一个能点掉的标签,
不然一直被一个看不见的条件筛着 -->
<button v-if="!teacher && query.className" class="chip on" title="点一下去掉班级筛选" @click="query.className = ''">
{{ classLabel(query.className) }}<Icon icon="ph:x-bold" :width="12" />
@@ -605,7 +605,7 @@ function dayBreak(index: number) {
/>
</template>
<!-- 按整名只看一个人(「只看他」、协作条、看板点进来):用户名有八九个字,塞进 108px 的
<!-- 按整名只看一个人(「只看他」、协作条、任务页点进来):用户名有八九个字,塞进 108px 的
输入框只剩半截,还被清除按钮压着 —— 换成一个带名字的标签,点 × 取消 -->
<button v-if="!mine && exactPerson" class="chip on person" :title="`只看 ${query.username}(点一下取消)`" @click="clearPerson">
<Icon icon="ph:user-bold" :width="12" />
@@ -1,11 +1,11 @@
import type { LessonMode } from '@oj2/contract'
import type { Tone } from 'shared/composables/tone'
import { parseTime } from 'utils/functions'
import type { ClassBoardCell, ClassBoardStudent } from 'utils/types'
import type { TaskBoardCell, TaskBoardStudent } from 'utils/types'
/**
* 课堂看板的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
* 和 errorKind.ts 一样单放一个文件(看板组件只管排版和交互)。
* 课堂任务的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
* 和 errorKind.ts 一样单放一个文件(任务页组件只管排版和交互)。
*
* `mode` 是这节课做什么(契约 lessonModeSchema),推断出来的课为 null。先画再写的格子是一条进度:
* 在画 → 图画对了(空心绿「图 ✓」)→ 在写 → 两样都对(实心绿 ✓)。先写了代码、还没画图的写「缺图」
@@ -20,7 +20,7 @@ export const STUCK_ATTEMPTS = 3
*/
export type CellStage = 'draw' | 'code' | 'nodraw'
export function cellStage(cell: ClassBoardCell, mode: LessonMode | null): CellStage {
export function cellStage(cell: TaskBoardCell, mode: LessonMode | null): CellStage {
if (mode === 'flowchart') return 'draw'
if (mode !== 'both' || !cell.flowchart) return 'code'
if (cell.flowchart.status === 'tried') return 'draw'
@@ -29,29 +29,29 @@ export function cellStage(cell: ClassBoardCell, mode: LessonMode | null): CellSt
}
/** 今天在卡着的那一步上交了几次:先画再写的课里,画了三张才画对的人写代码时不该算卡住 */
export function stageAttempts(cell: ClassBoardCell, mode: LessonMode | null) {
export function stageAttempts(cell: TaskBoardCell, mode: LessonMode | null) {
if (mode !== 'both') return cell.attempts
const stage = cellStage(cell, mode)
return stage === 'draw' ? cell.flowchartAttempts : stage === 'code' ? cell.codeAttempts : 0
}
/** 卡住了:没做完,而且在卡着的那一步上今天交了 STUCK_ATTEMPTS 次以上 */
export function isStuck(cell: ClassBoardCell, mode: LessonMode | null) {
export function isStuck(cell: TaskBoardCell, mode: LessonMode | null) {
return cell.status === 'tried' && stageAttempts(cell, mode) >= STUCK_ATTEMPTS
}
/** 先画再写:图画对了、代码还没交过 —— 空心绿的「图 ✓」 */
function halfDone(cell: ClassBoardCell, mode: LessonMode | null) {
function halfDone(cell: TaskBoardCell, mode: LessonMode | null) {
return mode === 'both' && cell.status === 'tried' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none'
}
/** 今天之前就通过了的,颜色淡一档。`day` 是看板的「今天」(东八区日期) */
export function acceptedToday(cell: ClassBoardCell, day: string | undefined) {
/** 今天之前就通过了的,颜色淡一档。`day` 是任务页的「今天」(东八区日期) */
export function acceptedToday(cell: TaskBoardCell, day: string | undefined) {
return !!cell.acceptedAt && parseTime(cell.acceptedAt, 'YYYY-MM-DD') === day
}
/** 最后一张图的那句:评分中 / 评失败 / 走对几组 */
function drawText(cell: ClassBoardCell, prefix: string) {
function drawText(cell: TaskBoardCell, prefix: string) {
const latest = cell.flowchart?.latest
if (!latest) return '✗'
if (latest.status === 0 || latest.status === 1) return '评分中'
@@ -61,7 +61,7 @@ function drawText(cell: ClassBoardCell, prefix: string) {
}
/** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */
export function cellText(cell: ClassBoardCell, mode: LessonMode | null = null) {
export function cellText(cell: TaskBoardCell, mode: LessonMode | null = null) {
if (cell.status === 'accepted') return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : '✓'
if (cell.status === 'tried') {
const stage = cellStage(cell, mode)
@@ -75,7 +75,7 @@ export function cellText(cell: ClassBoardCell, mode: LessonMode | null = null) {
}
export function cellStyle(
cell: ClassBoardCell,
cell: TaskBoardCell,
day: string | undefined,
colors: {
tone: (kind: Tone) => { color: string; background: string }
@@ -102,7 +102,7 @@ export function cellStyle(
return { background: colors.actionColor }
}
export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day: string | undefined, mode: LessonMode | null = null) {
export function cellTitle(cell: TaskBoardCell, student: TaskBoardStudent, day: string | undefined, mode: LessonMode | null = null) {
if (cell.status === 'accepted') {
const when = acceptedToday(cell, day) ? `${parseTime(cell.acceptedAt!, 'HH:mm')} 做对` : '以前就做对了'
if (cell.challenge === 'done') return `${when},挑战也做到了`
@@ -126,7 +126,7 @@ export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day:
}
/** 点开做对的格子,方块串旁边那句 */
export function trailSummary(cell: ClassBoardCell, day: string | undefined) {
export function trailSummary(cell: TaskBoardCell, day: string | undefined) {
const total = cell.trail.length
if (cell.acceptedTry === 1) return total === 1 ? '一次就做对' : `交了 ${total} 次,第 1 次就做对`
if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对`
@@ -19,23 +19,23 @@ import SubmissionView from 'oj/submission/components/SubmissionView.vue'
import UserName from 'shared/components/UserName.vue'
import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
import type { ClassBoardProblem } from 'utils/types'
import type { TaskBoardProblem } from 'utils/types'
import FlowchartExplain from './FlowchartExplain.vue'
import ProblemBrief from './ProblemBrief.vue'
/**
* 课堂看板「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
* 课堂任务「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
* 一个个讲这道题错着的人,所以顶上是这几个人,← → 换人;下面和独立的提交详情页同一块
* (代码占满宽、为什么错在后),只是不出名字那一行、点「第几次」就地换。
*
* 「题目」按钮:演示错误时题目和代码要并排(用户要的),点了左边出题目,再点收起。
* 平时收着,代码照样占满宽。开没开记在这台电脑上,讲下一个人、下一道题还是那样
*
* 手机上(老师拿手机看看板):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮;
* 手机上(老师拿手机看任务页):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮;
* 没有键盘所以不写「← → 换人」;题目展开时叠在代码上面,不并排
*/
const props = defineProps<{
problem: ClassBoardProblem | null
problem: TaskBoardProblem | null
people: ExplainPerson[]
}>()
const show = defineModel<boolean>('show', { required: true })
@@ -43,7 +43,7 @@ const index = defineModel<number>('index', { required: true })
const theme = useThemeVars()
const { isMobile } = useBreakpoints()
// 手机上是整屏的:按返回键关掉它回到看板,不是退出看板
// 手机上是整屏的:按返回键关掉它回到任务页,不是退出任务页
useBackClose(show, isMobile)
/**
* 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面,
@@ -7,7 +7,7 @@ import { caseReport, fitFlowchartGraph, flowchartVerdict, markFlowchartFocus } f
import type { FlowchartSubmission } from 'utils/types'
/**
* 课堂看板「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。
* 课堂任务「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。
* 左边是他最后画的那张(要改的框标红),右边是 AI 写的哪里错、可以怎么改 —— 和提交列表右栏
* (FlowchartPane)、题目页结果页签同一套点评(FlowchartNote)。
*
@@ -1,13 +1,13 @@
<script setup lang="ts">
import type { ClassBoardStudent } from 'utils/types'
import type { TaskBoardStudent } from 'utils/types'
/**
* 看板的「请假」框:勾今天不在的人。规则在看板那边(只有今天还没交过的人能算请假、
* 任务页的「请假」框:勾今天不在的人。规则在任务页那边(只有今天还没交过的人能算请假、
* 交了题或举手就自动算到,见 board.vue 的 canLeave),这里只管勾。
*/
const props = defineProps<{
/** 能勾的人:今天还没交过的(刚上课时就是全班),按名字排好 */
candidates: ClassBoardStudent[]
candidates: TaskBoardStudent[]
/** 打开时已经标着请假的 */
current: string[]
}>()
@@ -23,7 +23,7 @@ watch(show, (open) => {
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
*/
const unseen = (student: ClassBoardStudent | undefined) => !!student && !student.seenAt
const unseen = (student: TaskBoardStudent | undefined) => !!student && !student.seenAt
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
const sections = computed(() =>
@@ -36,7 +36,7 @@ const sections = computed(() =>
].filter((section) => section.students.length),
)
function selectAll(list: ClassBoardStudent[]) {
function selectAll(list: TaskBoardStudent[]) {
selected.value = [...new Set([...selected.value, ...list.map((student) => student.username)])]
}
@@ -3,12 +3,12 @@ import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode }
import { setClassLesson } from 'oj/api'
import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import type { ClassBoard } from 'utils/types'
import type { TaskBoard } from 'utils/types'
import { Icon } from '@iconify/vue'
import ProblemPicker from './ProblemPicker.vue'
/**
* 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
* 任务页上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
* - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条;
* - `start`:刚上课还没有题时,这页最大的那个框。
*
@@ -16,7 +16,7 @@ import ProblemPicker from './ProblemPicker.vue'
* `carry`:新开的这一节带上这一节的题、改成写代码 —— 先布置画流程图、再让全班写代码时用
*/
const props = defineProps<{
board: ClassBoard
board: TaskBoard
className: string
variant: 'inline' | 'start'
lessonId: number | null
@@ -27,7 +27,7 @@ const message = useMessage()
/**
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
* 上一次布置时选的;选了之后看板和学生那边的「做完」都只认这个语言交对的
* 上一次布置时选的;选了之后任务页和学生那边的「做完」都只认这个语言交对的
*/
const lessonLanguage = ref<LessonLanguage>('Python')
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
@@ -109,7 +109,7 @@ function pickerConfirm(ids: string[]) {
</n-button>
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
<n-button @click="emit('cancel')">取消</n-button>
<!-- 弹层挂在 body 下,放在根节点里面:两个根会让看板传进来的 class 落空 -->
<!-- 弹层挂在 body 下,放在根节点里面:两个根会让任务页传进来的 class 落空 -->
<ProblemPicker
v-model:show="picking"
v-model:language="lessonLanguage"
@@ -9,7 +9,7 @@ import type { ProblemRow } from 'utils/types'
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
/**
* 看板布置作业的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
* 任务页布置任务的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
* 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的,
* 要画流程图的只列打开了画流程图的。不用再一个个记题号。
* 点「布置」直接存,和在输入框里敲题号回车是一回事
@@ -23,7 +23,7 @@ const props = defineProps<{
}>()
const show = defineModel<boolean>('show', { required: true })
const language = defineModel<LessonLanguage>('language', { required: true })
/** 这节课做什么(写代码 / 画流程图 / 先画再写),和看板上那个下拉是同一个 */
/** 这节课做什么(写代码 / 画流程图 / 先画再写),和任务页上那个下拉是同一个 */
const lessonMode = defineModel<LessonMode>('lessonMode', { required: true })
const needs = computed(() => lessonNeeds(lessonMode.value))
/** 只画流程图不看语言 */
@@ -6,7 +6,7 @@ import UserName from 'shared/components/UserName.vue'
import { attemptTone, cachedSubmission } from 'oj/statistics/attempts'
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions'
import type { ClassBoardCell, ClassBoardProblem, Submission } from 'utils/types'
import type { TaskBoardCell, TaskBoardProblem, Submission } from 'utils/types'
/**
* 点开一个做对了的格子:他这道题今天交的每一次,一格一次、从早到晚,下面是停着的那一次的代码
@@ -17,8 +17,8 @@ import type { ClassBoardCell, ClassBoardProblem, Submission } from 'utils/types'
*/
const props = defineProps<{
username: string
problem: ClassBoardProblem
cell: ClassBoardCell
problem: TaskBoardProblem
cell: TaskBoardCell
summary: string
/** 「在提交列表看这 N 次」:和格子原来点进去的是同一个列表 */
listHref: string
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui'
import { getClassBoard } from 'oj/api'
import { getTaskBoard } from 'oj/api'
import { useTone } from 'shared/composables/tone'
import { groupClassOptions } from 'utils/classes'
import UserName from 'shared/components/UserName.vue'
@@ -12,7 +12,7 @@ import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions'
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from 'utils/types'
import type { TaskBoard, TaskBoardCell, TaskBoardProblem, TaskBoardStudent } from 'utils/types'
import ExplainModal, { type ExplainPerson } from './components/ExplainModal.vue'
import LeaveModal from './components/LeaveModal.vue'
import AdminLink from 'shared/components/AdminLink.vue'
@@ -21,10 +21,10 @@ import LessonForm from './components/LessonForm.vue'
import TrailPanel from './components/TrailPanel.vue'
import { errorKind, type ErrorKind } from 'utils/errorKind'
import { LESSON_MODE_LABEL } from '@oj2/contract'
import { cellStage, cellStyle as cellStyleOf, cellText as cellTextOf, cellTitle as cellTitleOf, isStuck, trailSummary } from './boardCells'
import { cellStage, cellStyle as cellStyleOf, cellText as cellTextOf, cellTitle as cellTitleOf, isStuck, trailSummary } from './cells'
/**
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
* 课堂任务(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
*
* 用法(用户说的):开在教师电脑上,不投影;看完成情况、谁卡住了,讲没交对的代码错在哪;
* 题号是上课时当场填的。所以:
@@ -49,7 +49,7 @@ const router = useRouter()
const route = useRoute()
const { help, peek } = useTeacherHelp()
const board = ref<ClassBoard | null>(null)
const board = ref<TaskBoard | null>(null)
/**
* 带着 ?className= 进来的(协作条「帮完了」回来、举手面板「讲这道题」)就看那个班,
* 否则第一次由后端猜
@@ -67,7 +67,7 @@ const classOptions = computed(() => {
async function load() {
if (document.hidden) return
try {
const data = await getClassBoard(className.value ?? undefined)
const data = await getTaskBoard(className.value ?? undefined)
board.value = data
// 第一次由后端猜,之后就固定在这个班上,别每次刷新都换
className.value = data.className
@@ -90,7 +90,7 @@ function changeClass(value: string) {
}
onMounted(load)
// 已经停在看板上时又从举手面板点了「讲这道题」:同一条路由只换 query,组件不重建
// 已经停在任务页上时又从举手面板点了「讲这道题」:同一条路由只换 query,组件不重建
watch(
() => route.query,
(query) => {
@@ -125,9 +125,9 @@ const editing = ref<false | 'edit' | 'new' | 'carry'>(false)
const mode = computed(() => board.value?.mode ?? null)
/**
* 一个班一天可以上几节内容不同的课。打开看板(或换到这个班)时,这一节已经布置了这么久,
* 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久,
* 多半是上一节课的 —— 先问这节课做哪几道题,底下能点回去接着看上一节。
* 只在第一次看到这一节时判一次:老师一直开着看板时,15 秒一刷不会把表突然换掉
* 只在第一次看到这一节时判一次:老师一直开着任务页时,15 秒一刷不会把表突然换掉
*/
const STALE_LESSON_MINUTES = 100
const staleLessonId = ref<number | null>(null)
@@ -159,10 +159,10 @@ type Group = 'help' | 'idle' | 'stuck' | 'working' | 'done' | 'leave'
/**
* 正在课堂求助的学生。列表是老师端 WebSocket 实时推的(collabStore,全局常驻连接),
* 不经看板接口 —— 求助只在 serve 进程的内存里,本来就不落库。所以也只看得到连着
* 不经任务页接口 —— 求助只在 serve 进程的内存里,本来就不落库。所以也只看得到连着
* **本站**的学生:服务器和机房各有各的求助队列,和课堂求助本身一样。
*/
function helpOf(student: ClassBoardStudent) {
function helpOf(student: TaskBoardStudent) {
return collabStore.requests.find((request) => request.studentId === student.userId) ?? null
}
@@ -170,7 +170,7 @@ function helpOf(student: ClassBoardStudent) {
* 经常不动手:最近几节课里最多只交过一节。至少有 3 节课可比才下结论,
* 开学头两节谁都一样。没交过的人里把他们排在前面,「最后交」那格写上来过几节
*/
function oftenIdle(student: ClassBoardStudent) {
function oftenIdle(student: TaskBoardStudent) {
const total = board.value?.recentLessons ?? 0
return total >= 3 && student.recentAttended <= 1
}
@@ -179,7 +179,7 @@ function oftenIdle(student: ClassBoardStudent) {
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
*/
function unseen(student: ClassBoardStudent) {
function unseen(student: TaskBoardStudent) {
return !student.seenAt
}
@@ -189,11 +189,11 @@ function seenText(seenAt: string) {
return minutes < 60 ? `${minutes} 分钟前在` : `${Math.floor(minutes / 60)} 小时前在`
}
function doneCount(student: ClassBoardStudent) {
function doneCount(student: TaskBoardStudent) {
return student.cells.filter((cell) => cell.status === 'accepted').length
}
function groupOf(student: ClassBoardStudent): Group {
function groupOf(student: TaskBoardStudent): Group {
if (absent(student)) return 'leave'
if (helpOf(student)) return 'help'
const cells = student.cells
@@ -230,10 +230,10 @@ const GROUP_LABEL: Record<Group, string> = {
* 就是来了(迟到),自动取消标记,回到正常的分组里 —— 老师得看得见他卡住、举手
*/
const { onLeave, setLeave, cancelLeave } = useLeaveStudents('oj_leave_students')
function canLeave(student: ClassBoardStudent) {
function canLeave(student: TaskBoardStudent) {
return !student.lastSubmitAt && !helpOf(student)
}
function absent(student: ClassBoardStudent) {
function absent(student: TaskBoardStudent) {
return canLeave(student) && onLeave(student.username)
}
const boardStudents = computed(() => board.value?.students ?? [])
@@ -255,7 +255,7 @@ function saveLeave(selected: string[]) {
)
}
function byName(a: ClassBoardStudent, b: ClassBoardStudent) {
function byName(a: TaskBoardStudent, b: TaskBoardStudent) {
return a.username.localeCompare(b.username, 'zh-CN')
}
@@ -279,7 +279,7 @@ const groupCount = computed(() => {
return count
})
/**
* 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 classBoardCellSchema.challenge)。
* 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 taskBoardCellSchema.challenge)。
* 做完了又没在挑战的,就是空着的
*/
const challengingCount = computed(
@@ -300,8 +300,8 @@ const groupStarts = computed(() => {
// ---------- 题卡片和「错着的人按错法归堆」 ----------
interface WrongPerson {
student: ClassBoardStudent
cell: ClassBoardCell
student: TaskBoardStudent
cell: TaskBoardCell
kind: ErrorKind
/** 点名字讲的是哪一份:代码(cell.latest)还是最后一张图(cell.flowchart.latest);null = 没有可讲的 */
explain: 'code' | 'draw' | null
@@ -311,7 +311,7 @@ interface WrongPerson {
* 卡在画流程图上的人按最后一张图归堆:一组都没走对的、对了一部分的分开讲;还在评、评失败的、
* 老评法(数不出几组)各一堆
*/
function drawKind(cell: ClassBoardCell): ErrorKind {
function drawKind(cell: TaskBoardCell): ErrorKind {
const latest = cell.flowchart?.latest
if (!latest) return { key: 'draw-none', label: '还没画对', line: null }
if (latest.status === 0 || latest.status === 1) return { key: 'draw-wait', label: 'AI 在评', line: null }
@@ -433,7 +433,7 @@ function openExplain(problemIndex: number, userId: number) {
explainShow.value = true
}
// ---------- 格子(显示规则在 boardCells.ts) ----------
// ---------- 格子(显示规则在 cells.ts) ----------
const cellColors = computed(() => ({
tone,
@@ -441,12 +441,12 @@ const cellColors = computed(() => ({
actionColor: theme.value.actionColor,
successColor: theme.value.successColor,
}))
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value)
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value)
const cellText = (cell: ClassBoardCell) => cellTextOf(cell, mode.value)
const cellStyle = (cell: TaskBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value)
const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value)
const cellText = (cell: TaskBoardCell) => cellTextOf(cell, mode.value)
/** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */
function explainable(cell: ClassBoardCell) {
function explainable(cell: TaskBoardCell) {
if (cell.status !== 'tried') return false
const stage = cellStage(cell, mode.value)
if (stage === 'draw') {
@@ -473,15 +473,15 @@ function trailOutside(event: MouseEvent) {
}
/**
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。
* 点格子 / 名字到提交列表看他今天交的。新标签打开:任务页一般开着不关,别把它顶掉。
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。
*
* 今天只画了流程图、没写代码的,跳到列表的「流程图」那边 —— 不然这节课整班在画流程图时,
* 点进去是一片「没有符合条件的提交」
*/
function submissionsHref(
student: ClassBoardStudent,
problem: ClassBoardProblem | undefined,
student: TaskBoardStudent,
problem: TaskBoardProblem | undefined,
flowchart: boolean,
/** 「以前交过、今天还没交」的人要看以前的,不能只列今天 */
today = true,
@@ -503,7 +503,7 @@ function submissionsHref(
}).href
}
function lastText(student: ClassBoardStudent) {
function lastText(student: TaskBoardStudent) {
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
if (oftenIdle(student)) {
return `近 ${board.value?.recentLessons} 节交过 ${student.recentAttended} 节`
@@ -559,7 +559,7 @@ function lastText(student: ClassBoardStudent) {
</n-button>
<!-- 布置过的每一节课:改错了班、隔天想起来补,去后台 -->
<AdminLink v-if="isDesktop" :to="{ name: 'admin lesson list', query: className ? { className } : {} }">布置过的课</AdminLink>
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
<!-- 任务页只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
<n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })"> 回头看 → </n-button>
</div>
+7 -7
View File
@@ -78,9 +78,9 @@ export const ojs: RouteRecordRaw = {
component: () => import('oj/rank/list.vue'),
},
{
path: 'classroom',
name: 'classroom board',
component: () => import('oj/classroom/board.vue'),
path: 'task',
name: 'task',
component: () => import('oj/task/index.vue'),
meta: { requiresAuth: true, requiresTeacherAdmin: true },
},
{
@@ -90,9 +90,9 @@ export const ojs: RouteRecordRaw = {
meta: { requiresAuth: true, requiresTeacherAdmin: true },
},
{
path: 'class',
name: 'class',
component: () => import('oj/class/pk.vue'),
path: 'pk',
name: 'pk',
component: () => import('oj/pk/index.vue'),
meta: { requiresAuth: true },
},
{
@@ -303,7 +303,7 @@ export const admins: RouteRecordRaw = {
meta: { requiresTeacherAdmin: true },
},
{
// 课堂看板布置的每一节课:改、删、补(数据统计按布置切课)
// 课堂任务布置的每一节课:改、删、补(数据统计按布置切课)
path: 'lesson',
name: 'admin lesson list',
component: () => import('admin/lesson/list.vue'),
+1 -1
View File
@@ -32,7 +32,7 @@ export async function getProfile(username: string = ''): Promise<Profile | null>
return contractDeferred('GET /profiles/:username', () => import('./profileSchema').then((m) => m.userProfileSchema), response)
}
/** 带 language 只数能用这个语言交的题(看板「选题」弹框用) */
/** 带 language 只数能用这个语言交的题(任务页「选题」弹框用) */
/** 课堂布置选题用:language 只数能用它交的题,flowchart 只数打开了画流程图的题 */
export function getProblemTagList(language = '', flowchart = false) {
return api.get<Tag[]>('problem-tags', { params: { language, ...(flowchart ? { flowchart: '1' } : {}) } })
@@ -39,7 +39,7 @@ watch(
)
/**
* 老师这边房间开好了(「帮他」、看板上点「在写」、协作条的「下一个」):跳到那道题。
* 老师这边房间开好了(「帮他」、任务页上点「在写」、协作条的「下一个」):跳到那道题。
* 等 room_open 到了才跳,不在点的那一刻跳 —— 服务端可能拒了(别的老师先接走、学生刚走),
* 那时候跳过去就是对着一个空编辑器。已经在这道题上就不跳(同一道题换下一个人)
*/
+7 -7
View File
@@ -23,7 +23,7 @@ const { isDark, toggleDark } = useDarkTransition()
const theme = useThemeVars()
/**
* 1280 以下(老师的笔记本)收一收:题号框变窄、「课堂看板」只留图标。
* 1280 以下(老师的笔记本)收一收:题号框变窄、「课堂任务」只留图标。
* 机房的两种屏是 1280 和 1366,都走完整那版 —— 老师也会用机房的电脑。
*/
const compact = useMediaQuery('(max-width: 1279px)')
@@ -142,7 +142,7 @@ function renderThemeRow() {
/**
* 个人菜单只放「自己的东西」+ 后台入口 + 暗色开关。
* 课堂求助、课堂看板挪到了顶栏上(HelpButton、下面的课堂看板链接),不在这里。
* 课堂求助、课堂任务挪到了顶栏上(HelpButton、下面的课堂任务链接),不在这里。
*/
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
const work = userStore.isAdminRole || userStore.canToggleDemoMode
@@ -220,13 +220,13 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
<n-button
quaternary
class="tool-link"
:type="active === 'classroom' ? 'primary' : 'default'"
title="课堂看板"
aria-label="课堂看板"
@click="router.push('/classroom')"
:type="active === 'task' ? 'primary' : 'default'"
title="课堂任务"
aria-label="课堂任务"
@click="router.push('/task')"
>
<template #icon><Icon icon="ph:monitor" /></template>
<template v-if="!compact">课堂看板</template>
<template v-if="!compact">课堂任务</template>
</n-button>
<span class="divider"></span>
</template>
@@ -10,8 +10,8 @@ import HelpRequestList from './HelpRequestList.vue'
* 按钮闪一下(约 1 秒、没声音、不挡内容),代替原来每来一个就弹一条的 toast:
* 那条 toast 一节课能弹十几次,讲台电脑投着屏的时候还弹在全班面前。
*
* 点开是挂在按钮下面的举手面板(HelpRequestList),不是弹框。课堂看板的表里举手的人
* 排最前、行里就有「帮他」,在看板上不用点这里。
* 点开是挂在按钮下面的举手面板(HelpRequestList),不是弹框。课堂任务的表里举手的人
* 排最前、行里就有「帮他」,在任务页上不用点这里。
*/
const props = withDefaults(defineProps<{ size?: 'medium' | 'small' }>(), { size: 'medium' })
@@ -11,8 +11,8 @@ import UserName from './UserName.vue'
/**
* 顶栏「N 人举手」点开的面板(设计稿「举手与协作」HcPanel),挂在按钮下面,不是弹框。
*
* 按题分组,每人写错法(和课堂看板同一套 errorKind)和等了多久。同一道题两人以上举手,
* 用户的做法是**停下来全班讲**:组头给「讲这道题」(去看板、选中这道题看错法)和
* 按题分组,每人写错法(和课堂任务同一套 errorKind)和等了多久。同一道题两人以上举手,
* 用户的做法是**停下来全班讲**:组头给「讲这道题」(去任务页、选中这道题看错法)和
* 「讲完了,都放下」(这道题还在排队的手一次放下)。
*/
const emit = defineEmits<{ close: [] }>()
@@ -48,12 +48,12 @@ function onHelp(item: CollabRequestItem) {
if (help(item.studentId)) emit('close')
}
/** 「讲这道题」:去这个班的看板、选中这道题,下面展开错着的人按错法归堆,点人就是讲错误弹层 */
/** 「讲这道题」:去这个班的任务页、选中这道题,下面展开错着的人按错法归堆,点人就是讲错误弹层 */
function explain(items: CollabRequestItem[], problemId: string) {
const className = items.find((item) => item.className)?.className
emit('close')
router.push({
name: 'classroom board',
name: 'task',
query: { ...(className ? { className } : {}), problem: problemId },
})
}
@@ -191,7 +191,7 @@ watch(
)
/**
* 学生开着这道题的编辑器:报给服务端,老师看板「在写」那一列就是它,点了能直接打开他的
* 学生开着这道题的编辑器:报给服务端,老师任务页「在写」那一列就是它,点了能直接打开他的
* 代码(不用等他举手)。换题、换语言重报一次,编辑器卸载(切走、切成流程图)报 null。
* 比赛入口不报(collab = false):比赛题号和公开题撞号,老师打开的会是另一道题
*/
@@ -2,7 +2,7 @@ import { onMounted, ref } from 'vue'
import { parseTime } from 'utils/functions'
/**
* 课堂看板的「请假」:老师手动标记今天不在的学生,看板的人数、进度、「还没交过」都不算他们。
* 课堂任务的「请假」:老师手动标记今天不在的学生,任务页的人数、进度、「还没交过」都不算他们。
*
* **今天有效**(按东八区的日子),第二天自动清掉 —— 请假一般是请一整天,原来按两小时算,
* 连上两节课的第二节中途人又冒出来,得重标一遍。
@@ -11,7 +11,7 @@ import { parseTime } from 'utils/functions'
* 换个人、换台机器都不该继承(2026-10 用户定的,不上服务器)。
*
* 原来叫「请假隐藏」(开关 → 名字变成带 × 的标签 → 点 ×),用户说不会用,2026-10 改成
* 看板上一个「请假」按钮勾名单。旧键 `oj_hidden_students` 存的是两小时的过期时刻,
* 任务页上一个「请假」按钮勾名单。旧键 `oj_hidden_students` 存的是两小时的过期时刻,
* 口径不同,不迁移,直接删掉。
*
* @param storageKey localStorage 的键
@@ -36,7 +36,7 @@ export function useLeaveStudents(storageKey: string) {
try {
localStorage.setItem(storageKey, JSON.stringify(data))
} catch {
// 存不下就只在这次打开里有效,不影响看板
// 存不下就只在这次打开里有效,不影响任务页
}
}
@@ -48,7 +48,7 @@ export function useLeaveStudents(storageKey: string) {
/**
* 把 `usernames` 这批人的标记换成 `selected`:在里面但没选的取消,选了的标上。
* 只动这一批(看板上这个班),别的班标着的人不受影响
* 只动这一批(任务页上这个班),别的班标着的人不受影响
*/
function setLeave(usernames: string[], selected: string[]) {
const scope = new Set(usernames)
@@ -1,7 +1,7 @@
import { useCollabStore } from 'shared/store/collab'
/**
* 老师接单 / 主动打开学生的编辑器。两处入口(顶栏举手面板、课堂看板)共用。
* 老师接单 / 主动打开学生的编辑器。两处入口(顶栏举手面板、课堂任务)共用。
*
* 只发消息,不跳页面:房间开好(room_open 到了)才跳到那道题,跳转统一在 CollabHost 里做
* —— 原来是点完就跳,服务端拒了(别的老师先接走、学生刚走)前端也已经跳过去了。
+3 -3
View File
@@ -7,9 +7,9 @@ import { useUserStore } from 'shared/store/user'
import { getAccessToken } from 'utils/auth'
import { STORAGE_KEY } from 'utils/constants'
import storage from 'utils/storage'
import type { ClassBoardCell } from 'utils/types'
import type { TaskBoardCell } from 'utils/types'
type BoardLatest = NonNullable<ClassBoardCell['latest']>
type BoardLatest = NonNullable<TaskBoardCell['latest']>
// 全站唯一一处,脱离 n-message-provider 也能弹 —— 强制登出跟当前挂着哪个组件无关。
// utils/api.ts 里也是这么做的
@@ -580,7 +580,7 @@ export interface CollabRequestItem {
className: string | null
problemId: string
problemTitle: string
/** 他这道题最后一次代码提交(和看板格子的 latest 同形),面板按它写错法;没交过为 null */
/** 他这道题最后一次代码提交(和任务页格子的 latest 同形),面板按它写错法;没交过为 null */
latest: BoardLatest | null
createdAt: number
status: 'pending' | 'active'
+1 -1
View File
@@ -67,7 +67,7 @@ const NAV: NavGroup[] = [
to: '/admin/problemset/list',
role: 'teacher',
},
{ label: '课堂作业', icon: 'ph:chalkboard-teacher', path: '/admin/lesson', role: 'teacher' },
{ label: '课堂任务', icon: 'ph:chalkboard-teacher', path: '/admin/lesson', role: 'teacher' },
{
label: '教程',
icon: 'ph:book-open',
+5 -5
View File
@@ -6,13 +6,13 @@ export type HelpStatus = 'idle' | 'pending' | 'active'
export interface RoomInfo {
/**
* help = 学生举手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。
* help = 学生举手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。
* 学生那边按钮写「在帮你」还是「在看你的代码」、结束时说「帮完了」还是「看完了」看它
*/
mode: 'help' | 'peek'
peerName: string
peerRole: 'student' | 'teacher'
/** 教师端才有:学生的 id(协作条按它在列表里找他举手多久了)和班级(「帮完了」回哪个班的看板) */
/** 教师端才有:学生的 id(协作条按它在列表里找他举手多久了)和班级(「帮完了」回哪个班的任务页) */
peerId: number | null
peerClassName: string | null
problemId: string
@@ -107,7 +107,7 @@ export const useCollabStore = defineStore('collab', () => {
// 自己离开了这道题(跳走页面、把语言切成流程图)
case 'self_left':
return teacher ? '你已离开这道题,求助退回队列了' : '你已离开这道题,协作结束'
// done:老师自己点的「帮完了」,页面跟着回看板或者去下一个,不用再说一句
// done:老师自己点的「帮完了」,页面跟着回任务页或者去下一个,不用再说一句
default:
return teacher ? '' : '老师已结束这次帮忙'
}
@@ -231,7 +231,7 @@ export const useCollabStore = defineStore('collab', () => {
ws.send({ type: 'accept', studentId })
}
/** 老师在看板上主动打开一个学生正在写的代码(他没举手也行;举着手就等于接单) */
/** 老师在任务页上主动打开一个学生正在写的代码(他没举手也行;举着手就等于接单) */
function peek(studentId: number) {
ws.send({ type: 'peek', studentId })
}
@@ -242,7 +242,7 @@ export const useCollabStore = defineStore('collab', () => {
}
/**
* 学生报告他开着哪道题的编辑器(null = 离开了),老师看板「在写」那一列用。
* 学生报告他开着哪道题的编辑器(null = 离开了),老师任务页「在写」那一列用。
* 老师不报:老师的编辑器不会被别人打开
*/
function reportPresence(problemId: string | null, language: LANGUAGE = 'Python') {
+1 -1
View File
@@ -8,7 +8,7 @@ import type { ClassActivity, ClassActivityProblem } from 'utils/types'
const STALE_MS = 2 * 60 * 1000
/**
* 「这节课的题」:首页「班里在做」那几道(老师在课堂看板布置的,或者从同班提交推断的)。
* 「这节课的题」:首页「班里在做」那几道(老师在课堂任务布置的,或者从同班提交推断的)。
*
* 题目页有两处要用:顶部的课堂条(做完几道、每道的状态),和通过之后的「下一题」。
* 原来「下一题」每次通过都自己拉一遍,现在两处读同一份,通过之后由判题那边
+1 -1
View File
@@ -19,7 +19,7 @@ export const useUserStore = defineStore('user', () => {
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏。
// 开关存在 storage 里、只有主动退登才清,会话过期后换人登录它还在 —— 所以只对超管
// 生效。不然老师在超管用过的机房电脑上一登录,后台、看板、求助全没了,
// 生效。不然老师在超管用过的机房电脑上一登录,后台、任务页、求助全没了,
// 而「退出演示」只给超管,他自己关不掉
const demoSwitch = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
const demoMode = computed(() => demoSwitch.value && realIsSuperAdmin.value)
+3 -3
View File
@@ -1,15 +1,15 @@
import { classifyDiff, type DiffKind } from 'utils/wrongAnswer'
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
import type { ClassBoardCell } from 'utils/types'
import type { TaskBoardCell } from 'utils/types'
/**
* 课堂看板:一道题错着的人「按错法归堆」(设计稿「课堂看板重设计」A+B)。老师讲错误时
* 课堂任务:一道题错着的人「按错法归堆」(设计稿「课堂看板重设计」A+B)。老师讲错误时
* 要的是「这道题全班错在哪」:同一种错讲一遍就够,所以按最后一次提交归类,堆名要短,
* 一眼能读完 —— 长的中文解释在点开之后的弹层里(和提交详情同一份)。
*
* 依据是提交里存的 runtime_error / sample_check(判题机写的),和提交详情、题目页结果区同源。
*/
export type BoardLatest = NonNullable<ClassBoardCell['latest']>
export type BoardLatest = NonNullable<TaskBoardCell['latest']>
export interface ErrorKind {
/** 归堆用:同一个 key 是同一种错 */
+2 -2
View File
@@ -251,8 +251,8 @@ export type { WeeklyRank, WeeklyRankItem } from '@oj2/contract'
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem, LastVisit } from '@oj2/contract'
/** 课堂看板:一个班今天这节课的题 × 全班学生 */
export type { ClassBoard, ClassBoardProblem, ClassBoardStudent, ClassBoardCell, ClassLesson } from '@oj2/contract'
/** 课堂任务:一个班今天这节课的题 × 全班学生 */
export type { TaskBoard, TaskBoardProblem, TaskBoardStudent, TaskBoardCell, ClassLesson } from '@oj2/contract'
/** 知识点地图:每个知识点做对几道、在第几档,只给自己看 */
export type { KnowledgeLevel, KnowledgeMap } from '@oj2/contract'