feat(阶段4): 教程 / 练习 / AI 学情报告的后台接口
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 <noreply@anthropic.com>
This commit is contained in:
101
apps/api/src/routes/admin/ai.ts
Normal file
101
apps/api/src/routes/admin/ai.ts
Normal file
@@ -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<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; 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 }))
|
||||
})
|
||||
@@ -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<AppEnv>()
|
||||
|
||||
adminRoutes.route("/", adminAiRoutes)
|
||||
adminRoutes.route("/", adminAnnouncementRoutes)
|
||||
adminRoutes.route("/", adminTutorialRoutes)
|
||||
|
||||
185
apps/api/src/routes/admin/tutorial.ts
Normal file
185
apps/api/src/routes/admin/tutorial.ts
Normal file
@@ -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<AppEnv>()
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -293,38 +293,58 @@ export function createAnnouncement(announcement: AnnouncementEdit) {
|
||||
return legacyResponse(api2.post("admin/announcements", body))
|
||||
}
|
||||
|
||||
/** 组件里的 Tutorial 仍是 snake_case,出站时转成新后端要的 camelCase */
|
||||
function toTutorialBody(data: Partial<Tutorial>) {
|
||||
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<Tutorial[]>("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<Tutorial>("admin/tutorial", { params: { id } })
|
||||
const res = await legacyResponse<Tutorial>(api2.get(`admin/tutorials/${id}`))
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createTutorial(data: Partial<Tutorial>) {
|
||||
const res = await http.post<Tutorial>("admin/tutorial", data)
|
||||
const res = await legacyResponse<Tutorial>(
|
||||
api2.post("admin/tutorials", toTutorialBody(data)),
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateTutorial(data: Partial<Tutorial>) {
|
||||
const res = await http.put("admin/tutorial", data)
|
||||
const res = await legacyResponse<Tutorial>(
|
||||
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<Exercise[]>("admin/exercise", {
|
||||
params: { tutorial_id: tutorialId },
|
||||
})
|
||||
const res = await legacyResponse<Exercise[]>(
|
||||
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<Exercise>("admin/exercise", data)
|
||||
const res = await legacyResponse<Exercise>(
|
||||
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<Exercise>(
|
||||
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" } }),
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 成就 ====================
|
||||
|
||||
Reference in New Issue
Block a user