Files
OJ2/apps/web/src/oj/home/dashboard.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

618 lines
23 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 { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } from 'oj/learn/lesson'
import { Icon } from '@iconify/vue'
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
import { getClassActivity } from 'shared/api'
import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue'
import { pickHeadline, type Headline } from 'oj/user/knowledge'
import { useUserStore } from 'shared/store/user'
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'
interface Track {
type: TutorialType
label: string
/** 接着学的那一课,1 起 */
step: number
title: string
total: number
/** 学完的课数(练一练全对) */
done: number
lastViewedAt: string | null
}
const router = useRouter()
const userStore = useUserStore()
const tracks = ref<Track[]>([])
const contests = ref<Contest[]>([])
const submissions = ref<SubmissionListItem[]>([])
const announcements = ref<AnnouncementListItem[]>([])
const weekly = ref<WeeklyRank | null>(null)
const classActivity = ref<ClassActivity | null>(null)
const lastVisit = ref<LastVisit | null>(null)
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
const knowledgeHeadline = ref<Headline | null>(null)
const loaded = ref(false)
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
const firstVisit = computed(() => !!lastVisit.value && !lastVisit.value.previousLogin && (userStore.profile?.submissionNumber ?? 0) === 0)
/** 离上次来多少天。超过 30 天后端不给「上次来」卡,这里只在问候里说一句 */
const awayDays = computed(() => {
const previous = lastVisit.value?.previousLogin
if (!previous) return 0
return Math.floor((Date.now() - new Date(previous).getTime()) / 864e5)
})
const longAway = computed(() => awayDays.value > 30)
const awayText = computed(() => {
const previous = lastVisit.value?.previousLogin
if (!previous) return ''
const days = awayDays.value
const gap = days >= 365 ? `隔了 ${Math.floor(days / 365)} 年多` : days >= 60 ? `隔了 ${Math.floor(days / 30)} 个月` : `隔了 ${days} 天`
return `上次来是 ${dateText(previous)},${gap}`
})
const greeting = computed(() => {
if (longAway.value) return '好久不见'
const hour = zonedParts(new Date())!.hour
if (hour < 5) return '夜深了'
if (hour < 11) return '早上好'
if (hour < 13) return '中午好'
if (hour < 18) return '下午好'
return '晚上好'
})
const weeklyText = computed(() => {
const me = weekly.value?.me
if (!weekly.value) return ''
const where = weekly.value.scope === 'class' ? '班里' : '全站'
if (!me) return '本周还没有新通过的题,做出 1 题就能上进步榜'
return `本周新通过 ${me.solvedCount} 题,${where}第 ${me.rank} 名`
})
/**
* 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/lesson.ts),不是本机记的:
* 机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
*/
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
const [titles, rows] = await Promise.all([getTutorials(type), getLearnProgress(type)])
if (!titles.length) return null
const progress = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
const step = continueStep(titles, progress) ?? 1
const lastViewedAt = rows.reduce<string | null>((acc, row) => (row.lastViewedAt && (!acc || row.lastViewedAt > acc) ? row.lastViewedAt : acc), null)
return {
type,
label,
step,
title: splitTitle(titles[step - 1]!.title).kp,
total: titles.length,
done: doneCount(titles, progress),
lastViewedAt,
}
}
async function loadTracks() {
const res = await Promise.all([loadTrack('python', 'Python'), loadTrack('c', 'C 语言')])
// 最近在学的那门排前面;两门都没碰过就按原顺序
tracks.value = res.filter((t): t is Track => !!t).sort((a, b) => (b.lastViewedAt ?? '').localeCompare(a.lastViewedAt ?? ''))
}
async function loadContests() {
const query = { offset: 0, limit: 5, keyword: '', tag: '' }
const [underway, upcoming] = await Promise.all([
getContestList({ ...query, status: ContestStatus.underway }),
getContestList({ ...query, status: ContestStatus.not_started }),
])
contests.value = [...underway.results, ...upcoming.results].slice(0, 5)
}
async function loadSubmissions() {
const res = await getSubmissions({ myself: '1', offset: 0, limit: 6, language: '' })
submissions.value = res.results
}
/** 首页放前三条公告(后端置顶在前、再按发布时间),其余从顶栏「公告」进 */
async function loadAnnouncements() {
const res = await getAnnouncementList(0, 3)
announcements.value = res.results
}
async function loadKnowledge() {
knowledgeHeadline.value = pickHeadline(await getKnowledgeMap())
}
async function loadClassActivity() {
classActivity.value = await getClassActivity()
}
async function loadLastVisit() {
lastVisit.value = await getLastVisit()
}
/** 东八区的 YYYY-MM-DD,和后端 class-activity 的 day 同一个写法 */
function dayKey(value: Date | string) {
const p = zonedParts(value)!
return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`
}
function dateText(value: string) {
return parseTime(value, zonedParts(value)!.year === zonedYear() ? 'M月D日' : 'YYYY年M月D日')
}
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
/** 「9月27日 周日上午」:机房一个班一周来一两次,说星期几和上下午比说几点几分好认 */
function visitText(value: string) {
const p = zonedParts(value)!
const weekday = WEEKDAYS[new Date(Date.UTC(p.year, p.month - 1, p.day)).getUTCDay()]
const part = p.hour < 12 ? '上午' : p.hour < 18 ? '下午' : '晚上'
return `${dateText(value)} ${weekday}${part}`
}
/** 班里的题是今天的(老师布置的就是今天):今天的课排最前,「上次来」只剩卡底一句 */
const classToday = computed(() => classActivity.value?.source === 'teacher' || classActivity.value?.day === dayKey(new Date()))
const visit = computed(() => lastVisit.value?.summary ?? null)
/**
* 班里那天就是上次来的那天:机房一个班一周来一次,上次来做的往往就是班里那节课的题,
* 「上次来」卡已经讲了,班里卡不再把同一批题列一遍,只在卡底说一句
*/
const visitCoversClass = computed(
() => !!visit.value && !!classActivity.value?.problems.length && !classToday.value && classActivity.value.day === dayKey(visit.value.lastSubmitTime),
)
const classSummaryText = computed(() => {
const problems = classActivity.value?.problems ?? []
const done = problems.filter((item) => item.myStatus === 'accepted').length
return done === problems.length ? `那天班里一起做的 ${problems.length} 道,你都做对了` : `那天班里一起做了 ${problems.length} 道,你做对了 ${done} 道`
})
/**
* 「上次还有 N 道没过」那一句:上次来就是今天(上午上课、下午又登录)时不说 ——
* 原来照样写「上次(10月13日)」,和今天是同一天
*/
const visitBeforeToday = computed(() => !!lastVisit.value?.previousLogin && dayKey(lastVisit.value.previousLogin) !== dayKey(new Date()))
/** 这节课下一道没做对的:卡底「接着做」那个大按钮 */
const nextProblem = computed(() => classActivity.value?.problems.find((item) => item.myStatus !== 'accepted') ?? null)
const doneInClass = computed(() => classActivity.value?.problems.filter((item) => item.myStatus === 'accepted').length ?? 0)
const solvedText = computed(() => (visit.value?.solved ?? []).map((item) => `${item.problemDisplayId} ${item.title}`).join('、'))
/** 今天的就叫「今天」,往前找到的那天标出日期 —— 周一早上看到的是上周五的课 */
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
if (mode === 'flowchart') return '这节课 · 画流程图'
if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码`
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}`
}
if (classToday.value) return '这节课'
const [, month, date] = day.split('-').map(Number)
return `${month}月${date}日班里做了`
})
const MY_STATUS: Record<ClassActivityProblem['myStatus'], { label: string; type: 'success' | 'warning' | 'default' }> = {
accepted: { label: '已做对', type: 'success' },
tried: { label: '没做对', type: 'warning' },
none: { label: '还没做', type: 'default' },
}
/**
* 每行前面那个小标签。要画又要写的题只做到一步时说差哪一步 ——「没做对」会让人以为是代码错了
*/
function statusTag(item: ClassActivityProblem) {
if (item.myStatus === 'tried' && item.draw && item.code) {
if (item.draw === 'accepted') return { label: '还要写代码', type: 'warning' as const }
if (item.code === 'accepted') return { label: '还要画图', type: 'warning' as const }
}
return MY_STATUS[item.myStatus]
}
async function loadWeekly() {
weekly.value = await getWeeklyRank(userStore.user?.className ? 'class' : 'global')
}
async function load() {
// 各块互不依赖,哪块挂了就空着那一块,别让一个接口拖垮整页
await Promise.allSettled([
loadTracks(),
loadContests(),
loadSubmissions(),
loadAnnouncements(),
loadWeekly(),
loadClassActivity(),
loadLastVisit(),
loadKnowledge(),
])
loaded.value = true
}
watch(
() => userStore.isFinished && userStore.isAuthed,
(ready) => ready && load(),
{ immediate: true },
)
function learnLink(track: Track) {
return lessonPath(track.type, track.step)
}
function openSubmission(row: SubmissionListItem) {
if (row.showLink) router.push(`/submission/${row.id}`)
else router.push(`/problem/${row.problemDisplayId}`)
}
/** 刚刚 / 12 分钟前 / 今天 14:03 / 9月21日 */
function relativeTime(value: string) {
const diff = Date.now() - new Date(value).getTime()
if (diff < 60_000) return '刚刚'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
const now = zonedParts(new Date())!
const then = zonedParts(value)!
if (now.year === then.year && now.month === then.month && now.day === then.day) {
return parseTime(value, '今天 HH:mm')
}
return parseTime(value, now.year === then.year ? 'M月D日' : 'YYYY年M月D日')
}
function contestTime(contest: Contest) {
if (contest.status === ContestStatus.underway) {
return `${parseTime(contest.endTime, 'M月D日 HH:mm')} 结束`
}
return `${parseTime(contest.startTime, 'M月D日 HH:mm')} 开始 · 时长 ${duration(contest.startTime, contest.endTime)}`
}
</script>
<template>
<div class="home">
<!-- 中间原来还有一个「题号或题目名 找题」框,和顶栏右上角那个重复,拿掉了 -->
<section class="hero">
<h1 v-if="firstVisit" class="hello">欢迎来到判题狗,{{ userStore.user?.username }}</h1>
<h1 v-else class="hello">{{ greeting }},{{ userStore.user?.username }}</h1>
<n-text v-if="firstVisit" depth="3">老师报了题号,在右上角的框里输入就能找到题</n-text>
<n-text v-else depth="3">
<template v-if="longAway">{{ awayText }} · </template>
已解决 {{ userStore.profile?.acceptedNumber ?? 0 }} 题
<template v-if="weeklyText">
·
<router-link to="/rank" class="plain-link">{{ weeklyText }}</router-link>
</template>
<template v-if="knowledgeHeadline">
·
<router-link v-if="knowledgeHeadline.to" :to="knowledgeHeadline.to" class="plain-link">
{{ knowledgeHeadline.text }}
</router-link>
<span v-else>{{ knowledgeHeadline.text }}</span>
</template>
</n-text>
</section>
<div class="grid">
<div class="column">
<!-- 上课时最上面是这节课(设计稿 HomeStuClass):几道题做没做对,底下一个「接着做」 -->
<n-card v-if="classToday && classActivity?.problems.length" :bordered="false" class="card lesson">
<template #header>{{ classActivityTitle }}</template>
<template #header-extra>
<n-text depth="3" class="row-meta"> {{ classActivity.problems.length }} 道做对了 {{ doneInClass }} 道 </n-text>
</template>
<router-link v-for="item in classActivity.problems" :key="item.problemDisplayId" :to="`/problem/${item.problemDisplayId}`" class="row">
<n-tag :type="statusTag(item).type" size="small" :bordered="false">
{{ statusTag(item).label }}
</n-tag>
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
<!-- 老师刚布置、还没人交的时候不显示「0/0」 -->
<n-text v-if="item.userCount" depth="3" class="row-meta"> {{ item.acceptedCount }}/{{ item.userCount }} 人做对 </n-text>
</router-link>
<div class="lesson-foot">
<n-button v-if="nextProblem" type="primary" size="large" @click="router.push(`/problem/${nextProblem.problemDisplayId}`)">
接着做 {{ nextProblem.problemDisplayId }} {{ nextProblem.title }} →
</n-button>
<n-text v-else type="success">这节课的题都做对了</n-text>
</div>
<div v-if="visitBeforeToday && visit?.unsolved.length" class="visit-foot warn">
<span class="dot"></span>
<span class="row-title">
上次({{ dateText(lastVisit!.previousLogin!) }})还有 {{ visit.unsolved.length }} 道没做对:{{ visit.unsolved[0]!.problemDisplayId }}
{{ visit.unsolved[0]!.title }}
</span>
<router-link :to="`/problem/${visit.unsolved[0]!.problemDisplayId}`" class="go"> 接着做 › </router-link>
</div>
</n-card>
<!-- 不在上课:上次没做完的排最前(设计稿 HomeStuAfter) -->
<n-card v-if="visit && !classToday" :bordered="false" class="card">
<template #header>
{{ visit.unsolved.length ? '上次没做完' : '上次来' }} ·
{{ visitText(lastVisit!.previousLogin!) }}
</template>
<template #header-extra>
<n-text depth="3" class="row-meta"> 做对 {{ visit.solvedCount }} 道 · 交了 {{ visit.submissionCount }} 次 </n-text>
</template>
<router-link v-for="item in visit.unsolved" :key="item.problemDisplayId" :to="`/problem/${item.problemDisplayId}`" class="unsolved">
<n-tag type="warning" size="small" :bordered="false">没做对</n-tag>
<div class="unsolved-main">
<span class="row-title">
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.title }}
</span>
<n-text depth="3" class="row-meta"> 交了 {{ item.attempts }} 次,最后一次是{{ JUDGE_STATUS[item.lastResult].name }} </n-text>
</div>
<n-button type="primary" tag="span">接着做</n-button>
</router-link>
<div v-if="visit.solvedCount" class="row">
<n-tag type="success" size="small" :bordered="false">已做对</n-tag>
<span class="row-title">{{ visit.solvedCount }} 道:{{ solvedText }}</span>
</div>
<div v-if="visitCoversClass" class="visit-foot"><Icon icon="ph:check-bold" :width="14" />{{ classSummaryText }}</div>
</n-card>
<!-- 前几天班里一起做的(不是今天、也不是上次来那天):只在有的时候出现 -->
<n-card v-if="!classToday && classActivity?.problems.length && !visitCoversClass" :title="classActivityTitle" :bordered="false" class="card">
<template #header-extra>
<n-text depth="3" class="row-meta">{{ classActivity.className }} 班</n-text>
</template>
<router-link v-for="item in classActivity.problems" :key="item.problemDisplayId" :to="`/problem/${item.problemDisplayId}`" class="row">
<n-tag :type="statusTag(item).type" size="small" :bordered="false">
{{ statusTag(item).label }}
</n-tag>
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
<n-text v-if="item.userCount" depth="3" class="row-meta"> {{ item.acceptedCount }}/{{ item.userCount }} 人做对 </n-text>
</router-link>
</n-card>
<!-- 继续学习:一门一行 -->
<n-card title="继续学习" :bordered="false" class="card">
<router-link v-for="track in tracks" :key="track.type" :to="learnLink(track)" class="row">
<n-text depth="3" class="track-label">{{ track.label }}</n-text>
<span class="row-title"> 第 {{ track.step }} / {{ track.total }} 课 · {{ track.title }} </span>
<n-button :type="track.lastViewedAt ? 'primary' : 'default'" tag="span" size="small">
{{ track.lastViewedAt ? '继续' : '开始' }}
</n-button>
</router-link>
<n-empty v-if="loaded && !tracks.length" description="还没有公开的教程" />
</n-card>
</div>
<div class="column">
<n-card title="最近提交" :bordered="false" class="card">
<template #header-extra>
<router-link to="/submission?myself=1" class="more">全部</router-link>
</template>
<div v-for="row in submissions" :key="row.id" class="row clickable" @click="openSubmission(row)">
<SubmissionResultTag :result="row.result" size="small" />
<span class="row-title">{{ row.problemDisplayId }} {{ row.problemTitle }}</span>
<n-text depth="3" class="row-meta">{{ relativeTime(row.createTime) }}</n-text>
</div>
<n-empty v-if="loaded && !submissions.length" description="还没有提交过">
<template #extra>
<n-button size="small" @click="router.push('/problem')">去做题</n-button>
</template>
</n-empty>
</n-card>
<!-- 比赛只在有进行中 / 快开始的时候出现(这学期一场都没办,原来常年一块空卡) -->
<n-card v-if="contests.length" title="比赛" :bordered="false" class="card">
<template #header-extra>
<router-link to="/contest" class="more">全部</router-link>
</template>
<router-link v-for="contest in contests" :key="contest.id" :to="`/contest/${contest.id}`" class="row stacked">
<n-flex align="center" :size="8" :wrap="false">
<n-tag :type="CONTEST_STATUS[contest.status].type" size="small" :bordered="false">
{{ CONTEST_STATUS[contest.status].name }}
</n-tag>
<span class="row-title">{{ contest.title }}</span>
</n-flex>
<n-text depth="3" class="row-meta">{{ contestTime(contest) }}</n-text>
</router-link>
</n-card>
<n-card v-if="announcements.length" title="公告" :bordered="false" class="card">
<template #header-extra>
<router-link to="/announcement" class="more">全部</router-link>
</template>
<router-link v-for="item in announcements" :key="item.id" :to="{ path: '/announcement', query: { id: item.id } }" class="row">
<n-tag v-if="item.top" type="warning" size="small" :bordered="false">置顶</n-tag>
<span class="row-title">{{ item.title }}</span>
<n-text depth="3" class="row-meta">{{ dateText(item.createTime) }}</n-text>
</router-link>
</n-card>
</div>
</div>
</div>
</template>
<style scoped>
.home {
max-width: var(--oj-page-width);
margin: 0 auto;
}
.hero {
display: flex;
flex-direction: column;
gap: 6px;
margin: 16px 0 24px;
}
.hello {
font-size: 28px;
font-weight: 600;
margin: 0;
}
/* 这节课:卡片描一圈主色,底下一个大按钮 */
.lesson {
box-shadow: inset 0 0 0 1px rgba(24, 160, 88, 0.35);
}
.lesson-foot {
padding-top: 12px;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: var(--oj-gap);
}
@media (max-width: 900px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
}
.column {
display: flex;
flex-direction: column;
gap: var(--oj-gap);
min-width: 0;
}
.card {
border-radius: var(--oj-radius);
background-color: var(--n-color-embedded, rgba(128, 128, 128, 0.06));
}
/* 卡片用回 Naive 默认的 medium(左右 24),手机上太费地方,收回 16 */
@media (max-width: 600px) {
.card {
--n-padding-left: 16px !important;
--n-padding-right: 16px !important;
}
}
.track-label {
flex: none;
width: 64px;
font-size: var(--oj-fs-meta);
}
.row-meta {
font-size: var(--oj-fs-meta);
}
.row {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
box-sizing: border-box;
padding: 8px 6px;
font-size: var(--oj-fs-body);
border-radius: 4px;
color: inherit;
text-decoration: none;
}
.row + .row {
border-top: 1px solid rgba(128, 128, 128, 0.12);
}
.row.stacked {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.clickable,
a.row {
cursor: pointer;
}
.clickable:hover,
a.row:hover {
background-color: rgba(128, 128, 128, 0.08);
}
.row-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-meta {
flex-shrink: 0;
}
/* 「上次来」没做对的那几道:一道一个白底块,「接着做」是这张卡唯一的主按钮 */
.unsolved {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-bottom: 10px;
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.18);
background-color: var(--n-color, #fff);
color: inherit;
text-decoration: none;
}
.unsolved:hover {
border-color: rgba(24, 160, 88, 0.5);
}
.unsolved-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.visit-foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 10px 4px 2px;
border-top: 1px solid rgba(128, 128, 128, 0.12);
font-size: var(--oj-fs-sec);
opacity: 0.75;
}
.visit-foot.warn {
opacity: 1;
}
.visit-foot .dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #f0a020;
}
.visit-foot .go {
flex: none;
color: #18a058;
font-weight: 600;
text-decoration: none;
}
.more,
.plain-link {
color: #18a058;
text-decoration: none;
}
.more {
font-size: var(--oj-fs-sec);
}
.more:hover,
.plain-link:hover {
text-decoration: underline;
}
</style>