142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
import type {
|
||
AdminAiReport,
|
||
AdminAiReportList,
|
||
AdminAiReportListItem,
|
||
ToggleAiReportPinResponse,
|
||
} 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<AppEnv>()
|
||
|
||
/** 对齐旧 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
|
||
className: string | null
|
||
createTime: string
|
||
analysis: string
|
||
isPinned: boolean
|
||
}) {
|
||
return {
|
||
id: row.id,
|
||
username: row.username,
|
||
className: row.className,
|
||
createTime: row.createTime,
|
||
analysisExcerpt: excerpt(row.analysis),
|
||
isPinned: row.isPinned,
|
||
} satisfies AdminAiReportListItem
|
||
}
|
||
|
||
const listColumns = {
|
||
id: schema.aiAnalysis.id,
|
||
username: schema.user.username,
|
||
className: schema.user.className,
|
||
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
|
||
|
||
// 置顶列表不分页:它是「每个学生最新钉住的那份」,数量等于学生数,前端一次性拿走。
|
||
// 但**形状必须和分页那支一样**:同一个 URL 返回两种形状,调用方没法照着一个类型写。
|
||
// 这里原来返回裸数组,前端 getPinnedAIReports 声明的是 AdminAiReportList、
|
||
// 读的是 res.results,于是拿到 undefined,`pinnedReports.length` 在渲染时抛
|
||
// 「Cannot read properties of 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, {
|
||
results: rows.map(listItem),
|
||
total: rows.length,
|
||
} satisfies AdminAiReportList)
|
||
}
|
||
|
||
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, {
|
||
results: rows.map(listItem),
|
||
total: totalRows[0]?.value ?? 0,
|
||
} satisfies AdminAiReportList)
|
||
})
|
||
|
||
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, row satisfies AdminAiReport)
|
||
})
|
||
|
||
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, { isPinned: next } satisfies ToggleAiReportPinResponse)
|
||
})
|