Files
OJ2/packages/contract/src/content.ts
yuetsh 286487acf3 fix(自学): 少于三分钟的不算已读
原来只要打开过(viewCount > 0)就记成已读,于是「点开看一眼就退」和「认真读完」
在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。加一道 3 分钟的门槛 ——
按最短的一课定的下限,读不完还翻不动的课文,扫一眼是到不了这个数的。

阈值 TUTORIAL_READ_SECONDS 放在 contract 里,前后端共用一个数:学生端目录的 ✓、
后台「按学生」的已读课数、「按课程」的读过人数,三处口径必须一致,各写各的迟早分叉。

**累计时长故意不卡**。时长记的是真实停留,不满三分钟的秒数照样算进去,于是
「已读 0 课、累计 25 分钟」成为一种能看见的状态 —— 他翻了很多课,每课都没读满。
这正是这道门槛想让老师看见的东西,把时长一起滤掉反而把信息删了。

「按课程」的人均时长跟着改了分子:分母是读满三分钟的人,分子就得是同一批人的时长,
否则拿全部时长去除达标人数,人均会被翻一眼就走的人凭空抬高。

顺带修掉「✓ 已读 · -」:心跳 15 秒一跳,点开就走会落在 0 秒上,而 readableDuration(0)
返回的是 "-"。新口径下打勾要求 ≥180 秒,这个组合不可能再出现;打开过但没读满的
显示灰色的「读了 N 分钟」,记是记下了,只是还没到「已读」。

后台筛选栏上方写了一行口径说明,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了。

本机实跑:student 读 A 课 500 秒、B 课 60 秒 → 已读 1/2、累计 560 秒;
A 课「读过 2 人、累计 700、人均 350」,B 课「读过 0 人、累计 60、人均 0」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7
2026-09-02 20:37:30 -06:00

157 lines
5.6 KiB
TypeScript
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.
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { embeddedSubmissionSchema } from "./submission"
export const announcementSchema = z.object({
id: z.number().int(),
title: z.string(),
tag: z.string(),
content: z.string(),
top: z.boolean(),
createdBy: sampleUserSchema,
createTime: z.string(),
lastUpdateTime: z.string(),
})
/**
* 列表不下发正文:公告是 8MB 上限的富文本,列表页只显示标题。
* 原来 content 写成 `.optional()` 让一个 schema 兼两种形态,结果详情页
* 拿到的 content 类型上也是 `string | undefined`,组件只能 ?? 兜底。
* 与后台侧 adminAnnouncementListItemSchema 同一个套路。
*/
export const announcementListItemSchema = announcementSchema.omit({
content: true,
})
export const announcementListSchema = paginatedSchema(
announcementListItemSchema,
)
export const messageSchema = z.object({
id: z.number().int(),
sender: sampleUserSchema,
createTime: z.string(),
message: z.string(),
submission: embeddedSubmissionSchema,
})
export const messageListSchema = paginatedSchema(messageSchema)
export const createMessageRequestSchema = z.object({
recipientId: z.number().int().positive(),
submissionId: z.string().min(1),
message: z.string().min(1).max(1024 * 1024),
})
export const reactionKeySchema = z.enum([
"too_easy",
"too_hard",
"confusing",
"buggy",
"learned",
"interesting",
"want_explain",
])
export const reactionCountsSchema = z.record(reactionKeySchema, z.number().int())
export const reactionStateSchema = z.object({
mine: reactionKeySchema.nullable(),
counts: reactionCountsSchema.nullable(),
})
export const setReactionRequestSchema = z.object({ type: reactionKeySchema })
export const tutorialSummarySchema = z.object({
id: z.number().int(),
title: z.string(),
})
export const tutorialSchema = tutorialSummarySchema.extend({
content: z.string(),
code: z.string().nullable(),
isPublic: z.boolean(),
order: z.number().int(),
type: z.enum(["python", "c"]),
createdBy: sampleUserSchema,
createdAt: z.string(),
updatedAt: z.string(),
})
/**
* 自学留痕的上报。前端每进一课发一次 `opened: true``seconds` 为 0
* 之后按心跳补时长发 `opened: false`。
*
* `seconds` 卡在 1 小时以内:一次上报最多也就攒几分钟,超出这个量级只可能是
* 客户端算错或有人手造请求,直接拒掉比默默入库好——停留时长是要给老师看的。
*/
export const tutorialProgressPingSchema = z.object({
seconds: z.number().int().min(0).max(3600),
opened: z.boolean(),
})
/**
* 学习页目录要的整套进度:**每篇公开教程都有一行**,没读过的就是一行零。
* 让前端 `progress[id]` 永远取得到,省得目录里每处都判一次 undefined。
*/
export const tutorialProgressSchema = z.object({
tutorialId: z.number().int(),
viewCount: z.number().int(),
totalSeconds: z.number().int(),
// 没读过时是 null不是假的零时间
firstViewedAt: z.string().nullable(),
lastViewedAt: z.string().nullable(),
exerciseTotal: z.number().int(),
exerciseSolved: z.number().int(),
})
/**
* 一次练一练的作答。
*
* `answer` 是前端拼好的一句人话(「选了 A、C」只在做错时留下来给老师看
* 做对了没什么好看的。长度卡在 200 字符:它是给人扫一眼的摘要,不是完整作答,
* 填空题写一整段进来只会把后台表格撑爆。
*/
export const exerciseAttemptRequestSchema = z.object({
correct: z.boolean(),
answer: z.string().max(200).optional(),
})
export const exerciseSchema = z.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})
export type Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema>
export type Announcement = z.infer<typeof announcementSchema>
export type AnnouncementListItem = z.infer<typeof announcementListItemSchema>
export type TutorialSummary = z.infer<typeof tutorialSummarySchema>
export type AnnouncementList = z.infer<typeof announcementListSchema>
export type CreateMessageRequest = z.infer<typeof createMessageRequestSchema>
export type ReactionKey = z.infer<typeof reactionKeySchema>
export type ReactionCounts = z.infer<typeof reactionCountsSchema>
export type ReactionState = z.infer<typeof reactionStateSchema>
export type SetReactionRequest = z.infer<typeof setReactionRequestSchema>
export type Tutorial = z.infer<typeof tutorialSchema>
export type Exercise = z.infer<typeof exerciseSchema>
export type TutorialProgress = z.infer<typeof tutorialProgressSchema>
export type TutorialProgressPing = z.infer<typeof tutorialProgressPingSchema>
export type ExerciseAttemptRequest = z.infer<typeof exerciseAttemptRequestSchema>
/**
* 「已读」的门槛:累计停留满 3 分钟才算读过这一课。
*
* 之前只要打开过(`viewCount > 0`)就记成已读,于是「点开看一眼就退」和「认真读完」
* 在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。3 分钟是按最短的一课
* 定的下限——读不完还翻不动的课文,扫一眼是到不了这个数的。
*
* 门槛只管**「已读」这个口径**(学生端的 ✓、后台的已读课数与读过的人数),
* 不动累计时长:时长记的是真实停留,不满 3 分钟的那些秒数照样算进去,
* 「已读 0 课、累计 25 分钟」正是要让老师看见的一种情况。
*/
export const TUTORIAL_READ_SECONDS = 180