名单改为用户名、密码两列;去掉姓名前的班级代号,学生用姓名登录;别班已有同名学生时报错,不再转班
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
61a32e900d
commit
e4c53f5e55
11 files changed
+91
-47
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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' }])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) : '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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('科学计数法'), '用户名为空', '姓名为空'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user