feat(名字): 界面上不再显示 ks + 班号前缀,混着几个班的地方名字后面写班级
Deploy / deploy (push) Canceled after 0s

- 学生名字一律只显示名字(老号 ks232张三 显示成 张三),顶栏、首页问候、悬停提示、请假名单、比赛成绩表都一样
- 全服 / 年级排名、全部提交、几个班同时举手时,名字后面淡写班级;提交列表的名字列窄,只写班号(陈长 258)
- AI 班级分析点名「要多关心的同学」时不再带 ks 前缀
- 建号查重补漏:同班同名也比真名(加过数字的张三2、编号制老号);名字自己带数字(王芳1)也认得出被占;
  导入时后端再拦一次同班同名,建号页要等查重结果对上当前名单才能点
- 登录名单换成新接口 /classes/:c/members(名字按界面规则剥,换过班的老号不再露出全名),
  旧接口原样留给上线时还开着旧登录页的机房电脑
- 后台手写老格式 ks232张三 新建用户、没选班时按前缀补班级;PUT /me/profile 只带真名时不再 500

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 23:41:49 +08:00
1 parent 221d264701
commit 2bda05a978
32 files changed
+229 -111

No files matched your search

+7 -4
View File
@@ -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)
+49 -19
View File
@@ -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<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 { 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<string>`lower(${schema.user.username})` })
.from(schema.user)
.where(inArray(sql`regexp_replace(lower(${schema.user.username}), '[0-9]+$', '')`, wanted))
return success(c, { existing, usernames: suggestUsernames(names, new Set(taken.map((row) => row.username))) } satisfies CheckUsernamesResponse)
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<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)
+4 -1
View File
@@ -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),
)
})
+8 -16
View File
@@ -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,
+3 -3
View File
@@ -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 会删掉字符串中间的匹配,
* 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。
+26 -3
View File
@@ -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)),
)
})
+5 -4
View File
@@ -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,
}))
@@ -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: [],
}
+2 -1
View File
@@ -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'
@@ -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">
+16 -5
View File
@@ -86,25 +86,36 @@ const duplicates = computed(() => {
// 这个班已经有的同名账号(老号按剥掉 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(
() => `${klass.value}\n${names.value.join('\n')}`,
async () => {
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 {
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 },
@@ -112,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 && !!klass.value)
const canSubmit = computed(() => rows.value.length > 0 && !issueCount.value && !checking.value && !!klass.value && checkedKey.value === inputKey.value)
// ---------------------------------------------------------------- 建号
@@ -220,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 && klass" 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>
+13 -2
View File
@@ -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'
@@ -315,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.className],
[
userEditing.value.username,
password.value,
userEditing.value.email ?? '',
userEditing.value.realName ?? '',
// 没选班、但手写的是老格式 ks232张三:按前缀补上班级
userEditing.value.className ?? legacyClassOf(userEditing.value.username) ?? null,
],
]
await importUsers(newUser)
listUsers()
+15 -6
View File
@@ -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>
+2 -1
View File
@@ -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>
+3 -2
View File
@@ -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>
+2 -1
View File
@@ -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>
@@ -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
+4 -2
View File
@@ -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) }" />
+5 -2
View File
@@ -287,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 () => {
@@ -314,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 () => {
@@ -369,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"
@@ -377,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"
@@ -407,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"
+6 -4
View File
@@ -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>
+3 -2
View File
@@ -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>
+3 -2
View File
@@ -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 -1
View File
@@ -44,7 +44,7 @@ export function getHitokoto() {
/** 登录框点名字的名单:名字给人点,用户名拿去登录(老号 ks232张三,新号就是名字) */
export function getClassUsernames(className: string) {
return api.get<ClassMember[]>(`classes/${encodeURIComponent(className)}/usernames`)
return api.get<ClassMember[]>(`classes/${encodeURIComponent(className)}/members`)
}
// 下面这个是启动就要调的(config store),所以放在 shared 而不是
+2 -1
View File
@@ -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'">
+1 -1
View File
@@ -360,7 +360,7 @@ watch(
v-model:value="username"
size="large"
clearable
placeholder="完整用户名,如 ks231张三"
placeholder="用户名"
:input-props="{ name: 'username', autocomplete: 'username' }"
@keyup.enter="passwordRef?.focus()"
/>
+19 -17
View File
@@ -1,26 +1,26 @@
<script setup lang="ts">
import { displayName } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { LEGACY_CLASS_PREFIX_RE } from '@oj2/contract'
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(LEGACY_CLASS_PREFIX_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');
+2 -1
View File
@@ -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>
<!-- 老师在后台改题时也得知道有人举手(前台顶栏在这里是卸载的)。