feat(前台): 顶栏可以输入题号直达;首页列出班里最近一次课一起做的题和自己的完成情况
Deploy / deploy (push) Canceled after 0s

课上老师报题号、全班去找,是 2025 秋七成非比赛提交的来路,界面上原本没有它的入口。

- 顶栏(桌面端)加题号框,首页「找题」同一套逻辑:题号对上直接进题,否则去列表搜
- 新接口 GET /me/class-activity:同班同一天 ≥5 人做过的题,取最近 7 天里最近的一天,
  附带我自己的状态与班里通过人数;首页据此显示「今天班里在做」卡片,没有就不占位

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 20:58:15 +08:00
1 parent d07459928d
commit 039992dc86
7 files changed
+279 -8

No files matched your search

+5
View File
@@ -8,6 +8,7 @@ import {
type FormatCodeResponse,
type Metrics,
type TutorialSummary,
type ClassActivity,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
@@ -216,6 +217,10 @@ export function getWeeklyRank(scope: "global" | "class") {
return api.get<WeeklyRank>("rankings/weekly", { params: { scope } })
}
export function getClassActivity() {
return api.get<ClassActivity>("me/class-activity")
}
export function getClassRank(grade?: number | null) {
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
}
+66 -7
View File
@@ -3,6 +3,7 @@ import { Icon } from "@iconify/vue"
import {
getAnnouncement,
getAnnouncementList,
getClassActivity,
getContestList,
getLearnProgress,
getSubmissions,
@@ -11,10 +12,18 @@ import {
} from "oj/api"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useProblemJump } from "shared/composables/problemJump"
import { useUserStore } from "shared/store/user"
import { ContestStatus, CONTEST_STATUS } from "utils/constants"
import { duration, parseTime, zonedParts } from "utils/functions"
import type { AnnouncementListItem, Contest, SubmissionListItem, WeeklyRank } from "utils/types"
import type {
AnnouncementListItem,
ClassActivity,
ClassActivityProblem,
Contest,
SubmissionListItem,
WeeklyRank,
} from "utils/types"
type TutorialType = "python" | "c"
@@ -39,6 +48,7 @@ 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 loaded = ref(false)
const keyword = ref("")
@@ -111,6 +121,30 @@ async function loadAnnouncements() {
announcements.value = res.results
}
async function loadClassActivity() {
classActivity.value = await getClassActivity()
}
/** 今天的就叫「今天」,往前找到的那天标出日期 —— 周一早上看到的是上周五的课 */
const classActivityTitle = computed(() => {
const day = classActivity.value?.day
if (!day) return ""
const now = zonedParts(new Date())!
const today = `${now.year}-${String(now.month).padStart(2, "0")}-${String(now.day).padStart(2, "0")}`
if (day === today) 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" },
}
async function loadWeekly() {
weekly.value = await getWeeklyRank(userStore.user?.className ? "class" : "global")
}
@@ -123,6 +157,7 @@ async function load() {
loadSubmissions(),
loadAnnouncements(),
loadWeekly(),
loadClassActivity(),
])
loaded.value = true
}
@@ -137,10 +172,7 @@ function learnLink(track: Track) {
return `/learn/${track.type}/${track.step.toString().padStart(2, "0")}`
}
function searchProblem() {
const value = keyword.value.trim()
router.push({ path: "/problem", query: value ? { keyword: value } : {} })
}
const { jump, jumping } = useProblemJump()
function openSubmission(row: SubmissionListItem) {
if (row.showLink) router.push(`/submission/${row.id}`)
@@ -198,14 +230,41 @@ async function openAnnouncement(item: AnnouncementListItem) {
v-model:value="keyword"
placeholder="题号或题目名"
clearable
@keyup.enter="searchProblem"
@keyup.enter="jump(keyword)"
/>
<n-button type="primary" @click="searchProblem">找题</n-button>
<n-button type="primary" :loading="jumping" @click="jump(keyword)">找题</n-button>
</n-input-group>
</section>
<div class="grid">
<div class="column">
<!-- 只在有的时候出现:没班级、最近一周班里没一起做过题的,这块不占位置 -->
<n-card
v-if="classActivity?.problems.length"
:title="classActivityTitle"
size="small"
: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="MY_STATUS[item.myStatus].type" size="small" :bordered="false">
{{ MY_STATUS[item.myStatus].label }}
</n-tag>
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
<n-text depth="3" class="row-meta">
{{ item.acceptedCount }}/{{ item.userCount }} 人通过
</n-text>
</router-link>
</n-card>
<n-card title="继续学习" size="small" :bordered="false" class="card">
<n-flex vertical :size="12">
<router-link
+29
View File
@@ -4,6 +4,7 @@ import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useDarkTransition } from "shared/composables/darkTransition"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useProblemJump } from "shared/composables/problemJump"
import { useAuthModalStore } from "shared/store/authModal"
import { useCollabStore } from "shared/store/collab"
import { useScreenModeStore } from "shared/store/screenMode"
@@ -62,6 +63,18 @@ function getRandomAvatar() {
const avatar = ref(getRandomAvatar())
/**
* 顶栏的题号框:老师报完题号,学生在哪一页都能直接敲,不用先回首页或题目列表。
* 只给桌面端 —— 窄屏顶栏已经挤满了,手机上从首页的搜索框走同一套逻辑。
*/
const { jump, jumping } = useProblemJump()
const jumpKeyword = ref("")
async function handleJump() {
await jump(jumpKeyword.value)
jumpKeyword.value = ""
}
const envVersion = computed(() => {
if (import.meta.env.PUBLIC_ENV === "test") {
return "测试版"
@@ -263,6 +276,18 @@ function goHome() {
<span style="padding-left: 8px">菜单</span>
</n-button>
</n-dropdown>
<n-input
v-if="isDesktop"
v-model:value="jumpKeyword"
class="jump"
placeholder="输入题号直达"
:loading="jumping"
@keyup.enter="handleJump"
>
<template #prefix>
<Icon icon="ph:magnifying-glass" />
</template>
</n-input>
<n-button
v-if="isDesktop && (route.name === 'problem' || route.name === 'contest problem')"
@click="() => screenModeStore.switchScreenMode()"
@@ -305,4 +330,8 @@ function goHome() {
.title {
font-size: 18px;
}
.jump {
width: 160px;
}
</style>
@@ -0,0 +1,27 @@
import { getProblemList } from "oj/api"
/**
* 按题号直达。课上老师报一个题号、全班去找,是非比赛提交最主要的来路,
* 所以敲完题号回车就该进题,而不是先落到列表再点一下。
*
* 题号对上了(不分大小写)才直达;对不上、或者敲的是题目名,就去列表搜 ——
* 列表那边的 keyword 是题号和标题一起模糊匹配,这里只是在它前面加了一步。
*/
export function useProblemJump() {
const router = useRouter()
const jumping = ref(false)
async function jump(input: string) {
const keyword = input.trim()
if (!keyword) return router.push("/problem")
jumping.value = true
// 查询失败就当没对上,照样去列表搜,别让一次网络抖动把回车吞掉
const res = await getProblemList(0, 20, { keyword }).catch(() => null)
jumping.value = false
const exact = res?.results.find((row) => row._id.toLowerCase() === keyword.toLowerCase())
if (exact) return router.push(`/problem/${exact._id}`)
return router.push({ path: "/problem", query: { keyword } })
}
return { jump, jumping }
}
+3
View File
@@ -287,6 +287,9 @@ export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract"
export type { ClassComparison, ClassRankItem, ClassUserRank } from "@oj2/contract"
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem } from "@oj2/contract"
/**
* 学生侧的比赛:不含 password / visible。后台用 `AdminContest` ——
* oj 侧的 contestSchema 永远不含 password,后台要能看到(告诉学生)