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
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "tutorial_problem" (
|
||||
"tutorial_id" integer NOT NULL,
|
||||
"problem_id" integer NOT NULL,
|
||||
"order" integer NOT NULL,
|
||||
CONSTRAINT "tutorial_problem_pkey" PRIMARY KEY("tutorial_id","problem_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tutorial" ADD COLUMN "project" varchar(64) DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tutorial_problem" ADD CONSTRAINT "tutorial_problem_tutorial_id_fk_tutorial_id" FOREIGN KEY ("tutorial_id") REFERENCES "public"."tutorial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tutorial_problem" ADD CONSTRAINT "tutorial_problem_problem_id_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- 项目原来只写在正文第一段(「**所属项目:项目三 · 智慧农业·蜜橘园**」),抽出来回填。
|
||||
-- 没写的(9 篇没公开的老 Python 教程)留空,自学目录里归到「其他」
|
||||
UPDATE "tutorial"
|
||||
SET "project" = left(trim(substring("content" from '\*\*所属项目[::]\s*([^*\n]+)\*\*')), 64)
|
||||
WHERE "content" ~ '\*\*所属项目[::]';
|
||||
File diff suppressed because it is too large.
Load diff
@@ -218,6 +218,13 @@
|
||||
"when": 1791587559546,
|
||||
"tag": "0030_add_user_rank_hidden",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1791766110876,
|
||||
"tag": "0031_tutorial_project_and_problems",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1042,6 +1042,9 @@ export const tutorial = pgTable(
|
||||
createdById: integer("created_by_id").notNull(),
|
||||
code: text(),
|
||||
type: varchar({ length: 10 }).notNull().$type<TutorialType>(),
|
||||
// 所属项目(「项目三 · 智慧农业·蜜橘园」),自学目录按它分组。0031 从正文第一段的
|
||||
// 「**所属项目:…**」回填;发布脚本不传它,后端照样从正文里抽,见 admin/tutorial.ts
|
||||
project: varchar({ length: 64 }).default("").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("tutorial_created_by_id_07973cab").using(
|
||||
@@ -1056,6 +1059,37 @@ export const tutorial = pgTable(
|
||||
],
|
||||
)
|
||||
|
||||
/**
|
||||
* 一课挂的「去做几道题」:老师在后台手挑的题目,按 `order` 排。
|
||||
*
|
||||
* 两头都 CASCADE:教程删了这串题就没意义了;题目删了,教程少挂一道题也不是学生的
|
||||
* 做题证据(证据在 submission 那边,它对 problem 是拦住的)。
|
||||
*/
|
||||
export const tutorialProblem = pgTable(
|
||||
"tutorial_problem",
|
||||
{
|
||||
tutorialId: integer("tutorial_id").notNull(),
|
||||
problemId: integer("problem_id").notNull(),
|
||||
order: integer().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.tutorialId, table.problemId],
|
||||
name: "tutorial_problem_pkey",
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [table.tutorialId],
|
||||
foreignColumns: [tutorial.id],
|
||||
name: "tutorial_problem_tutorial_id_fk_tutorial_id",
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.problemId],
|
||||
foreignColumns: [problem.id],
|
||||
name: "tutorial_problem_problem_id_fk_problem_id",
|
||||
}).onDelete("cascade"),
|
||||
],
|
||||
)
|
||||
|
||||
export const userStat = pgTable(
|
||||
"user_stat",
|
||||
{
|
||||
|
||||
+104
-145
@@ -1,13 +1,10 @@
|
||||
import {
|
||||
STUDENT_ROLES,
|
||||
TUTORIAL_READ_SECONDS,
|
||||
isLessonDone,
|
||||
type LearnBoard,
|
||||
type LearnExerciseAttempt,
|
||||
type LearnExerciseProgress,
|
||||
type LearnExerciseProgressList,
|
||||
type LearnStudentProgress,
|
||||
type LearnStudentProgressList,
|
||||
type LearnTutorialProgress,
|
||||
type LearnTutorialProgressList,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -15,6 +12,8 @@ import { Hono } from "hono"
|
||||
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { defaultClassFor } from "../../services/ranking"
|
||||
import { exerciseCountColumn } from "../../services/tutorial"
|
||||
import { queryInteger, rounded } from "../helpers"
|
||||
|
||||
/**
|
||||
@@ -64,157 +63,117 @@ function studentCondition(value: string | null) {
|
||||
)
|
||||
}
|
||||
|
||||
adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
|
||||
/**
|
||||
* 方块格:一个班的学生 × 这门课的每一课,打开过没有、学完没有(练一练全对)。
|
||||
*
|
||||
* 不选班就按排名页同一套规则猜老师在教哪个班(`defaultClassFor`);也能传年级前缀,
|
||||
* 但一个年级两三百行方块,页面上主要还是按班看。
|
||||
*/
|
||||
adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
|
||||
const type = tutorialTypeOf(c.req.query("type"))
|
||||
const className = classFilter(c.req.query("className"))
|
||||
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
|
||||
const requested = classFilter(c.req.query("className"))
|
||||
if (!requested.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
|
||||
const className = requested.value ?? (await defaultClassFor(c.get("user")!.id))
|
||||
|
||||
// 该语言下已公开的教程,既是分母,也是「哪些课算数」的白名单 ——
|
||||
// 未公开的课学生本来就打不开,混进来会让读完的人显示成没读完
|
||||
const tutorials = await db
|
||||
.select({ id: schema.tutorial.id })
|
||||
.from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
|
||||
const tutorialIds = tutorials.map((row) => row.id)
|
||||
|
||||
// 学生表打底 left join 进度:没读过的人也要出现在结果里,这是这张表的重点
|
||||
const progressJoin = tutorialIds.length
|
||||
? and(
|
||||
eq(schema.tutorialProgress.userId, schema.user.id),
|
||||
inArray(schema.tutorialProgress.tutorialId, tutorialIds),
|
||||
)
|
||||
: sql`false`
|
||||
|
||||
// 阅读和练习**分两条查**再在内存里拼。写成一条的话,一个学生读了 3 课、
|
||||
// 做了 8 道练习,join 出来是 24 行,count 全是错的 —— 两个一对多挂在同一张表上
|
||||
// 就是这个下场,用 filter 也救不回来
|
||||
const [rows, exerciseRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
realName: schema.userProfile.realName,
|
||||
className: schema.user.className,
|
||||
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
|
||||
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
|
||||
readCount:
|
||||
sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
totalSeconds:
|
||||
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number),
|
||||
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`,
|
||||
})
|
||||
.from(schema.user)
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.leftJoin(schema.tutorialProgress, progressJoin)
|
||||
.where(studentCondition(className.value))
|
||||
.groupBy(
|
||||
schema.user.id,
|
||||
schema.user.username,
|
||||
schema.userProfile.realName,
|
||||
schema.user.className,
|
||||
)
|
||||
// 前端默认按「已读」升序排,同分的一大批(尤其一堆 0)就落回这里的次序。
|
||||
// 不给 orderBy 的话那是聚合吐出来的任意顺序,刷一次换一个样 —— 按班级、
|
||||
// 学号排稳住它。className 为空的(推不出班级的)ASC 默认排在最后
|
||||
.orderBy(asc(schema.user.className), asc(schema.user.username)),
|
||||
db
|
||||
.select({
|
||||
userId: schema.exerciseAttempt.userId,
|
||||
tried: count(),
|
||||
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}), 0)`.mapWith(Number),
|
||||
})
|
||||
.from(schema.exerciseAttempt)
|
||||
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
|
||||
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
|
||||
.groupBy(schema.exerciseAttempt.userId),
|
||||
])
|
||||
const attempts = new Map(exerciseRows.map((row) => [row.userId, row]))
|
||||
|
||||
const [exerciseCountRow] = tutorialIds.length
|
||||
? await db
|
||||
.select({ value: count() })
|
||||
.from(schema.exercise)
|
||||
.where(inArray(schema.exercise.tutorialId, tutorialIds))
|
||||
: [{ value: 0 }]
|
||||
|
||||
return success(c, {
|
||||
tutorialCount: tutorialIds.length,
|
||||
exerciseCount: exerciseCountRow?.value ?? 0,
|
||||
results: rows.map(
|
||||
(row) =>
|
||||
({
|
||||
...row,
|
||||
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
|
||||
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
|
||||
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
|
||||
}) satisfies LearnStudentProgress,
|
||||
),
|
||||
} satisfies LearnStudentProgressList)
|
||||
})
|
||||
|
||||
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => {
|
||||
const type = tutorialTypeOf(c.req.query("type"))
|
||||
const className = classFilter(c.req.query("className"))
|
||||
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
|
||||
|
||||
const [studentCountRow] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.user)
|
||||
.where(studentCondition(className.value))
|
||||
const studentCount = studentCountRow?.value ?? 0
|
||||
|
||||
// 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面
|
||||
const rows = await db
|
||||
.select({
|
||||
tutorialId: schema.tutorial.id,
|
||||
id: schema.tutorial.id,
|
||||
title: schema.tutorial.title,
|
||||
order: schema.tutorial.order,
|
||||
// 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、
|
||||
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
|
||||
// 而 progress.user_id 那边永远非空,会把过滤当没发生
|
||||
readers:
|
||||
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
totalSeconds:
|
||||
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
|
||||
Number,
|
||||
),
|
||||
// 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长,
|
||||
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
|
||||
readSeconds:
|
||||
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(
|
||||
Number,
|
||||
),
|
||||
project: schema.tutorial.project,
|
||||
exerciseTotal: exerciseCountColumn,
|
||||
})
|
||||
.from(schema.tutorial)
|
||||
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
|
||||
.leftJoin(
|
||||
schema.user,
|
||||
and(eq(schema.user.id, schema.tutorialProgress.userId), studentCondition(className.value)),
|
||||
)
|
||||
// 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉,
|
||||
// 而「一节课一个人都没读」恰恰是老师最需要看见的一行
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
|
||||
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
|
||||
.orderBy(asc(schema.tutorial.order))
|
||||
const tutorialIds = tutorials.map((row) => row.id)
|
||||
if (!className)
|
||||
return success(c, { className: null, tutorials, students: [] } satisfies LearnBoard)
|
||||
|
||||
const students = await db
|
||||
.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
realName: schema.userProfile.realName,
|
||||
className: schema.user.className,
|
||||
})
|
||||
.from(schema.user)
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(studentCondition(className))
|
||||
.orderBy(asc(schema.user.className), asc(schema.user.username))
|
||||
const userIds = students.map((row) => row.userId)
|
||||
|
||||
// 阅读和练习分两条查再拼:两个一对多挂在同一张表上 join,count 全是错的
|
||||
const [progressRows, solvedRows] =
|
||||
userIds.length && tutorialIds.length
|
||||
? await Promise.all([
|
||||
db
|
||||
.select({
|
||||
userId: schema.tutorialProgress.userId,
|
||||
tutorialId: schema.tutorialProgress.tutorialId,
|
||||
viewCount: schema.tutorialProgress.viewCount,
|
||||
totalSeconds: schema.tutorialProgress.totalSeconds,
|
||||
lastViewedAt: schema.tutorialProgress.lastViewedAt,
|
||||
})
|
||||
.from(schema.tutorialProgress)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.tutorialProgress.userId, userIds),
|
||||
inArray(schema.tutorialProgress.tutorialId, tutorialIds),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({
|
||||
userId: schema.exerciseAttempt.userId,
|
||||
tutorialId: schema.exercise.tutorialId,
|
||||
solved: count(),
|
||||
})
|
||||
.from(schema.exerciseAttempt)
|
||||
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.exerciseAttempt.userId, userIds),
|
||||
inArray(schema.exercise.tutorialId, tutorialIds),
|
||||
eq(schema.exerciseAttempt.solved, true),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.exerciseAttempt.userId, schema.exercise.tutorialId),
|
||||
])
|
||||
: [[], []]
|
||||
|
||||
const progress = new Map<string, (typeof progressRows)[number]>()
|
||||
for (const row of progressRows) progress.set(`${row.userId}:${row.tutorialId}`, row)
|
||||
const solved = new Map<string, number>()
|
||||
for (const row of solvedRows) solved.set(`${row.userId}:${row.tutorialId}`, row.solved)
|
||||
|
||||
return success(c, {
|
||||
studentCount,
|
||||
results: rows.map(
|
||||
({ readSeconds, ...row }) =>
|
||||
({
|
||||
...row,
|
||||
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
|
||||
}) satisfies LearnTutorialProgress,
|
||||
),
|
||||
} satisfies LearnTutorialProgressList)
|
||||
className,
|
||||
tutorials,
|
||||
students: students.map((student) => {
|
||||
const opened: number[] = []
|
||||
const done: number[] = []
|
||||
let exerciseSolved = 0
|
||||
let totalSeconds = 0
|
||||
let lastViewedAt: string | null = null
|
||||
for (const tutorial of tutorials) {
|
||||
const key = `${student.userId}:${tutorial.id}`
|
||||
const row = progress.get(key)
|
||||
const solvedHere = solved.get(key) ?? 0
|
||||
exerciseSolved += solvedHere
|
||||
if (row) {
|
||||
opened.push(tutorial.id)
|
||||
totalSeconds += row.totalSeconds
|
||||
if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt
|
||||
}
|
||||
const lesson = {
|
||||
viewCount: row?.viewCount ?? 0,
|
||||
exerciseTotal: tutorial.exerciseTotal,
|
||||
exerciseSolved: solvedHere,
|
||||
}
|
||||
if (isLessonDone(lesson)) done.push(tutorial.id)
|
||||
}
|
||||
return { ...student, opened, done, exerciseSolved, totalSeconds, lastViewedAt }
|
||||
}),
|
||||
} satisfies LearnBoard)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,34 +1,57 @@
|
||||
import {
|
||||
STUDENT_ROLES,
|
||||
createExerciseRequestSchema,
|
||||
createTutorialRequestSchema,
|
||||
renameTutorialProjectRequestSchema,
|
||||
reorderTutorialsRequestSchema,
|
||||
setTutorialProblemsRequestSchema,
|
||||
setTutorialVisibilityRequestSchema,
|
||||
updateExerciseRequestSchema,
|
||||
updateTutorialRequestSchema,
|
||||
type AdminExercise,
|
||||
type AdminTutorial,
|
||||
type AdminTutorialGroups,
|
||||
type AdminTutorialListItem,
|
||||
type TutorialProblemCandidate,
|
||||
} from "@oj2/contract"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { and, asc, countDistinct, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import { exerciseDataError } from "../../services/exercise"
|
||||
import {
|
||||
MUST_DO_TAG,
|
||||
exerciseCountColumn,
|
||||
mustDoColumn,
|
||||
problemCountColumn,
|
||||
problemTagNames,
|
||||
projectFromContent,
|
||||
tutorialProblems,
|
||||
} from "../../services/tutorial"
|
||||
import { asRecord, queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
export const adminTutorialRoutes = new Hono<AppEnv>()
|
||||
|
||||
function serializeTutorial(row: {
|
||||
async function serializeTutorial(row: {
|
||||
tutorial: typeof schema.tutorial.$inferSelect
|
||||
user: typeof schema.user.$inferSelect
|
||||
realName: string | null
|
||||
}) {
|
||||
const problems = await tutorialProblems([row.tutorial.id], false)
|
||||
return {
|
||||
id: row.tutorial.id,
|
||||
title: row.tutorial.title,
|
||||
content: row.tutorial.content,
|
||||
code: row.tutorial.code,
|
||||
project: row.tutorial.project,
|
||||
problems: problems.map(({ id, displayId, title, mustDo }) => ({
|
||||
id,
|
||||
displayId,
|
||||
title,
|
||||
mustDo,
|
||||
})),
|
||||
isPublic: row.tutorial.isPublic,
|
||||
order: row.tutorial.order,
|
||||
type: row.tutorial.type,
|
||||
@@ -53,59 +76,174 @@ function selectTutorial(id: number) {
|
||||
}
|
||||
|
||||
adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
tutorial: schema.tutorial,
|
||||
user: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
})
|
||||
.from(schema.tutorial)
|
||||
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt))
|
||||
const all = rows.map(serializeTutorial)
|
||||
// 分组返回,形状对齐旧 TutorialAdminAPI.get;列表 schema omit 掉了 content/code,Zod 会 strip
|
||||
const [rows, readerRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
tutorial: schema.tutorial,
|
||||
user: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
exerciseCount: exerciseCountColumn,
|
||||
problemCount: problemCountColumn,
|
||||
})
|
||||
.from(schema.tutorial)
|
||||
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt)),
|
||||
// 打开过的学生数:老师自己点开看的不算
|
||||
db
|
||||
.select({
|
||||
tutorialId: schema.tutorialProgress.tutorialId,
|
||||
readers: countDistinct(schema.tutorialProgress.userId),
|
||||
})
|
||||
.from(schema.tutorialProgress)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.tutorialProgress.userId))
|
||||
.where(inArray(schema.user.adminType, [...STUDENT_ROLES]))
|
||||
.groupBy(schema.tutorialProgress.tutorialId),
|
||||
])
|
||||
const readers = new Map(readerRows.map((row) => [row.tutorialId, row.readers]))
|
||||
const all = rows.map(
|
||||
(row) =>
|
||||
({
|
||||
id: row.tutorial.id,
|
||||
title: row.tutorial.title,
|
||||
project: row.tutorial.project,
|
||||
isPublic: row.tutorial.isPublic,
|
||||
order: row.tutorial.order,
|
||||
type: row.tutorial.type,
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
createdAt: row.tutorial.createdAt,
|
||||
updatedAt: row.tutorial.updatedAt,
|
||||
exerciseCount: row.exerciseCount,
|
||||
problemCount: row.problemCount,
|
||||
readers: readers.get(row.tutorial.id) ?? 0,
|
||||
}) satisfies AdminTutorialListItem,
|
||||
)
|
||||
// 分组返回,形状对齐旧 TutorialAdminAPI.get
|
||||
return success(c, {
|
||||
python: all.filter((item) => item.type === "python"),
|
||||
c: all.filter((item) => item.type === "c"),
|
||||
} satisfies AdminTutorialGroups)
|
||||
})
|
||||
|
||||
/**
|
||||
* 拖动排序:把这一串 id 的 order 依次改成 1、2、3……
|
||||
* 路径不放在 `/tutorials/` 下,理由同 learn.ts 顶上那段:`/tutorials/:id` 会先吃掉它。
|
||||
*/
|
||||
adminTutorialRoutes.put("/tutorial-order", requireSuperAdmin, async (c) => {
|
||||
const parsed = await parseBody(c, reorderTutorialsRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const ids = parsed.data.ids
|
||||
await db.transaction(async (tx) => {
|
||||
for (const [index, id] of ids.entries()) {
|
||||
// 排序不是内容修改,不动 updatedAt
|
||||
await tx
|
||||
.update(schema.tutorial)
|
||||
.set({ order: index + 1 })
|
||||
.where(eq(schema.tutorial.id, id))
|
||||
}
|
||||
})
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
/** 项目改名:这门课里挂在旧名字下的教程一起改 */
|
||||
adminTutorialRoutes.put("/tutorial-projects", requireSuperAdmin, async (c) => {
|
||||
const parsed = await parseBody(c, renameTutorialProjectRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { type, from, to } = parsed.data
|
||||
await db
|
||||
.update(schema.tutorial)
|
||||
.set({ project: to })
|
||||
.where(and(eq(schema.tutorial.type, type), eq(schema.tutorial.project, from)))
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
/**
|
||||
* 给一课挑题的候选。不带关键字列出全部必会题;带关键字按题号、标题找,
|
||||
* 必会题排在前面。只给公开题库里的题(比赛题学生点不进去)。
|
||||
*/
|
||||
adminTutorialRoutes.get("/tutorial-problem-candidates", requireSuperAdmin, async (c) => {
|
||||
const keyword = c.req.query("keyword")?.trim() ?? ""
|
||||
const pattern = `%${keyword.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
mustDo: mustDoColumn,
|
||||
tags: problemTagNames,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.problem.visible, true),
|
||||
keyword
|
||||
? or(eq(schema.problem.displayId, keyword), ilike(schema.problem.title, pattern))
|
||||
: mustDoColumn,
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
desc(sql`${schema.problem.displayId} = ${keyword}`),
|
||||
desc(mustDoColumn),
|
||||
asc(schema.problem.displayId),
|
||||
)
|
||||
.limit(keyword ? 20 : 60)
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
...row,
|
||||
tags: row.tags.filter((tag) => tag !== MUST_DO_TAG),
|
||||
}) satisfies TutorialProblemCandidate,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
|
||||
const parsed = await parseBody(c, createTutorialRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const now = new Date().toISOString()
|
||||
const { project, ...data } = parsed.data
|
||||
const [created] = await db
|
||||
.insert(schema.tutorial)
|
||||
.values({
|
||||
...parsed.data,
|
||||
...data,
|
||||
project: project ?? projectFromContent(data.content),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdById: c.get("user")!.id,
|
||||
})
|
||||
.returning({ id: schema.tutorial.id })
|
||||
const [row] = await selectTutorial(created!.id)
|
||||
return success(c, serializeTutorial(row!), 201)
|
||||
return success(c, await serializeTutorial(row!), 201)
|
||||
})
|
||||
|
||||
adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||
const [row] = await selectTutorial(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
return success(c, serializeTutorial(row))
|
||||
return success(c, await serializeTutorial(row))
|
||||
})
|
||||
|
||||
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = await parseBody(c, updateTutorialRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { project, ...data } = parsed.data
|
||||
// 没传 project(发布脚本)就从正文里抽;正文里也没写就不动原来的
|
||||
const derived = project ?? (projectFromContent(data.content) || undefined)
|
||||
const updated = await db
|
||||
.update(schema.tutorial)
|
||||
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
|
||||
.set({
|
||||
...data,
|
||||
...(derived === undefined ? {} : { project: derived }),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.tutorial.id, id))
|
||||
.returning({ id: schema.tutorial.id })
|
||||
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const [row] = await selectTutorial(id)
|
||||
return success(c, serializeTutorial(row!))
|
||||
return success(c, await serializeTutorial(row!))
|
||||
})
|
||||
|
||||
adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c) => {
|
||||
@@ -120,13 +258,41 @@ adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c
|
||||
.returning({ id: schema.tutorial.id })
|
||||
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const [row] = await selectTutorial(id)
|
||||
return success(c, serializeTutorial(row!))
|
||||
return success(c, await serializeTutorial(row!))
|
||||
})
|
||||
|
||||
/** 一课挂哪几道题:整串替换,顺序就是数组顺序 */
|
||||
adminTutorialRoutes.put("/tutorials/:id/problems", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = await parseBody(c, setTutorialProblemsRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const problemIds = [...new Set(parsed.data.problemIds)]
|
||||
const [row] = await selectTutorial(id)
|
||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
if (problemIds.length) {
|
||||
const found = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(and(inArray(schema.problem.id, problemIds), isNull(schema.problem.contestId)))
|
||||
if (found.length !== problemIds.length) {
|
||||
return failure(c, 400, "problem-not-found", "有的题不存在,或者是比赛题")
|
||||
}
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(schema.tutorialProblem).where(eq(schema.tutorialProblem.tutorialId, id))
|
||||
if (problemIds.length) {
|
||||
await tx
|
||||
.insert(schema.tutorialProblem)
|
||||
.values(problemIds.map((problemId, index) => ({ tutorialId: id, problemId, order: index })))
|
||||
}
|
||||
})
|
||||
return success(c, await serializeTutorial(row))
|
||||
})
|
||||
|
||||
adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 练习与学习留痕都随教程一起没:exercise.tutorial_id 与 tutorial_progress.tutorial_id
|
||||
// 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**,
|
||||
// 练习、学习留痕、挂的题都随教程一起没:exercise / tutorial_progress / tutorial_problem
|
||||
// 的 tutorial_id 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**,
|
||||
// 别默认新表会自己连坐 —— 0010 只改了当时存在的那批外键。
|
||||
const deleted = await db
|
||||
.delete(schema.tutorial)
|
||||
|
||||
@@ -20,10 +20,12 @@ import {
|
||||
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
|
||||
import { optionalAuth, requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { problemStates } from "../services/problem-progress"
|
||||
import { tutorialProblems } from "../services/tutorial"
|
||||
import { asRecord, queryInteger, sampleUser } from "./helpers"
|
||||
|
||||
export const contentRoutes = new Hono<AppEnv>()
|
||||
@@ -254,15 +256,20 @@ contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
contentRoutes.get("/tutorials", async (c) => {
|
||||
const type = c.req.query("type") === "c" ? "c" : "python"
|
||||
const rows = await db
|
||||
.select({ id: schema.tutorial.id, title: schema.tutorial.title })
|
||||
.select({
|
||||
id: schema.tutorial.id,
|
||||
title: schema.tutorial.title,
|
||||
project: schema.tutorial.project,
|
||||
})
|
||||
.from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
|
||||
.orderBy(asc(schema.tutorial.order))
|
||||
return success(c, rows satisfies TutorialSummary[])
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
contentRoutes.get("/tutorials/:id", optionalAuth, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const user = c.get("user")
|
||||
const [row] = await db
|
||||
.select({
|
||||
tutorial: schema.tutorial,
|
||||
@@ -275,11 +282,22 @@ contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
|
||||
.limit(1)
|
||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const [problems, states] = await Promise.all([
|
||||
tutorialProblems([id], true),
|
||||
user ? problemStates(user.id) : null,
|
||||
])
|
||||
return success(c, {
|
||||
id: row.tutorial.id,
|
||||
title: row.tutorial.title,
|
||||
project: row.tutorial.project,
|
||||
content: row.tutorial.content,
|
||||
code: row.tutorial.code,
|
||||
problems: problems.map(({ id, displayId, title, mustDo }) => ({
|
||||
id,
|
||||
displayId,
|
||||
title,
|
||||
mustDo,
|
||||
solved: states?.solved.has(id) ?? false,
|
||||
})),
|
||||
isPublic: row.tutorial.isPublic,
|
||||
order: row.tutorial.order,
|
||||
type: row.tutorial.type,
|
||||
@@ -475,8 +493,9 @@ contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
|
||||
contentRoutes.get("/tutorials/:id/exercises", optionalAuth, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const user = c.get("user")
|
||||
const [tutorial] = await db
|
||||
.select({ id: schema.tutorial.id })
|
||||
.from(schema.tutorial)
|
||||
@@ -484,19 +503,33 @@ contentRoutes.get("/tutorials/:id/exercises", async (c) => {
|
||||
.limit(1)
|
||||
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const rows = await db
|
||||
.select()
|
||||
.select({
|
||||
exercise: schema.exercise,
|
||||
// 目录、大纲上标「做对过」,一课的练一练全对就算学完这课
|
||||
solved: user
|
||||
? sql<boolean>`coalesce(${schema.exerciseAttempt.solved}, false)`
|
||||
: sql<boolean>`false`,
|
||||
})
|
||||
.from(schema.exercise)
|
||||
.leftJoin(
|
||||
schema.exerciseAttempt,
|
||||
and(
|
||||
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
|
||||
eq(schema.exerciseAttempt.userId, user?.id ?? 0),
|
||||
),
|
||||
)
|
||||
.where(eq(schema.exercise.tutorialId, id))
|
||||
.orderBy(asc(schema.exercise.order))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({ exercise: row, solved }) =>
|
||||
({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
data: asRecord(row.data),
|
||||
order: row.order,
|
||||
solved,
|
||||
}) satisfies Exercise,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type SampleUser,
|
||||
type WeeklyChampion,
|
||||
} from "@oj2/contract"
|
||||
import { and, countDistinct, desc, eq, gte, inArray, isNotNull, ne } from "drizzle-orm"
|
||||
import { and, eq, inArray, isNotNull } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
@@ -23,10 +23,10 @@ import {
|
||||
classBattle,
|
||||
classDetail,
|
||||
classPk,
|
||||
defaultClassFor,
|
||||
loadEntrants,
|
||||
PK_MAX_CLASSES,
|
||||
pkPartner,
|
||||
rankedStudents,
|
||||
standings,
|
||||
WEEK_MS,
|
||||
type Standing,
|
||||
@@ -73,48 +73,6 @@ function periodStart(period: RankPeriod) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师打开排名页默认看哪个班:自己最近布置过作业的班 → 最近一周交题人数最多的班。
|
||||
* 不记老师上次选的:统计面板吃过那个亏(上一节课的班悄悄留在框里)。
|
||||
*/
|
||||
async function defaultClassFor(teacherId: number) {
|
||||
const [lesson] = await db
|
||||
.select({ className: schema.classLesson.className })
|
||||
.from(schema.classLesson)
|
||||
.where(eq(schema.classLesson.createdBy, teacherId))
|
||||
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.updatedAt))
|
||||
.limit(1)
|
||||
if (lesson) return lesson.className
|
||||
|
||||
const users = countDistinct(schema.submission.userId)
|
||||
const [busiest] = await db
|
||||
.select({ className: schema.user.className, users })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(
|
||||
and(
|
||||
rankedStudents,
|
||||
isNotNull(schema.user.className),
|
||||
ne(schema.user.className, ""),
|
||||
gte(schema.submission.createTime, new Date(Date.now() - WEEK_MS).toISOString()),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.user.className)
|
||||
.orderBy(desc(users))
|
||||
.limit(1)
|
||||
if (busiest?.className) return busiest.className
|
||||
|
||||
// 放假回来第一次打开:一周里没人交过题,就看最后一个交题的学生是哪个班
|
||||
const [latest] = await db
|
||||
.select({ className: schema.user.className })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(and(rankedStudents, isNotNull(schema.user.className), ne(schema.user.className, "")))
|
||||
.orderBy(desc(schema.submission.createTime))
|
||||
.limit(1)
|
||||
return latest?.className ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次请求看的是哪个班 / 哪个年级。学生只能看自己的班和年级;老师用 `className` 选,
|
||||
* 不给就猜(`defaultClassFor`)。返回 undefined 表示该报「没有班级」。
|
||||
|
||||
@@ -16,6 +16,9 @@ export const trialRunRoutes = new Hono<AppEnv>()
|
||||
/** 后台生成测试点时题目可能还没存,没有题目的限制可用,按这两个跑 */
|
||||
const ADMIN_TIME_LIMIT = 3000
|
||||
const ADMIN_MEMORY_LIMIT = 256
|
||||
/** 自学教程里的代码块:都是几行的例子,给和普通题一样的限制,死循环早点停 */
|
||||
const TUTORIAL_TIME_LIMIT = 1000
|
||||
const TUTORIAL_MEMORY_LIMIT = 256
|
||||
|
||||
/**
|
||||
* 试运行,见 judge/trial.ts。不落库、不算提交、不走令牌桶 ——
|
||||
@@ -37,7 +40,22 @@ trialRunRoutes.post("/trial-runs", requireAuth, async (c) => {
|
||||
let memoryLimit = ADMIN_MEMORY_LIMIT
|
||||
let source = body.code
|
||||
let prependLines = 0
|
||||
if (body.problemId === undefined) {
|
||||
if (body.problemId === undefined && body.tutorialId !== undefined) {
|
||||
// 教程正文里的代码块:只有一组输入(代码块下面那个输入框),教程得是公开的
|
||||
if (body.cases.length !== 1) {
|
||||
return failure(c, 400, "too-many-cases", "教程里的代码一次只能跑一组")
|
||||
}
|
||||
const [tutorial] = await db
|
||||
.select({ isPublic: schema.tutorial.isPublic })
|
||||
.from(schema.tutorial)
|
||||
.where(eq(schema.tutorial.id, body.tutorialId))
|
||||
.limit(1)
|
||||
if (!tutorial || (!tutorial.isPublic && !admin)) {
|
||||
return failure(c, 404, "tutorial-not-found", "教程不存在")
|
||||
}
|
||||
timeLimit = TUTORIAL_TIME_LIMIT
|
||||
memoryLimit = TUTORIAL_MEMORY_LIMIT
|
||||
} else if (body.problemId === undefined) {
|
||||
if (!admin) return failure(c, 403, "permission-denied", "请从题目页试运行")
|
||||
} else {
|
||||
let contestId: number | null = null
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import {
|
||||
and,
|
||||
count,
|
||||
countDistinct,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
@@ -534,3 +536,45 @@ export async function classPk(
|
||||
solo,
|
||||
} satisfies ClassPk
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师打开排名页(和后台「自学情况」)默认看哪个班:自己最近布置过作业的班 → 最近一周交题人数最多的班。
|
||||
* 不记老师上次选的:统计面板吃过那个亏(上一节课的班悄悄留在框里)。
|
||||
*/
|
||||
export async function defaultClassFor(teacherId: number) {
|
||||
const [lesson] = await db
|
||||
.select({ className: schema.classLesson.className })
|
||||
.from(schema.classLesson)
|
||||
.where(eq(schema.classLesson.createdBy, teacherId))
|
||||
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.updatedAt))
|
||||
.limit(1)
|
||||
if (lesson) return lesson.className
|
||||
|
||||
const users = countDistinct(schema.submission.userId)
|
||||
const [busiest] = await db
|
||||
.select({ className: schema.user.className, users })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(
|
||||
and(
|
||||
rankedStudents,
|
||||
isNotNull(schema.user.className),
|
||||
ne(schema.user.className, ""),
|
||||
gte(schema.submission.createTime, new Date(Date.now() - WEEK_MS).toISOString()),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.user.className)
|
||||
.orderBy(desc(users))
|
||||
.limit(1)
|
||||
if (busiest?.className) return busiest.className
|
||||
|
||||
// 放假回来第一次打开:一周里没人交过题,就看最后一个交题的学生是哪个班
|
||||
const [latest] = await db
|
||||
.select({ className: schema.user.className })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(and(rankedStudents, isNotNull(schema.user.className), ne(schema.user.className, "")))
|
||||
.orderBy(desc(schema.submission.createTime))
|
||||
.limit(1)
|
||||
return latest?.className ?? null
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
|
||||
/** 「去做几道题」里优先推荐、要标出来的那批题挂的标签 */
|
||||
export const MUST_DO_TAG = "必会题"
|
||||
|
||||
const PROJECT_RE = /\*\*所属项目[::]\s*([^*\n]+)\*\*/
|
||||
|
||||
/**
|
||||
* 从正文第一段的「**所属项目:项目三 · 智慧农业·蜜橘园**」抽出项目名,抽不到给空串。
|
||||
* 和 0031 迁移回填用的是同一个写法;发布脚本不传 `project` 时靠它。
|
||||
*/
|
||||
export function projectFromContent(content: string) {
|
||||
return (content.match(PROJECT_RE)?.[1] ?? "").trim().slice(0, 64)
|
||||
}
|
||||
|
||||
/**
|
||||
* 放进 select 列表的关联子查询**一律手写表别名**:drizzle 在 select 列表里渲染列时
|
||||
* 会省掉表名,子查询里的 `"id"` 要么报 ambiguous,要么就近绑到子查询自己的表上 ——
|
||||
* 后一种不报错,只是数全错。外层表也写死成 `"problem"` / `"tutorial"`。
|
||||
*/
|
||||
|
||||
/** 这道题带不带「必会题」标签,做成 select 里的一列 */
|
||||
export const mustDoColumn = sql<boolean>`exists (
|
||||
select 1 from "problem_tags" pt join "problem_tag" t on t."id" = pt."problemtag_id"
|
||||
where pt."problem_id" = "problem"."id" and t."name" = ${MUST_DO_TAG}
|
||||
)`
|
||||
|
||||
/** 这道题的全部标签名 */
|
||||
export const problemTagNames = sql<string[]>`coalesce((
|
||||
select array_agg(t."name" order by t."name")
|
||||
from "problem_tags" pt join "problem_tag" t on t."id" = pt."problemtag_id"
|
||||
where pt."problem_id" = "problem"."id"
|
||||
), '{}')`
|
||||
|
||||
/** 一课有几道练一练 / 挂了几道题 */
|
||||
export const exerciseCountColumn = sql<number>`(
|
||||
select count(*) from "exercise" e where e."tutorial_id" = "tutorial"."id"
|
||||
)`.mapWith(Number)
|
||||
export const problemCountColumn = sql<number>`(
|
||||
select count(*) from "tutorial_problem" tp where tp."tutorial_id" = "tutorial"."id"
|
||||
)`.mapWith(Number)
|
||||
|
||||
/**
|
||||
* 几课各挂了哪些题,按老师排的顺序。`publicOnly` 给学生端用:没公开的题、比赛题不出现
|
||||
* (老师挑的时候公开、后来藏起来了的,学生点进去只会 404)。
|
||||
*/
|
||||
export async function tutorialProblems(tutorialIds: number[], publicOnly: boolean) {
|
||||
if (tutorialIds.length === 0) return []
|
||||
return db
|
||||
.select({
|
||||
tutorialId: schema.tutorialProblem.tutorialId,
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
mustDo: mustDoColumn,
|
||||
})
|
||||
.from(schema.tutorialProblem)
|
||||
.innerJoin(schema.problem, eq(schema.problem.id, schema.tutorialProblem.problemId))
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.tutorialProblem.tutorialId, tutorialIds),
|
||||
publicOnly
|
||||
? and(eq(schema.problem.visible, true), isNull(schema.problem.contestId))
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.tutorialProblem.tutorialId), asc(schema.tutorialProblem.order))
|
||||
}
|
||||
Reference in new issue
Block a user