Files
OJ2/apps/web/src/oj/statistics/components/Lessons.vue
T
xuyueandClaude Opus 5.5 29c31640b5 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>
2026-11-02 22:53:07 +08:00

563 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { LESSON_MODE_LABEL } from '@oj2/contract'
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui'
import type { SubmissionLessons } from '@oj2/contract'
import { parseTime, zonedParts } from 'utils/functions'
import { useTone } from 'shared/composables/tone'
import { classLabel } from 'utils/classes'
import { resultName, useResultColor } from '../results'
type Lesson = SubmissionLessons['lessons'][number]
type Scattered = SubmissionLessons['scattered'][number]
/**
* 统计「一行一节课」的总览(设计稿「统计合二为一」B)。没选班、没填学生和题号时用它:
* 原来「今日统计」老师那一版(今天哪几个班上了课、几点到几点、错得最多的题)并进来,
* 换成任意时间段。按天分组,从近到远 —— 这节课在最上面;点一行进那节课(班、题、时段都带上)。
* 课怎么切的在后端(契约 submissionLessonsSchema)。
*/
const props = defineProps<{
data: SubmissionLessons
/** 时间段落在今天以内:每行多一根 7–20 点的横条(原来今日统计的时间轴) */
timeline: boolean
/** 「再往前看」在加载 */
loadingMore: boolean
}>()
const emit = defineEmits<{
pick: [lesson: Lesson]
/** 点某天的零散提交:那个班(或没填班级的)那一天 */
scattered: [item: Scattered]
/** 点「错得最多的题」里的一道:只看这道(按班级汇总) */
problem: [displayId: string]
more: []
}>()
const router = useRouter()
const theme = useThemeVars()
const tone = useTone()
const resultColor = useResultColor()
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function dayText(day: string) {
const [y, m, d] = day.split('-').map(Number) as [number, number, number]
const weekday = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]
const today = parseTime(new Date(), 'YYYY-MM-DD')
const yesterday = parseTime(new Date(Date.now() - 86_400_000), 'YYYY-MM-DD')
const date = `${m}月${d}日 ${weekday}`
if (day === today) return `今天 · ${date}`
if (day === yesterday) return `昨天 · ${date}`
return y === Number(today.slice(0, 4)) ? date : `${y}年${date}`
}
/** 按天分组,天和天里的课都是从近到远,零散提交挂在对应那天下面 */
const days = computed(() => {
const map = new Map<string, Lesson[]>()
for (const lesson of props.data.lessons) {
map.set(lesson.day, [...(map.get(lesson.day) ?? []), lesson])
}
const scatteredByDay = new Map<string, Scattered[]>()
for (const item of props.data.scattered) {
scatteredByDay.set(item.day, [...(scatteredByDay.get(item.day) ?? []), item])
if (!map.has(item.day)) map.set(item.day, [])
}
return [...map.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([day, lessons]) => ({
day,
label: dayText(day),
// 最近的一节在最上面(用户说的):打开就看到这节课,往下是更早的
lessons: [...lessons].sort((a, b) => b.start.localeCompare(a.start)),
scattered: scatteredByDay.get(day) ?? [],
}))
})
const lessonCount = computed(() => props.data.lessons.length)
const liveLessons = computed(() => props.data.lessons.filter((lesson) => lesson.live))
function clock(lesson: Lesson) {
return `${parseTime(lesson.start, 'HH:mm')}–${parseTime(lesson.end, 'HH:mm')}`
}
/**
* 布置的课知道要做什么,写全班几人做完;推断的课只知道谁交了,写来了几人。
* 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」
*/
function attendance(lesson: Lesson) {
if (lesson.doneCount !== null) return lesson.classSize ? `做完 ${lesson.doneCount} / ${lesson.classSize}` : `做完 ${lesson.doneCount} 人`
return lesson.classSize >= lesson.userCount ? `${lesson.userCount} / ${lesson.classSize} 人` : `${lesson.userCount} 人`
}
/** 要画流程图的课写出来:这一行的次数和正确率连流程图一起数(画到 A / S 算对) */
function problemText(lesson: Lesson) {
if (lesson.assigned) return `布置 ${lesson.problems.length} 道${lesson.mode && lesson.mode !== 'code' ? ` · ${LESSON_MODE_LABEL[lesson.mode]}` : ''}`
return `${lesson.fromProblemSet ? '题单 ' : ''}${lesson.problems.length} 道`
}
// ---------- 时间轴(只在今天以内) ----------
function hourOf(value: string) {
const p = zonedParts(value)!
return p.hour + p.minute / 60
}
/** 横轴默认 7 点到 20 点(机房上课的时段),有更早更晚的课就往外扩 */
const axis = computed(() => {
const list = props.data.lessons
const start = Math.min(7, ...list.map((item) => Math.floor(hourOf(item.start))))
const end = Math.max(20, ...list.map((item) => Math.ceil(hourOf(item.end))))
const ticks = []
for (let hour = start + (start % 2); hour <= end; hour += 2) ticks.push(hour)
return { start, end, ticks }
})
function axisLeft(hour: number) {
const { start, end } = axis.value
return `${((hour - start) / (end - start)) * 100}%`
}
function barStyle(lesson: Lesson) {
const from = hourOf(lesson.start)
const to = hourOf(lesson.end)
const { start, end } = axis.value
return {
left: axisLeft(from),
width: `max(6px, ${((to - from) / (end - start)) * 100}%)`,
background: lesson.live ? theme.value.successColor : '#3f5f87',
}
}
const nowHour = computed(() => {
const hour = hourOf(new Date().toISOString())
return hour >= axis.value.start && hour <= axis.value.end ? hour : null
})
function percent(value: number, total: number) {
return total ? `${(value / total) * 100}%` : '0'
}
</script>
<template>
<div class="lessons">
<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('/task')"> <Icon icon="ph:monitor" :width="14" />去课堂任务 </a>
</div>
<div v-if="!lessonCount && !data.scattered.length" class="empty">这段时间没有人交</div>
<div v-else-if="!lessonCount" class="note">这段时间没有哪个班一起上课(同一个班 5 人以上做同一道题才算一节课),只有零散提交</div>
<template v-for="group in days" :key="group.day">
<div class="day">{{ group.label }}</div>
<a v-for="lesson in group.lessons" :key="`${lesson.className}|${lesson.start}`" href="#" class="row" @click.prevent="emit('pick', lesson)">
<span class="c-time">{{ clock(lesson) }}</span>
<span v-if="timeline" class="c-axis">
<span v-for="tick in axis.ticks" :key="tick" class="grid" :style="{ left: axisLeft(tick) }"></span>
<span class="bar" :style="barStyle(lesson)"></span>
<span v-if="nowHour !== null" class="now" :style="{ left: axisLeft(nowHour) }"></span>
</span>
<b class="c-class">{{ classLabel(lesson.className) }}</b>
<span class="c-came" :title="lesson.assigned ? `交过的 ${lesson.userCount} 人` : undefined">{{ attendance(lesson) }}</span>
<span class="c-probs">{{ problemText(lesson) }}</span>
<span class="c-rate">
{{ lesson.total }} 次 ·
{{ lesson.live ? '上课中' : `正确率 ${Math.round(lesson.correctRate)}%` }}
</span>
<span class="c-hard">
<template v-if="lesson.hardest">
<n-text depth="3">错得最多</n-text>
<n-text depth="3" class="pid">{{ lesson.hardest.problemDisplayId }}</n-text>
{{ lesson.hardest.problemTitle }}
<span :style="{ color: tone('warning').color }">· 没过 {{ lesson.hardest.failed }} 次</span>
</template>
</span>
<span class="c-go">看这节课<Icon icon="ph:caret-right-bold" :width="12" /></span>
</a>
<div v-if="group.scattered.length" class="scattered">
{{ group.lessons.length ? '另有零散提交:' : '零散提交:' }}
<template v-for="(item, index) in group.scattered" :key="item.className ?? ''">
<template v-if="index"> · </template>
<a href="#" title="看这个班这一天的提交" @click.prevent="emit('scattered', item)"
>{{ classLabel(item.className) }} {{ item.userCount }} 人 {{ item.total }} 次</a
>
</template>
</div>
</template>
<div v-if="timeline && lessonCount" class="axis-row">
<span class="c-time"></span>
<span class="c-axis axis">
<!-- 挨着「现在」的刻度不写,不然两个字叠在一起 -->
<template v-for="tick in axis.ticks" :key="tick">
<span v-if="nowHour === null || Math.abs(tick - nowHour) > 0.8" :style="{ left: axisLeft(tick) }">
{{ tick }}
</span>
</template>
<span v-if="nowHour !== null" class="now-label" :style="{ left: axisLeft(nowHour) }"> 现在 </span>
</span>
</div>
<div v-if="data.hasMore" class="more">
<n-button size="small" :loading="loadingMore" @click="emit('more')"> 更早的课(现在列了最近 {{ lessonCount }} 节) </n-button>
</div>
<template v-if="data.hardProblems.length">
<div class="section-title">
<b>错得最多的题</b>
<n-text depth="3" class="small">按没过的次数 · 点题目看各班做得怎样</n-text>
</div>
<table class="hard">
<thead>
<tr>
<th>题目</th>
<th>哪个班</th>
<th>提交</th>
<th>没过的原因</th>
<th class="right">做对</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data.hardProblems" :key="item.problemDisplayId">
<td class="hard-title">
<a href="#" @click.prevent="emit('problem', item.problemDisplayId)">
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.problemTitle }}
</a>
</td>
<td>
<n-text depth="2">{{ classLabel(item.className) }}</n-text>
</td>
<td>{{ item.total }} 次</td>
<td>
<span class="mini">
<span :style="{ width: percent(item.accepted, item.total), background: resultColor(0) }"></span>
<span
v-for="fail in item.failures"
:key="fail.result"
:style="{
width: percent(fail.count, item.total),
background: resultColor(fail.result),
}"
></span>
</span>
<n-text v-if="item.failures[0]" depth="2" class="small"> {{ resultName(item.failures[0].result) }} {{ item.failures[0].count }} </n-text>
</td>
<td
class="right"
:style="{
color: item.acceptedUsers < item.userCount ? tone('error').color : undefined,
fontWeight: item.acceptedUsers < item.userCount ? 600 : undefined,
}"
>
{{ item.acceptedUsers }} / {{ item.userCount }} 人
</td>
</tr>
</tbody>
</table>
</template>
</div>
</template>
<style scoped>
.lessons {
flex: 1;
min-height: 0;
overflow: auto;
padding-bottom: 20px;
font-size: var(--oj-fs-body);
}
.live {
display: flex;
align-items: center;
margin: 12px 20px 4px;
padding: 8px 14px;
border-radius: 8px;
}
/* 横幅本身就是绿的,字再绿也看不出能点,靠下划线 */
.live-link {
color: inherit;
font-weight: 600;
text-decoration: underline;
text-underline-offset: 3px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.spacer {
flex: 1 1 0;
}
.empty {
padding: 48px 0;
text-align: center;
color: v-bind('theme.textColor3');
}
.note {
padding: 20px var(--oj-pad-x) 6px;
font-size: var(--oj-fs-sec);
color: v-bind('theme.textColor3');
}
.day {
position: sticky;
top: 0;
z-index: 1;
height: 36px;
display: flex;
align-items: center;
padding: 0 var(--oj-pad-x);
font-size: var(--oj-fs-meta);
font-weight: 600;
color: v-bind('theme.textColor3');
background: v-bind('theme.actionColor');
border-bottom: 1px solid v-bind('theme.dividerColor');
}
.row {
height: 52px;
display: flex;
align-items: center;
padding: 0 var(--oj-pad-x);
border-bottom: 1px solid v-bind('theme.dividerColor');
color: v-bind('theme.textColor1');
text-decoration: none;
white-space: nowrap;
}
.row:hover {
background: v-bind('theme.hoverColor');
}
.c-time {
width: 110px;
flex: none;
color: v-bind('theme.textColor2');
font-variant-numeric: tabular-nums;
}
.c-axis {
position: relative;
width: 200px;
height: 28px;
flex: none;
margin-right: 20px;
}
.c-class {
width: 120px;
flex: none;
}
.c-came,
.c-probs {
width: 96px;
flex: none;
color: v-bind('theme.textColor2');
}
.c-rate {
width: 170px;
flex: none;
color: v-bind('theme.textColor2');
}
.c-hard {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--oj-fs-sec);
}
.pid {
margin-left: 10px;
}
.c-go {
flex: none;
margin-left: 12px;
display: inline-flex;
align-items: center;
gap: 2px;
font-size: var(--oj-fs-sec);
color: v-bind('theme.primaryColor');
}
.grid {
position: absolute;
top: 4px;
bottom: 4px;
width: 1px;
background: v-bind('theme.dividerColor');
}
.bar {
position: absolute;
top: 50%;
height: 12px;
margin-top: -6px;
border-radius: 3px;
}
.now {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: v-bind('theme.successColor');
}
.axis-row {
display: flex;
padding: 0 var(--oj-pad-x);
height: 20px;
}
.axis-row .c-axis {
height: 20px;
}
.axis span {
position: absolute;
top: 3px;
transform: translateX(-50%);
white-space: nowrap;
font-size: 12px;
color: v-bind('theme.textColor3');
}
.axis .now-label {
font-weight: 600;
color: v-bind('theme.successColor');
background: v-bind('theme.cardColor');
padding: 0 2px;
}
.scattered {
padding: 8px var(--oj-pad-x);
font-size: var(--oj-fs-meta);
color: v-bind('theme.textColor3');
border-bottom: 1px solid v-bind('theme.dividerColor');
}
.scattered a {
color: v-bind('theme.textColor2');
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.scattered a:hover {
color: v-bind('theme.primaryColor');
}
.more {
padding: 14px var(--oj-pad-x) 0;
}
.section-title {
display: flex;
align-items: baseline;
gap: 10px;
padding: 22px var(--oj-pad-x) 8px;
}
.small {
font-size: var(--oj-fs-sec);
}
.hard {
width: calc(100% - 40px);
margin: 0 20px;
border-collapse: collapse;
font-size: var(--oj-fs-sec);
}
.hard th {
text-align: left;
font-weight: normal;
color: v-bind('theme.textColor3');
padding: 4px 8px 6px 0;
border-bottom: 1px solid v-bind('theme.dividerColor');
}
.hard td {
padding: 8px 8px 8px 0;
border-bottom: 1px solid v-bind('theme.dividerColor');
white-space: nowrap;
}
.hard .right {
text-align: right;
padding-right: 0;
}
.hard-title a {
color: v-bind('theme.primaryColor');
text-decoration: none;
}
.hard-title a:hover {
text-decoration: underline;
}
.mini {
display: inline-flex;
width: 70px;
height: 8px;
border-radius: 2px;
overflow: hidden;
gap: 1px;
margin-right: 8px;
vertical-align: middle;
}
/* 1366 以下(机房 1280):横条窄一点,给「错得最多」留地方 */
@media (max-width: 1365px) {
.c-axis {
width: 150px;
margin-right: 14px;
}
.c-rate {
width: 150px;
}
}
/* 手机:老师很少在手机上看统计,只保证不坏 —— 一行折成两行 */
@media (max-width: 767px) {
.row {
height: auto;
flex-wrap: wrap;
row-gap: 4px;
padding: 8px 14px;
white-space: normal;
}
.c-axis,
.axis-row {
display: none;
}
.c-hard {
flex-basis: 100%;
}
.hard {
display: block;
overflow-x: auto;
}
/* 表横着滑,表头别被挤成竖排(「提 / 交」) */
.hard th {
white-space: nowrap;
}
.section-title {
flex-wrap: wrap;
row-gap: 2px;
}
.section-title b {
flex: none;
}
}
</style>