From 2bda05a97809f5f3dd54a66a0b0cbccfbbc80709 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 2 Nov 2026 23:41:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8D=E5=AD=97):=20=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E4=B8=8A=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA=20ks=20+=20?= =?UTF-8?q?=E7=8F=AD=E5=8F=B7=E5=89=8D=E7=BC=80=EF=BC=8C=E6=B7=B7=E7=9D=80?= =?UTF-8?q?=E5=87=A0=E4=B8=AA=E7=8F=AD=E7=9A=84=E5=9C=B0=E6=96=B9=E5=90=8D?= =?UTF-8?q?=E5=AD=97=E5=90=8E=E9=9D=A2=E5=86=99=E7=8F=AD=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 学生名字一律只显示名字(老号 ks232张三 显示成 张三),顶栏、首页问候、悬停提示、请假名单、比赛成绩表都一样 - 全服 / 年级排名、全部提交、几个班同时举手时,名字后面淡写班级;提交列表的名字列窄,只写班号(陈长 258) - AI 班级分析点名「要多关心的同学」时不再带 ks 前缀 - 建号查重补漏:同班同名也比真名(加过数字的张三2、编号制老号);名字自己带数字(王芳1)也认得出被占; 导入时后端再拦一次同班同名,建号页要等查重结果对上当前名单才能点 - 登录名单换成新接口 /classes/:c/members(名字按界面规则剥,换过班的老号不再露出全名), 旧接口原样留给上线时还开着旧登录页的机房电脑 - 后台手写老格式 ks232张三 新建用户、没选班时按前缀补班级;PUT /me/profile 只带真名时不再 500 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/account.ts | 11 +-- apps/api/src/routes/admin/account.ts | 68 +++++++++++++------ apps/api/src/routes/ai.ts | 5 +- apps/api/src/routes/flowchart.ts | 24 +++---- apps/api/src/routes/helpers.ts | 6 +- apps/api/src/routes/site.ts | 29 +++++++- apps/api/src/routes/submission-statistics.ts | 9 +-- apps/web/src/admin/home/index.vue | 3 +- apps/web/src/admin/user/generate.vue | 21 ++++-- apps/web/src/admin/user/list.vue | 15 +++- apps/web/src/oj/contest/pages/class.vue | 21 ++++-- apps/web/src/oj/contest/pages/problems.vue | 3 +- apps/web/src/oj/home/student.vue | 5 +- apps/web/src/oj/home/teacher.vue | 3 +- .../src/oj/problem/components/CollabBar.vue | 3 +- .../oj/problemset/components/ClassView.vue | 7 +- apps/web/src/oj/rank/components/RankMe.vue | 6 +- .../web/src/oj/rank/components/RankPodium.vue | 4 +- apps/web/src/oj/rank/components/RankTrack.vue | 6 +- apps/web/src/oj/rank/list.vue | 7 +- apps/web/src/oj/submission/list.vue | 10 +-- apps/web/src/oj/task/cells.ts | 5 +- .../web/src/oj/task/components/LeaveModal.vue | 9 ++- apps/web/src/oj/task/index.vue | 5 +- apps/web/src/shared/api.ts | 2 +- apps/web/src/shared/components/Header.vue | 3 +- .../src/shared/components/HelpRequestList.vue | 5 +- apps/web/src/shared/components/Login.vue | 2 +- apps/web/src/shared/components/UserName.vue | 36 +++++----- apps/web/src/shared/layout/admin.vue | 3 +- packages/contract/src/flowchart.ts | 2 + packages/contract/src/username.ts | 2 +- 32 files changed, 229 insertions(+), 111 deletions(-) diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index c1fa134..3eaf46e 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -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) diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index f72effc..81c0b5b 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -198,30 +198,47 @@ 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`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 { 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(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`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) + const [existing, taken] = await Promise.all([classmatesNamed(className, names), takenUsernames(names)]) + return success(c, { existing, usernames: suggestUsernames(names, taken) } satisfies CheckUsernamesResponse) }) /** @@ -379,6 +396,19 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => { return failure(c, 409, 'email-exists', `这批名单里邮箱重复:${batchMails.join('、')}`) } + // 同班同名再查一遍:用户名不再带班号以后,「同一个学生粘了两次」撞不上用户名唯一约束了(新号会被加成张三2), + // 只靠批量建号页的查重挡 —— 那边有半秒防抖,粘完马上点就绕过去了 + const byClass = new Map() + 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) diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 454e375..d569296 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -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), ) }) diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 9c614de..8632a5e 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -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() @@ -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, diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index 526de63..f334166 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -29,9 +29,9 @@ export function sampleUser( } /** - * 去掉老号用户名里的 `ks<班级号>` 前缀,得到学生本人那一段:`ks251张三` + `251` → `张三`。 - * 2026-11 起新建的号不带前缀(用户名就是名字),原样返回。和契约 `displayName` 是同一件事, - * 这里按这个人自己的班级剥,比正则更严:班号对不上的不剥。 + * 去掉老号用户名里的 `ks<班级号>` 前缀:`ks251张三` + `251` → `张三`。只剩旧版登录名单(site.ts 的 + * /classes/:className/usernames)在用 —— 旧登录页拿名字拼回「ks + 班号 + 名字」去登录,所以必须严格按这个班的班号剥。 + * 界面上显示名字一律用契约的 `displayName`。 * * 用 startsWith + slice 而不是 replace:replace 会删掉字符串中间的匹配, * 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。 diff --git a/apps/api/src/routes/site.ts b/apps/api/src/routes/site.ts index ef93020..1043763 100644 --- a/apps/api/src/routes/site.ts +++ b/apps/api/src/routes/site.ts @@ -1,4 +1,4 @@ -import { CLASS_NAME_RE, type ClassMember, type Quote, type 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,6 +92,30 @@ siteRoutes.get('/quotes/random', async (c) => { } }) +/** + * 登录框点名字的名单:名字给人点,用户名拿去登录(老号 ks232张三,新号就是名字)。名字按界面上的显示规则剥, + * 和 UserName 一致 —— 不能只剥「ks + 当前班号」:班级现在能在后台改,换过班的老号会露出整串 + */ +siteRoutes.get('/classes/:className/members', 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 + .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)) { @@ -102,9 +126,8 @@ 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)) - // 名字给人点,用户名拿去登录:老号是 ks232张三(点的是「张三」),新号两个一样 return success( c, - rows.map(({ username }) => ({ name: stripClassPrefix(username, className), username }) satisfies ClassMember), + rows.map(({ username }) => stripClassPrefix(username, className)), ) }) diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index 102db5d..e098cb7 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -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() @@ -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, })) @@ -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: [], } diff --git a/apps/web/src/admin/home/index.vue b/apps/web/src/admin/home/index.vue index 51b72b8..31906f8 100644 --- a/apps/web/src/admin/home/index.vue +++ b/apps/web/src/admin/home/index.vue @@ -1,4 +1,5 @@