From ba823ecb7d68ab1642d3f0ab245a677efbb45ad1 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 13 Oct 2026 13:56:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E5=8F=B0=E7=94=A8=E6=88=B7):=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=88=97=E8=A1=A8=E8=83=BD=E6=8C=89=E7=8F=AD?= =?UTF-8?q?=E7=BA=A7=E7=AD=9B=E3=80=81=E6=8C=89=E7=94=A8=E6=88=B7=E5=90=8D?= =?UTF-8?q?=E6=8E=92=E3=80=81=E7=9C=8B=E5=87=BA=E5=87=A0=E4=BA=BA=E4=BB=8E?= =?UTF-8?q?=E6=B2=A1=E7=99=BB=E5=BD=95=EF=BC=9B=E6=89=B9=E9=87=8F=E5=BB=BA?= =?UTF-8?q?=E5=8F=B7=E5=89=8D=E8=83=BD=E6=9F=A5=E9=87=8D=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E7=8F=AD=E7=BA=A7=E5=BB=BA=E5=A5=BD=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8A=A0=E8=BF=9B=E7=8F=AD=E7=BA=A7=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/admin/account.ts | 59 ++++++++++++++++++++++++++-- packages/contract/src/admin.ts | 14 ++++++- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index 3f7b652..ae5f8f3 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -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() @@ -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`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) }) /** diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 2ec8d10..ed0ec21 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -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 export type AdminContestList = z.infer export type AdminUser = z.infer export type AdminUserList = z.infer +export type CheckUsernamesResponse = z.infer export type JudgeServer = z.infer export type DashboardInfo = z.infer export type JudgeServerList = z.infer