// app.js // 沃林学生管理系统前端逻辑(Vue 3 Options API) const { createApp } = Vue; const API = "/api"; // 统计接口配置:key -> { url, method, headers, rowKeys, headerMap } const STAT_CONFIG = { byAge: { url: () => `/statistics/students/by-age?op=${S.params.op}` + (S.params.op === "between" ? `&min_value=${S.params.min_value}&max_value=${S.params.max_value}` : `&value=${S.params.value}`), pick: (s) => ({ 学号: s.stu_id, 姓名: s.stu_name, 班级: s.class_name, 性别: s.gender, 年龄: s.age, 状态: s.status }), }, genderStats: { url: () => "/statistics/class/gender-stats", pick: (r) => ({ 班级ID: r.class_id, 班级: r.class_name, 总人数: r.total, 男: r.male, 女: r.female }), }, allAbove: { url: () => `/statistics/score/all-above?line=${S.params.line}`, pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 考核次数: r.exam_count, 最低分: r.min_score, 明细: JSON.stringify(r.scores) }), }, fail: { url: () => `/statistics/score/fail?times=${S.params.times}`, pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 不及格次数: r.fail_count, 明细: JSON.stringify(r.fail_details) }), }, classAvg: { url: () => `/statistics/score/class-avg?order=${S.params.order}` + (S.params.exam_id ? `&exam_id=${S.params.exam_id}` : ""), pick: (r) => ({ 考核序次: `第${r.exam_id}次`, 班级ID: r.class_id, 班级: r.class_name, 平均分: r.avg_score }), }, topSalary: { url: () => `/statistics/employment/top-salary?n=${S.params.n}`, pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, offer时间: r.job_time, 公司: r.company_name, 薪资: r.salary }), }, duration: { url: () => "/statistics/employment/duration", pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 开放时间: r.employment_open_time, offer时间: r.job_time, 就业时长: r.duration_days < 0 ? "未就业" : r.duration_days + " 天" }), }, classAvgDuration: { url: () => "/statistics/employment/class-avg-duration", pick: (r) => ({ 班级ID: r.class_id, 班级: r.class_name, 进入就业人数: r.opened_count, 已拿offer人数: r.offered_count, 平均就业时长: r.avg_duration_days + " 天" }), }, volatility: { url: () => `/statistics/score/volatility?top_n=${S.params.top_n}`, pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 最高分: r.max_score, 最低分: r.min_score, 最大分差: r.diff }), }, funnel: { url: () => "/statistics/employment/funnel", pick: (r) => ({ 班级ID: r.class_id, 班级: r.class_name, 总人数: r.total, 已就业: r.employed, 高薪人数: r.high_salary, 就业率: r.employment_rate + "%" }), }, }; // 状态对象(非响应式共享,供 STAT_CONFIG 读取) const S = { params: { op: "gt", value: 25, min_value: 20, max_value: 30, line: 80, times: 2, n: 10, exam_id: "", order: "desc", top_n: 5 }, }; const app = createApp({ data() { return { token: localStorage.getItem("token") || "", me: { username: "", role: "" }, loading: false, tip: "", loginForm: { username: "admin", password: "admin123" }, tabs: [ { key: "students", label: "学生管理" }, { key: "scores", label: "成绩管理" }, { key: "employment", label: "就业管理" }, { key: "classes", label: "班级管理" }, { key: "teachers", label: "老师管理" }, { key: "statistics", label: "统计分析" }, { key: "filter", label: "高级筛选" }, ], tab: "students", // 学生 students: [], stuTotal: 0, stuPage: 1, stuQ: { stu_id: "", stu_name: "", class_id: "", status: "" }, showStu: false, stuForm: {}, // 成绩 scores: [], scoreQ: { stu_id: "", exam_id: "" }, scoreWarning: "", showScore: false, scoreForm: {}, // 就业 employment: [], empTotal: 0, empPage: 1, empQ: { stu_id: "", company_name: "", salary_min: "", salary_max: "" }, showEmp: false, empFormMode: "open", empForm: {}, // 班级 / 老师 classes: [], showClass: false, classForm: {}, teachers: [], teacherTotal: 0, teacherPage: 1, showTeacher: false, teacherForm: {}, // 统计 statKey: "", statRows: [], statParams: S.params, // 高级筛选 filterJson: "", filterRows: [], }; }, computed: { roleLabel() { return { admin: "管理员", teacher: "教师", student: "学生" }[this.me.role] || this.me.role; }, statKeys() { return this.statRows.length ? Object.keys(this.statRows[0]) : []; }, statHeaders() { return this.statKeys; }, }, methods: { // ==================== 基础 ==================== async api(path, method = "GET", body = null, raw = false) { const opt = { method, headers: { Authorization: "Bearer " + this.token }, }; if (body !== null) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); } const resp = await fetch(API + path, opt); if (resp.status === 401) { this.logout(true); throw new Error("登录已失效,请重新登录"); } if (!resp.ok) { let detail = `请求失败(${resp.status})`; try { detail = (await resp.json()).detail || detail; } catch (e) {} // detail 可能是 pydantic 校验数组 if (Array.isArray(detail)) detail = detail.map((d) => `${d.loc.join(".")}: ${d.msg}`).join("; "); throw new Error(detail); } if (resp.status === 204) return null; return raw ? resp : resp.json(); }, flash(msg, ok = true) { this.tip = msg; setTimeout(() => (this.tip = ""), ok ? 2500 : 5000); }, logout(silent = false) { localStorage.removeItem("token"); this.token = ""; this.me = { username: "", role: "" }; if (!silent) this.tip = ""; }, async login() { this.loading = true; this.tip = ""; try { const resp = await fetch(API + "/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(this.loginForm), }); if (!resp.ok) throw new Error((await resp.json()).detail || "登录失败"); const data = await resp.json(); this.token = data.access_token; localStorage.setItem("token", this.token); await this.afterLogin(); } catch (e) { this.tip = e.message; } finally { this.loading = false; } }, async afterLogin() { try { this.me = await this.api("/auth/me"); await Promise.all([this.loadClasses(), this.loadStudents(1), this.loadTeachers(1)]); this.switchTab(this.me.role === "student" ? "students" : this.tab); this.flash("登录成功"); } catch (e) { this.tip = e.message; } }, switchTab(key) { this.tab = key; const loaders = { students: () => this.loadStudents(1), scores: () => this.loadScores(), employment: () => this.loadEmployment(1), classes: () => this.loadClasses(), teachers: () => this.loadTeachers(1), }; if (loaders[key]) loaders[key](); }, closeAll() { this.showStu = this.showScore = this.showEmp = this.showClass = this.showTeacher = false; }, // ==================== 学生 ==================== async loadStudents(page = 1) { try { const q = new URLSearchParams(); if (this.stuQ.stu_id) q.set("stu_id", this.stuQ.stu_id); if (this.stuQ.stu_name) q.set("stu_name", this.stuQ.stu_name); if (this.stuQ.class_id) q.set("class_id", this.stuQ.class_id); if (this.stuQ.status) q.set("status", this.stuQ.status); q.set("skip", (page - 1) * 10); q.set("limit", 10); const data = await this.api("/students/total_query?" + q.toString()); this.students = data.items; this.stuTotal = data.total; this.stuPage = page; } catch (e) { this.flash(e.message, false); } }, resetStuQ() { this.stuQ = { stu_id: "", stu_name: "", class_id: "", status: "" }; this.loadStudents(1); }, openStuForm(s = null) { this.stuForm = s ? { ...s, _editing: true } : { stu_id: "", class_id: this.classes[0]?.class_id, stu_name: "", gender: "男", age: 20, education: "本科", major: "", native_place: "", graduate_school: "", advisor_id: 1, enroll_time: "", graduate_time: "", status: "在读" }; this.showStu = true; }, async saveStudent() { try { if (this.stuForm._editing) { await this.api(`/students/update?stu_id=${this.stuForm.stu_id}`, "PUT", { stu_name: this.stuForm.stu_name, native_place: this.stuForm.native_place, graduate_school: this.stuForm.graduate_school, major: this.stuForm.major, education: this.stuForm.education, age: this.stuForm.age, gender: this.stuForm.gender, status: this.stuForm.status, }); this.flash("学生更新成功"); } else { const body = { ...this.stuForm }; if (body.stu_id) body.stu_id = Number(body.stu_id); else delete body.stu_id; await this.api("/students/add", "POST", body); this.flash("学生创建成功"); } this.closeAll(); this.loadStudents(this.stuPage); } catch (e) { this.flash(e.message, false); } }, async delStudent(s) { if (!confirm(`确认删除学生 ${s.stu_name}(${s.stu_id})?`)) return; try { await this.api(`/students/delete?stu_id=${s.stu_id}`, "DELETE"); this.flash("删除成功"); this.loadStudents(this.stuPage); } catch (e) { this.flash(e.message, false); } }, // ==================== 成绩 ==================== async loadScores() { try { const q = new URLSearchParams(); if (this.scoreQ.stu_id) q.set("stu_id", this.scoreQ.stu_id); if (this.scoreQ.exam_id) q.set("exam_id", this.scoreQ.exam_id); if (!q.toString()) { this.scores = []; this.flash("请输入学号或考核序次查询", false); return; } const data = await this.api("/scores/query?" + q.toString()); this.scores = data.items; this.scoreWarning = ""; } catch (e) { this.flash(e.message, false); } }, openScoreForm(s = null) { this.scoreForm = s ? { stu_id: s.stu_id, exam_id: s.exam_id, score: s.score, _editing: true } : { stu_id: "", exam_id: 1, score: 80 }; this.showScore = true; }, async saveScore() { try { if (this.scoreForm._editing) { await this.api(`/scores/update?stu_id=${this.scoreForm.stu_id}&exam_id=${this.scoreForm.exam_id}`, "PUT", { score: this.scoreForm.score }); this.flash("成绩修改成功"); } else { const data = await this.api("/scores/add", "POST", { stu_id: this.scoreForm.stu_id, exam_id: this.scoreForm.exam_id, score: this.scoreForm.score }); if (data.warning) { this.scoreWarning = data.warning; this.flash(data.warning, false); } else this.flash("成绩录入成功"); } this.closeAll(); if (this.scoreQ.stu_id || this.scoreQ.exam_id) this.loadScores(); } catch (e) { this.flash(e.message, false); } }, async delScore(s) { if (!confirm(`确认删除第 ${s.exam_id} 次考核成绩?`)) return; try { await this.api("/scores/delete", "POST", { stu_id: s.stu_id, exam_id: s.exam_id }); this.flash("删除成功"); this.loadScores(); } catch (e) { this.flash(e.message, false); } }, // ==================== 就业 ==================== async loadEmployment(page = 1) { try { const q = new URLSearchParams(); if (this.empQ.stu_id) q.set("stu_id", this.empQ.stu_id); if (this.empQ.company_name) q.set("company_name", this.empQ.company_name); if (this.empQ.salary_min !== "" && this.empQ.salary_min !== null) q.set("salary_min", this.empQ.salary_min); if (this.empQ.salary_max !== "" && this.empQ.salary_max !== null) q.set("salary_max", this.empQ.salary_max); q.set("skip", (page - 1) * 10); q.set("limit", 10); const data = await this.api("/employment/total_query?" + q.toString()); this.employment = data.items; this.empTotal = data.total; this.empPage = page; } catch (e) { this.flash(e.message, false); } }, openEmpEdit(e) { this.empFormMode = "edit"; this.empForm = { stu_id: e.stu_id, company_name: e.company_name, salary: e.salary }; this.showEmp = true; }, async saveEmployment() { try { if (this.empFormMode === "open") { await this.api("/employment/open", "POST", { stu_id: this.empForm.stu_id, employment_open_time: this.empForm.employment_open_time }); this.flash("就业开放登记成功,学生状态已更新为『进入就业』"); } else if (this.empFormMode === "offer") { await this.api("/employment/offer", "POST", this.empForm); this.flash("offer 登记成功,学生状态已更新为『已就业』"); } else { await this.api(`/employment/update?stu_id=${this.empForm.stu_id}`, "PUT", { company_name: this.empForm.company_name, salary: this.empForm.salary }); this.flash("就业信息更新成功"); } this.closeAll(); this.loadEmployment(this.empPage); } catch (e) { this.flash(e.message, false); } }, async delEmployment(e) { if (!confirm(`确认删除 ${e.stu_name} 的就业信息?学生状态将回退为在读。`)) return; try { await this.api(`/employment/delete?stu_id=${e.stu_id}`, "DELETE"); this.flash("删除成功"); this.loadEmployment(this.empPage); } catch (err) { this.flash(err.message, false); } }, // ==================== 班级 ==================== async loadClasses() { try { const data = await this.api("/classes/total_query?limit=200"); this.classes = data.items; } catch (e) { this.flash(e.message, false); } }, openClassForm(c = null) { this.classForm = c ? { ...c } : { class_id: "", class_name: "", start_time: "" }; this.showClass = true; }, async saveClass() { try { if (this.classForm.class_id && !this.classForm._editing) { await this.api("/classes/add", "POST", { class_id: Number(this.classForm.class_id), class_name: this.classForm.class_name, start_time: this.classForm.start_time }); } else if (this.classForm._editing) { await this.api(`/classes/update?class_id=${this.classForm.class_id}`, "PUT", { class_name: this.classForm.class_name, start_time: this.classForm.start_time }); } else { await this.api("/classes/add", "POST", { class_name: this.classForm.class_name, start_time: this.classForm.start_time }); } this.flash("班级保存成功"); this.closeAll(); this.loadClasses(); } catch (e) { this.flash(e.message, false); } }, async delClass(c) { if (!confirm(`确认删除班级 ${c.class_name}?`)) return; try { await this.api(`/classes/delete?class_id=${c.class_id}`, "DELETE"); this.flash("删除成功"); this.loadClasses(); } catch (e) { this.flash(e.message, false); } }, // ==================== 老师 ==================== async loadTeachers(page = 1) { try { const data = await this.api(`/teachers/teacher/total_query?skip=${(page - 1) * 10}&limit=10`); this.teachers = data.items; this.teacherTotal = data.total; this.teacherPage = page; } catch (e) { this.flash(e.message, false); } }, openTeacherForm(t = null) { this.teacherForm = t ? { ...t, _editing: true } : { teacher_name: "", job_name: "主讲", class_id: this.classes[0]?.class_id }; this.showTeacher = true; }, async saveTeacher() { try { if (this.teacherForm._editing) { await this.api(`/teachers/teacher/update?teacher_id=${this.teacherForm.teacher_id}`, "PUT", { class_id: this.teacherForm.class_id, teacher_name: this.teacherForm.teacher_name, job_name: this.teacherForm.job_name, }); } else { await this.api("/teachers/teacher/add", "POST", { class_id: this.teacherForm.class_id, teacher_name: this.teacherForm.teacher_name, job_name: this.teacherForm.job_name, }); } this.flash("老师保存成功"); this.closeAll(); this.loadTeachers(this.teacherPage); } catch (e) { this.flash(e.message, false); } }, async delTeacher(t) { if (!confirm(`确认删除老师 ${t.teacher_name}?`)) return; try { await this.api(`/teachers/teacher/delete?teacher_id=${t.teacher_id}`, "DELETE"); this.flash("删除成功"); this.loadTeachers(this.teacherPage); } catch (e) { this.flash(e.message, false); } }, // ==================== 统计 ==================== runStat(key) { this.statKey = key; this.statRows = []; // 只有需要参数的统计自动执行一次默认查询 if (!["byAge", "allAbove", "fail", "topSalary", "classAvg", "volatility"].includes(key)) this.doStat(); }, async doStat() { const cfg = STAT_CONFIG[this.statKey]; if (!cfg) return; try { const data = await this.api(cfg.url()); this.statRows = data.map(cfg.pick); } catch (e) { this.flash(e.message, false); } }, // ==================== 高级筛选 ==================== resetFilterJson() { this.filterJson = JSON.stringify( { model: "student", rules: [ { field: "age", operator: ">", value: 20 }, { logic: "OR", sub_rules: [ { field: "salary", operator: ">=", value: 10000 }, { field: "class_name", operator: "like", value: "Java" }, ], }, ], }, null, 2 ); }, async runFilter() { try { const body = JSON.parse(this.filterJson); const data = await this.api("/statistics/filter", "POST", body); this.filterRows = data.items; this.flash(`筛选完成,共命中 ${data.total} 条记录`); } catch (e) { this.flash("筛选失败:" + e.message, false); } }, }, mounted() { if (this.token) this.afterLogin(); this.resetFilterJson(); }, }); // 分页组件 app.component("pager", { props: ["page", "total"], emits: ["go"], computed: { pages() { return Math.max(1, Math.ceil((this.total || 0) / 10)); }, }, template: `
第 {{ page }} / {{ pages }} 页(共 {{ total }} 条)
`, }); app.mount("#app");