Compare commits
2
Commits
48590eee60
...
2bda05a978
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bda05a978 | ||
|
|
221d264701 |
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -263,10 +263,13 @@ accountRoutes.put('/me/profile', requireAuth, async (c) => {
|
||||
const parsed = await parseBody(c, updateProfileRequestSchema, 'Invalid profile payload')
|
||||
if (!parsed.success) return parsed.response
|
||||
const values = Object.fromEntries(Object.entries(parsed.data).map(([key, value]) => [key, value === '' ? null : value]))
|
||||
await db
|
||||
.update(schema.userProfile)
|
||||
.set(values)
|
||||
.where(eq(schema.userProfile.userId, c.get('user')!.id))
|
||||
// 只带了不收的字段(比如老页面还发 realName):没什么可改的,别让 drizzle 的空 set 报成 500
|
||||
if (Object.keys(values).length) {
|
||||
await db
|
||||
.update(schema.userProfile)
|
||||
.set(values)
|
||||
.where(eq(schema.userProfile.userId, c.get('user')!.id))
|
||||
}
|
||||
const profile = await getUserProfileById(c.get('user')!.id, true)
|
||||
if (!profile) return failure(c, 404, 'profile-not-found', 'User profile does not exist')
|
||||
return success(c, profile)
|
||||
|
||||
@@ -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,62 @@ adminAccountRoutes.get('/users', requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 批量建号之前查重(批量建号页边粘名单边查):哪些用户名已经有了、哪天建的。
|
||||
* 比较时归一成小写,和导入、登录同一个口径
|
||||
* 这个班里已经有的、叫这几个名字的人(多半是同一个学生粘了两次)。三种写法都要认:老号 `ks232张三`
|
||||
* (剥掉前缀比)、名字被占过加了数字的新号 `张三2`、24 级数媒那批编号制用户名 —— 后两种名字只在真名里,
|
||||
* 所以真名也比。比较归一成小写,和登录、导入同一个口径
|
||||
*/
|
||||
async function classmatesNamed(className: string, names: string[]) {
|
||||
const wanted = new Set(names.map((name) => name.toLowerCase()))
|
||||
const rows = await db
|
||||
.select({ username: schema.user.username, realName: schema.userProfile.realName, createTime: schema.user.createTime })
|
||||
.from(schema.user)
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(eq(schema.user.className, className))
|
||||
return rows.flatMap((row) => {
|
||||
const name = [displayName(row.username), row.realName ?? ''].find((candidate) => candidate && wanted.has(candidate.toLowerCase()))
|
||||
return name ? [{ name, username: row.username, createTime: row.createTime }] : []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 全站已经被占的用户名里,和这几个名字撞的:名字本身,以及「名字 + 数字」(给新号加数字时要绕开)。
|
||||
* 名字自己就带数字(王芳1)也照样认 —— 不能先把库里的尾数剥掉再比,那样 `王芳1` 剥成 `王芳` 就漏了
|
||||
*/
|
||||
async function takenUsernames(names: string[]) {
|
||||
const escape = (text: string) => text.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&')
|
||||
const pattern = `^(${[...new Set(names.map((name) => escape(name.toLowerCase())))].join('|')})[0-9]*$`
|
||||
const rows = await db
|
||||
.select({ username: sql<string>`lower(${schema.user.username})` })
|
||||
.from(schema.user)
|
||||
.where(sql`lower(${schema.user.username}) ~ ${pattern}`)
|
||||
return new Set(rows.map((row) => row.username))
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量建号之前查重(批量建号页边粘名单边查):这个班里已经有谁了,以及每个名字会建成什么用户名
|
||||
* (名字被别的班、注册的号占了就加数字)。导入时后端还会再查一遍同班同名,这里只是让老师提前看到
|
||||
*/
|
||||
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)
|
||||
const { className, names } = parsed.data
|
||||
const [existing, taken] = await Promise.all([classmatesNamed(className, names), takenUsernames(names)])
|
||||
return success(c, { existing, usernames: suggestUsernames(names, taken) } 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 +270,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 +290,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 +357,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 +379,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
|
||||
raw: password,
|
||||
email: mail,
|
||||
realName,
|
||||
className: className.value,
|
||||
className,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -372,6 +396,19 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
|
||||
return failure(c, 409, 'email-exists', `这批名单里邮箱重复:${batchMails.join('、')}`)
|
||||
}
|
||||
|
||||
// 同班同名再查一遍:用户名不再带班号以后,「同一个学生粘了两次」撞不上用户名唯一约束了(新号会被加成张三2),
|
||||
// 只靠批量建号页的查重挡 —— 那边有半秒防抖,粘完马上点就绕过去了
|
||||
const byClass = new Map<string, string[]>()
|
||||
for (const item of prepared) {
|
||||
if (item.className) byClass.set(item.className, [...(byClass.get(item.className) ?? []), item.realName || displayName(item.username)])
|
||||
}
|
||||
for (const [className, names] of byClass) {
|
||||
const clash = await classmatesNamed(className, names)
|
||||
if (clash.length) {
|
||||
return failure(c, 409, 'name-exists-in-class', `${className} 班已经有${clash.map((row) => `「${row.name}」`).join('、')}了`)
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ username: schema.user.username, email: schema.user.email })
|
||||
.from(schema.user)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
aiHintRequestSchema,
|
||||
classAnalysisRequestSchema,
|
||||
classPkAnalysisRequestSchema,
|
||||
displayName,
|
||||
HINT_MIN_FAILURES,
|
||||
LEARNING_PERIOD_LABELS,
|
||||
learningPeriodSchema,
|
||||
@@ -354,6 +355,8 @@ aiRoutes.post('/ai/class-analysis', requireAuth, async (c) => {
|
||||
// 数字和抽屉里看到的是同一份(services/ranking.ts 的 classDetail),不再由前端整包传上来
|
||||
const detail = await classDetail(parsed.data.className, true)
|
||||
if (!detail.members) return failure(c, 404, 'class-not-found', '这个班没有学生')
|
||||
// 「要多关心的同学」会被点名:老号的用户名带 ks252 前缀,喂给模型之前剥掉,报告里写的才是界面上那个名字
|
||||
const forModel = { ...detail, care: detail.care?.map((row) => ({ ...row, user: { ...row.user, username: displayName(row.user.username) } })) ?? null }
|
||||
return streamChat(
|
||||
[
|
||||
'你是编程课老师的助教。下面是一个班这学期在 OJ 上的做题数据(JSON,人名是用户名)。',
|
||||
@@ -362,7 +365,7 @@ aiRoutes.post('/ai/class-analysis', requireAuth, async (c) => {
|
||||
'**分化**(前后 10%、人最多的那一档同分)、**建议**(点名 care 里的同学,给下节课能直接做的事)。',
|
||||
'用大白话,不要出现四分位数、标准差、方差这类统计词,不要编数据里没有的数字。',
|
||||
].join(''),
|
||||
JSON.stringify(detail),
|
||||
JSON.stringify(forModel),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import {
|
||||
displayName,
|
||||
createFlowchartRequestSchema,
|
||||
isFlowchartPass,
|
||||
type CreateFlowchartResponse,
|
||||
@@ -27,18 +28,7 @@ import { consumeToken } from '../services/throttling'
|
||||
import { buildWordFrequencies } from '../services/word-frequency'
|
||||
import { dayStart } from '../time'
|
||||
import { unknownDisplayIds } from '../services/submission-filters'
|
||||
import {
|
||||
classCondition,
|
||||
isAdminRole,
|
||||
isTeacherOrAbove,
|
||||
matchedUsers,
|
||||
asRecord,
|
||||
parseDisplayIds,
|
||||
queryInteger,
|
||||
scopedUsers,
|
||||
rounded,
|
||||
stripClassPrefix,
|
||||
} from './helpers'
|
||||
import { classCondition, isAdminRole, isTeacherOrAbove, matchedUsers, asRecord, parseDisplayIds, queryInteger, scopedUsers, rounded } from './helpers'
|
||||
|
||||
export const flowchartRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -293,6 +283,7 @@ const flowchartListColumns = {
|
||||
evaluationTime: schema.flowchartSubmission.evaluationTime,
|
||||
},
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
problem: {
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
@@ -349,10 +340,11 @@ flowchartRoutes.get('/flowcharts', requireAuth, async (c) => {
|
||||
)
|
||||
return success(c, {
|
||||
results: rows.map(
|
||||
({ flowchart, username, problem }) =>
|
||||
({ flowchart, username, className, problem }) =>
|
||||
({
|
||||
id: flowchart.id,
|
||||
username,
|
||||
className,
|
||||
problemDisplayId: problem.displayId,
|
||||
problemTitle: problem.title,
|
||||
status: flowchart.status,
|
||||
@@ -540,7 +532,7 @@ flowchartRoutes.get('/flowcharts/statistics', requireTeacher, async (c) => {
|
||||
const peopleTruncated = peopleRows.length > PEOPLE_LIMIT
|
||||
const people = peopleRows.slice(0, PEOPLE_LIMIT).map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
realName: displayName(row.username),
|
||||
bestScore: row.bestScore === null ? null : Number(row.bestScore),
|
||||
bestGrade: row.bestGrade,
|
||||
count: row.count,
|
||||
@@ -561,7 +553,7 @@ flowchartRoutes.get('/flowcharts/statistics', requireTeacher, async (c) => {
|
||||
// 于是一节课刚开始、最该点名的时候,教师面板反而一个名字都不给
|
||||
dataUnaccepted: roster.map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
realName: displayName(row.username),
|
||||
})),
|
||||
people: [],
|
||||
peopleTruncated: false,
|
||||
@@ -639,7 +631,7 @@ flowchartRoutes.get('/flowcharts/statistics', requireTeacher, async (c) => {
|
||||
.filter((row) => !submitted.has(row.id))
|
||||
.map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
realName: displayName(row.username),
|
||||
})),
|
||||
people,
|
||||
peopleTruncated,
|
||||
|
||||
@@ -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` → `张三`。只剩旧版登录名单(site.ts 的
|
||||
* /classes/:className/usernames)在用 —— 旧登录页拿名字拼回「ks + 班号 + 名字」去登录,所以必须严格按这个班的班号剥。
|
||||
* 界面上显示名字一律用契约的 `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, displayName, 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'
|
||||
@@ -92,9 +92,33 @@ siteRoutes.get('/quotes/random', async (c) => {
|
||||
}
|
||||
})
|
||||
|
||||
siteRoutes.get('/classes/:className/usernames', async (c) => {
|
||||
/**
|
||||
* 登录框点名字的名单:名字给人点,用户名拿去登录(老号 ks232张三,新号就是名字)。名字按界面上的显示规则剥,
|
||||
* 和 UserName 一致 —— 不能只剥「ks + 当前班号」:班级现在能在后台改,换过班的老号会露出整串
|
||||
*/
|
||||
siteRoutes.get('/classes/:className/members', 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
|
||||
.select({ username: schema.user.username })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, className))
|
||||
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
||||
return success(
|
||||
c,
|
||||
rows.map(({ username }) => ({ name: displayName(username), username }) satisfies ClassMember),
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 旧版登录框的名单:只回名字,前端拼「ks + 班号 + 名字」去登录。2026-11 的新前端改走上面的 /members;
|
||||
* 这条原样留着,是给上线时还开着旧登录页、没刷新的机房电脑用的(老号照样登得上)。过一阵可以删
|
||||
*/
|
||||
siteRoutes.get('/classes/:className/usernames', async (c) => {
|
||||
const className = c.req.param('className').trim()
|
||||
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,7 +126,6 @@ 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 会把中间的匹配也删掉,前缀对不上时截出乱码
|
||||
return success(
|
||||
c,
|
||||
rows.map(({ username }) => stripClassPrefix(username, className)),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
displayName,
|
||||
FLOWCHART_PASS_GRADES,
|
||||
NO_CLASS,
|
||||
STUDENT_ROLES,
|
||||
@@ -27,7 +28,7 @@ import { type ContestEnv } from '../services/contest'
|
||||
import { getBooleanOption } from '../services/options'
|
||||
import { lessonDoneCounts } from '../services/lesson'
|
||||
import { calendarDay, dayStart, localTime } from '../time'
|
||||
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from './helpers'
|
||||
import { isAdminRole, parseDisplayIds, rounded, scopedUsers } from './helpers'
|
||||
|
||||
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
||||
|
||||
@@ -345,7 +346,7 @@ submissionStatisticsRoutes.get('/submissions/statistics', requireTeacher, async
|
||||
.filter((row) => !submittedUserIds.has(row.id))
|
||||
.map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
realName: displayName(row.username),
|
||||
}))
|
||||
|
||||
/**
|
||||
@@ -374,7 +375,7 @@ submissionStatisticsRoutes.get('/submissions/statistics', requireTeacher, async
|
||||
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
|
||||
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
|
||||
*/
|
||||
realName: scoped ? stripClassPrefix(row.username, row.className) : row.username,
|
||||
realName: scoped ? displayName(row.username) : row.username,
|
||||
submissionCount: row.submissionCount,
|
||||
solvedCount: row.solvedCount,
|
||||
}))
|
||||
@@ -403,7 +404,7 @@ const GRID_LIMIT = 5000
|
||||
|
||||
/**
|
||||
* 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。
|
||||
* 用户名的匹配和统计接口同一套(ilike,填 `ks253` 圈一个班)。
|
||||
* 班级和学生名的匹配和统计接口同一套(scopedUsers:班级精确、名字包含)。
|
||||
*/
|
||||
submissionStatisticsRoutes.get('/submissions/statistics/grid', requireTeacher, async (c) => {
|
||||
const scope = await statisticsScope(c)
|
||||
@@ -481,7 +482,7 @@ submissionStatisticsRoutes.get('/submissions/statistics/grid', requireTeacher, a
|
||||
if (!entry) {
|
||||
entry = {
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
realName: displayName(row.username),
|
||||
className: row.className,
|
||||
submissions: [],
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import type { DashboardInfo } from '@oj2/contract'
|
||||
import { BarElement, CategoryScale, Chart as ChartJS, LinearScale, Tooltip } from 'chart.js'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
@@ -118,8 +119,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
|
||||
@@ -146,7 +147,7 @@ onUnmounted(stopRolling)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader :title="`你好,${userStore.user?.username ?? '管理员'}`" />
|
||||
<PageHeader :title="`你好,${userStore.user ? displayName(userStore.user.username) : '管理员'}`" />
|
||||
|
||||
<div v-if="info" class="tiles">
|
||||
<div class="tile">
|
||||
|
||||
@@ -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,24 +83,39 @@ 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)
|
||||
/** 查重结果对应的是哪一份「班级 + 名单」:和眼前的对不上(刚改过、还在防抖)就不让建 */
|
||||
const inputKey = computed(() => `${klass.value}\n${names.value.join('\n')}`)
|
||||
const checkedKey = ref('')
|
||||
// 改得快时前一次请求可能后回来,只认最后发出去的那次
|
||||
let checkSeq = 0
|
||||
watchDebounced(
|
||||
() => rows.value.map((row) => row.username).join('\n'),
|
||||
async () => {
|
||||
const usernames = [...new Set(rows.value.map((row) => row.username))]
|
||||
if (!usernames.length || classError.value || usernames.length > 500) {
|
||||
inputKey,
|
||||
async (key) => {
|
||||
const seq = ++checkSeq
|
||||
const list = [...new Set(names.value)]
|
||||
if (!list.length || !klass.value || list.length > 500) {
|
||||
existing.value = []
|
||||
suggested.value = new Map()
|
||||
checkedKey.value = ''
|
||||
return
|
||||
}
|
||||
checking.value = true
|
||||
try {
|
||||
existing.value = (await checkUsernames(usernames)).existing
|
||||
const res = await checkUsernames(klass.value, list)
|
||||
if (seq !== checkSeq) return
|
||||
existing.value = res.existing
|
||||
suggested.value = new Map(list.map((name, i) => [name, res.usernames[i] ?? name]))
|
||||
checkedKey.value = key
|
||||
} catch {
|
||||
if (seq !== checkSeq) return
|
||||
existing.value = []
|
||||
suggested.value = new Map()
|
||||
checkedKey.value = ''
|
||||
} finally {
|
||||
checking.value = false
|
||||
if (seq === checkSeq) checking.value = false
|
||||
}
|
||||
},
|
||||
{ debounce: 500, immediate: true },
|
||||
@@ -102,7 +123,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 && checkedKey.value === inputKey.value)
|
||||
|
||||
// ---------------------------------------------------------------- 建号
|
||||
|
||||
@@ -118,8 +139,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 +164,7 @@ function again() {
|
||||
|
||||
function download() {
|
||||
if (!done.value) return
|
||||
downloadAccountsCsv(done.value, prefix.value || '账号')
|
||||
downloadAccountsCsv(done.value, klass.value ? classLabel(klass.value) : '账号')
|
||||
}
|
||||
|
||||
/** 打印账号条:一人一张,一页四列,打出来剪开发下去 */
|
||||
@@ -196,7 +218,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 +231,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 && checkedKey === inputKey" type="success" :bordered="false" round> 没有重名 </n-tag>
|
||||
<n-tag v-if="issueCount" type="warning" :bordered="false" round> 有 {{ issueCount }} 处要先改 </n-tag>
|
||||
</div>
|
||||
|
||||
@@ -221,7 +242,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 +270,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 +309,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;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import UserNameCell from './components/UserNameCell.vue'
|
||||
import PasswordCell from './components/PasswordCell.vue'
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from 'utils/constants'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { NO_CLASS, STUDENT_ROLES, type ImportUsersRequest } from '@oj2/contract'
|
||||
import { legacyClassOf, NO_CLASS, STUDENT_ROLES, type ImportUsersRequest } from '@oj2/contract'
|
||||
import { downloadAccountsCsv } from './accountsCsv'
|
||||
import { classLabel, groupClassOptions } from 'utils/classes'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
@@ -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)
|
||||
@@ -311,10 +315,21 @@ async function handleEditUser() {
|
||||
}
|
||||
// http 拦截器只对 login-required / permission-denied 自动弹提示,
|
||||
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
|
||||
if (create.value && !userEditing.value.className && legacyClassOf(userEditing.value.username) === undefined) {
|
||||
message.error('用户名里 ks 后面的班级号位数不对,班级号要是 3~4 位数字')
|
||||
return
|
||||
}
|
||||
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 ?? '',
|
||||
// 没选班、但手写的是老格式 ks232张三:按前缀补上班级
|
||||
userEditing.value.className ?? legacyClassOf(userEditing.value.username) ?? null,
|
||||
],
|
||||
]
|
||||
await importUsers(newUser)
|
||||
listUsers()
|
||||
@@ -388,10 +403,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import type { ContestClassView, ContestScoreCell, ContestScoreRow } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { updateACMHelperChecked } from 'admin/api'
|
||||
@@ -225,12 +226,13 @@ onKeyStroke(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'], (event) => {
|
||||
|
||||
function exportGrades() {
|
||||
const title = contestStore.contest?.title ?? '比赛'
|
||||
const head = ['用户名', '班级', '做对', '名次', '用时', ...problems.value.map((p) => `第${p._id}题 ${p.title}`)]
|
||||
const head = ['名字', '班级', '做对', '名次', '用时', ...problems.value.map((p) => `第${p._id}题 ${p.title}`)]
|
||||
const lines = [head]
|
||||
const byUsername = [...rows.value].sort((a, b) => a.username.localeCompare(b.username, 'zh-CN'))
|
||||
for (const row of byUsername) {
|
||||
// 按名字的拼音排:老号 ks 开头、新号是中文名,按用户名排两种号会各排各的
|
||||
const byName = [...rows.value].sort((a, b) => displayName(a.username).localeCompare(displayName(b.username), 'zh-CN'))
|
||||
for (const row of byName) {
|
||||
lines.push([
|
||||
row.username,
|
||||
displayName(row.username),
|
||||
row.className ? classLabel(row.className) : '',
|
||||
String(row.solved),
|
||||
String(row.rank ?? ''),
|
||||
@@ -242,7 +244,14 @@ function exportGrades() {
|
||||
])
|
||||
}
|
||||
for (const user of absent.value) {
|
||||
lines.push([user.username, user.className ? classLabel(user.className) : '', user.leave ? '请假' : '没考', '', '', ...problems.value.map(() => '')])
|
||||
lines.push([
|
||||
displayName(user.username),
|
||||
user.className ? classLabel(user.className) : '',
|
||||
user.leave ? '请假' : '没考',
|
||||
'',
|
||||
'',
|
||||
...problems.value.map(() => ''),
|
||||
])
|
||||
}
|
||||
const csv = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(',')).join('\r\n')
|
||||
// 带 BOM,Excel 双击打开中文才不乱码
|
||||
@@ -430,7 +439,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
no: cellOf(row, p.id) && !cellOf(row, p.id)!.isAc,
|
||||
sel: selected?.userId === row.userId && selected.problemId === p.id,
|
||||
}"
|
||||
:title="`${row.username} · 第 ${p._id} 题`"
|
||||
:title="`${displayName(row.username)} · 第 ${p._id} 题`"
|
||||
@click="pick(row, p.id)"
|
||||
>
|
||||
<template v-if="cellOf(row, p.id)?.isAc">✓</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import type { ContestScoreRow } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useContestStore } from 'oj/store/contest'
|
||||
@@ -131,7 +132,7 @@ const warning = computed(() => tone('warning'))
|
||||
<span class="count-num">{{ item.solved }} 人做对</span>
|
||||
</span>
|
||||
<span class="first">
|
||||
<template v-if="item.first">最先:{{ item.first.username }} · {{ firstMinute(item.first.acTime) }}</template>
|
||||
<template v-if="item.first">最先:{{ displayName(item.first.username) }} · {{ firstMinute(item.first.acTime) }}</template>
|
||||
<template v-else>还没人做对</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import { continueStep, doneCount, tutorialPath, splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
|
||||
@@ -276,8 +277,8 @@ function contestTime(contest: Contest) {
|
||||
<div class="home">
|
||||
<!-- 中间原来还有一个「题号或题目名 找题」框,和顶栏右上角那个重复,拿掉了 -->
|
||||
<section class="hero">
|
||||
<h1 v-if="firstVisit" class="hello">欢迎来到判题狗,{{ userStore.user?.username }}</h1>
|
||||
<h1 v-else class="hello">{{ greeting }},{{ userStore.user?.username }}</h1>
|
||||
<h1 v-if="firstVisit" class="hello">欢迎来到判题狗,{{ displayName(userStore.user?.username ?? '') }}</h1>
|
||||
<h1 v-else class="hello">{{ greeting }},{{ displayName(userStore.user?.username ?? '') }}</h1>
|
||||
<n-text v-if="firstVisit" depth="3">老师报了题号,在右上角的框里输入就能找到题</n-text>
|
||||
<n-text v-else depth="3">
|
||||
<template v-if="longAway">{{ awayText }} · </template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import type { ClassToday } from '@oj2/contract'
|
||||
import type { AnnouncementListItem } from 'utils/types'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
@@ -78,7 +79,7 @@ const shortcuts = computed(() =>
|
||||
<template>
|
||||
<div class="home">
|
||||
<section class="hero">
|
||||
<h1 class="hello">{{ greeting }},{{ userStore.user?.username }}</h1>
|
||||
<h1 class="hello">{{ greeting }},{{ displayName(userStore.user?.username ?? '') }}</h1>
|
||||
<n-text v-if="today" depth="3" class="tagline">
|
||||
{{ today.classes.length ? `今天 ${today.classes.length} 个班上过课` : '今天还没有班在上课' }}
|
||||
</n-text>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useProblemStore } from 'oj/store/problem'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
@@ -85,7 +86,7 @@ function finishAndNext() {
|
||||
<button type="button" :class="next ? 'ghost' : 'end'" @click="finish">
|
||||
{{ peek ? '看完了' : '帮完了' }}
|
||||
</button>
|
||||
<button v-if="next" type="button" class="end" @click="finishAndNext">下一个:{{ next.studentName }} {{ next.problemId }} →</button>
|
||||
<button v-if="next" type="button" class="end" @click="finishAndNext">下一个:{{ displayName(next.studentName) }} {{ next.problemId }} →</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
}
|
||||
// 协作中跳走这一页协作就断了
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getProblemSetClassView } from 'oj/api'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
@@ -84,8 +85,8 @@ const error = computed(() => tone('error'))
|
||||
><span class="muted">做完</span> <b class="num big">{{ finished }}</b
|
||||
><span class="muted"> 人</span></span
|
||||
>
|
||||
<span v-if="absent.length" class="absent ell" :title="absent.map((s) => s.username).join('、')"
|
||||
>没加入:{{ absent.map((s) => s.username).join('、') }}</span
|
||||
<span v-if="absent.length" class="absent ell" :title="absent.map((s) => displayName(s.username)).join('、')"
|
||||
>没加入:{{ absent.map((s) => displayName(s.username)).join('、') }}</span
|
||||
>
|
||||
<div class="spacer"></div>
|
||||
<span v-if="beforeCount" class="legend-before"><span class="corner"></span>加入前就做对过({{ beforeCount }} 格)</span>
|
||||
@@ -116,7 +117,7 @@ const error = computed(() => tone('error'))
|
||||
target="_blank"
|
||||
class="box"
|
||||
:class="{ ok: c.solvedTime, bad: !c.solvedTime && c.wrongCount }"
|
||||
:title="`${s.username} · ${data.problems[i]!.title}${c.solvedBefore ? ' · 加入前就做对过' : ''}`"
|
||||
:title="`${displayName(s.username)} · ${data.problems[i]!.title}${c.solvedBefore ? ' · 加入前就做对过' : ''}`"
|
||||
>
|
||||
<template v-if="c.solvedTime"
|
||||
>✓ <span class="num">{{ cellTime(c.solvedTime) }}</span></template
|
||||
|
||||
@@ -40,6 +40,8 @@ const props = defineProps<{
|
||||
minis: MiniRank[]
|
||||
/** 手机:前后一名的说明换到名字下面一行,别被截断 */
|
||||
compact?: boolean
|
||||
/** 本年级 / 全服的榜混着好几个班:名字后面淡写班级(本班榜不写) */
|
||||
showClass?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ moodSaved: [mood: string | null] }>()
|
||||
|
||||
@@ -153,7 +155,7 @@ function threatNote(me: RankRow, behind: RankRow) {
|
||||
<RankAvatar :username="ahead.user.username" :avatar="ahead.avatar" :size="20" />
|
||||
<div class="rival-body">
|
||||
<div class="rival-line">
|
||||
<UserName :username="ahead.user.username" class="rival-name" />
|
||||
<UserName :username="ahead.user.username" :class-name="showClass ? ahead.className : null" class="rival-name" />
|
||||
<span class="note">{{ chaseNote(me, ahead) }}</span>
|
||||
</div>
|
||||
<span v-if="ahead.mood" class="rival-mood" :title="ahead.mood">“{{ ahead.mood }}”</span>
|
||||
@@ -164,7 +166,7 @@ function threatNote(me: RankRow, behind: RankRow) {
|
||||
<RankAvatar :username="behind.user.username" :avatar="behind.avatar" :size="20" />
|
||||
<div class="rival-body">
|
||||
<div class="rival-line">
|
||||
<UserName :username="behind.user.username" class="rival-name" />
|
||||
<UserName :username="behind.user.username" :class-name="showClass ? behind.className : null" class="rival-name" />
|
||||
<span class="note">{{ threatNote(me, behind) }}</span>
|
||||
</div>
|
||||
<span v-if="behind.mood" class="rival-mood" :title="behind.mood">“{{ behind.mood }}”</span>
|
||||
|
||||
@@ -19,6 +19,8 @@ const props = defineProps<{
|
||||
chaseId?: number
|
||||
threatId?: number
|
||||
teacher?: boolean
|
||||
/** 本年级 / 全服的榜混着好几个班:名字后面淡写班级(本班榜不写) */
|
||||
showClass?: boolean
|
||||
}>()
|
||||
defineEmits<{ open: [username: string]; clearMood: [row: RankRow] }>()
|
||||
|
||||
@@ -59,7 +61,7 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
|
||||
<div class="words">
|
||||
<div class="name-line">
|
||||
<button class="name" @click="$emit('open', row.user.username)">
|
||||
<UserName :username="row.user.username" />
|
||||
<UserName :username="row.user.username" :class-name="showClass ? row.className : null" />
|
||||
</button>
|
||||
<span v-if="row.user.id === chaseId" class="tag chase">前一名</span>
|
||||
<span v-else-if="row.user.id === threatId" class="tag threat">后一名</span>
|
||||
|
||||
@@ -43,6 +43,8 @@ const props = defineProps<{
|
||||
expanding?: boolean
|
||||
/** 手机上只有末尾一段折起来时按钮上的字,比如「看全班 39 人」 */
|
||||
wholeLabel?: string
|
||||
/** 本年级 / 全服的榜混着好几个班:名字后面淡写班级(本班榜不写) */
|
||||
showClass?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
open: [username: string]
|
||||
@@ -188,7 +190,7 @@ function onMenu(key: string, row: RankRow) {
|
||||
<div class="pmain">
|
||||
<div class="pname">
|
||||
<button class="who" @click="emit('open', item.row.user.username)">
|
||||
<UserName :username="item.row.user.username" />
|
||||
<UserName :username="item.row.user.username" :class-name="showClass ? item.row.className : null" />
|
||||
</button>
|
||||
<span v-if="item.row.user.id === chaseId" class="tag chase">前一名</span>
|
||||
<span v-else-if="item.row.user.id === threatId" class="tag threat">后一名</span>
|
||||
@@ -211,7 +213,7 @@ function onMenu(key: string, row: RankRow) {
|
||||
<span class="rank">{{ item.row.solved ? item.row.rank : '—' }}</span>
|
||||
<RankAvatar :username="item.row.user.username" :avatar="item.row.avatar" :size="20" :me="item.row.user.id === meId" />
|
||||
<button class="who" @click="emit('open', item.row.user.username)">
|
||||
<UserName :username="item.row.user.username" />
|
||||
<UserName :username="item.row.user.username" :class-name="showClass ? item.row.className : null" />
|
||||
</button>
|
||||
<span class="bar-box" :style="{ background: palette.track }">
|
||||
<span class="bar" :style="{ width: width(item.row), background: barColor(item.row) }" />
|
||||
|
||||
@@ -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 }] : []),
|
||||
@@ -286,7 +287,7 @@ function openSubmissions(username: string) {
|
||||
function hide(row: RankRow) {
|
||||
dialog.warning({
|
||||
title: '不计入排名',
|
||||
content: `${row.user.username} 会从所有排名里消失(本班、本年级、全服、首页周榜),本人会看到「不计入排名」。比赛排名不受影响,随时能恢复。`,
|
||||
content: `${displayName(row.user.username)} 会从所有排名里消失(本班、本年级、全服、首页周榜),本人会看到「不计入排名」。比赛排名不受影响,随时能恢复。`,
|
||||
positiveText: '不计入排名',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
@@ -313,7 +314,7 @@ function onMoodSaved(mood: string | null) {
|
||||
function confirmClearMood(row: RankRow) {
|
||||
dialog.warning({
|
||||
title: '清空个性签名',
|
||||
content: `${row.user.username} 的签名「${row.mood}」会被清空。只清这一句,之后还能重新写。`,
|
||||
content: `${displayName(row.user.username)} 的签名「${row.mood}」会被清空。只清这一句,之后还能重新写。`,
|
||||
positiveText: '清空',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
@@ -368,6 +369,7 @@ async function restore(userId: number) {
|
||||
</div>
|
||||
<RankPodium
|
||||
:rows="podium"
|
||||
:show-class="board.scope !== 'class'"
|
||||
:me-id="meId"
|
||||
:chase-id="board.ahead?.user.id"
|
||||
:threat-id="board.behind?.user.id"
|
||||
@@ -376,6 +378,7 @@ async function restore(userId: number) {
|
||||
@clear-mood="confirmClearMood"
|
||||
/>
|
||||
<RankTrack
|
||||
:show-class="board.scope !== 'class'"
|
||||
:key="`${board.scope}|${board.period}|${board.className}`"
|
||||
:rows="trackRows"
|
||||
:whole-label="board.scope === 'class' ? `看全班 ${board.total} 人` : undefined"
|
||||
@@ -406,6 +409,7 @@ async function restore(userId: number) {
|
||||
<template v-if="board">
|
||||
<RankMe
|
||||
v-if="me"
|
||||
:show-class="board.scope !== 'class'"
|
||||
:me="me"
|
||||
:ahead="board.ahead"
|
||||
:behind="board.behind"
|
||||
|
||||
@@ -63,6 +63,8 @@ const narrowList = computed(() => isDesktop.value && splitWidth.value > 0 && spl
|
||||
const twoLine = computed(() => isMobile.value || narrowList.value)
|
||||
|
||||
const inContest = computed(() => route.name === 'contest submissions')
|
||||
/** 没按班筛、又不在比赛里:列表混着好几个班,名字后面淡写班级(同名的人靠它分开) */
|
||||
const mixedClasses = computed(() => !inContest.value && !query.className && query.myself !== '1')
|
||||
|
||||
/**
|
||||
* 手机上点开的那一条,记在地址的 `?id=` 上:详情是从右边滑出的整屏,学生下意识按返回 ——
|
||||
@@ -754,7 +756,7 @@ function dayBreak(index: number) {
|
||||
<div class="row" :class="{ on: row.id === selectedId && !isMobile, 'two-line': twoLine }" :data-row-id="row.id" @click="rowClicked(row)">
|
||||
<template v-if="!twoLine">
|
||||
<span class="c-time">{{ submissionClockText(row.createTime) }}</span>
|
||||
<span class="c-user"><UserName :username="row.username" /></span>
|
||||
<span class="c-user"><UserName :username="row.username" :class-name="mixedClasses ? row.className : null" short-class /></span>
|
||||
<span class="c-problem" :title="`${row.problemDisplayId} ${row.problemTitle}`">
|
||||
<span class="pid">{{ row.problemDisplayId }}</span> {{ row.problemTitle }}
|
||||
</span>
|
||||
@@ -764,7 +766,7 @@ function dayBreak(index: number) {
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="m-line">
|
||||
<UserName :username="row.username" />
|
||||
<UserName :username="row.username" :class-name="mixedClasses ? row.className : null" short-class />
|
||||
<div class="spacer"></div>
|
||||
<FlowchartState :status="row.status" :grade="row.aiGrade" />
|
||||
</div>
|
||||
@@ -785,7 +787,7 @@ function dayBreak(index: number) {
|
||||
<template v-if="!twoLine">
|
||||
<span class="c-time">{{ submissionClockText(row.createTime) }}</span>
|
||||
<span class="c-user">
|
||||
<UserName :username="row.username" :muted="othersRow(row)" />
|
||||
<UserName :username="row.username" :class-name="mixedClasses ? row.className : null" short-class :muted="othersRow(row)" />
|
||||
</span>
|
||||
<span class="c-problem" :title="`${row.problemDisplayId} ${row.problemTitle}`">
|
||||
<span class="title">
|
||||
@@ -808,7 +810,7 @@ function dayBreak(index: number) {
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="m-line">
|
||||
<UserName :username="row.username" :muted="othersRow(row)" />
|
||||
<UserName :username="row.username" :class-name="mixedClasses ? row.className : null" short-class :muted="othersRow(row)" />
|
||||
<div class="spacer"></div>
|
||||
<StatusPill :result="row.result" />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { displayName } from '@oj2/contract'
|
||||
import type { LessonMode } from '@oj2/contract'
|
||||
import type { Tone } from 'shared/composables/tone'
|
||||
import { parseTime } from 'utils/functions'
|
||||
@@ -114,11 +115,11 @@ export function cellTitle(cell: TaskBoardCell, student: TaskBoardStudent, day: s
|
||||
if (stage === 'draw') {
|
||||
const latest = cell.flowchart?.latest
|
||||
if (latest?.status === 0 || latest?.status === 1) return 'AI 正在评这张图'
|
||||
return latest ? `讲讲 ${student.username} 这张图哪里画错了` : '还没画对'
|
||||
return latest ? `讲讲 ${displayName(student.username)} 这张图哪里画错了` : '还没画对'
|
||||
}
|
||||
if (stage === 'nodraw') return '代码做对了,流程图还没画'
|
||||
if (halfDone(cell, mode)) return '流程图画对了,代码还没交'
|
||||
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
|
||||
if (cell.latest) return `讲讲 ${displayName(student.username)} 这道题的错`
|
||||
if (mode === 'both' && cell.flowchart?.status === 'accepted') return '流程图画对了,代码以前交过、没做对'
|
||||
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : '以前交过,没做对'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import type { TaskBoardStudent } from 'utils/types'
|
||||
|
||||
/**
|
||||
@@ -68,7 +69,13 @@ function save() {
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="leave-grid">
|
||||
<n-checkbox v-for="student in section.students" :key="student.userId" :value="student.username" :label="student.username" class="leave-option" />
|
||||
<n-checkbox
|
||||
v-for="student in section.students"
|
||||
:key="student.userId"
|
||||
:value="student.username"
|
||||
:label="displayName(student.username)"
|
||||
class="leave-option"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-checkbox-group>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getTaskBoard } from 'oj/api'
|
||||
@@ -714,7 +715,7 @@ function lastText(student: TaskBoardStudent) {
|
||||
class="plain"
|
||||
:href="submissionsHref(student, undefined, !student.codeToday)"
|
||||
target="_blank"
|
||||
:title="`看 ${student.username} 今天交的全部`"
|
||||
:title="`看 ${displayName(student.username)} 今天交的全部`"
|
||||
>
|
||||
<UserName :username="student.username" />
|
||||
</a>
|
||||
@@ -733,7 +734,7 @@ function lastText(student: TaskBoardStudent) {
|
||||
v-else-if="student.editing"
|
||||
type="button"
|
||||
class="where"
|
||||
:title="`打开 ${student.username} 正在写的 ${student.editing}`"
|
||||
:title="`打开 ${displayName(student.username)} 正在写的 ${student.editing}`"
|
||||
@click="peek(student.userId)"
|
||||
>
|
||||
{{ student.editing }}
|
||||
|
||||
@@ -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)}/members`)
|
||||
}
|
||||
|
||||
// 下面这个是启动就要调的(config store),所以放在 shared 而不是
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { NSwitch, useThemeVars } from 'naive-ui'
|
||||
import { RouterLink } from 'vue-router'
|
||||
@@ -236,7 +237,7 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
|
||||
<template v-if="userStore.isFinished">
|
||||
<n-dropdown v-if="userStore.isAuthed" :options="options" size="large">
|
||||
<n-button class="name" :title="userStore.user!.username">
|
||||
<span class="name-text">{{ userStore.user!.username }}</span>
|
||||
<span class="name-text">{{ displayName(userStore.user!.username) }}</span>
|
||||
<Icon icon="ph:caret-down-bold" :width="11" class="caret" />
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
|
||||
@@ -57,6 +57,9 @@ function explain(items: CollabRequestItem[], problemId: string) {
|
||||
query: { ...(className ? { className } : {}), problem: problemId },
|
||||
})
|
||||
}
|
||||
|
||||
/** 举手的人来自不止一个班(两个老师同时上课):名字后面淡写班级 */
|
||||
const manyClasses = computed(() => new Set(collabStore.requests.map((item) => item.className)).size > 1)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -80,7 +83,7 @@ function explain(items: CollabRequestItem[], problemId: string) {
|
||||
</div>
|
||||
<div v-for="item in group.items" :key="item.studentId" class="row">
|
||||
<div class="who">
|
||||
<UserName :username="item.studentName" />
|
||||
<UserName :username="item.studentName" :class-name="manyClasses ? item.className : null" />
|
||||
<span class="kind">{{ kindOf(item) }}</span>
|
||||
</div>
|
||||
<template v-if="item.status === 'active'">
|
||||
|
||||
@@ -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>
|
||||
@@ -361,7 +360,7 @@ watch(
|
||||
v-model:value="username"
|
||||
size="large"
|
||||
clearable
|
||||
placeholder="完整用户名,如 ks231张三"
|
||||
placeholder="用户名"
|
||||
:input-props="{ name: 'username', autocomplete: 'username' }"
|
||||
@keyup.enter="passwordRef?.focus()"
|
||||
/>
|
||||
|
||||
@@ -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,26 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { USERNAME_CLASS_RE } from 'utils/constants'
|
||||
import { classLabel } from 'utils/classes'
|
||||
|
||||
/**
|
||||
* 用户名:班级前缀(`ks253`)淡一档,名字加粗 —— 一个班的人前缀都一样,
|
||||
* 老师要认的是后面那段。`muted` 用在学生看别人的那几行(看得到结果、看不到代码)。
|
||||
* 学生的名字。显示的是用户名(学生改不了;真名早几届有人改成过外号),老号的 `ks232` 前缀剥掉不显示 ——
|
||||
* 班级单独存一列,前缀在界面上只是噪音。2026-11 起新号的用户名就是名字。
|
||||
*
|
||||
* `className` 给了就在名字后面淡写班级:一页上混着几个班(全服排名、全部提交、举手列表)时用,
|
||||
* 不同班同名的人(252、253 都有杨涵宇)靠它分开。一页只有一个班的别传。
|
||||
* `shortClass`:窄的定宽列(提交列表的名字列 116px)里只写班号(`李鑫楠 2312`),和原来 `ks2312李鑫楠` 一样宽。
|
||||
* `muted` 用在学生看别人的那几行(看得到结果、看不到代码)。悬停看得到完整用户名。
|
||||
*/
|
||||
const props = defineProps<{ username: string; muted?: boolean }>()
|
||||
|
||||
const props = defineProps<{ username: string; muted?: boolean; className?: string | null; shortClass?: boolean }>()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const parts = computed(() => {
|
||||
const match = props.username.match(USERNAME_CLASS_RE)
|
||||
if (!match) return { prefix: '', name: props.username }
|
||||
return { prefix: match[0], name: props.username.slice(match[0].length) || match[0] }
|
||||
})
|
||||
const name = computed(() => displayName(props.username))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="user" :class="{ muted }" :title="username">
|
||||
<span v-if="parts.prefix" class="prefix">{{ parts.prefix }}</span>
|
||||
<span class="name">{{ parts.name }}</span>
|
||||
<span class="name">{{ name }}</span>
|
||||
<span v-if="className" class="klass">{{ shortClass ? className : classLabel(className) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -32,15 +32,17 @@ const parts = computed(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prefix {
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
color: v-bind('theme.textColor1');
|
||||
}
|
||||
|
||||
.klass {
|
||||
margin-left: 6px;
|
||||
font-size: 0.85em;
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.muted .name {
|
||||
font-weight: 400;
|
||||
color: v-bind('theme.textColor2');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@oj2/contract'
|
||||
import { h } from 'vue'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
@@ -286,7 +287,7 @@ onMounted(async () => {
|
||||
<n-dropdown v-if="userStore.user" :options="userOptions" trigger="click" @select="onUserSelect">
|
||||
<n-button quaternary size="small">
|
||||
<template #icon><Icon icon="ph:user-circle" /></template>
|
||||
<template v-if="!isMobile">{{ userStore.user.username }}</template>
|
||||
<template v-if="!isMobile">{{ displayName(userStore.user.username) }}</template>
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
<!-- 老师在后台改题时也得知道有人举手(前台顶栏在这里是卸载的)。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,8 +13,11 @@ export const registerRequestSchema = z.object({
|
||||
password: z.string().min(6).max(20),
|
||||
})
|
||||
|
||||
/**
|
||||
* 学生自己能改的资料。**真名不在这里**:它只在后台改(PUT /admin/users/:id)。原来这里收 realName,
|
||||
* 界面上早就没入口了,但接口还认 —— 早几届有 44 人把真名改成了外号(「超级无敌暴龙战士」)
|
||||
*/
|
||||
export const updateProfileRequestSchema = z.object({
|
||||
realName: z.string().max(32).nullable().optional(),
|
||||
avatar: z.string().max(256).optional(),
|
||||
/** 排名上要挂出来(`MOOD_MAX`),太长了榜上放不下 */
|
||||
mood: z.string().trim().max(MOOD_MAX, `个性签名最多 ${MOOD_MAX} 个字`).nullable().optional(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { achievementRaritySchema } from './achievement'
|
||||
import { rankProfileSchema } from './account'
|
||||
import { paginatedSchema, sampleUserSchema } from './common'
|
||||
import { reactionKeySchema } from './content'
|
||||
import { classNameSchema } from './username'
|
||||
import { badgeConditionTypeSchema } from './problemset'
|
||||
import { problemLanguageSchema, runnableLanguageSchema } from './language'
|
||||
import {
|
||||
@@ -285,12 +286,19 @@ export const adminUserListSchema = paginatedSchema(adminUserSchema).extend({
|
||||
neverLoggedIn: z.number().int(),
|
||||
})
|
||||
|
||||
/** 批量建号之前查重:这几个用户名哪些已经有了 */
|
||||
/** 批量建号之前查重:给一个班和一串名字 */
|
||||
export const checkUsernamesRequestSchema = z.object({
|
||||
usernames: z.array(z.string().trim().min(1).max(32)).min(1).max(500),
|
||||
className: classNameSchema,
|
||||
names: 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() })),
|
||||
/** 这个班里已经有的同名账号(老号按剥掉 ks 前缀后的名字比)—— 多半是同一个人,建之前要先处理 */
|
||||
existing: z.array(z.object({ name: z.string(), username: z.string(), createTime: z.string().nullable() })),
|
||||
/**
|
||||
* 每个名字会建成的用户名,和 `names` 一一对应。名字没被别人占就是名字本身;占了(别的班同名、
|
||||
* 注册的号)往后加数字,`杨涵宇2`。用户名界面上看不到,只用来登录和查重
|
||||
*/
|
||||
usernames: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const updateUserRequestSchema = z.object({
|
||||
@@ -299,14 +307,19 @@ export const updateUserRequestSchema = z.object({
|
||||
adminType: adminTypeSchema,
|
||||
problemPermission: problemPermissionSchema,
|
||||
realName: z.string().max(32).nullable().default(null),
|
||||
/** 班级。不给就不动(老页面不发这一项);null = 没填班级(老师、管理员) */
|
||||
className: classNameSchema.nullable().optional(),
|
||||
isDisabled: z.boolean(),
|
||||
// 空串表示不改密码,与旧 EditUserSerializer 的 allow_blank 一致
|
||||
password: z.string().max(128).default(''),
|
||||
})
|
||||
|
||||
/** 导入用户:每行 [用户名, 密码, 邮箱, 真名],与前端粘贴的 Excel 列序一致 */
|
||||
/**
|
||||
* 导入用户:每行 [用户名, 密码, 邮箱, 真名, 班级]。班级不给的老写法退回从 `ks` 前缀推(`legacyClassOf`),
|
||||
* 开着旧页面、手粘老名单的照样能建
|
||||
*/
|
||||
export const importUsersRequestSchema = z.object({
|
||||
users: z.array(z.tuple([z.string().trim().min(1).max(32), z.string().min(1), z.string(), z.string()])).min(1),
|
||||
users: z.array(z.tuple([z.string().trim().min(1).max(32), z.string().min(1), z.string(), z.string(), classNameSchema.nullable().optional()])).min(1),
|
||||
})
|
||||
|
||||
export const deleteUsersRequestSchema = z.object({
|
||||
|
||||
@@ -42,6 +42,8 @@ export const flowchartSubmissionSchema = z.object({
|
||||
export const flowchartListItemSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
/** 提交人现在的班级;混着几个班列的时候名字后面淡写它 */
|
||||
className: z.string().nullable(),
|
||||
problemDisplayId: z.string(),
|
||||
problemTitle: z.string(),
|
||||
status: flowchartStatusSchema,
|
||||
|
||||
@@ -19,3 +19,4 @@ export * from './roles'
|
||||
export * from './site'
|
||||
export * from './submission'
|
||||
export * from './time'
|
||||
export * from './username'
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* 班级号:3~4 位数字(`232` = 23 级 2 班,`2311` = 23 级 11 班)。存在 `user.class_name`,
|
||||
* 全站认班级只认这一列。
|
||||
*/
|
||||
export const CLASS_NAME_MIN_DIGITS = 3
|
||||
export const CLASS_NAME_MAX_DIGITS = 4
|
||||
export const CLASS_NAME_RE = new RegExp(`^\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}$`)
|
||||
export const classNameSchema = z.string().trim().regex(CLASS_NAME_RE, `班级号要是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`)
|
||||
|
||||
/**
|
||||
* 老账号的用户名带着班级:`ks232张三`(ks 是「考试」的拼音缩写)。2026-11 起新建的号不再带,
|
||||
* 用户名就是名字;老账号不改名(学生要换登录名、历史提交里存着当时的用户名),只是界面上不再露出这段。
|
||||
*
|
||||
* 这条正则是**唯一**还认 `ks` 的地方:显示名、登录名单、建号查重都从这里剥。新号是中文名,不会误中。
|
||||
*/
|
||||
export const LEGACY_CLASS_PREFIX_RE = new RegExp(`^ks\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}`, 'i')
|
||||
|
||||
/** 界面上显示的名字:老号剥掉 `ks232`,新号原样。剥完是空串的(只有前缀的怪号)退回原名 */
|
||||
export function displayName(username: string) {
|
||||
return username.replace(LEGACY_CLASS_PREFIX_RE, '') || username
|
||||
}
|
||||
|
||||
/**
|
||||
* 老号用户名里的班号(`ks232张三` → `232`),只给**没明确给班级**的旧调用方兜底(老页面、手粘的 ks 名单)。
|
||||
* 位数不对返回 undefined,不猜 —— `ks251001` 猜成 2510 班比报错更糟
|
||||
*/
|
||||
export function legacyClassOf(username: string): string | null | undefined {
|
||||
const matched = /^ks(\d+)/i.exec(username)
|
||||
if (!matched) return null
|
||||
const digits = matched[1]!
|
||||
return CLASS_NAME_RE.test(digits) ? digits : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录框点名字用的名单(`GET /classes/:className/members`):`name` 是点的那个名字,
|
||||
* `username` 是拿去登录的。原来只回名字、前端拼 `ks` + 班号 + 名字去登录,新号不带前缀就拼错了
|
||||
*/
|
||||
export const classMemberSchema = z.object({ name: z.string(), username: z.string() })
|
||||
export type ClassMember = z.infer<typeof classMemberSchema>
|
||||
Reference in new issue
Block a user