feat(账号): 新建的号不再加 ks + 班号前缀,班级单独选;学生改不了真名了
- 批量建号先选班再贴名单,用户名就是名字;被别的班或注册的号占了自动加数字(xuyue2)。 查重改成查「这个班里有没有同名的」,老号按剥掉 ks 前缀的名字比 - 后台编辑、新建用户能直接选班级;以前班级只能从用户名的 ks 前缀推,改不了 - 登录点名字时名单接口一并给出用户名,新号老号都登得上;本机记的班级兼容旧的 ks257 写法 - 筛选、抽人、「看看他们的写法」都按班级认人,不再拼 ks 前缀(新号拼出来永远圈不进去) - 接口 PUT /me/profile 不再收真名:界面早没入口,但早几届有 44 人把真名改成了外号 - 0042:两个被推成非班号班级的老号(xrm、王仁杰)改成没填班级 - 老账号不改名,只认 ks 前缀的地方收在契约 username.ts 一处 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48590eee60
commit
221d264701
25 files changed
+4234
-156
No files matched your search
@@ -0,0 +1,4 @@
|
||||
-- 2026-11 班级不再从用户名的 ks 前缀推出来,改成建号时明确给。两个老号当年被推成了不是班号的班级
|
||||
-- (ksxrm → 'xrm',ks王仁杰2 → '王仁杰'),任何班级下拉都选不到它们,后台一编辑还会被班号校验挡住,
|
||||
-- 改成没填班级。只动不是 3~4 位数字的,正常的班一行不碰
|
||||
UPDATE "user" SET "class_name" = NULL WHERE "class_name" IS NOT NULL AND "class_name" !~ '^[0-9]{3,4}$';
|
||||
File diff suppressed because it is too large.
Load diff
@@ -295,6 +295,13 @@
|
||||
"when": 1793626595966,
|
||||
"tag": "0041_lesson_mode",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "7",
|
||||
"when": 1793632543220,
|
||||
"tag": "0042_class_name_cleanup",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
adminTypeSchema,
|
||||
checkUsernamesRequestSchema,
|
||||
deleteUsersRequestSchema,
|
||||
displayName,
|
||||
legacyClassOf,
|
||||
NO_CLASS,
|
||||
type CheckUsernamesResponse,
|
||||
importUsersRequestSchema,
|
||||
@@ -26,36 +28,12 @@ import { revokeUserSessions } from '../../auth/session'
|
||||
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 { classKeywordCondition, queryInteger, sampleUser } from '../helpers'
|
||||
import { publishConfigUpdate } from '../../events'
|
||||
import { getWebsiteOptions } from '../../services/options'
|
||||
|
||||
export const adminAccountRoutes = new Hono<AppEnv>()
|
||||
|
||||
const CLASS_NAME_MIN_DIGITS = 3
|
||||
const CLASS_NAME_MAX_DIGITS = 4
|
||||
|
||||
/**
|
||||
* `ks251XXX` / `ks2510XX` → `251` / `2510`。不以 `ks+数字` 开头的(管理员、教师账号)返回 null。
|
||||
*
|
||||
* 位数不对**直接报错,不猜** —— 猜错会把 className 存歪,而剥前缀显示姓名、班级下拉、
|
||||
* 统计页都依赖它准确。先用 `\d+` 抓全再判位数,不能直接用固定位数的正则匹配:
|
||||
* 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。
|
||||
* 对齐旧 `account/views/admin.py:get_class_name`。
|
||||
*/
|
||||
function classNameOf(username: string): { ok: true; value: string | null } | { ok: false; message: string } {
|
||||
const matched = /^ks(\d+)/.exec(username)
|
||||
if (!matched) return { ok: true, value: null }
|
||||
const digits = matched[1]!
|
||||
if (digits.length < CLASS_NAME_MIN_DIGITS || digits.length > CLASS_NAME_MAX_DIGITS) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `用户名 ${username} 的班级号 ${digits} 是 ${digits.length} 位,必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
|
||||
}
|
||||
}
|
||||
return { ok: true, value: digits }
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission:
|
||||
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
|
||||
@@ -120,8 +98,8 @@ adminAccountRoutes.get('/rankings/users', requireSuperAdmin, async (c) => {
|
||||
const where = and(
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
// 输入班级前缀(ks251)按班级匹配,否则照旧按用户名包含
|
||||
keyword ? (classPrefixCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`)) : undefined,
|
||||
// 填的是班号(251,或者老习惯 ks251)按班级圈,否则按用户名包含
|
||||
keyword ? (classKeywordCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`)) : undefined,
|
||||
)
|
||||
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
@@ -220,20 +198,45 @@ adminAccountRoutes.get('/users', requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 批量建号之前查重(批量建号页边粘名单边查):哪些用户名已经有了、哪天建的。
|
||||
* 比较时归一成小写,和导入、登录同一个口径
|
||||
* 批量建号之前查重(批量建号页边粘名单边查):这个班里已经有谁了(多半是同一个人粘了两次),
|
||||
* 以及每个名字会建成什么用户名(名字被别的班、注册的号占了就加数字)。比较归一成小写,和导入、登录同一个口径
|
||||
*/
|
||||
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
|
||||
const { className, names } = parsed.data
|
||||
const wanted = [...new Set(names.map((name) => name.toLowerCase()))]
|
||||
|
||||
// 同班同名:老号是 ks232张三,按剥掉前缀的名字比
|
||||
const classmates = 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)
|
||||
.where(eq(schema.user.className, className))
|
||||
const existing = classmates
|
||||
.map((row) => ({ name: displayName(row.username), username: row.username, createTime: row.createTime }))
|
||||
.filter((row) => wanted.includes(row.name.toLowerCase()))
|
||||
|
||||
// 用户名全站唯一:名字本身和「名字+数字」占了哪些
|
||||
const taken = await db
|
||||
.select({ username: sql<string>`lower(${schema.user.username})` })
|
||||
.from(schema.user)
|
||||
.where(inArray(sql`regexp_replace(lower(${schema.user.username}), '[0-9]+$', '')`, wanted))
|
||||
return success(c, { existing, usernames: suggestUsernames(names, new Set(taken.map((row) => row.username))) } satisfies CheckUsernamesResponse)
|
||||
})
|
||||
|
||||
/**
|
||||
* 新号的用户名:名字没被占就是名字本身,占了往后加数字(杨涵宇2、杨涵宇3)。同一批里先到先得。
|
||||
* 比较按小写,和登录、导入同一个口径
|
||||
*/
|
||||
function suggestUsernames(names: string[], taken: Set<string>) {
|
||||
return names.map((name) => {
|
||||
let candidate = name
|
||||
for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${name}${n}`
|
||||
taken.add(candidate.toLowerCase())
|
||||
return candidate
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -250,8 +253,8 @@ adminAccountRoutes.put('/users/:id', requireSuperAdmin, async (c) => {
|
||||
|
||||
const username = data.username.trim().toLowerCase()
|
||||
const email = data.email.trim().toLowerCase()
|
||||
const className = classNameOf(username)
|
||||
if (!className.ok) return failure(c, 400, 'invalid-class-name', className.message)
|
||||
// 班级明确给了就用;老页面不发这一项,不动原来的班(以前是从用户名的 ks 前缀推,改名就跟着变)
|
||||
const className = data.className !== undefined ? data.className : existing.user.className
|
||||
|
||||
const [dupUsername] = await db
|
||||
.select({ id: schema.user.id })
|
||||
@@ -270,7 +273,7 @@ adminAccountRoutes.put('/users/:id', requireSuperAdmin, async (c) => {
|
||||
const patch: Partial<typeof schema.user.$inferInsert> = {
|
||||
username,
|
||||
email,
|
||||
className: className.value,
|
||||
className,
|
||||
adminType: data.adminType,
|
||||
isDisabled: data.isDisabled,
|
||||
problemPermission: normalizePermission(data.adminType, data.problemPermission),
|
||||
@@ -337,10 +340,14 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
|
||||
// PUT /users/:id 也都存小写。只有这条导入路径原样存,于是 `ks251Ab` 能绕过下面的
|
||||
// 查重建出第二个账号,两个人登录时撞成同一条记录。
|
||||
const prepared: Prepared[] = []
|
||||
for (const [username, password, email, realName] of rows) {
|
||||
for (const [username, password, email, realName, explicitClass] of rows) {
|
||||
const name = username.toLowerCase()
|
||||
const className = classNameOf(name)
|
||||
if (!className.ok) return failure(c, 400, 'invalid-class-name', className.message)
|
||||
// 班级明确给了就用(新号不带 ks 前缀);老写法没给,退回从 ks 前缀推,位数不对报错不猜
|
||||
const legacy = explicitClass === undefined ? legacyClassOf(name) : null
|
||||
if (legacy === undefined) {
|
||||
return failure(c, 400, 'invalid-class-name', `用户名 ${name} 里 ks 后面的班级号位数不对,班级号要是 3~4 位数字`)
|
||||
}
|
||||
const className = explicitClass ?? legacy
|
||||
const mail = email.trim().toLowerCase()
|
||||
// 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前
|
||||
// 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批
|
||||
@@ -355,7 +362,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
|
||||
raw: password,
|
||||
email: mail,
|
||||
realName,
|
||||
className: className.value,
|
||||
className,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { randomInt } from 'node:crypto'
|
||||
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { count, countDistinct, desc, eq, gte, ilike, inArray, isNull, not, sql } from 'drizzle-orm'
|
||||
import { and, count, countDistinct, desc, eq, gte, inArray, isNull, not, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { requireAdmin, requireSuperAdmin, type AppEnv } from '../../auth/middleware'
|
||||
@@ -22,12 +22,12 @@ import { failure, parseBody, success } from '../../http'
|
||||
import { sniffImageExtension } from '../../services/image'
|
||||
import { getWebsiteOptions } from '../../services/options'
|
||||
import { calendarDay, dayStart, localTime, termStart, weekStart } from '../../time'
|
||||
import { STUDENT_ROLES } from '@oj2/contract'
|
||||
import { displayName, STUDENT_ROLES } from '@oj2/contract'
|
||||
import { TEACHER_ROLES } from '../helpers'
|
||||
import { onlineUserIds } from '../../auth/presence'
|
||||
import { JudgeStatus } from '../../judge/status'
|
||||
import { suggestActiveClass } from '../../services/class-activity'
|
||||
import { classPrefixCondition } from '../helpers'
|
||||
import { classKeywordCondition } from '../helpers'
|
||||
|
||||
export const adminConfRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -346,20 +346,20 @@ adminConfRoutes.get('/dashboard', requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
adminConfRoutes.get('/random-usernames', requireSuperAdmin, async (c) => {
|
||||
// 传的是**班级前缀**(形如 ks251),不是班级号 —— 前端输入框写的就是「班级前缀」,
|
||||
// 拿到结果后按这个前缀 split 取姓名。班级前缀走 classPrefixCondition(ks251 不带上
|
||||
// ks2511);别的形状的输入照旧 istartswith,与旧接口一致
|
||||
// 后台首页「抽一个人」:从这个班随机拿 10 个,回的是界面上显示的名字(老号剥掉 ks 前缀)。
|
||||
// 班号按 class_name 精确匹配;老页面发的是 ks251,classKeywordCondition 两种都认
|
||||
const classroom = c.req.query('classroom')?.trim()
|
||||
if (!classroom) return failure(c, 400, 'invalid-request', '需要班级号')
|
||||
const where = classroom ? classKeywordCondition(classroom) : undefined
|
||||
if (!where) return failure(c, 400, 'invalid-request', '需要班级号')
|
||||
const rows = await db
|
||||
.select({ username: schema.user.username })
|
||||
.from(schema.user)
|
||||
.where(classPrefixCondition(classroom) ?? ilike(schema.user.username, `${classroom}%`))
|
||||
.where(and(where, eq(schema.user.isDisabled, false)))
|
||||
.orderBy(sql`random()`)
|
||||
.limit(10)
|
||||
return success(
|
||||
c,
|
||||
rows.map((row) => row.username),
|
||||
rows.map((row) => displayName(row.username)),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ function classCondition(value: string | null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 没有班级的学生**照样统计**(班级列显示为空)。班级是从用户名的数字前缀推出来的,
|
||||
* 推不出来时就是 null(见 admin/account.ts 的 classNameOf),把这些人过滤掉等于让他们
|
||||
* 没有班级的学生**照样统计**(班级列显示为空)。班级是建号时给的(老号当年从用户名的 ks 前缀推),
|
||||
* 没给就是 null,把这些人过滤掉等于让他们
|
||||
* 在「谁没学」这张表上凭空消失 —— 班级榜可以只算入班的人,这里不行。
|
||||
*/
|
||||
function studentCondition(value: string | null) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ADMIN_ROLES, NO_CLASS, STUDENT_ROLES, TEACHER_ROLES, type SampleUser } from '@oj2/contract'
|
||||
|
||||
import { and, count, eq, ilike, inArray, isNull, notInArray, sql } from 'drizzle-orm'
|
||||
import { and, count, eq, ilike, inArray, isNull, notInArray } from 'drizzle-orm'
|
||||
|
||||
import type { AuthUser } from '../auth/session'
|
||||
import { db, schema } from '../db'
|
||||
@@ -29,8 +29,9 @@ export function sampleUser(
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉用户名里的 `ks<班级号>` 前缀,得到学生本人那一段:`ks251张三` + `251` → `张三`。
|
||||
* 对齐旧后端 `utils/shortcuts.py:52` 的 `strip_class_prefix`。
|
||||
* 去掉老号用户名里的 `ks<班级号>` 前缀,得到学生本人那一段:`ks251张三` + `251` → `张三`。
|
||||
* 2026-11 起新建的号不带前缀(用户名就是名字),原样返回。和契约 `displayName` 是同一件事,
|
||||
* 这里按这个人自己的班级剥,比正则更严:班号对不上的不剥。
|
||||
*
|
||||
* 用 startsWith + slice 而不是 replace:replace 会删掉字符串中间的匹配,
|
||||
* 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。
|
||||
@@ -42,19 +43,15 @@ export function stripClassPrefix(username: string, className: string | null | un
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户名按「班级前缀」匹配:`ks251` 只匹配 251 班,**不能**把 `ks2511张三` 也捞进来。
|
||||
* 搜索框里填的是一个班(`232`,或者老习惯 `ks232`):按 `user.class_name` 精确圈这个班。
|
||||
* 不是这个形状返回 undefined,由调用方退回按用户名包含匹配。
|
||||
*
|
||||
* 班级号 3~4 位并存(见 admin/account.ts 的 classNameOf),裸 `ilike 'ks251%'` 会把
|
||||
* 2510~2519 班全算进 251 班。所以输入是 `ks` + 3 位以上数字时,要求紧跟着的不是数字;
|
||||
* 1~2 位(`ks25`)是年级前缀,照旧宽松匹配。输入不是 `ks+数字` 形状时返回 undefined,
|
||||
* 由调用方决定退回什么匹配方式。
|
||||
* 原来是拿用户名前缀 `ks232` 去 ilike,还得专门挡住 `ks2321…` 被 232 班捞走;
|
||||
* 2026-11 起新号不带前缀,前缀匹配会把他们全漏掉,所以只认班级这一列
|
||||
*/
|
||||
export function classPrefixCondition(input: string) {
|
||||
const matched = /^ks(\d+)$/i.exec(input)
|
||||
if (!matched) return undefined
|
||||
const prefix = ilike(schema.user.username, `${input}%`)
|
||||
if (matched[1]!.length < 3) return prefix
|
||||
return and(prefix, sql`${schema.user.username} !~* ${`^${input}[0-9]`}`)
|
||||
export function classKeywordCondition(input: string) {
|
||||
const matched = /^(?:ks)?(\d{3,4})$/i.exec(input)
|
||||
return matched ? eq(schema.user.className, matched[1]!) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Quote, WebsiteConfig } from '@oj2/contract'
|
||||
import { CLASS_NAME_RE, type ClassMember, type Quote, type WebsiteConfig } from '@oj2/contract'
|
||||
import { asc, desc, eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { resolve } from 'node:path'
|
||||
@@ -94,7 +94,7 @@ siteRoutes.get('/quotes/random', async (c) => {
|
||||
|
||||
siteRoutes.get('/classes/:className/usernames', async (c) => {
|
||||
const className = c.req.param('className').trim()
|
||||
if (!/^\d{3,4}$/.test(className)) {
|
||||
if (!CLASS_NAME_RE.test(className)) {
|
||||
return failure(c, 400, 'invalid-class', 'Class name must contain 3 or 4 digits')
|
||||
}
|
||||
const rows = await db
|
||||
@@ -102,9 +102,9 @@ siteRoutes.get('/classes/:className/usernames', async (c) => {
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, className))
|
||||
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
||||
// 用 stripClassPrefix 而不是 replace:replace 会把中间的匹配也删掉,前缀对不上时截出乱码
|
||||
// 名字给人点,用户名拿去登录:老号是 ks232张三(点的是「张三」),新号两个一样
|
||||
return success(
|
||||
c,
|
||||
rows.map(({ username }) => stripClassPrefix(username, className)),
|
||||
rows.map(({ username }) => ({ name: stripClassPrefix(username, className), username }) satisfies ClassMember),
|
||||
)
|
||||
})
|
||||
@@ -403,7 +403,7 @@ const GRID_LIMIT = 5000
|
||||
|
||||
/**
|
||||
* 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。
|
||||
* 用户名的匹配和统计接口同一套(ilike,填 `ks253` 圈一个班)。
|
||||
* 班级和学生名的匹配和统计接口同一套(scopedUsers:班级精确、名字包含)。
|
||||
*/
|
||||
submissionStatisticsRoutes.get('/submissions/statistics/grid', requireTeacher, async (c) => {
|
||||
const scope = await statisticsScope(c)
|
||||
|
||||
@@ -103,6 +103,7 @@ export function getBaseInfo() {
|
||||
return api.get<DashboardInfo>('admin/dashboard')
|
||||
}
|
||||
|
||||
/** 后台首页「抽一个人」:从这个班(班号)随机拿 10 个显示用的名字 */
|
||||
export function randomUser10(classroom: string) {
|
||||
return api.get<string[]>('admin/random-usernames', {
|
||||
params: { classroom },
|
||||
@@ -245,9 +246,11 @@ export function getUserList(
|
||||
}
|
||||
|
||||
/** 批量建号之前查重:这些用户名哪些已经有了 */
|
||||
export function checkUsernames(usernames: string[]) {
|
||||
/** 批量建号查重:这个班里已经有的同名账号,和每个名字会建成的用户名 */
|
||||
export function checkUsernames(className: string, names: string[]) {
|
||||
return api.post<CheckUsernamesResponse>('admin/users/check', {
|
||||
usernames,
|
||||
className,
|
||||
names,
|
||||
} satisfies CheckUsernamesRequest)
|
||||
}
|
||||
|
||||
@@ -260,6 +263,7 @@ export function editUser(user: AdminUserForm) {
|
||||
adminType: toAdminType(user.adminType),
|
||||
problemPermission: toProblemPermission(user.problemPermission),
|
||||
realName: user.realName ?? null,
|
||||
className: user.className ?? null,
|
||||
isDisabled: user.isDisabled,
|
||||
password: user.password ?? '',
|
||||
} satisfies UpdateUserRequest)
|
||||
|
||||
@@ -118,8 +118,8 @@ function stopRolling() {
|
||||
/** 名字滚两秒停下:接口一次给 10 个随机的,最后一个就是抽中的 */
|
||||
async function draw() {
|
||||
if (!luckyClass.value) return
|
||||
const prefix = `ks${luckyClass.value}`
|
||||
const names = (await randomUser10(prefix)).map((name) => name.slice(prefix.length) || name)
|
||||
// 后端按班级随机拿 10 个,回的就是显示的名字(老号已经剥掉 ks 前缀)
|
||||
const names = await randomUser10(luckyClass.value)
|
||||
if (!names.length) {
|
||||
luckyGuy.value = '这个班没有人'
|
||||
return
|
||||
|
||||
@@ -34,7 +34,8 @@ const classError = computed(() => {
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const prefix = computed(() => (classNo.value.trim() ? `ks${classNo.value.trim()}` : ''))
|
||||
/** 填好了、格式也对的班号;没填或填错是空串 */
|
||||
const klass = computed(() => (classNo.value.trim() && !classError.value ? classNo.value.trim() : ''))
|
||||
|
||||
/** 名单:一行一个,前后和中间的空格都去掉(从表格里复制常带空格),空行不算 */
|
||||
const names = computed(() =>
|
||||
@@ -56,11 +57,16 @@ function passwordOf(name: string) {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户名就是名字(2026-11 起不再加 ks + 班号)。名字被别的班、注册的号占了,后端给的是加了数字的(杨涵宇2);
|
||||
* 查重还没回来之前先按名字摆着
|
||||
*/
|
||||
const suggested = ref<Map<string, string>>(new Map())
|
||||
const rows = computed(() =>
|
||||
names.value.map((name) => ({
|
||||
name,
|
||||
username: prefix.value + name,
|
||||
password: passwordOf(prefix.value + name),
|
||||
username: suggested.value.get(name) ?? name,
|
||||
password: passwordOf(name),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -69,7 +75,7 @@ const duplicates = computed(() => {
|
||||
const first = new Map<string, number>()
|
||||
const result: { line: number; firstLine: number; name: string }[] = []
|
||||
rows.value.forEach((row, i) => {
|
||||
const key = row.username.toLowerCase()
|
||||
const key = row.name.toLowerCase()
|
||||
const seen = first.get(key)
|
||||
if (seen === undefined) first.set(key, i + 1)
|
||||
else result.push({ line: i + 1, firstLine: seen, name: row.name })
|
||||
@@ -77,22 +83,26 @@ const duplicates = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
// 已经有的账号:停手半秒再去查
|
||||
const existing = ref<{ username: string; createTime: string | null }[]>([])
|
||||
// 这个班已经有的同名账号(老号按剥掉 ks 前缀的名字比),顺带拿回每个名字会建成的用户名:停手半秒再去查
|
||||
const existing = ref<{ name: string; username: string; createTime: string | null }[]>([])
|
||||
const checking = ref(false)
|
||||
watchDebounced(
|
||||
() => rows.value.map((row) => row.username).join('\n'),
|
||||
() => `${klass.value}\n${names.value.join('\n')}`,
|
||||
async () => {
|
||||
const usernames = [...new Set(rows.value.map((row) => row.username))]
|
||||
if (!usernames.length || classError.value || usernames.length > 500) {
|
||||
const list = [...new Set(names.value)]
|
||||
if (!list.length || !klass.value || list.length > 500) {
|
||||
existing.value = []
|
||||
suggested.value = new Map()
|
||||
return
|
||||
}
|
||||
checking.value = true
|
||||
try {
|
||||
existing.value = (await checkUsernames(usernames)).existing
|
||||
const res = await checkUsernames(klass.value, list)
|
||||
existing.value = res.existing
|
||||
suggested.value = new Map(list.map((name, i) => [name, res.usernames[i] ?? name]))
|
||||
} catch {
|
||||
existing.value = []
|
||||
suggested.value = new Map()
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
@@ -102,7 +112,7 @@ watchDebounced(
|
||||
|
||||
const tooMany = computed(() => rows.value.length > 500)
|
||||
const issueCount = computed(() => (classError.value ? 1 : 0) + duplicates.value.length + existing.value.length + (tooMany.value ? 1 : 0))
|
||||
const canSubmit = computed(() => rows.value.length > 0 && !issueCount.value && !checking.value && !!prefix.value)
|
||||
const canSubmit = computed(() => rows.value.length > 0 && !issueCount.value && !checking.value && !!klass.value)
|
||||
|
||||
// ---------------------------------------------------------------- 建号
|
||||
|
||||
@@ -118,8 +128,9 @@ async function submit() {
|
||||
const payload: ImportUsersRequest['users'] = rows.value.map((row, i) => [
|
||||
row.username,
|
||||
row.password,
|
||||
`${prefix.value || 'user'}.${batch}.${i + 1}@example.com`,
|
||||
`c${klass.value}.${batch}.${i + 1}@example.com`,
|
||||
row.name,
|
||||
klass.value,
|
||||
])
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -142,7 +153,7 @@ function again() {
|
||||
|
||||
function download() {
|
||||
if (!done.value) return
|
||||
downloadAccountsCsv(done.value, prefix.value || '账号')
|
||||
downloadAccountsCsv(done.value, klass.value ? classLabel(klass.value) : '账号')
|
||||
}
|
||||
|
||||
/** 打印账号条:一人一张,一页四列,打出来剪开发下去 */
|
||||
@@ -196,7 +207,6 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
||||
<div class="left">
|
||||
<label class="label" for="class-no">班级号</label>
|
||||
<div class="class-row">
|
||||
<span class="ks">ks</span>
|
||||
<n-input v-model:value="classNo" :input-props="{ id: 'class-no', inputmode: 'numeric' }" placeholder="如 2612" style="width: 120px" />
|
||||
<span v-if="classNo.trim() && !classError" class="class-name">
|
||||
{{ classLabel(classNo.trim()) }}
|
||||
@@ -210,7 +220,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
||||
<template v-if="rows.length">
|
||||
<div class="head">
|
||||
<b>会建这 {{ rows.length }} 个账号</b>
|
||||
<n-tag v-if="!issueCount && !checking && prefix" type="success" :bordered="false" round> 没有重名 </n-tag>
|
||||
<n-tag v-if="!issueCount && !checking && klass" type="success" :bordered="false" round> 没有重名 </n-tag>
|
||||
<n-tag v-if="issueCount" type="warning" :bordered="false" round> 有 {{ issueCount }} 处要先改 </n-tag>
|
||||
</div>
|
||||
|
||||
@@ -221,7 +231,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
||||
第 {{ dup.line }} 行「{{ dup.name }}」和第 {{ dup.firstLine }} 行重名 —— 同班同名的,名字后面加个 1、2 区分
|
||||
</div>
|
||||
<div v-for="it in existing" :key="it.username">
|
||||
{{ it.username }} 已经有这个账号了<template v-if="it.createTime">({{ parseTime(it.createTime, 'YYYY-MM-DD') }} 建的)</template>
|
||||
{{ classLabel(klass) }}已经有「{{ it.name }}」了<template v-if="it.createTime">({{ parseTime(it.createTime, 'YYYY-MM-DD') }} 建的)</template>
|
||||
—— 删掉这一行,或者换个名字
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,7 +259,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
||||
<n-button type="primary" size="large" :disabled="!canSubmit" :loading="saving || checking" @click="submit">
|
||||
建好这 {{ rows.length }} 个账号
|
||||
</n-button>
|
||||
<n-text v-if="!prefix" depth="3" class="hint" style="margin-left: 12px"> 先填班级号 </n-text>
|
||||
<n-text v-if="!klass" depth="3" class="hint" style="margin-left: 12px"> 先填班级号 </n-text>
|
||||
</div>
|
||||
</template>
|
||||
<n-empty v-else :description="`${isMobile ? '上面' : '左边'}粘进名单,这里就列出会建成的账号`" :style="{ marginTop: isMobile ? '24px' : '80px' }" />
|
||||
@@ -288,11 +298,6 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ks {
|
||||
font-size: 18px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.class-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ const classOptions = computed(() => [
|
||||
...groupClassOptions(configStore.config?.classList ?? []),
|
||||
{ label: classLabel(NO_CLASS), value: NO_CLASS },
|
||||
])
|
||||
/** 编辑框的班级:配置里的班,外加这个人现在的班(老班不在配置里也要显示得出来) */
|
||||
const editClassOptions = computed(() =>
|
||||
groupClassOptions([...(configStore.config?.classList ?? []), ...(userEditing.value?.className ? [userEditing.value.className] : [])]),
|
||||
)
|
||||
const neverLoggedIn = ref(0)
|
||||
|
||||
const total = ref(0)
|
||||
@@ -314,7 +318,7 @@ async function handleEditUser() {
|
||||
try {
|
||||
if (create.value) {
|
||||
const newUser: ImportUsersRequest['users'] = [
|
||||
[userEditing.value.username, password.value, userEditing.value.email ?? '', userEditing.value.realName ?? ''],
|
||||
[userEditing.value.username, password.value, userEditing.value.email ?? '', userEditing.value.realName ?? '', userEditing.value.className],
|
||||
]
|
||||
await importUsers(newUser)
|
||||
listUsers()
|
||||
@@ -388,10 +392,9 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
||||
<n-form-item-gi :span="1" label="真名">
|
||||
<n-input v-model:value="userEditing.realName" />
|
||||
</n-form-item-gi>
|
||||
<!-- 班级是后端从用户名的 ks 数字前缀推出来的(classNameOf),
|
||||
editUser 也不发这个字段,所以这里只读 —— 以前是个能改、改了没用的输入框 -->
|
||||
<n-form-item-gi v-if="!create" :span="1" label="班级">
|
||||
<n-input :value="userEditing.className" disabled placeholder="由用户名自动推导" />
|
||||
<!-- 班级单独存一列,2026-11 起不再从用户名的 ks 前缀推(新号不带前缀)。老师、管理员不填 -->
|
||||
<n-form-item-gi :span="1" label="班级">
|
||||
<n-select v-model:value="userEditing.className" :options="editClassOptions" filterable clearable placeholder="没填班级" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="1" label="邮箱">
|
||||
<n-input v-model:value="userEditing.email" />
|
||||
|
||||
@@ -179,7 +179,7 @@ export function getSubmissionStatistics(
|
||||
duration: { start?: string; end: string },
|
||||
problemDisplayId?: string,
|
||||
username?: string,
|
||||
/** 班级(user.class_name)精确匹配。别再拼 `ks231` 塞进 username —— 会连带 2311、2312 班 */
|
||||
/** 班级(user.class_name)精确匹配。别拿用户名前缀当班级 —— 新号不带 ks 前缀,老号 `ks231` 还会连带 2311、2312 班 */
|
||||
className?: string,
|
||||
) {
|
||||
return api.get<SubmissionStatistics>('submissions/statistics', {
|
||||
@@ -346,7 +346,7 @@ export function uploadAvatar(file: File) {
|
||||
return api.post('me/avatar', form)
|
||||
}
|
||||
|
||||
export function updateProfile(data: { realName?: string; mood?: string }) {
|
||||
export function updateProfile(data: { mood?: string }) {
|
||||
return api.put<Profile>('me/profile', data satisfies UpdateProfileRequest)
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ const rankLine = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
/** 「看看他们的写法」:同班的走 'ks' + 班级名的用户名前缀约定(设计文档第 13 节) */
|
||||
/** 「看看他们的写法」:同班的按班级(class_name)筛,全站的不筛 */
|
||||
function goAccepted() {
|
||||
const r = rank.value
|
||||
if (!r) return
|
||||
@@ -73,7 +73,7 @@ function goAccepted() {
|
||||
result: '0',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
...(r.className ? { username: 'ks' + r.className } : {}),
|
||||
...(r.className ? { className: r.className } : {}),
|
||||
},
|
||||
}
|
||||
// 协作中跳走这一页协作就断了
|
||||
|
||||
@@ -8,7 +8,8 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { errorCode } from 'utils/api'
|
||||
import { STORAGE_KEY, USERNAME_CLASS_RE } from 'utils/constants'
|
||||
import { displayName } from '@oj2/contract'
|
||||
import { STORAGE_KEY } from 'utils/constants'
|
||||
import storage from 'utils/storage'
|
||||
import ChampionList from './components/ChampionList.vue'
|
||||
import ClassBattle from './components/ClassBattle.vue'
|
||||
@@ -236,7 +237,7 @@ const daysLeft = computed(() => {
|
||||
const trendLines = computed(() => {
|
||||
const b = board.value
|
||||
if (!b?.me) return []
|
||||
const short = (row: RankRow) => row.user.username.replace(USERNAME_CLASS_RE, '') || row.user.username
|
||||
const short = (row: RankRow) => displayName(row.user.username)
|
||||
return [
|
||||
...(b.behind ? [{ userId: b.behind.user.id, label: short(b.behind), color: palette.value.threat }] : []),
|
||||
...(b.ahead ? [{ userId: b.ahead.user.id, label: short(b.ahead), color: palette.value.chase }] : []),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AuthTokens, LoginRequest, RefreshTokenRequest, RegisterRequest } from '@oj2/contract'
|
||||
import type { ClassActivity, MarkAchievementsRead, ProblemAuthor, ProblemList, Quote, WebsiteConfig } from '@oj2/contract'
|
||||
import type { ClassActivity, ClassMember, MarkAchievementsRead, ProblemAuthor, ProblemList, Quote, WebsiteConfig } from '@oj2/contract'
|
||||
import api from 'utils/api'
|
||||
import { getRefreshToken, saveTokens } from 'utils/auth'
|
||||
import { contractDeferred } from 'utils/contract'
|
||||
@@ -42,8 +42,9 @@ export function getHitokoto() {
|
||||
return api.get<Quote>('quotes/random')
|
||||
}
|
||||
|
||||
export function getClassUsernames(classroom: string) {
|
||||
return api.get<string[]>(`classes/${encodeURIComponent(classroom)}/usernames`)
|
||||
/** 登录框点名字的名单:名字给人点,用户名拿去登录(老号 ks232张三,新号就是名字) */
|
||||
export function getClassUsernames(className: string) {
|
||||
return api.get<ClassMember[]>(`classes/${encodeURIComponent(className)}/usernames`)
|
||||
}
|
||||
|
||||
// 下面这个是启动就要调的(config store),所以放在 shared 而不是
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import RankAvatar from 'shared/components/RankAvatar.vue'
|
||||
import { classLabel, groupClassOptions } from 'utils/classes'
|
||||
import type { ClassMember } from '@oj2/contract'
|
||||
import { groupClassOptions } from 'utils/classes'
|
||||
import { errorCode } from 'utils/api'
|
||||
import { STORAGE_KEY } from 'utils/constants'
|
||||
import storage from 'utils/storage'
|
||||
@@ -46,12 +47,13 @@ const themeVars = computed(() => ({
|
||||
}))
|
||||
|
||||
const mode = ref<Mode>('pick')
|
||||
/** 选中的班,带 ks 前缀(`ks257`),和用户名的前缀同一个写法 */
|
||||
/** 选中的班(班号,`257`) */
|
||||
const klass = ref<string | null>(null)
|
||||
const names = ref<string[]>([])
|
||||
const names = ref<ClassMember[]>([])
|
||||
const namesLoading = ref(false)
|
||||
const query = ref('')
|
||||
const picked = ref<string | null>(null)
|
||||
/** 点中的人:名字给人看,用户名拿去登录(老号 ks257张三,新号就是名字) */
|
||||
const picked = ref<ClassMember | null>(null)
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -63,13 +65,8 @@ const searchRef = useTemplateRef('searchRef')
|
||||
const passwordRef = useTemplateRef('passwordRef')
|
||||
const usernameRef = useTemplateRef('usernameRef')
|
||||
|
||||
const knownClasses = computed(() => (configStore.config?.classList ?? []).map((c) => `ks${c}`))
|
||||
const classOptions = computed(() =>
|
||||
groupClassOptions(configStore.config?.classList ?? [], (item) => ({
|
||||
label: classLabel(item),
|
||||
value: `ks${item}`,
|
||||
})),
|
||||
)
|
||||
const knownClasses = computed(() => configStore.config?.classList ?? [])
|
||||
const classOptions = computed(() => groupClassOptions(knownClasses.value))
|
||||
/** 还没选过班的电脑:名单的位置先摆一排排班级 */
|
||||
const classGroups = computed(() => classOptions.value.flatMap((option) => ('children' in option ? [{ label: option.label, items: option.children }] : [])))
|
||||
|
||||
@@ -96,16 +93,16 @@ function matches(name: string, keyword: string) {
|
||||
const collator = new Intl.Collator('zh-CN')
|
||||
const shown = computed(() => {
|
||||
const keyword = query.value.trim()
|
||||
return keyword ? names.value.filter((name) => matches(name, keyword)) : names.value
|
||||
return keyword ? names.value.filter((member) => matches(member.name, keyword)) : names.value
|
||||
})
|
||||
|
||||
async function loadNames(value: string) {
|
||||
namesLoading.value = true
|
||||
names.value = []
|
||||
try {
|
||||
const list = await getClassUsernames(value.slice(2))
|
||||
const list = await getClassUsernames(value)
|
||||
// 切班的请求比上一个先回来时,别让旧班的名单盖上来
|
||||
if (klass.value === value) names.value = [...list].sort(collator.compare)
|
||||
if (klass.value === value) names.value = [...list].sort((a, b) => collator.compare(a.name, b.name))
|
||||
} catch {
|
||||
if (klass.value === value) names.value = []
|
||||
} finally {
|
||||
@@ -142,8 +139,8 @@ function focusSearch() {
|
||||
if (!touchScreen.value) focusLater(() => searchRef.value)
|
||||
}
|
||||
|
||||
function pick(name: string) {
|
||||
picked.value = name
|
||||
function pick(member: ClassMember) {
|
||||
picked.value = member
|
||||
password.value = ''
|
||||
clearError()
|
||||
focusLater(() => passwordRef.value)
|
||||
@@ -178,7 +175,7 @@ function goSignup() {
|
||||
|
||||
async function submit() {
|
||||
if (loading.value || locked.value) return
|
||||
const name = mode.value === 'pick' ? `${klass.value ?? ''}${picked.value ?? ''}` : username.value.trim()
|
||||
const name = mode.value === 'pick' ? (picked.value?.username ?? '') : username.value.trim()
|
||||
if (mode.value === 'user' && !name) {
|
||||
error.value = '写上用户名'
|
||||
focusLater(() => usernameRef.value)
|
||||
@@ -227,8 +224,10 @@ async function submit() {
|
||||
function restoreLast() {
|
||||
const lastMode = storage.get(STORAGE_KEY.LOGIN_MODE)
|
||||
mode.value = lastMode === 'user' ? 'user' : 'pick'
|
||||
const last = storage.get(STORAGE_KEY.LOGIN_CLASS)
|
||||
if (typeof last === 'string' && knownClasses.value.includes(last)) {
|
||||
// 2026-11 之前存的是带 ks 前缀的(ks257),读的时候剥掉
|
||||
const stored = storage.get(STORAGE_KEY.LOGIN_CLASS)
|
||||
const last = typeof stored === 'string' ? stored.replace(/^ks/i, '') : null
|
||||
if (last && knownClasses.value.includes(last)) {
|
||||
if (klass.value === last) {
|
||||
// 同一个班:名单留着,只把上一个人的痕迹清掉
|
||||
picked.value = null
|
||||
@@ -284,8 +283,8 @@ watch(
|
||||
<template v-if="mode === 'pick'">
|
||||
<!-- 点了名字:名单换成这个人,光标直接在密码框 -->
|
||||
<div v-if="picked" class="me">
|
||||
<RankAvatar :username="`${klass}${picked}`" :avatar="null" :size="64" />
|
||||
<div class="me-name"><UserName :username="`${klass}${picked}`" /></div>
|
||||
<RankAvatar :username="picked.username" :avatar="null" :size="64" />
|
||||
<div class="me-name"><UserName :username="picked.username" /></div>
|
||||
<div class="field narrow">
|
||||
<n-input
|
||||
ref="passwordRef"
|
||||
@@ -322,8 +321,8 @@ watch(
|
||||
<div v-else-if="!names.length" class="empty">这个班还没有名单</div>
|
||||
<div v-else-if="!shown.length" class="empty">没有叫这个的</div>
|
||||
<div v-else class="grid">
|
||||
<button v-for="name in shown" :key="name" class="cell" type="button" @click="pick(name)">
|
||||
{{ name }}
|
||||
<button v-for="member in shown" :key="member.username" class="cell" type="button" @click="pick(member)">
|
||||
{{ member.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { rgba } from 'shared/composables/tone'
|
||||
import { USERNAME_CLASS_RE } from 'utils/constants'
|
||||
import { displayName } from '@oj2/contract'
|
||||
|
||||
/**
|
||||
* 排名页的头像。九成以上的学生没换过头像(1797 个号是 default.png),一排一模一样的
|
||||
@@ -26,7 +26,7 @@ watch(
|
||||
const PALETTE = ['#2f6fd0', '#c76a12', '#6a4bd8', '#0f7a6c', '#b4315f', '#4d5967', '#8a6a00']
|
||||
|
||||
const letter = computed(() => {
|
||||
const name = props.username.replace(USERNAME_CLASS_RE, '') || props.username
|
||||
const name = displayName(props.username)
|
||||
return Array.from(name).at(-1) ?? '?'
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { USERNAME_CLASS_RE } from 'utils/constants'
|
||||
import { LEGACY_CLASS_PREFIX_RE } from '@oj2/contract'
|
||||
|
||||
/**
|
||||
* 用户名:班级前缀(`ks253`)淡一档,名字加粗 —— 一个班的人前缀都一样,
|
||||
@@ -11,7 +11,7 @@ const props = defineProps<{ username: string; muted?: boolean }>()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const parts = computed(() => {
|
||||
const match = props.username.match(USERNAME_CLASS_RE)
|
||||
const match = props.username.match(LEGACY_CLASS_PREFIX_RE)
|
||||
if (!match) return { prefix: '', name: props.username }
|
||||
return { prefix: match[0], name: props.username.slice(match[0].length) || match[0] }
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { JudgeStatusValue } from '@oj2/contract'
|
||||
import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, type JudgeStatusValue } from '@oj2/contract'
|
||||
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from './types'
|
||||
|
||||
// 与后端 judge/status.ts 的 JudgeStatus 逐条对齐(submitting 除外,见下)。
|
||||
@@ -308,18 +308,8 @@ export function sortFlowchartCriteria<T>(details: Record<string, T>): [string, T
|
||||
return Object.entries(details).sort(([a], [b]) => rank(a) - rank(b))
|
||||
}
|
||||
|
||||
// 班级号的位数范围。学生用户名形如 ks<班级号><姓名>,班级号还要跟
|
||||
// 网站配置里的班级列表对得上。
|
||||
// 后端 OnlineJudge/utils/shortcuts.py 的 CLASS_NAME_MIN/MAX_DIGITS
|
||||
// 是同一条规则的另一份,改这里必须同时改那边。
|
||||
export const CLASS_NAME_MIN_DIGITS = 3
|
||||
export const CLASS_NAME_MAX_DIGITS = 4
|
||||
|
||||
/** 合法班级号:3~4 位纯数字 */
|
||||
export const CLASS_NAME_RE = new RegExp(`^\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}$`)
|
||||
|
||||
/** 用户名开头的 ks<班级号>,用于从用户名里认出班级 */
|
||||
export const USERNAME_CLASS_RE = new RegExp(`^ks\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}`)
|
||||
// 班级号的位数和格式只有契约里一份(packages/contract/src/username.ts),这里转出去给旧的引用处用
|
||||
export { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, CLASS_NAME_RE } from '@oj2/contract'
|
||||
|
||||
/** 班级号作为数字时的上下界,给 n-input-number 用 */
|
||||
export const CLASS_NAME_MIN_VALUE = 10 ** (CLASS_NAME_MIN_DIGITS - 1)
|
||||
|
||||
Reference in new issue
Block a user