/* 唯一的网络出口。页面代码里不出现 fetch,所有接口怪癖都在这一层消化掉。 前端和后端不同源(前端在 static 目录下单独用 python -m http.server 起), 所以这里必须写全后端地址;后端 main.py 里配了 CORS 中间件放行。 */ const API_BASE = 'http://127.0.0.1:8001'; class ApiError extends Error { constructor(message, status, body) { super(message); this.name = 'ApiError'; this.status = status; this.body = body; } } // FastAPI 的错误统一放在 {"detail": ...};422 的 detail 是数组,拼成一句中文 function detailOf(data) { if (!data || data.detail === undefined || data.detail === null) return null; const d = data.detail; if (typeof d === 'string') return d; if (Array.isArray(d)) { return d.map(x => { const where = (x.loc || []).slice(1).join('.') || '参数'; return `${where}:${x.msg}`; }).join(';'); } return JSON.stringify(d); } /** * @param {string} method * @param {string} path 必须写成后端的规范路径(尾斜杠与否由这里一处决定, * 写错会吃 307 重定向,所以每个方法里都写死) * @param {object} opts { query: 查询参数对象, body: JSON 请求体 } * body 不传就不带请求体(成绩模块那几个接口全靠这个) */ async function request(method, path, opts = {}) { let url = API_BASE + path; if (opts.query) { const usp = new URLSearchParams(); Object.entries(opts.query).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') usp.append(k, v); }); const qs = usp.toString(); if (qs) url += '?' + qs; } const init = { method, headers: {} }; if (opts.body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(opts.body); } let res; try { res = await fetch(url, init); } catch (e) { throw new ApiError('无法连接后端服务,请确认 python main.py 已启动', 0, null); } const text = await res.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch (e) { // 正常走不到这里:路径写错时后端返回的是 JSON 404。多半是 API_BASE 配错了, // 请求打到前端自己的 8080 上,拿回 http.server 的一页 HTML。 throw new ApiError('接口返回了非 JSON 内容,请检查 API_BASE 是否指向后端(HTTP ' + res.status + ')', res.status, null); } } if (!res.ok) throw new ApiError(detailOf(data) || `请求失败(HTTP ${res.status})`, res.status, data); return data; } /* ---------- 学生 ---------- 注意:路径是 "",所以没有尾斜杠 */ const StudentApi = { list: (q) => request('GET', '/students', { query: q }), create: (b) => request('POST', '/students', { body: b }), // 200(不是 201) update: (no, b) => request('PUT', `/students/${encodeURIComponent(no)}`, { body: b }), remove: (no) => request('DELETE', `/students/${encodeURIComponent(no)}`), }; /* ---------- 班级 ---------- 集合路径带尾斜杠 */ const ClassApi = { // 只有这个列表接口会带上 head_teacher_name / course_teacher_names list: (q) => request('GET', '/classes/', { query: q }), create: (b) => request('POST', '/classes/', { body: b }), detail: (id) => request('GET', `/classes/${id}`), // 别用它取班主任名,永远是"暂无" update: (id, b) => request('PUT', `/classes/${id}`, { body: b }), async remove(id) { // 这个接口删除失败也返回 HTTP 200,只是 body 里 code=404,必须看 body 而不是状态码 const r = await request('DELETE', `/classes/${id}`); if (r && typeof r.code === 'number' && r.code !== 200) { throw new ApiError(r.msg || '删除失败', 200, r); } return r; }, }; /* ---------- 老师 ---------- 单数前缀 */ const TeacherApi = { list: () => request('GET', '/teacher/'), generateNo: () => request('GET', '/teacher/generate-no'), // -> { t_no } create: (b) => request('POST', '/teacher/', { body: b }), // 201 update: (id, b) => request('PUT', `/teacher/${id}`, { body: b }), async remove(id) { // 老师不存在时返回 HTTP 200 + body 为 null,不抛 404,得自己判 const r = await request('DELETE', `/teacher/${id}`); if (r === null) throw new ApiError('老师不存在', 200, null); return r; }, }; /* ---------- 顾问 ---------- */ const AdvisorApi = { list: (q) => request('GET', '/advisor/', { query: q }), create: (b) => request('POST', '/advisor/', { body: b }), // 201 update: (id, b) => request('PUT', `/advisor/${id}`, { body: b }), remove: (id) => request('DELETE', `/advisor/${id}`), }; /* ---------- 就业 ---------- 按学号定位 */ const EmpApi = { list: (q) => request('GET', '/emp/', { query: q }), create: (b) => request('POST', '/emp/', { body: b }), // 201,学号重复会 400 update: (no, b) => request('PUT', `/emp/${encodeURIComponent(no)}`, { body: b }), async remove(no) { const r = await request('DELETE', `/emp/${encodeURIComponent(no)}`); if (r === null) throw new ApiError('就业信息不存在', 200, null); return r; }, }; /* ---------- 成绩 ---------- 五个接口的标量参数都走 query string,没有 JSON body; 对外一律用业务学号 stu_no,不是内部主键 stu_id。 */ const ScoreApi = { listByStuNo: (no) => request('GET', `/score/list/${encodeURIComponent(no)}`), add: (no, order, score) => request('POST', '/score/add', { query: { stu_no: no, exam_order: order, score } }), update: (no, order, newScore) => request('PUT', '/score/update', { query: { stu_no: no, exam_order: order, new_score: newScore } }), del: (no, order) => request('DELETE', '/score/del', { query: { stu_no: no, exam_order: order } }), }; /* ---------- 统计 ---------- */ const StatsApi = { studentsByAge: (op, value, value2) => request('GET', '/statistics/students/age', { query: { op, value, value2 } }), classCount: () => request('GET', '/statistics/classes/count'), outScoreLine: (line) => request('GET', `/statistics/scores/out_score_line/${line}`), failScore: (n) => request('GET', `/statistics/scores/fail_score/${n}`), classAvgScore: (sortOrder) => request('GET', '/statistics/scores/get_class_avg_score', { query: { sort_order: sortOrder } }), topRank: (rank) => request('GET', `/statistics/emp/top_rank/${rank}`), empTime: () => request('GET', '/statistics/emp/emp_time'), classAvgEmpTime: (sortOrder) => request('GET', '/statistics/emp/class_avg_emp_time', { query: { sort_order: sortOrder } }), };