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