Files
web/client/src/api.ts
T
xuyueandClaude Opus 5.5 df3f842dac feat: 改版阶段 3:老师按班级 × 任务批改,学生看本班同学作品
老师
- 新的“批改”页(教学默认页):左边班级 + 任务列表和完成进度,中间作品墙
  (每人最新一版、未交 / 我未评 / 分数),右侧详情面板:各版本、提示词链、打分、
  加入课上展示、加入作品展;J/K 切换学生,1–5 打分,P 课上展示
- “开始讲评”全屏依次播放课上展示的作品;成绩册点分数打开同一个详情面板
- 四色标记和“一键清除全站标记”去掉,flag 只用 present(课上展示);全部提交只给超管

学生
- “同学作品”:只看本班、自己交过这道题才开放,打分前匿名(服务端不返回作者)
- 学生的提交列表只有自己的;别人的提交和提示词链只有本班同学交过同一题、或进了作品展才能看
- 随机互评不再返回作者,打完分由 PUT /:id/score 返回

服务端新增 routes/review.ts(/submission/review:options、progress、class、peers),
查询走 slim 覆盖索引

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:10:18 +08:00

626 lines
16 KiB
TypeScript

import axios from "axios"
import { router } from "./router"
import type {
TutorialIn,
ChallengeIn,
FlagType,
SubmissionOut,
PromptMessage,
PromptHistoryItem,
TaskStatsOut,
TaskAsset,
AwardSection,
AwardManageIn,
AwardManageOut,
AwardItemIn,
AwardItemUpdateIn,
AwardItemManageOut,
GradebookOut,
GradebookQuery,
ShowcaseSubmissionLookupOut,
ShowcaseDetail,
PromptRound,
RandomRatingItem,
CreationSlim,
CreationDetail,
CreationIn,
ReviewOptions,
ReviewProgress,
ReviewStudent,
PeerWorks,
} from "./utils/type"
import { BASE_URL } from "./utils/const"
import {
clearTokens,
getAccessToken,
getRefreshToken,
hasLogin,
refreshAccessToken,
setTokens,
} from "./token"
import { user } from "./store/user"
const http = axios.create({
baseURL: BASE_URL,
})
http.interceptors.request.use(async (config) => {
const token = await getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
http.interceptors.response.use(
(res) => {
return res
},
async (err) => {
if (err.response) {
// access token 失效(如服务端重启换了密钥):刷新一次后重试
if (
err.response.status === 401 &&
hasLogin() &&
!err.config._retried
) {
err.config._retried = true
if (await refreshAccessToken()) return http(err.config)
// 刷新失败但 refresh token 还在,说明只是网络问题:保留登录,下次请求再试
if (hasLogin()) return Promise.reject(err)
}
switch (err.response.status) {
case 401: // 未授权
clearTokens()
user.username = ""
router.push("/")
break
case 403: // 禁止访问
alert("权限不够,禁止访问")
router.push("/")
break
default:
console.error("出现错误:", err.response.status, err.response.data)
}
} else {
console.error("出现错误:", err.message)
}
return Promise.reject(err)
},
)
export const Account = {
async login(username: string, password: string) {
const res = await http.post("/account/login", { username, password })
setTokens(res.data)
return res.data
},
async logout() {
const refresh_token = getRefreshToken()
clearTokens()
await http.post("/account/logout", { refresh_token })
},
async getMyProfile() {
const res = await http.get("/account/profile")
return res.data
},
async list(query: { username: string; page: number }) {
const res = await http.get("/account/list", {
params: query,
})
return res.data
},
async create(payload: {
username: string
password: string
role: string
classname: string
}) {
const res = await http.post("/account/create", payload)
return res.data
},
async update(
id: number,
payload: {
username?: string
password?: string
role?: string
classname?: string
},
) {
const res = await http.put(`/account/${id}`, payload)
return res.data
},
async toggleActive(id: number) {
const res = await http.put(`/account/active/${id}`)
return res.data
},
async batchCreate(payload: { classname: string; names: string[] }) {
const res = await http.post("/account/batch", payload)
return res.data
},
async listClasses(): Promise<string[]> {
const res = await http.get("/account/classes")
return res.data
},
async listNamesByClass(
classname: string,
): Promise<{ name: string; username: string }[]> {
const res = await http.get("/account/names", { params: { classname } })
return res.data
},
}
export const Tutorial = {
async list() {
const res = await http.get("/tutorial/list")
return res.data
},
async createOrUpdate(payload: TutorialIn) {
const res = await http.post("/tutorial/", payload)
return res.data
},
async togglePublic(display: number) {
const res = await http.put(`/tutorial/public/${display}`)
return res.data
},
async remove(display: number) {
await http.delete(`/tutorial/${display}`)
},
async get(display: number) {
const res = await http.get(`/tutorial/${display}`)
return res.data
},
async listDisplay() {
const res = await http.get("/tutorial/display")
return res.data
},
}
export const Challenge = {
async list() {
const res = await http.get("/challenge/list")
return res.data
},
async createOrUpdate(payload: ChallengeIn) {
const res = await http.post("/challenge/", payload)
return res.data
},
async togglePublic(display: number) {
const res = await http.put(`/challenge/public/${display}`)
return res.data
},
async remove(display: number) {
await http.delete(`/challenge/${display}`)
},
async get(display: number) {
const res = await http.get(`/challenge/${display}`)
return res.data
},
async listDisplay() {
const res = await http.get("/challenge/display")
return res.data
},
}
export const Creation = {
async listMine(): Promise<CreationSlim[]> {
const res = await http.get("/creation/mine")
return res.data
},
async create(payload: CreationIn): Promise<CreationDetail> {
const res = await http.post("/creation/", payload)
return res.data
},
async get(display: number): Promise<CreationDetail> {
const res = await http.get(`/creation/${display}`)
return res.data
},
async update(display: number, payload: CreationIn): Promise<CreationDetail> {
const res = await http.put(`/creation/${display}`, payload)
return res.data
},
async saveCode(
display: number,
code: { html: string; css: string; js: string },
): Promise<CreationDetail> {
const res = await http.put(`/creation/${display}/code`, code)
return res.data
},
async remove(display: number) {
const res = await http.delete(`/creation/${display}`)
return res.data
},
}
export const Submission = {
async create(
taskId: number,
code: {
html?: string
css?: string
js?: string
prompt?: string
},
messageId?: number,
) {
const { prompt, ...rest } = code
const data = {
task_id: taskId,
...rest,
prompt: prompt || null,
message_id: messageId ?? null,
}
const res = await http.post("/submission/", data)
return res.data
},
async list(query: {
page: number
page_size?: number
username?: string
user_id?: number
flag?: string | null
zone?: string | null
task_id?: number
task_type?: string
score_min?: number
score_max_exclusive?: number
score_lt_threshold?: number
ordering?: string
grouped?: boolean
}) {
const res = await http.get("/submission", {
params: query,
})
return res.data
},
async listByUserTask(userId: number, taskId: number) {
const res = await http.get("/submission/by-user-task", {
params: { user_id: userId, task_id: taskId },
})
return res.data as SubmissionOut[]
},
async get(id: string) {
const res = await http.get("/submission/" + id)
return res.data
},
async getPromptChain(id: string): Promise<PromptRound[]> {
const res = await http.get(`/submission/${id}/prompt-chain`)
return res.data
},
async delete(id: string) {
const res = await http.delete("/submission/" + id)
return res.data
},
async incrementView(id: string) {
await http.post(`/submission/${id}/view`)
},
// 返回作者名:互评打分前是匿名的
async updateScore(
id: string,
score: number,
): Promise<{ message: string; username?: string }> {
const res = await http.put(`/submission/${id}/score`, { score })
return res.data
},
async randomForRating(excludeId?: string) {
const res = await http.get("/submission/random-for-rating/", {
params: excludeId ? { exclude_id: excludeId } : {},
})
return res.data as RandomRatingItem | null
},
async updateFlag(id: string, flag: FlagType) {
const res = await http.put(`/submission/${id}/flag`, { flag })
return res.data
},
async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> {
const params: Record<string, string | number> = {}
if (classname) params.classname = classname
const res = await http.get(`/submission/stats/${taskId}`, { params })
return res.data as TaskStatsOut
},
}
function gradebookParams(query: GradebookQuery) {
const params: Record<string, string | boolean> = {
classname: query.classname,
}
if (query.task_type) params.task_type = query.task_type
if (query.username) params.username = query.username
if (query.include_all_tasks) params.include_all_tasks = true
return params
}
function filenameFromDisposition(
disposition: string | undefined,
fallback: string,
) {
const match = disposition?.match(/filename\*=UTF-8''([^;]+)/)
return match ? decodeURIComponent(match[1]) : fallback
}
export const Gradebook = {
async get(query: GradebookQuery): Promise<GradebookOut> {
const res = await http.get("/submission/gradebook/", {
params: gradebookParams(query),
})
return res.data
},
async downloadCsv(query: GradebookQuery) {
const res = await http.get("/submission/gradebook/export/", {
params: gradebookParams(query),
responseType: "blob",
})
const blob = new Blob([res.data], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filenameFromDisposition(
res.headers["content-disposition"],
`gradebook-${query.classname}.csv`,
)
a.click()
URL.revokeObjectURL(url)
},
}
export const Prompt = {
// 保存一个不是 AI 生成的版本:手改的代码,或者带提示词的外部 AI 代码
async saveVersion(
taskId: number,
code: { html: string; css: string; js: string },
prompt?: string,
): Promise<{ message_id: number; user_message_id: number; source: string }> {
const res = await http.post("/prompt/versions", {
task_id: taskId,
...code,
prompt: prompt || undefined,
})
return res.data
},
async listConversations(taskId?: number, userId?: number) {
const params: Record<string, number> = {}
if (taskId) params.task_id = taskId
if (userId) params.user_id = userId
return (await http.get("/prompt/conversations/", { params })).data
},
async listHistory(taskId: number): Promise<PromptHistoryItem[]> {
const res = await http.get(`/prompt/history/${taskId}`)
return res.data
},
async getMessages(conversationId: string): Promise<PromptMessage[]> {
return (
await http.get<PromptMessage[]>(
`/prompt/conversations/${conversationId}/messages/`,
)
).data
},
async getMessagesByUserTask(
taskId: number,
userId: number,
): Promise<PromptMessage[]> {
const convs = await this.listConversations(taskId, userId)
if (!convs.length) return []
return this.getMessages(convs[0].id)
},
async deleteMessagePair(
messageId: number,
): Promise<{ deleted: boolean; submission_deleted: boolean }> {
const res = await http.delete(`/prompt/messages/${messageId}/pair`)
return res.data
},
}
export const Helper = {
async upload(file: File) {
const form = new window.FormData()
form.append("image", file)
const res = await http.post("/upload/", form, {
headers: { "content-type": "multipart/form-data" },
})
return !!res.data.url ? res.data.url : ""
},
}
// 老师按“班级 × 任务”批改;学生看本班同学的作品
export const Review = {
async options(): Promise<ReviewOptions> {
return (await http.get("/submission/review/options")).data
},
async progress(classname: string): Promise<ReviewProgress> {
return (await http.get("/submission/review/progress", { params: { classname } })).data
},
async classWall(classname: string, taskId: number): Promise<ReviewStudent[]> {
return (
await http.get("/submission/review/class", {
params: { classname, task_id: taskId },
})
).data
},
async peers(taskId: number): Promise<PeerWorks> {
return (await http.get(`/submission/review/peers/${taskId}`)).data
},
}
export const Showcase = {
async list(): Promise<AwardSection[]> {
const res = await http.get("/submission/showcase/")
return res.data
},
async listManageAwards(): Promise<AwardManageOut[]> {
const res = await http.get("/submission/showcase/manage/awards")
return res.data
},
async createAward(payload: AwardManageIn): Promise<AwardManageOut> {
const res = await http.post("/submission/showcase/manage/awards", payload)
return res.data
},
async updateAward(
id: number,
payload: AwardManageIn,
): Promise<AwardManageOut> {
const res = await http.put(
`/submission/showcase/manage/awards/${id}`,
payload,
)
return res.data
},
async deleteAward(id: number) {
const res = await http.delete(`/submission/showcase/manage/awards/${id}`)
return res.data
},
async listAwardItems(id: number): Promise<AwardItemManageOut[]> {
const res = await http.get(`/submission/showcase/manage/awards/${id}/items`)
return res.data
},
async findSubmissionForAward(
submissionId: string,
): Promise<ShowcaseSubmissionLookupOut> {
const res = await http.get(
`/submission/showcase/manage/submissions/${submissionId}`,
)
return res.data
},
async addAwardItem(
id: number,
payload: AwardItemIn,
): Promise<AwardItemManageOut> {
const res = await http.post(
`/submission/showcase/manage/awards/${id}/items`,
payload,
)
return res.data
},
async updateAwardItem(
itemId: number,
payload: AwardItemUpdateIn,
): Promise<AwardItemManageOut> {
const res = await http.put(
`/submission/showcase/manage/items/${itemId}`,
payload,
)
return res.data
},
async deleteAwardItem(itemId: number) {
const res = await http.delete(`/submission/showcase/manage/items/${itemId}`)
return res.data
},
async refreshAwardItem(itemId: number): Promise<AwardItemManageOut> {
const res = await http.put(
`/submission/showcase/manage/items/${itemId}/refresh`,
)
return res.data
},
async getDetail(submissionId: string): Promise<ShowcaseDetail> {
const res = await http.get(`/submission/showcase/${submissionId}/`)
return res.data
},
async getPromptChain(submissionId: string): Promise<PromptRound[]> {
const res = await http.get(
`/submission/showcase/${submissionId}/prompt-chain/`,
)
return res.data
},
}
export const TaskAssets = {
async listChallenge(display: number): Promise<TaskAsset[]> {
return (await http.get<TaskAsset[]>(`/assets/challenge/${display}`)).data
},
async uploadChallenge(
display: number,
name: string,
file: File,
): Promise<TaskAsset> {
const form = new window.FormData()
form.append("name", name)
form.append("file", file)
return (
await http.post<TaskAsset>(`/assets/challenge/${display}`, form, {
headers: { "content-type": "multipart/form-data" },
})
).data
},
async deleteChallenge(display: number, name: string) {
return (
await http.delete(
`/assets/challenge/${display}/${encodeURIComponent(name)}`,
)
).data
},
async listTutorial(display: number): Promise<TaskAsset[]> {
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
},
async uploadTutorial(
display: number,
name: string,
file: File,
): Promise<TaskAsset> {
const form = new window.FormData()
form.append("name", name)
form.append("file", file)
return (
await http.post<TaskAsset>(`/assets/tutorial/${display}`, form, {
headers: { "content-type": "multipart/form-data" },
})
).data
},
async deleteTutorial(display: number, name: string) {
return (
await http.delete(
`/assets/tutorial/${display}/${encodeURIComponent(name)}`,
)
).data
},
}