第 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,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