From af9cb796bdd5524ae298e7666b4d54e71baae745 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 7 Aug 2026 07:10:34 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E9=98=B6=E6=AE=B54):=20=E6=95=99=E7=A8=8B?= =?UTF-8?q?=20/=20=E7=BB=83=E4=B9=A0=20/=20AI=20=E5=AD=A6=E6=83=85?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E7=9A=84=E5=90=8E=E5=8F=B0=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/POST admin/tutorials GET/PUT/DELETE admin/tutorials/:id PUT admin/tutorials/:id/visibility GET admin/tutorials/:id/exercises POST admin/exercises PUT/DELETE admin/exercises/:id GET admin/ai/reports (?pinnedOnly=true 不分页) GET admin/ai/reports/:id POST admin/ai/reports/:id/pin 几处判断: - 练习改成挂在教程下的嵌套路径,旧后端是 ?tutorial_id= 查询参数。本来就是一对多的 从属关系,嵌套更贴事实,也省掉「忘了传 tutorial_id」这类错误。 - 删教程必须先删练习。Django 的 on_delete=CASCADE 是应用层实现的,库里外键实际是 NO ACTION(核对过 pg_constraint.confdeltype='a'),直接删会撞外键变 500。 已实测:带 2 个练习的教程能正常删掉且练习一并清除。**后台每个 DELETE 都要照此 核一遍子表**,这是本阶段的通用陷阱。 - 改可见性不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉。 - AI 报告的 data / systemPrompt / userPrompt 一律不下发,里面是喂给模型的原始 学情数据与提示词。列表只给 120 字摘要,与旧 AIAnalysisListSerializer 一致。 实测:匿名 401 / 学生 403 / 超管 200;教程练习增删改查、非法 type 400、 挂到不存在的教程 404、置顶互斥(同一学生至多一份)、再钉一次取消,全部符合预期。 Co-Authored-By: Claude Opus 5 --- apps/api/src/routes/admin/ai.ts | 101 ++++++++++++++ apps/api/src/routes/admin/index.ts | 4 + apps/api/src/routes/admin/tutorial.ts | 185 ++++++++++++++++++++++++++ apps/web/src/admin/api.ts | 75 ++++++++--- packages/contract/src/admin.ts | 95 +++++++++++++ 5 files changed, 441 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/routes/admin/ai.ts create mode 100644 apps/api/src/routes/admin/tutorial.ts diff --git a/apps/api/src/routes/admin/ai.ts b/apps/api/src/routes/admin/ai.ts new file mode 100644 index 0000000..5490b8f --- /dev/null +++ b/apps/api/src/routes/admin/ai.ts @@ -0,0 +1,101 @@ +import { + adminAiReportListSchema, + adminAiReportListItemSchema, + adminAiReportSchema, + toggleAiReportPinResponseSchema, +} from "@oj2/contract" +import { and, count, desc, eq, ilike } from "drizzle-orm" +import { Hono } from "hono" + +import { requireTeacher, type AppEnv } from "../../auth/middleware" +import { db, schema } from "../../db" +import { failure, success } from "../../http" +import { queryInteger } from "../helpers" + +export const adminAiRoutes = new Hono() + +/** 对齐旧 AIAnalysisListSerializer.get_analysis_excerpt:压掉空白后截 120 字,超出加省略号 */ +function excerpt(analysis: string | null) { + if (!analysis) return "" + const text = analysis.split(/\s+/).filter(Boolean).join(" ") + return text.length <= 120 ? text : `${text.slice(0, 120)}…` +} + +function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) { + return adminAiReportListItemSchema.parse({ + id: row.id, + username: row.username, + createTime: row.createTime, + analysisExcerpt: excerpt(row.analysis), + isPinned: row.isPinned, + }) +} + +const listColumns = { + id: schema.aiAnalysis.id, + username: schema.user.username, + createTime: schema.aiAnalysis.createTime, + analysis: schema.aiAnalysis.analysis, + isPinned: schema.aiAnalysis.isPinned, +} + +adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => { + const username = c.req.query("username")?.trim() + const where = username ? ilike(schema.user.username, `%${username}%`) : undefined + + // 置顶列表不分页:它是「每个学生最新钉住的那份」,数量等于学生数,前端一次性拿走 + if (c.req.query("pinnedOnly") === "true") { + const rows = await db.select(listColumns).from(schema.aiAnalysis) + .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)) + .where(and(eq(schema.aiAnalysis.isPinned, true), where)) + .orderBy(desc(schema.aiAnalysis.createTime)) + return success(c, rows.map(listItem)) + } + + const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) + const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) + const [totalRows, rows] = await Promise.all([ + db.select({ value: count() }).from(schema.aiAnalysis) + .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where), + db.select(listColumns).from(schema.aiAnalysis) + .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where) + .orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset), + ]) + return success(c, adminAiReportListSchema.parse({ + results: rows.map(listItem), + total: totalRows[0]?.value ?? 0, + })) +}) + +adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => { + const [row] = await db.select({ + id: schema.aiAnalysis.id, + username: schema.user.username, + className: schema.user.className, + createTime: schema.aiAnalysis.createTime, + analysis: schema.aiAnalysis.analysis, + }).from(schema.aiAnalysis) + .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)) + .where(eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1) + if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found") + // data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词 + return success(c, adminAiReportSchema.parse(row)) +}) + +adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const [report] = await db.select({ id: schema.aiAnalysis.id, userId: schema.aiAnalysis.userId, isPinned: schema.aiAnalysis.isPinned }) + .from(schema.aiAnalysis).where(eq(schema.aiAnalysis.id, id)).limit(1) + if (!report) return failure(c, 404, "report-not-found", "AIAnalysis not found") + + // 切换语义,与旧后端一致:已置顶则取消;未置顶则先把该学生其它置顶清掉,保证每人至多一份 + const next = !report.isPinned + await db.transaction(async (tx) => { + if (next) { + await tx.update(schema.aiAnalysis).set({ isPinned: false }) + .where(and(eq(schema.aiAnalysis.userId, report.userId), eq(schema.aiAnalysis.isPinned, true))) + } + await tx.update(schema.aiAnalysis).set({ isPinned: next }).where(eq(schema.aiAnalysis.id, id)) + }) + return success(c, toggleAiReportPinResponseSchema.parse({ isPinned: next })) +}) diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts index 42db1d1..82fb8a6 100644 --- a/apps/api/src/routes/admin/index.ts +++ b/apps/api/src/routes/admin/index.ts @@ -1,7 +1,9 @@ import { Hono } from "hono" import type { AppEnv } from "../../auth/middleware" +import { adminAiRoutes } from "./ai" import { adminAnnouncementRoutes } from "./announcement" +import { adminTutorialRoutes } from "./tutorial" /** * 后台路由总入口,挂在 `/api/admin` 下。 @@ -13,4 +15,6 @@ import { adminAnnouncementRoutes } from "./announcement" */ export const adminRoutes = new Hono() +adminRoutes.route("/", adminAiRoutes) adminRoutes.route("/", adminAnnouncementRoutes) +adminRoutes.route("/", adminTutorialRoutes) diff --git a/apps/api/src/routes/admin/tutorial.ts b/apps/api/src/routes/admin/tutorial.ts new file mode 100644 index 0000000..68a64a5 --- /dev/null +++ b/apps/api/src/routes/admin/tutorial.ts @@ -0,0 +1,185 @@ +import { + adminExerciseSchema, + adminTutorialGroupsSchema, + adminTutorialSchema, + createExerciseRequestSchema, + createTutorialRequestSchema, + setTutorialVisibilityRequestSchema, + updateExerciseRequestSchema, + updateTutorialRequestSchema, +} from "@oj2/contract" +import { asc, desc, eq } from "drizzle-orm" +import { Hono } from "hono" + +import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" +import { db, schema } from "../../db" +import { failure, success } from "../../http" +import { objectValue, queryInteger, sampleUser } from "../helpers" + +export const adminTutorialRoutes = new Hono() + +function serializeTutorial(row: { + tutorial: typeof schema.tutorial.$inferSelect + user: typeof schema.user.$inferSelect + realName: string | null +}) { + return adminTutorialSchema.parse({ + id: row.tutorial.id, + title: row.tutorial.title, + content: row.tutorial.content, + code: row.tutorial.code, + 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, + }) +} + +function selectTutorial(id: number) { + return 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)) + .where(eq(schema.tutorial.id, id)) + .limit(1) +} + +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 + return success(c, adminTutorialGroupsSchema.parse({ + python: all.filter((item) => item.type === "python"), + c: all.filter((item) => item.type === "c"), + })) +}) + +adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => { + const parsed = createTutorialRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") + } + const now = new Date().toISOString() + const [created] = await db.insert(schema.tutorial).values({ + ...parsed.data, + 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) +}) + +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)) +}) + +adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const parsed = updateTutorialRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") + } + const updated = await db.update(schema.tutorial) + .set({ ...parsed.data, 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!)) +}) + +adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const parsed = setTutorialVisibilityRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) return failure(c, 400, "invalid-request", "isPublic is required") + // 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉 + const updated = await db.update(schema.tutorial) + .set({ isPublic: parsed.data.isPublic }) + .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!)) +}) + +adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + // 必须先删练习。Django 的 on_delete=CASCADE 是**应用层**实现的, + // 库里的外键实际是 NO ACTION(已核对 pg_constraint.confdeltype='a'), + // 直接删教程会撞外键约束、变成 500。后台每个 DELETE 都要照此核一遍子表。 + const deleted = await db.transaction(async (tx) => { + await tx.delete(schema.exercise).where(eq(schema.exercise.tutorialId, id)) + return tx.delete(schema.tutorial).where(eq(schema.tutorial.id, id)) + .returning({ id: schema.tutorial.id }) + }) + if (deleted.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") + return success(c, null) +}) + +// ---------------------------------------------------------------- 练习 + +function serializeExercise(row: typeof schema.exercise.$inferSelect) { + return adminExerciseSchema.parse({ + id: row.id, + type: row.type, + data: objectValue(row.data), + order: row.order, + }) +} + +// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数, +// 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误 +adminTutorialRoutes.get("/tutorials/:id/exercises", requireSuperAdmin, async (c) => { + const rows = await db.select().from(schema.exercise) + .where(eq(schema.exercise.tutorialId, queryInteger(c.req.param("id"), 0, { min: 1 }))) + .orderBy(asc(schema.exercise.order), asc(schema.exercise.id)) + return success(c, rows.map(serializeExercise)) +}) + +adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => { + const parsed = createExerciseRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") + } + const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial) + .where(eq(schema.tutorial.id, parsed.data.tutorialId)).limit(1) + if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") + const [created] = await db.insert(schema.exercise).values({ + tutorialId: parsed.data.tutorialId, + type: parsed.data.type, + data: parsed.data.data, + order: parsed.data.order, + createdAt: new Date().toISOString(), + }).returning() + return success(c, serializeExercise(created!), 201) +}) + +adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => { + const parsed = updateExerciseRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") + } + const [updated] = await db.update(schema.exercise) + .set({ type: parsed.data.type, data: parsed.data.data, order: parsed.data.order }) + .where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) + .returning() + if (!updated) return failure(c, 404, "exercise-not-found", "Exercise does not exist") + return success(c, serializeExercise(updated)) +}) + +adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => { + const deleted = await db.delete(schema.exercise) + .where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) + .returning({ id: schema.exercise.id }) + if (deleted.length === 0) return failure(c, 404, "exercise-not-found", "Exercise does not exist") + return success(c, null) +}) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 7db45d6..a21369f 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -293,38 +293,58 @@ export function createAnnouncement(announcement: AnnouncementEdit) { return legacyResponse(api2.post("admin/announcements", body)) } +/** 组件里的 Tutorial 仍是 snake_case,出站时转成新后端要的 camelCase */ +function toTutorialBody(data: Partial) { + return { + title: data.title, + content: data.content, + code: data.code ?? null, + isPublic: data.is_public ?? false, + order: data.order ?? 0, + type: data.type, + } +} + export async function getTutorialList() { - const res = await http.get("admin/tutorial") + const res = await legacyResponse<{ [key: string]: Tutorial[] }>( + api2.get("admin/tutorials"), + ) return res.data } export async function getTutorial(id: number) { - const res = await http.get("admin/tutorial", { params: { id } }) + const res = await legacyResponse(api2.get(`admin/tutorials/${id}`)) return res.data } export async function createTutorial(data: Partial) { - const res = await http.post("admin/tutorial", data) + const res = await legacyResponse( + api2.post("admin/tutorials", toTutorialBody(data)), + ) return res.data } export async function updateTutorial(data: Partial) { - const res = await http.put("admin/tutorial", data) + const res = await legacyResponse( + api2.put(`admin/tutorials/${data.id}`, toTutorialBody(data)), + ) return res.data } export function deleteTutorial(id: number) { - return http.delete("admin/tutorial", { params: { id } }) + return api2.delete(`admin/tutorials/${id}`) } export function setTutorialVisibility(id: number, is_public: boolean) { - return http.put("admin/tutorial/visibility", { id, is_public }) + return legacyResponse( + api2.put(`admin/tutorials/${id}/visibility`, { isPublic: is_public }), + ) } export async function getAdminExercises(tutorialId: number) { - const res = await http.get("admin/exercise", { - params: { tutorial_id: tutorialId }, - }) + const res = await legacyResponse( + api2.get(`admin/tutorials/${tutorialId}/exercises`), + ) return res.data } @@ -334,7 +354,14 @@ export async function createExercise(data: { data: object order: number }) { - const res = await http.post("admin/exercise", data) + const res = await legacyResponse( + api2.post("admin/exercises", { + tutorialId: data.tutorial_id, + type: data.type, + data: data.data, + order: data.order, + }), + ) return res.data } @@ -344,12 +371,18 @@ export async function updateExercise(data: { data: object order: number }) { - const res = await http.put("admin/exercise", data) - return res.data as Exercise + const res = await legacyResponse( + api2.put(`admin/exercises/${data.id}`, { + type: data.type, + data: data.data, + order: data.order, + }), + ) + return res.data } export function deleteExercise(id: number) { - return http.delete("admin/exercise", { params: { id } }) + return api2.delete(`admin/exercises/${id}`) } // 将竞赛题目转为公开题目 @@ -543,21 +576,25 @@ export function getTopACTrend(params: { // AI 学习分析报告 export function getAIReportList(offset = 0, limit = 10, username = "") { - return http.get("admin/ai/reports", { - params: { paging: true, offset, limit, username: username || undefined }, - }) + return legacyResponse( + api2.get("admin/ai/reports", { + params: { offset, limit, username: username || undefined }, + }), + ) } export function getAIReportDetail(id: number) { - return http.get("admin/ai/reports", { params: { id } }) + return legacyResponse(api2.get(`admin/ai/reports/${id}`)) } export function pinAIReport(id: number) { - return http.post("admin/ai/reports", { id }) + return legacyResponse(api2.post(`admin/ai/reports/${id}/pin`)) } export function getPinnedAIReports() { - return http.get("admin/ai/reports", { params: { pinned_only: "true" } }) + return legacyResponse( + api2.get("admin/ai/reports", { params: { pinnedOnly: "true" } }), + ) } // ==================== 成就 ==================== diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 9401a4d..e2708fe 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -36,3 +36,98 @@ export const createAnnouncementRequestSchema = z.object({ export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema export type AdminAnnouncement = z.infer + +// ---------------------------------------------------------------- 教程 / 练习 + +export const tutorialTypeSchema = z.enum(["python", "c"]) + +export const adminTutorialSchema = z.object({ + id: z.number().int(), + title: z.string(), + content: z.string(), + code: z.string().nullable(), + isPublic: z.boolean(), + order: z.number().int(), + type: tutorialTypeSchema, + createdBy: sampleUserSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) + +export const adminTutorialListItemSchema = adminTutorialSchema.omit({ + // 教程正文是整篇 markdown,列表页只排序和切换可见性,不需要它 + content: true, + code: true, +}) + +/** 后台教程列表按语言分组返回,对齐旧 TutorialAdminAPI.get 的 `{python, c}` 形状 */ +export const adminTutorialGroupsSchema = z.object({ + python: z.array(adminTutorialListItemSchema), + c: z.array(adminTutorialListItemSchema), +}) + +export const createTutorialRequestSchema = z.object({ + title: z.string().trim().min(1).max(128), + content: z.string(), + code: z.string().nullable().default(null), + isPublic: z.boolean().default(false), + order: z.number().int().default(0), + type: tutorialTypeSchema, +}) + +export const updateTutorialRequestSchema = createTutorialRequestSchema +export const setTutorialVisibilityRequestSchema = z.object({ isPublic: z.boolean() }) + +export const exerciseTypeSchema = z.enum([ + "mcq", + "sort", + "fill", + "match", + "predict", + "debug", + "group", +]) + +export const adminExerciseSchema = z.object({ + id: z.number().int(), + type: exerciseTypeSchema, + data: z.record(z.string(), z.unknown()), + order: z.number().int(), +}) + +export const createExerciseRequestSchema = z.object({ + tutorialId: z.number().int().positive(), + type: exerciseTypeSchema, + data: z.record(z.string(), z.unknown()), + order: z.number().int().default(0), +}) + +export const updateExerciseRequestSchema = createExerciseRequestSchema.omit({ + // 练习不支持改挂到别的教程下 —— 旧 EditExerciseSerializer 也没有 tutorial_id + tutorialId: true, +}) + +// ---------------------------------------------------------------- AI 报告 + +/** + * 列表只给摘要,正文要点开详情才拿 —— 与旧 AIAnalysisListSerializer 一致。 + * `data`(原始 prompt 与结构化输入)和 `system_prompt` / `user_prompt` 两侧都不下发。 + */ +export const adminAiReportListItemSchema = z.object({ + id: z.number().int(), + username: z.string(), + createTime: z.string(), + analysisExcerpt: z.string(), + isPinned: z.boolean(), +}) + +export const adminAiReportSchema = z.object({ + id: z.number().int(), + username: z.string(), + className: z.string().nullable(), + createTime: z.string(), + analysis: z.string(), +}) + +export const adminAiReportListSchema = paginatedSchema(adminAiReportListItemSchema) +export const toggleAiReportPinResponseSchema = z.object({ isPinned: z.boolean() })