458 lines
19 KiB
JavaScript
458 lines
19 KiB
JavaScript
// 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: `
|
||
<div class="pager" v-if="pages > 1">
|
||
<button class="btn mini" :disabled="page <= 1" @click="$emit('go', page - 1)">上一页</button>
|
||
<span>第 {{ page }} / {{ pages }} 页(共 {{ total }} 条)</span>
|
||
<button class="btn mini" :disabled="page >= pages" @click="$emit('go', page + 1)">下一页</button>
|
||
</div>
|
||
`,
|
||
});
|
||
|
||
app.mount("#app");
|