feat(阶段4): 用户管理 + 成就管理;补上 rescan 与 contest_joined
GET/POST/DELETE admin/users (DELETE 走 body 传 ids) GET/PUT admin/users/:id POST admin/users/:id/reset-password GET admin/achievement-metrics GET/POST admin/achievements GET/PUT/DELETE admin/achievements/:id 顺带补了新后端缺失的两块(不补的话成就后台建出来的东西是坏的): 1. **rescanAchievement**:旧后端 `rescan_achievement` 的对应实现。判定平时只在判题 结算时发生,后台新建成就或调低阈值不会自动补发,必须显式扫一遍。补发标记 backfilled=true —— 前端据此只显示「已获得」不显示日期,否则一次补发会给几百人 盖同一个时间戳,把「最近获得」板块冲垮。 触发判据包含 metric 与 visible 的变化,不只看 operator/threshold:换维度、 以及从下架改上架(草稿期已达标的人)这两种都会漏。 2. **contest_joined 指标整个漏掉了**。旧 METRIC_REGISTRY 有 18 个指标,新后端只算 17 个,配在这个指标上的成就永远解锁不了。已补上计算,并把注册表抽成 services/achievement-metrics.ts 作为单一事实源 —— 后台下拉框和参数校验都读它, 避免「下拉框里选得到但没人算」这种组合。 用户管理的几处要点: - className 解析位数不对**直接报错不猜**。猜错会把 class_name 存歪,而剥前缀显示 姓名、班级下拉、统计页都依赖它准确。 - problem_permission 按 admin_type 归一(超管恒 All、普通用户恒 None),否则把超管 降级成普通用户后他还留着 All。 - 改用户名要同步 submission.username 这个冗余列,否则历史提交查不到。 - openApi 已开着就不重置 appkey,否则每次保存用户都把对方的 key 换掉。 - **删除用户不复刻 Django 的应用层级联硬删**。用户是被引用最广的一张表,改成让 数据库外键拦下来:撞外键说明还有历史数据,应当禁用而不是删除,返回 409 并说明。 实测:学生 403;导入 2 人 / 重复 409 / 班级号位数报错文案正确;改名+降权后 permission 归一为 None、重名 409;重置密码 6 位无 0;删自己 400; 成就指标 18 项、新建后补发 unlockCount=2、野指标与野稀有度均 400。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -126,29 +126,53 @@ export function getUserList(
|
||||
keyword: string,
|
||||
orderBy = "",
|
||||
) {
|
||||
return http.get("admin/user", {
|
||||
params: { paging: true, offset, limit, keyword, type, order_by: orderBy },
|
||||
})
|
||||
return legacyResponse(
|
||||
api2.get("admin/users", {
|
||||
// 旧接口的 order_by 只有 "-last_login" 一个取值
|
||||
params: {
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
type,
|
||||
orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
export function editUser(user: User) {
|
||||
return http.put("admin/user", user)
|
||||
return legacyResponse(
|
||||
api2.put(`admin/users/${user.id}`, {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
adminType: user.admin_type,
|
||||
problemPermission: user.problem_permission,
|
||||
realName: user.real_name ?? null,
|
||||
isDisabled: user.is_disabled,
|
||||
openApi: user.open_api,
|
||||
password: user.password ?? "",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 重置用户密码
|
||||
export function resetPassword(userID: number) {
|
||||
return http.post("admin/reset_password", { id: userID })
|
||||
// 重置用户密码。调用方直接用 res.data 当密码字符串(旧后端返回的就是裸字符串),
|
||||
// 新后端返回 { password },在这里解包,组件不动
|
||||
export async function resetPassword(userID: number) {
|
||||
const res = await api2.post<{ password: string }>(
|
||||
`admin/users/${userID}/reset-password`,
|
||||
)
|
||||
return { error: res.error, data: res.data.password }
|
||||
}
|
||||
|
||||
// 导入用户
|
||||
export function importUsers(users: string[][]) {
|
||||
return http.post("admin/user", { users })
|
||||
return api2.post("admin/users", { users })
|
||||
}
|
||||
|
||||
// 批量删除用户
|
||||
export function deleteUsers(userIDs: number[]) {
|
||||
return http.delete("admin/user", { params: { id: userIDs.join(",") } })
|
||||
return api2.delete("admin/users", { data: { ids: userIDs } })
|
||||
}
|
||||
|
||||
export function getContestList(offset = 0, limit = 10, keyword: string) {
|
||||
@@ -622,22 +646,42 @@ export interface MetricOption {
|
||||
help_text: string
|
||||
}
|
||||
|
||||
/** 组件里的成就对象是 snake_case,出站转成新后端要的 camelCase */
|
||||
function toAchievementBody(data: Partial<AdminAchievement>) {
|
||||
return {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
icon: data.icon,
|
||||
rarity: data.rarity,
|
||||
hidden: data.hidden ?? false,
|
||||
metric: data.metric,
|
||||
operator: data.operator,
|
||||
threshold: data.threshold,
|
||||
visible: data.visible ?? true,
|
||||
order: data.order ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminAchievements() {
|
||||
return http.get<AdminAchievement[]>("admin/achievement")
|
||||
return legacyResponse<AdminAchievement[]>(api2.get("admin/achievements"))
|
||||
}
|
||||
|
||||
export function getMetricOptions() {
|
||||
return http.get<MetricOption[]>("admin/achievement/metrics")
|
||||
return legacyResponse<MetricOption[]>(api2.get("admin/achievement-metrics"))
|
||||
}
|
||||
|
||||
export function createAchievement(data: Partial<AdminAchievement>) {
|
||||
return http.post<AdminAchievement>("admin/achievement", data)
|
||||
return legacyResponse<AdminAchievement>(
|
||||
api2.post("admin/achievements", toAchievementBody(data)),
|
||||
)
|
||||
}
|
||||
|
||||
export function updateAchievement(data: Partial<AdminAchievement>) {
|
||||
return http.put<AdminAchievement>("admin/achievement", data)
|
||||
return legacyResponse<AdminAchievement>(
|
||||
api2.put(`admin/achievements/${data.id}`, toAchievementBody(data)),
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteAchievement(id: number) {
|
||||
return http.delete("admin/achievement", { params: { id } })
|
||||
return api2.delete(`admin/achievements/${id}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user