feat(自学): 教程有了「所属项目」和课末的相关题,正文里的代码能直接运行
- 0031 迁移:tutorial 加 project 列(从正文「所属项目:」回填),新表 tutorial_problem 挂每课的相关题(两头 CASCADE) - 学完一课 = 练一练全对(契约 isLessonDone),不再按停留满 3 分钟算 - /tutorials/:id 带相关题和做对没有,/tutorials/:id/exercises 带每道做对没有 - /trial-runs 收 tutorialId:教程里的代码块走同一台判题机跑一组 - 后台:教程列表带练一练 / 相关题数 / 打开过的人,拖动排序、项目改名、挑题候选(必会题在前) - 自学情况换成学生 × 课的方块格(/learn-analytics/board),默认班和排名页同一套猜法 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d1bfc522d
commit
11194543a3
14 files changed
+4621
-268
No files matched your search
@@ -66,11 +66,22 @@ export type AdminAnnouncementListItem = z.infer<typeof adminAnnouncementListItem
|
||||
|
||||
export const tutorialTypeSchema = z.enum(["python", "c"])
|
||||
|
||||
/** 后台一课挂的一道「去做几道题」。按 tutorial_problem.order 排 */
|
||||
export const adminTutorialProblemSchema = z.object({
|
||||
id: z.number().int(),
|
||||
displayId: z.string(),
|
||||
title: z.string(),
|
||||
mustDo: z.boolean(),
|
||||
})
|
||||
|
||||
export const adminTutorialSchema = z.object({
|
||||
id: z.number().int(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
// 「示例代码」字段界面上已经去掉(正文里的代码块能直接运行了),发布脚本还在传,照收照回
|
||||
code: z.string().nullable(),
|
||||
project: z.string(),
|
||||
problems: z.array(adminTutorialProblemSchema),
|
||||
isPublic: z.boolean(),
|
||||
order: z.number().int(),
|
||||
type: tutorialTypeSchema,
|
||||
@@ -79,11 +90,19 @@ export const adminTutorialSchema = z.object({
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
export const adminTutorialListItemSchema = adminTutorialSchema.omit({
|
||||
// 教程正文是整篇 markdown,列表页只排序和切换可见性,不需要它
|
||||
content: true,
|
||||
code: true,
|
||||
})
|
||||
export const adminTutorialListItemSchema = adminTutorialSchema
|
||||
.omit({
|
||||
// 教程正文是整篇 markdown,列表页用不上
|
||||
content: true,
|
||||
code: true,
|
||||
problems: true,
|
||||
})
|
||||
.extend({
|
||||
exerciseCount: z.number().int(),
|
||||
problemCount: z.number().int(),
|
||||
/** 打开过这一课的学生数(全部时间),老师判断哪课没人看 */
|
||||
readers: z.number().int(),
|
||||
})
|
||||
|
||||
/** 后台教程列表按语言分组返回,对齐旧 TutorialAdminAPI.get 的 `{python, c}` 形状 */
|
||||
export const adminTutorialGroupsSchema = z.object({
|
||||
@@ -91,10 +110,15 @@ export const adminTutorialGroupsSchema = z.object({
|
||||
c: z.array(adminTutorialListItemSchema),
|
||||
})
|
||||
|
||||
/**
|
||||
* `project` 不传就从正文第一段的「**所属项目:…**」抽 —— 发布脚本(docs/*-tutorials/
|
||||
* publish.js)不认识这个字段,更新时也不能因为它没传就把项目清空。
|
||||
*/
|
||||
export const createTutorialRequestSchema = z.object({
|
||||
title: z.string().trim().min(1).max(128),
|
||||
content: z.string(),
|
||||
code: z.string().nullable().default(null),
|
||||
project: z.string().trim().max(64).optional(),
|
||||
isPublic: z.boolean().default(false),
|
||||
order: z.number().int().default(0),
|
||||
type: tutorialTypeSchema,
|
||||
@@ -105,6 +129,29 @@ export const setTutorialVisibilityRequestSchema = z.object({
|
||||
isPublic: z.boolean(),
|
||||
})
|
||||
|
||||
/** 一课挂哪几道题,整串替换,顺序就是数组顺序 */
|
||||
export const TUTORIAL_PROBLEM_MAX = 6
|
||||
export const setTutorialProblemsRequestSchema = z.object({
|
||||
problemIds: z.array(z.number().int().positive()).max(TUTORIAL_PROBLEM_MAX),
|
||||
})
|
||||
|
||||
/** 拖动排序:同一门课的教程 id 按新顺序排好整串交上来 */
|
||||
export const reorderTutorialsRequestSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).min(1).max(500),
|
||||
})
|
||||
|
||||
/** 项目改名:这门课里所有挂在 `from` 下的教程一起改 */
|
||||
export const renameTutorialProjectRequestSchema = z.object({
|
||||
type: tutorialTypeSchema,
|
||||
from: z.string().max(64),
|
||||
to: z.string().trim().min(1).max(64),
|
||||
})
|
||||
|
||||
/** 后台给一课挑题时的候选:必会题排在前面 */
|
||||
export const tutorialProblemCandidateSchema = adminTutorialProblemSchema.extend({
|
||||
tags: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const exerciseTypeSchema = z.enum([
|
||||
"mcq",
|
||||
"sort",
|
||||
@@ -512,29 +559,36 @@ export const acTrendSchema = z.object({
|
||||
// ---------------------------------------------------------------- 自学情况
|
||||
|
||||
/**
|
||||
* 「自学情况」按学生的一行。**没读过任何一课的学生也要有一行**(readCount = 0)——
|
||||
* 这张表首要回答的问题是「谁还没开始」,只列有记录的人等于把该看的人筛掉了。
|
||||
* 「自学情况」的方块格:一个班的学生 × 这门课的每一课。
|
||||
*
|
||||
* **没打开过任何一课的学生也要有一行**(`opened` 为空)—— 页面上把他们收成一串名单,
|
||||
* 这张表首要回答的就是「谁还没开始」。`done` 是学完的课(练一练全对,见 `isLessonDone`),
|
||||
* 一定也在 `opened` 里或者是没打开却把练一练做完了的(老链接直达练习的情况)。
|
||||
*/
|
||||
export const learnStudentProgressSchema = z.object({
|
||||
export const learnBoardStudentSchema = z.object({
|
||||
userId: z.number().int(),
|
||||
username: z.string(),
|
||||
realName: z.string().nullable(),
|
||||
className: z.string().nullable(),
|
||||
readCount: z.number().int(),
|
||||
opened: z.array(z.number().int()),
|
||||
done: z.array(z.number().int()),
|
||||
exerciseSolved: z.number().int(),
|
||||
totalSeconds: z.number().int(),
|
||||
lastViewedAt: z.string().nullable(),
|
||||
// 练一练:做过几道、做对几道、一共点了几次提交
|
||||
exerciseTried: z.number().int(),
|
||||
exerciseSolved: z.number().int(),
|
||||
exerciseAttempts: z.number().int(),
|
||||
})
|
||||
|
||||
export const learnStudentProgressListSchema = z.object({
|
||||
// 分母:该语言下已公开的教程篇数,前端拿它显示 3/17
|
||||
tutorialCount: z.number().int(),
|
||||
// 分母:这些教程里一共有多少道练一练
|
||||
exerciseCount: z.number().int(),
|
||||
results: z.array(learnStudentProgressSchema),
|
||||
export const learnBoardSchema = z.object({
|
||||
/** 实际看的是哪个班。老师没选时后端按排名页同一套规则猜,猜不出是 null */
|
||||
className: z.string().nullable(),
|
||||
tutorials: z.array(
|
||||
z.object({
|
||||
id: z.number().int(),
|
||||
title: z.string(),
|
||||
project: z.string(),
|
||||
exerciseTotal: z.number().int(),
|
||||
}),
|
||||
),
|
||||
students: z.array(learnBoardStudentSchema),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -577,22 +631,6 @@ export const learnExerciseAttemptSchema = z.object({
|
||||
lastAttemptAt: z.string(),
|
||||
})
|
||||
|
||||
/** 「自学情况」按课的一行 */
|
||||
export const learnTutorialProgressSchema = z.object({
|
||||
tutorialId: z.number().int(),
|
||||
title: z.string(),
|
||||
order: z.number().int(),
|
||||
readers: z.number().int(),
|
||||
totalSeconds: z.number().int(),
|
||||
avgSeconds: z.number().int(),
|
||||
})
|
||||
|
||||
export const learnTutorialProgressListSchema = z.object({
|
||||
// 分母:统计范围内的学生数(受班级筛选影响)
|
||||
studentCount: z.number().int(),
|
||||
results: z.array(learnTutorialProgressSchema),
|
||||
})
|
||||
|
||||
export const generateFlowchartRequestSchema = z.object({
|
||||
python: z
|
||||
.string()
|
||||
@@ -905,10 +943,8 @@ export type AdminAiReportListItem = z.infer<typeof adminAiReportListItemSchema>
|
||||
export type AdminAiReport = z.infer<typeof adminAiReportSchema>
|
||||
export type AdminAiReportList = z.infer<typeof adminAiReportListSchema>
|
||||
export type StuckProblem = z.infer<typeof stuckProblemSchema>
|
||||
export type LearnStudentProgress = z.infer<typeof learnStudentProgressSchema>
|
||||
export type LearnStudentProgressList = z.infer<typeof learnStudentProgressListSchema>
|
||||
export type LearnTutorialProgress = z.infer<typeof learnTutorialProgressSchema>
|
||||
export type LearnTutorialProgressList = z.infer<typeof learnTutorialProgressListSchema>
|
||||
export type LearnBoard = z.infer<typeof learnBoardSchema>
|
||||
export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
|
||||
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
|
||||
export type LearnExerciseProgressList = z.infer<typeof learnExerciseProgressListSchema>
|
||||
export type LearnExerciseAttempt = z.infer<typeof learnExerciseAttemptSchema>
|
||||
@@ -932,6 +968,11 @@ export type TutorialType = z.infer<typeof tutorialTypeSchema>
|
||||
export type AdminTutorial = z.infer<typeof adminTutorialSchema>
|
||||
export type AdminTutorialListItem = z.infer<typeof adminTutorialListItemSchema>
|
||||
export type AdminTutorialGroups = z.infer<typeof adminTutorialGroupsSchema>
|
||||
export type AdminTutorialProblem = z.infer<typeof adminTutorialProblemSchema>
|
||||
export type TutorialProblemCandidate = z.infer<typeof tutorialProblemCandidateSchema>
|
||||
export type SetTutorialProblemsRequest = z.infer<typeof setTutorialProblemsRequestSchema>
|
||||
export type ReorderTutorialsRequest = z.infer<typeof reorderTutorialsRequestSchema>
|
||||
export type RenameTutorialProjectRequest = z.infer<typeof renameTutorialProjectRequestSchema>
|
||||
export type CreateTutorialRequest = z.infer<typeof createTutorialRequestSchema>
|
||||
export type UpdateTutorialRequest = z.infer<typeof updateTutorialRequestSchema>
|
||||
export type SetTutorialVisibilityRequest = z.infer<typeof setTutorialVisibilityRequestSchema>
|
||||
|
||||
@@ -63,14 +63,31 @@ export const reactionStateSchema = z.object({
|
||||
|
||||
export const setReactionRequestSchema = z.object({ type: reactionKeySchema })
|
||||
|
||||
/**
|
||||
* 目录里的一课。`title` 是「情境——知识点」(「蜜橘园环境预警——条件语句基础」),
|
||||
* 前端拆成知识点在前、情境在后显示;`project` 是所属项目,目录按它分组,可能为空。
|
||||
*/
|
||||
export const tutorialSummarySchema = z.object({
|
||||
id: z.number().int(),
|
||||
title: z.string(),
|
||||
project: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 一课结尾「去做几道题」里的一道。`solved` 是当前学生做对过没有(未登录恒为 false),
|
||||
* `mustDo` 是带不带「必会题」标签。
|
||||
*/
|
||||
export const tutorialProblemSchema = z.object({
|
||||
id: z.number().int(),
|
||||
displayId: z.string(),
|
||||
title: z.string(),
|
||||
mustDo: z.boolean(),
|
||||
solved: z.boolean(),
|
||||
})
|
||||
|
||||
export const tutorialSchema = tutorialSummarySchema.extend({
|
||||
content: z.string(),
|
||||
code: z.string().nullable(),
|
||||
problems: z.array(tutorialProblemSchema),
|
||||
isPublic: z.boolean(),
|
||||
order: z.number().int(),
|
||||
type: z.enum(["python", "c"]),
|
||||
@@ -173,6 +190,8 @@ export const exerciseSchema = z.object({
|
||||
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
order: z.number().int(),
|
||||
/** 当前学生做对过没有。未登录恒为 false;一课的练一练全做对就算学完这课 */
|
||||
solved: z.boolean(),
|
||||
})
|
||||
|
||||
export type Message = z.infer<typeof messageSchema>
|
||||
@@ -188,20 +207,26 @@ 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 TutorialProblem = z.infer<typeof tutorialProblemSchema>
|
||||
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 秒 —— 一节课上十几分钟点开 17 课的大有人在。练一练是学生
|
||||
* 自己做的、看得见的,「全对了」比「待够了」说得清。停留时长照样记,只给老师看。
|
||||
*
|
||||
* 门槛只管**「已读」这个口径**(学生端的 ✓、后台的已读课数与读过的人数),
|
||||
* 不动累计时长:时长记的是真实停留,不满 3 分钟的那些秒数照样算进去,
|
||||
* 「已读 0 课、累计 25 分钟」正是要让老师看见的一种情况。
|
||||
* 前端目录、首页、后台「自学情况」都走这一个函数,别各写一份。
|
||||
*/
|
||||
export const TUTORIAL_READ_SECONDS = 180
|
||||
export function isLessonDone(progress: {
|
||||
viewCount: number
|
||||
exerciseTotal: number
|
||||
exerciseSolved: number
|
||||
}) {
|
||||
if (progress.exerciseTotal > 0) return progress.exerciseSolved >= progress.exerciseTotal
|
||||
return progress.viewCount > 0
|
||||
}
|
||||
@@ -558,7 +558,8 @@ export const TRIAL_ADMIN_MAX_CASES = 50
|
||||
* 试运行:「运行例子」「自己输入」、后台「生成测试点」。走本站判题机,不落库、不算提交。
|
||||
*
|
||||
* - 带 `problemId`:按这道题的时间 / 内存限制和代码模板跑,和提交一个口径;
|
||||
* - 不带:只有管理员能这么调(后台生成测试点时题目可能还没存)。
|
||||
* - 带 `tutorialId`:自学教程正文里的代码块「运行」,只能跑一组、限制用默认的;
|
||||
* - 都不带:只有管理员能这么调(后台生成测试点时题目可能还没存)。
|
||||
*
|
||||
* `output` 是这组的正确输出,给了就由判题机比对(和提交一样去掉末尾空白再比),
|
||||
* 结果是通过 / 答案错误;不给(「自己输入」、生成测试点)就只看有没有跑出错。
|
||||
@@ -566,6 +567,7 @@ export const TRIAL_ADMIN_MAX_CASES = 50
|
||||
export const trialRunRequestSchema = z.object({
|
||||
problemId: z.number().int().positive().optional(),
|
||||
contestId: z.number().int().positive().optional(),
|
||||
tutorialId: z.number().int().positive().optional(),
|
||||
language: runnableLanguageSchema,
|
||||
code: z
|
||||
.string()
|
||||
|
||||
Reference in new issue
Block a user