名单改为用户名、密码两列;去掉姓名前的班级代号,学生用姓名登录;别班已有同名学生时报错,不再转班
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 21:49:50 +08:00
1 parent 61a32e900d
commit e4c53f5e55
11 files changed
+91 -47

No files matched your search

+1 -1
View File
@@ -54,7 +54,7 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
const blocked = tooManyFailures(ip, String(username ?? '')) const blocked = tooManyFailures(ip, String(username ?? ''))
if (blocked) return c.json({ message: blocked }, 429) if (blocked) return c.json({ message: blocked }, 429)
const tokens = await login(db, String(username ?? ''), String(password ?? ''), ip) const tokens = await login(db, String(username ?? ''), String(password ?? ''), ip)
if (!tokens) return c.json({ message: '账号或密码不对' }, 401) if (!tokens) return c.json({ message: '用户名或密码不对' }, 401)
return c.json(await withUser(tokens)) return c.json(await withUser(tokens))
}) })
+15 -8
View File
@@ -72,15 +72,18 @@ export function listStudents(db: Database, classId: number): Student[] {
} }
/** /**
* 导入名单到一个班:账号不存在就新建,已存在就更新姓名、班级(以及填了的密码)。 * 导入名单到一个班:用户名不存在就新建,已是本班学生就更新姓名(以及填了的密码)。
* 用户名全站唯一(学生只用姓名登录),已是别的班的学生时报错,不把他转到本班。
* 密码留空时:新学生生成 6 位数字密码,老学生保持原密码。 * 密码留空时:新学生生成 6 位数字密码,老学生保持原密码。
*/ */
export function importStudents(db: Database, classId: number, rows: ImportRow[]): ImportResult { export function importStudents(db: Database, classId: number, rows: ImportRow[]): ImportResult {
const result: ImportResult = { created: 0, updated: 0, errors: [] } const result: ImportResult = { created: 0, updated: 0, errors: [] }
const seen = new Set<string>() const seen = new Set<string>()
const find = db.query<{ id: number; role: string }, [string]>('SELECT id, role FROM users WHERE username = ?') const find = db.query<{ id: number; role: string; classId: number | null; className: string | null }, [string]>(
'SELECT u.id, u.role, u.class_id AS classId, c.name AS className FROM users u LEFT JOIN classes c ON c.id = u.class_id WHERE u.username = ?',
)
const insert = db.query(`INSERT INTO users (username, name, password, role, class_id, created_at) VALUES (?, ?, ?, 'student', ?, ?)`) const insert = db.query(`INSERT INTO users (username, name, password, role, class_id, created_at) VALUES (?, ?, ?, 'student', ?, ?)`)
const update = db.query('UPDATE users SET name = ?, class_id = ?, password = COALESCE(?, password) WHERE id = ?') const update = db.query('UPDATE users SET name = ?, password = COALESCE(?, password) WHERE id = ?')
db.transaction(() => { db.transaction(() => {
for (const [index, row] of rows.entries()) { for (const [index, row] of rows.entries()) {
@@ -89,19 +92,19 @@ export function importStudents(db: Database, classId: number, rows: ImportRow[])
const name = row.name.trim() const name = row.name.trim()
const password = row.password?.trim() || undefined const password = row.password?.trim() || undefined
if (!username) { if (!username) {
fail('账号为空') fail('用户名为空')
continue continue
} }
if (/\s/.test(username)) { if (/\s/.test(username)) {
fail(`账号"${username}"里有空格`) fail(`用户名"${username}"里有空格`)
continue continue
} }
if (!name) { if (!name) {
fail(`账号"${username}"的姓名为空`) fail(`用户名"${username}"的姓名为空`)
continue continue
} }
if (seen.has(username)) { if (seen.has(username)) {
fail(`账号"${username}"在名单里重复`) fail(`用户名"${username}"在名单里重复`)
continue continue
} }
seen.add(username) seen.add(username)
@@ -111,8 +114,12 @@ export function importStudents(db: Database, classId: number, rows: ImportRow[])
fail(`"${username}"是老师账号`) fail(`"${username}"是老师账号`)
continue continue
} }
if (existing && existing.classId !== classId) {
fail(`"${username}"已经是${existing.className ?? '别的班'}的学生:同名时在用户名后面加数字区分,如"${username}2"`)
continue
}
if (existing) { if (existing) {
update.run(name, classId, password ?? null, existing.id) update.run(name, password ?? null, existing.id)
if (password) revokeTokens(db, existing.id) if (password) revokeTokens(db, existing.id)
result.updated++ result.updated++
} else { } else {
+14 -5
View File
@@ -77,11 +77,11 @@ test('没登录时接口返回 401', async () => {
expect((await browser.get('/api/works/sample:x')).status).toBe(401) expect((await browser.get('/api/works/sample:x')).status).toBe(401)
}) })
test('账号或密码不对时登录失败,老师登录后能看到自己的信息', async () => { test('用户名或密码不对时登录失败,老师登录后能看到自己的信息', async () => {
const browser = new Browser() const browser = new Browser()
const wrong = await browser.login('admin', 'wrong') const wrong = await browser.login('admin', 'wrong')
expect(wrong.status).toBe(401) expect(wrong.status).toBe(401)
expect(wrong.body.message).toBe('账号或密码不对') expect(wrong.body.message).toBe('用户名或密码不对')
const t = await teacher() const t = await teacher()
const me = await t.get('/api/me') const me = await t.get('/api/me')
@@ -144,9 +144,9 @@ describe('班级和名单', () => {
created: 2, created: 2,
updated: 0, updated: 0,
errors: [ errors: [
{ row: 3, message: '账号"2024001"在名单里重复' }, { row: 3, message: '用户名"2024001"在名单里重复' },
{ row: 4, message: '账号"2024003"的姓名为空' }, { row: 4, message: '用户名"2024003"的姓名为空' },
{ row: 5, message: '账号"2024 004"里有空格' }, { row: 5, message: '用户名"2024 004"里有空格' },
{ row: 6, message: '"admin"是老师账号' }, { row: 6, message: '"admin"是老师账号' },
], ],
}) })
@@ -167,6 +167,15 @@ describe('班级和名单', () => {
expect((await t.get(`/api/teacher/classes/${classId}/students`)).body[0]).toMatchObject({ name: '张叁', password: 'abc123' }) expect((await t.get(`/api/teacher/classes/${classId}/students`)).body[0]).toMatchObject({ name: '张叁', password: 'abc123' })
}) })
test('用户名已是别的班的学生时报错,不转到本班', async () => {
const t = await teacher()
const first = await classWithStudents(t, [{ username: '王超', name: '王超', password: 'abc123' }])
const { body: other } = await t.post('/api/teacher/classes', { name: '物联网 2402' })
const result = await t.post(`/api/teacher/classes/${other.id}/import`, { rows: [{ username: '王超', name: '王超', password: '999999' }] })
expect(result.body).toEqual({ created: 0, updated: 0, errors: [{ row: 1, message: expect.stringContaining('已经是物联网 2401的学生') }] })
expect((await t.get(`/api/teacher/classes/${first}/students`)).body[0]).toMatchObject({ username: '王超', password: 'abc123' })
})
test('学生用老师发的密码登录,不能使用老师接口', async () => { test('学生用老师发的密码登录,不能使用老师接口', async () => {
const t = await teacher() const t = await teacher()
await classWithStudents(t, [{ username: '2024001', name: '张三', password: 'abc123' }]) await classWithStudents(t, [{ username: '2024001', name: '张三', password: 'abc123' }])
+3 -3
View File
@@ -24,7 +24,7 @@ import { currentUser } from '../auth'
<h2>学生</h2> <h2>学生</h2>
<h3>1. 登录</h3> <h3>1. 登录</h3>
<p>用老师发给你的账号(学号)和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。</p> <p>用老师发给你的用户名和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。</p>
<h3>2. 自由练习和做作业</h3> <h3>2. 自由练习和做作业</h3>
<ul> <ul>
@@ -87,8 +87,8 @@ import { currentUser } from '../auth'
<ul> <ul>
<li>右上角<b>班级管理</b> → <b>班级 + 新建</b>,输入班级名。</li> <li>右上角<b>班级管理</b> → <b>班级 + 新建</b>,输入班级名。</li>
<li> <li>
<b>学生</b>标签页 → <b>导入名单(CSV)</b>。CSV 三列:学号、姓名、密码(可以点<b>下载名单模板</b>);Excel 另存为 CSV 即可,GBK、UTF-8 都行。 <b>学生</b>标签页 → <b>导入名单(CSV)</b>。CSV 两列:用户名、密码(可以点<b>下载名单模板</b>),用户名就是学生姓名,前面带的班级代号(如
密码留空时自动生成 6 位数字。 ks241)会自动去掉;Excel 另存为 CSV 即可,GBK、UTF-8 都行。密码留空时自动生成 6 位数字。
</li> </li>
<li>导入后点<b>导出名单(含密码)</b>,打印或发给学生。学生忘了密码,在名单里点<b>重置密码</b>。</li> <li>导入后点<b>导出名单(含密码)</b>,打印或发给学生。学生忘了密码,在名单里点<b>重置密码</b>。</li>
</ul> </ul>
+2 -2
View File
@@ -32,8 +32,8 @@ async function submit() {
<div class="gate"> <div class="gate">
<form class="card" @submit.prevent="submit"> <form class="card" @submit.prevent="submit">
<h1>物联网编程仿真平台</h1> <h1>物联网编程仿真平台</h1>
<p>用老师发给你的账号和密码登录</p> <p>用老师发给你的用户名和密码登录</p>
<NInput v-model:value="username" placeholder="账号" autofocus :input-props="{ autocomplete: 'username' }" /> <NInput v-model:value="username" placeholder="用户名" autofocus :input-props="{ autocomplete: 'username' }" />
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="密码" :input-props="{ autocomplete: 'current-password' }" /> <NInput v-model:value="password" type="password" show-password-on="click" placeholder="密码" :input-props="{ autocomplete: 'current-password' }" />
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error">{{ error }}</div>
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!username || !password">登录</NButton> <NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!username || !password">登录</NButton>
+1 -3
View File
@@ -262,8 +262,7 @@ function exportCsv() {
if (!a) return if (!a) return
downloadCsv(`${a.title}-成绩.csv`, [ downloadCsv(`${a.title}-成绩.csv`, [
[ [
'学号', '用户名',
'姓名',
`自动评分(${autoTotal.value})`, `自动评分(${autoTotal.value})`,
...a.scoring.manual.map((m) => `${m.title}(${m.points})`), ...a.scoring.manual.map((m) => `${m.title}(${m.points})`),
`总分(${autoTotal.value + manualTotal.value})`, `总分(${autoTotal.value + manualTotal.value})`,
@@ -272,7 +271,6 @@ function exportCsv() {
], ],
...rows.value.map((r) => [ ...rows.value.map((r) => [
`\t${r.username}`, `\t${r.username}`,
r.name,
r.status ? (r.autoScore ?? '') : '未提交', r.status ? (r.autoScore ?? '') : '未提交',
...a.scoring.manual.map((_, i) => r.review?.scores[i] ?? ''), ...a.scoring.manual.map((_, i) => r.review?.scores[i] ?? ''),
r.status ? (r.autoScore ?? 0) + (r.manualScore ?? 0) : '', r.status ? (r.autoScore ?? 0) + (r.manualScore ?? 0) : '',
+7 -10
View File
@@ -122,8 +122,7 @@ function deleteStudent(student: Student) {
} }
const columns: DataTableColumns<Student> = [ const columns: DataTableColumns<Student> = [
{ title: '账号', key: 'username', sorter: 'default' }, { title: '用户名', key: 'username', sorter: 'default' },
{ title: '姓名', key: 'name' },
{ title: '密码', key: 'password', render: (s) => h('code', s.password) }, { title: '密码', key: 'password', render: (s) => h('code', s.password) },
{ {
title: '操作', title: '操作',
@@ -178,13 +177,13 @@ async function confirmImport() {
function exportRoster() { function exportRoster() {
if (!selected.value) return if (!selected.value) return
downloadCsv(`${selected.value.name}-名单.csv`, [['学号', '姓名', '密码'], ...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`])]) downloadCsv(`${selected.value.name}-名单.csv`, [['用户名', '密码'], ...students.value.map((s) => [`\t${s.username}`, `\t${s.password}`])])
} }
function downloadTemplate() { function downloadTemplate() {
downloadCsv('学生名单模板.csv', [ downloadCsv('学生名单模板.csv', [
['学号', '姓名', '密码'], ['用户名', '密码'],
['\t2024001', '张三', ''], ['张三', ''],
]) ])
} }
@@ -255,8 +254,8 @@ async function signOut() {
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" /> <input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
</div> </div>
<p class="tip"> <p class="tip">
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 名单是 Excel 另存的 CSV,两列:"用户名、密码"。用户名就是学生姓名,前面带的班级代号(如 ks241张三 的 ks241)会自动去掉;
位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。 全校重名时在后面加数字区分(张三2)。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。再次导入同一个班时,密码留空则不变。
</p> </p>
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" /> <NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
</NTabPane> </NTabPane>
@@ -289,8 +288,7 @@ async function signOut() {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>学号</th> <th>用户名</th>
<th>姓名</th>
<th>密码</th> <th>密码</th>
<th /> <th />
</tr> </tr>
@@ -298,7 +296,6 @@ async function signOut() {
<tbody> <tbody>
<tr v-for="(r, i) in preview" :key="i" :class="{ bad: r.problem }"> <tr v-for="(r, i) in preview" :key="i" :class="{ bad: r.problem }">
<td>{{ r.username }}</td> <td>{{ r.username }}</td>
<td>{{ r.name }}</td>
<td>{{ r.password || '(自动生成)' }}</td> <td>{{ r.password || '(自动生成)' }}</td>
<td class="problem">{{ r.problem }}</td> <td class="problem">{{ r.problem }}</td>
</tr> </tr>
+2 -4
View File
@@ -51,8 +51,7 @@ function cell(row: Row, assignmentId: number) {
} }
const columns = computed<DataTableColumns<Row>>(() => [ const columns = computed<DataTableColumns<Row>>(() => [
{ title: '学号', key: 'username', fixed: 'left', width: 110, sorter: 'default' }, { title: '用户名', key: 'username', fixed: 'left', width: 130, sorter: 'default' },
{ title: '姓名', key: 'name', fixed: 'left', width: 80 },
...book.value.assignments.map((a) => ({ ...book.value.assignments.map((a) => ({
title: `${a.title}(${a.total})`, title: `${a.title}(${a.total})`,
key: `a${a.id}`, key: `a${a.id}`,
@@ -64,10 +63,9 @@ const columns = computed<DataTableColumns<Row>>(() => [
function exportCsv() { function exportCsv() {
downloadCsv(`${props.className}-成绩表.csv`, [ downloadCsv(`${props.className}-成绩表.csv`, [
['学号', '姓名', ...book.value.assignments.map((a) => `${a.title}(${a.total})`), `合计(${fullMarks.value})`], ['用户名', ...book.value.assignments.map((a) => `${a.title}(${a.total})`), `合计(${fullMarks.value})`],
...book.value.students.map((s) => [ ...book.value.students.map((s) => [
`\t${s.username}`, `\t${s.username}`,
s.name,
...book.value.assignments.map((a) => (s.scores[a.id]?.submitted ? s.scores[a.id].total : '未交')), ...book.value.assignments.map((a) => (s.scores[a.id]?.submitted ? s.scores[a.id].total : '未交')),
sum(s), sum(s),
]), ]),
+1 -1
View File
@@ -1,5 +1,5 @@
// 下载 CSV:开头带 BOM,Excel 打开时中文不乱码。 // 下载 CSV:开头带 BOM,Excel 打开时中文不乱码。
// 学号、密码等长数字前加制表符(cell 以 \t 开头),Excel 不会把它变成科学计数法。 // 账号、密码等长数字前加制表符(cell 以 \t 开头),Excel 不会把它变成科学计数法。
export function downloadCsv(filename: string, rows: (string | number | null)[][]) { export function downloadCsv(filename: string, rows: (string | number | null)[][]) {
const quote = (value: string | number | null) => { const quote = (value: string | number | null) => {
const cell = value === null ? '' : String(value) const cell = value === null ? '' : String(value)
+27 -4
View File
@@ -1,6 +1,6 @@
/// <reference types="bun" /> /// <reference types="bun" />
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { decodeCsv, parseCsv, toRoster } from './roster' import { decodeCsv, parseCsv, stripClassCode, toRoster } from './roster'
describe('decodeCsv', () => { describe('decodeCsv', () => {
test('UTF-8(带 BOM,Excel 的"CSV UTF-8")', () => { test('UTF-8(带 BOM,Excel 的"CSV UTF-8")', () => {
@@ -32,12 +32,35 @@ describe('toRoster', () => {
]) ])
}) })
test('没有表头时按"账号、姓名、密码"的顺序', () => { test('表头只有用户名和密码时,姓名用用户名;去掉姓名前的班级代号', () => {
expect(toRoster(parseCsv('2024001,张三\n'))).toEqual([{ username: '2024001', name: '张三', password: '' }]) expect(toRoster(parseCsv('用户名,密码\nks241张三,525517\n'))).toEqual([{ username: '张三', name: '张三', password: '525517' }])
})
test('没有表头时按"用户名、密码"的顺序', () => {
expect(toRoster(parseCsv('ks241张三,525517\nks241李四\n'))).toEqual([
{ username: '张三', name: '张三', password: '525517' },
{ username: '李四', name: '李四', password: '' },
])
})
test('只去掉"字母 + 数字 + 汉字"开头的代号', () => {
expect(['ks241张三', 'KS2401王超', '2024001', 'zhang3', 'abc张三', '张三'].map(stripClassCode)).toEqual([
'张三',
'王超',
'2024001',
'zhang3',
'abc张三',
'张三',
])
})
test('去掉代号后重名的标出问题', () => {
const rows = toRoster(parseCsv('用户名,密码\nks241王超,1\nks242王超,2\n'))
expect(rows.map((r) => r.problem)).toEqual([undefined, expect.stringContaining('同名')])
}) })
test('科学计数法的学号、空账号、空姓名标出问题', () => { test('科学计数法的学号、空账号、空姓名标出问题', () => {
const rows = toRoster(parseCsv('学号,姓名\n2.02401E+11,张三\n,李四\n2024003,\n')) const rows = toRoster(parseCsv('学号,姓名\n2.02401E+11,张三\n,李四\n2024003,\n'))
expect(rows.map((r) => r.problem)).toEqual([expect.stringContaining('科学计数法'), '账号为空', '姓名为空']) expect(rows.map((r) => r.problem)).toEqual([expect.stringContaining('科学计数法'), '用户名为空', '姓名为空'])
}) })
}) })
+18 -6
View File
@@ -1,6 +1,8 @@
// 读取老师上传的学生名单(CSV)。名单通常由 Excel 导出: // 读取老师上传的学生名单(CSV)。名单通常由 Excel 导出:
// 中文 Windows 上"CSV(逗号分隔)"是 GBK 编码,"CSV UTF-8"带 BOM,两种都要支持; // 中文 Windows 上"CSV(逗号分隔)"是 GBK 编码,"CSV UTF-8"带 BOM,两种都要支持;
// 第一行可能是表头(学号、姓名、密码),没有表头时按"账号、姓名、密码"的顺序读。 // 名单只有"用户名、密码"两列,平台里的姓名就用用户名;第一行可能是表头,没有表头时按"用户名、密码"的顺序读。
// 以前的名单有"姓名"列的,照样读进来。学校别的平台的用户名在姓名前带着班级代号(ks241张三),
// 这里有班级,代号去掉,学生只用姓名登录。
export interface RosterRow { export interface RosterRow {
username: string username: string
@@ -65,27 +67,37 @@ const HEADERS = {
password: ['密码', 'password'], password: ['密码', 'password'],
} }
/** 去掉姓名前面的班级代号(字母加数字):ks241张三 → 张三;不是"代号 + 汉字"的用户名不变 */
export function stripClassCode(username: string) {
return username.replace(/^[A-Za-z]+\d+(?=\p{Script=Han})/u, '')
}
/** 表格转成名单:识别表头,检查每行的问题 */ /** 表格转成名单:识别表头,检查每行的问题 */
export function toRoster(table: string[][]): RosterRow[] { export function toRoster(table: string[][]): RosterRow[] {
if (table.length === 0) return [] if (table.length === 0) return []
const header = table[0].map((cell) => cell.trim().toLowerCase()) const header = table[0].map((cell) => cell.trim().toLowerCase())
const column = (aliases: string[]) => header.findIndex((cell) => aliases.includes(cell)) const column = (aliases: string[]) => header.findIndex((cell) => aliases.includes(cell))
const hasHeader = column(HEADERS.username) >= 0 && column(HEADERS.name) >= 0 const hasHeader = column(HEADERS.username) >= 0
const index = hasHeader const index = hasHeader
? { username: column(HEADERS.username), name: column(HEADERS.name), password: column(HEADERS.password) } ? { username: column(HEADERS.username), name: column(HEADERS.name), password: column(HEADERS.password) }
: { username: 0, name: 1, password: 2 } : { username: 0, name: -1, password: 1 }
const seen = new Set<string>()
return table.slice(hasHeader ? 1 : 0).map((cells) => { return table.slice(hasHeader ? 1 : 0).map((cells) => {
const cell = (i: number) => (i >= 0 ? (cells[i] ?? '').trim() : '') const cell = (i: number) => (i >= 0 ? (cells[i] ?? '').trim() : '')
const row: RosterRow = { username: cell(index.username), name: cell(index.name), password: cell(index.password) } const username = stripClassCode(cell(index.username))
const row: RosterRow = { username, name: index.name >= 0 ? cell(index.name) : username, password: cell(index.password) }
// Excel 会把长数字(如 12 位学号)显示成科学计数法,导出 CSV 时也照样写出去 // Excel 会把长数字(如 12 位学号)显示成科学计数法,导出 CSV 时也照样写出去
if (/^\d+(\.\d+)?e\+\d+$/i.test(row.username)) { if (/^\d+(\.\d+)?e\+\d+$/i.test(row.username)) {
row.problem = '账号变成了科学计数法:在 Excel 里把这一列设为"文本"格式、重新输入后再导出' row.problem = '用户名变成了科学计数法:在 Excel 里把这一列设为"文本"格式、重新输入后再导出'
} else if (!row.username) { } else if (!row.username) {
row.problem = '账号为空' row.problem = '用户名为空'
} else if (!row.name) { } else if (!row.name) {
row.problem = '姓名为空' row.problem = '姓名为空'
} else if (seen.has(row.username)) {
row.problem = '名单里有同名的,在用户名后面加数字区分,如"张三2"'
} }
seen.add(row.username)
return row return row
}) })
} }