第 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,31 @@
|
||||
// 调用服务器接口。出错时抛出 ApiError,message 是服务器给的中文说明;
|
||||
// 登录过期(401)时回到登录页。
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录过期时的处理,由 router.ts 设置(避免这里依赖路由) */
|
||||
export const apiHooks = { onUnauthorized: () => {} }
|
||||
|
||||
export async function api<T>(method: string, path: string, json?: unknown, init?: RequestInit): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
method,
|
||||
headers: json === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: json === undefined ? undefined : JSON.stringify(json),
|
||||
...init,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError('连不上服务器,请检查网络', 0)
|
||||
}
|
||||
const data = (await response.json().catch(() => ({}))) as T & { message?: string }
|
||||
if (response.status === 401 && path !== '/api/login') apiHooks.onUnauthorized()
|
||||
if (!response.ok) throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status)
|
||||
return data
|
||||
}
|
||||
Reference in new issue
Block a user