第 2 阶段:账号与班级,作品保存在服务器

- SQLite(bun:sqlite,编号迁移):班级、用户、会话(只存令牌哈希)、作品
- 登录取代统一访问口令:老师账号由配置设定;接口和 WebSocket 都要登录;按 IP 限制输错次数
- 班级管理页:建班、改名、删除(有学生时不能删);上传 CSV 名单导入(兼容 GBK / UTF-8,
  识别表头,标出被 Excel 变成科学计数法的学号),密码留空生成 6 位数字;查看、重置密码,
  导出带密码的名单
- 学生作品自动保存到服务器(按账号和实验),显示保存状态;离开页面前立即保存
- 前端加路由:登录页、实验页、班级管理页
- 部署:TEACHER_PASSWORD 必填,数据库放在数据卷

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 17:36:11 +08:00
1 parent a00622be15
commit 7cec273d58
31 files changed
+1776 -382

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
/// <reference types="bun" />
import { describe, expect, test } from 'bun:test'
import { decodeCsv, parseCsv, toRoster } from './roster'
describe('decodeCsv', () => {
test('UTF-8(带 BOM,Excel 的"CSV UTF-8")', () => {
const bytes = new TextEncoder().encode('学号,姓名\n2024001,张三\n')
expect(decodeCsv(bytes.buffer)).toBe('学号,姓名\n2024001,张三\n')
})
test('GBK(中文 Windows 上 Excel 的"CSV(逗号分隔)")', () => {
// "学号,姓名" 的 GBK 编码
const gbk = new Uint8Array([0xd1, 0xa7, 0xba, 0xc5, 0x2c, 0xd0, 0xd5, 0xc3, 0xfb])
expect(decodeCsv(gbk.buffer)).toBe('学号,姓名')
})
})
describe('parseCsv', () => {
test('引号里的逗号、换行和两个双引号;CRLF 换行;忽略空行', () => {
expect(parseCsv('a,"b,c","d ""e""\nf"\r\n\r\n1,2,3\r\n,,\r\n')).toEqual([
['a', 'b,c', 'd "e"\nf'],
['1', '2', '3'],
])
})
})
describe('toRoster', () => {
test('有表头时按表头找列,列的顺序可以不同', () => {
expect(toRoster(parseCsv('姓名,学号,密码\n张三,2024001,abc\n李四,2024002,\n'))).toEqual([
{ username: '2024001', name: '张三', password: 'abc' },
{ username: '2024002', name: '李四', password: '' },
])
})
test('没有表头时按"账号、姓名、密码"的顺序', () => {
expect(toRoster(parseCsv('2024001,张三\n'))).toEqual([{ username: '2024001', name: '张三', password: '' }])
})
test('科学计数法的学号、空账号、空姓名标出问题', () => {
const rows = toRoster(parseCsv('学号,姓名\n2.02401E+11,张三\n,李四\n2024003,\n'))
expect(rows.map((r) => r.problem)).toEqual([expect.stringContaining('科学计数法'), '账号为空', '姓名为空'])
})
})
+91
View File
@@ -0,0 +1,91 @@
// 读取老师上传的学生名单(CSV)。名单通常由 Excel 导出:
// 中文 Windows 上"CSV(逗号分隔)"是 GBK 编码,"CSV UTF-8"带 BOM,两种都要支持;
// 第一行可能是表头(学号、姓名、密码),没有表头时按"账号、姓名、密码"的顺序读。
export interface RosterRow {
username: string
name: string
password: string
/** 这一行的问题;有问题的行不导入 */
problem?: string
}
/** 文件内容转成文字:先按 UTF-8 解码,不是合法的 UTF-8 就按 GBK(GB18030)解码 */
export function decodeCsv(bytes: ArrayBuffer): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/^/, '')
} catch {
return new TextDecoder('gb18030').decode(bytes)
}
}
/** 按 CSV 规则拆成表格:支持双引号包起来的字段(里面可以有逗号、换行、两个双引号表示一个) */
export function parseCsv(text: string): string[][] {
const table: string[][] = []
let row: string[] = []
let field = ''
let quoted = false
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
field += '"'
i++
} else if (ch === '"') {
quoted = false
} else {
field += ch
}
} else if (ch === '"') {
quoted = true
} else if (ch === ',') {
row.push(field)
field = ''
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++
row.push(field)
table.push(row)
row = []
field = ''
} else {
field += ch
}
}
if (field || row.length) {
row.push(field)
table.push(row)
}
// 去掉空行(Excel 导出的文件常在末尾多出空行)
return table.filter((r) => r.some((cell) => cell.trim()))
}
const HEADERS = {
username: ['账号', '帐号', '用户名', '学号', 'username', 'account'],
name: ['姓名', '名字', '真实姓名', 'name'],
password: ['密码', 'password'],
}
/** 表格转成名单:识别表头,检查每行的问题 */
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 index = hasHeader
? { username: column(HEADERS.username), name: column(HEADERS.name), password: column(HEADERS.password) }
: { username: 0, name: 1, password: 2 }
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) }
// Excel 会把长数字(如 12 位学号)显示成科学计数法,导出 CSV 时也照样写出去
if (/^\d+(\.\d+)?e\+\d+$/i.test(row.username)) {
row.problem = '账号变成了科学计数法:在 Excel 里把这一列设为"文本"格式、重新输入后再导出'
} else if (!row.username) {
row.problem = '账号为空'
} else if (!row.name) {
row.problem = '姓名为空'
}
return row
})
}