第 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:
1 parent
a00622be15
commit
7cec273d58
31 files changed
+1776
-382
No files matched your search
@@ -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('科学计数法'), '账号为空', '姓名为空'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user