课上老师报题号、全班去找,是 2025 秋七成非比赛提交的来路,界面上原本没有它的入口。 - 顶栏(桌面端)加题号框,首页「找题」同一套逻辑:题号对上直接进题,否则去列表搜 - 新接口 GET /me/class-activity:同班同一天 ≥5 人做过的题,取最近 7 天里最近的一天, 附带我自己的状态与班里通过人数;首页据此显示「今天班里在做」卡片,没有就不占位 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d07459928d
commit
039992dc86
7 files changed
+279
-8
No files matched your search
@@ -1,18 +1,21 @@
|
||||
import {
|
||||
classComparisonRequestSchema,
|
||||
STUDENT_ROLES,
|
||||
type ClassActivity,
|
||||
type ClassActivityProblem,
|
||||
type ClassComparison,
|
||||
type ClassComparisonResponse,
|
||||
type ClassRankItem,
|
||||
type ClassUserRank,
|
||||
} from "@oj2/contract"
|
||||
import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm"
|
||||
import { and, eq, gte, inArray, isNull, like, lte, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { dayStart, localTime } from "../time"
|
||||
import { queryInteger, rounded } from "./helpers"
|
||||
|
||||
export const classroomRoutes = new Hono<AppEnv>()
|
||||
@@ -141,6 +144,127 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
|
||||
} satisfies ClassUserRank)
|
||||
})
|
||||
|
||||
/**
|
||||
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
|
||||
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
|
||||
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
|
||||
*/
|
||||
const CLASS_ACTIVITY_MIN_USERS = 5
|
||||
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
|
||||
const CLASS_ACTIVITY_LOOKBACK_DAYS = 7
|
||||
const CLASS_ACTIVITY_LIMIT = 8
|
||||
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
|
||||
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
|
||||
/**
|
||||
* 班里最近一次「一起做」的那几道题。课上老师报题号、全班去找 —— 这是 2025 秋
|
||||
* 七成非比赛提交的来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
|
||||
*
|
||||
* 只取最近的**一天**,不把 7 天摊平:摊平了就是一张越来越长的旧题单,
|
||||
* 「现在该做哪道」反而看不出来。题按那天第一次有人提交的时间排,大致就是老师点题的顺序。
|
||||
*/
|
||||
classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
if (!user.className) {
|
||||
return success(c, { className: null, day: null, problems: [] } satisfies ClassActivity)
|
||||
}
|
||||
|
||||
const since = dayStart(Date.now() - (CLASS_ACTIVITY_LOOKBACK_DAYS - 1) * 86_400_000)
|
||||
const day = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
|
||||
const userCount = sql<number>`count(distinct ${schema.submission.userId})::int`
|
||||
const groups = await db
|
||||
.select({
|
||||
day,
|
||||
problemId: schema.submission.problemId,
|
||||
firstAt: sql<string>`min(${schema.submission.createTime})`,
|
||||
userCount,
|
||||
acceptedCount: sql<number>`count(distinct ${schema.submission.userId}) filter (where ${inArray(schema.submission.result, SOLVED_RESULTS)})::int`,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(
|
||||
and(
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, since),
|
||||
eq(schema.user.className, user.className),
|
||||
eq(schema.user.isDisabled, false),
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
),
|
||||
)
|
||||
.groupBy(day, schema.submission.problemId)
|
||||
.having(sql`${userCount} >= ${CLASS_ACTIVITY_MIN_USERS}`)
|
||||
|
||||
const latest = groups.reduce<string | null>(
|
||||
(acc, row) => (!acc || row.day > acc ? row.day : acc),
|
||||
null,
|
||||
)
|
||||
const picked = groups
|
||||
.filter((row) => row.day === latest)
|
||||
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
|
||||
.slice(0, CLASS_ACTIVITY_LIMIT)
|
||||
if (!picked.length) {
|
||||
return success(c, {
|
||||
className: user.className,
|
||||
day: null,
|
||||
problems: [],
|
||||
} satisfies ClassActivity)
|
||||
}
|
||||
|
||||
const ids = picked.map((row) => row.problemId)
|
||||
const [problems, mine] = await Promise.all([
|
||||
// 题目后来被藏起来的就不列了,点进去也是 404
|
||||
db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.problem.id, ids),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
inArray(schema.submission.problemId, ids),
|
||||
isNull(schema.submission.contestId),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.problemId),
|
||||
])
|
||||
const problemById = new Map(problems.map((row) => [row.id, row]))
|
||||
const mineById = new Map(mine.map((row) => [row.problemId, row.accepted]))
|
||||
|
||||
return success(c, {
|
||||
className: user.className,
|
||||
day: latest,
|
||||
problems: picked.flatMap((row) => {
|
||||
const problem = problemById.get(row.problemId)
|
||||
if (!problem) return []
|
||||
const accepted = mineById.get(row.problemId)
|
||||
return [
|
||||
{
|
||||
problemDisplayId: problem.displayId,
|
||||
title: problem.title,
|
||||
userCount: row.userCount,
|
||||
acceptedCount: row.acceptedCount,
|
||||
myStatus: accepted === undefined ? "none" : accepted ? "accepted" : "tried",
|
||||
} satisfies ClassActivityProblem,
|
||||
]
|
||||
}),
|
||||
} satisfies ClassActivity)
|
||||
})
|
||||
|
||||
classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
const parsed = await parseBody(c, classComparisonRequestSchema, "At least one class is required")
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,后台要能看到(告诉学生)
|
||||
|
||||
@@ -63,10 +63,34 @@ export const classComparisonResponseSchema = z.object({
|
||||
hasTimeRange: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。
|
||||
* `day` 是东八区日历日;最近 7 天都没有这样的题时为 null,`problems` 为空。
|
||||
*/
|
||||
export const classActivityProblemSchema = z.object({
|
||||
problemDisplayId: z.string(),
|
||||
title: z.string(),
|
||||
/** 那天做过这道题的同班人数(含自己) */
|
||||
userCount: z.number().int(),
|
||||
/** 其中那天通过了的人数 */
|
||||
acceptedCount: z.number().int(),
|
||||
/** 我自己在题库里(不含比赛)做这道题的状态,不限那一天 */
|
||||
myStatus: z.enum(["accepted", "tried", "none"]),
|
||||
})
|
||||
|
||||
export const classActivitySchema = z.object({
|
||||
className: z.string().nullable(),
|
||||
day: z.string().nullable(),
|
||||
problems: z.array(classActivityProblemSchema),
|
||||
})
|
||||
|
||||
export type ClassRankItem = z.infer<typeof classRankItemSchema>
|
||||
export type ClassUserRank = z.infer<typeof classUserRankSchema>
|
||||
export type ClassComparison = z.infer<typeof classComparisonSchema>
|
||||
export type ClassComparisonResponse = z.infer<typeof classComparisonResponseSchema>
|
||||
|
||||
export type ClassActivity = z.infer<typeof classActivitySchema>
|
||||
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
|
||||
|
||||
export type ClassUserRankItem = z.infer<typeof classUserRankItemSchema>
|
||||
export type ClassComparisonRequest = z.infer<typeof classComparisonRequestSchema>
|
||||
Reference in new issue
Block a user