feat(后台用户): 用户列表能按班级筛、按用户名排、看出几人从没登录;批量建号前能查重名,新班级建好自动加进班级列表

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 13:56:03 +08:00
1 parent 48738f74d9
commit ba823ecb7d
2 files changed
+68 -5

No files matched your search

+55 -4
View File
@@ -1,6 +1,9 @@
import {
adminTypeSchema,
checkUsernamesRequestSchema,
deleteUsersRequestSchema,
NO_CLASS,
type CheckUsernamesResponse,
importUsersRequestSchema,
STUDENT_ROLES,
updateUserRequestSchema,
@@ -14,7 +17,7 @@ import {
} from "@oj2/contract"
import { randomInt } from "node:crypto"
import { z } from "zod"
import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm"
import { and, asc, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { hashPassword } from "../../auth/password"
@@ -24,6 +27,8 @@ import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, parseBody, success } from "../../http"
import { classPrefixCondition, queryInteger, sampleUser } from "../helpers"
import { publishConfigUpdate } from "../../events"
import { getWebsiteOptions } from "../../services/options"
export const adminAccountRoutes = new Hono<AppEnv>()
@@ -186,6 +191,13 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
)!,
)
}
// 班级:和全站班级下拉同一套值,NO_CLASS 是「没填班级」
const className = c.req.query("className")?.trim()
if (className) {
filters.push(
className === NO_CLASS ? isNull(schema.user.className) : eq(schema.user.className, className),
)
}
const where = filters.length ? and(...filters) : undefined
// 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次
const online = await onlineUserIds()
@@ -205,11 +217,18 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
]
: orderBy === "-lastLogin"
? [sql`${schema.user.lastLogin} desc nulls last`]
: [desc(schema.user.createTime)]
: // 按用户名:选了一个班看名单时最顺手(和点名册一个顺序)
orderBy === "username"
? [asc(schema.user.username)]
: [desc(schema.user.createTime)]
const [totalRows, rows] = await Promise.all([
db
.select({ value: count() })
.select({
value: count(),
neverLoggedIn:
sql<number>`count(*) filter (where ${schema.user.lastLogin} is null)`.mapWith(Number),
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where),
@@ -225,9 +244,25 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
return success(c, {
results: rows.map((row) => serialize(row, online.has(row.user.id))),
total: totalRows[0]?.value ?? 0,
neverLoggedIn: totalRows[0]?.neverLoggedIn ?? 0,
} satisfies AdminUserList)
})
/**
* 批量建号之前查重(批量建号页边粘名单边查):哪些用户名已经有了、哪天建的。
* 比较时归一成小写,和导入、登录同一个口径
*/
adminAccountRoutes.post("/users/check", requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, checkUsernamesRequestSchema)
if (!parsed.success) return parsed.response
const names = [...new Set(parsed.data.usernames.map((name) => name.toLowerCase()))]
const rows = await db
.select({ username: schema.user.username, createTime: schema.user.createTime })
.from(schema.user)
.where(inArray(sql`lower(${schema.user.username})`, names))
return success(c, { existing: rows } satisfies CheckUsernamesResponse)
})
adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
const [row] = await selectUser(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row) return failure(c, 404, "user-not-found", "User does not exist")
@@ -451,7 +486,23 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
)
return users.length
})
return success(c, { imported: created }, 201)
// 新班级顺手加进班级列表:不加的话全站的班级下拉(看板、提交列表、排名……)里都找不到它,
// 原来要再去设置页手动加,经常忘
const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))]
const options = await getWebsiteOptions()
const missing = classes.filter((name) => !options.class_list.includes(name!)) as string[]
if (missing.length) {
const next = [...options.class_list, ...missing].sort((a, b) =>
a.localeCompare(b, "zh-CN", { numeric: true }),
)
await db
.insert(schema.optionsSysoptions)
.values({ key: "class_list", value: next })
.onConflictDoUpdate({ target: schema.optionsSysoptions.key, set: { value: next } })
await publishConfigUpdate("classList", next)
}
return success(c, { imported: created, addedClasses: missing }, 201)
})
/**
+13 -1
View File
@@ -272,7 +272,18 @@ export const adminUserSchema = z.object({
className: z.string().nullable(),
})
export const adminUserListSchema = paginatedSchema(adminUserSchema)
export const adminUserListSchema = paginatedSchema(adminUserSchema).extend({
/** 同样筛选条件下从没登录过的人数(列表顶上「48 人 · 1 人从没登录」) */
neverLoggedIn: z.number().int(),
})
/** 批量建号之前查重:这几个用户名哪些已经有了 */
export const checkUsernamesRequestSchema = z.object({
usernames: z.array(z.string().trim().min(1).max(32)).min(1).max(500),
})
export const checkUsernamesResponseSchema = z.object({
existing: z.array(z.object({ username: z.string(), createTime: z.string().nullable() })),
})
export const updateUserRequestSchema = z.object({
username: z.string().trim().min(1).max(32),
@@ -999,6 +1010,7 @@ export type AdminContest = z.infer<typeof adminContestSchema>
export type AdminContestList = z.infer<typeof adminContestListSchema>
export type AdminUser = z.infer<typeof adminUserSchema>
export type AdminUserList = z.infer<typeof adminUserListSchema>
export type CheckUsernamesResponse = z.infer<typeof checkUsernamesResponseSchema>
export type JudgeServer = z.infer<typeof judgeServerSchema>
export type DashboardInfo = z.infer<typeof dashboardInfoSchema>
export type JudgeServerList = z.infer<typeof judgeServerListSchema>