Files

156 lines
6.7 KiB
JavaScript
Raw Permalink Normal View History

2026-09-14 10:23:36 +08:00
/* 唯一的网络出口。页面代码里不出现 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 } }),
};