Files
stu_teacher/static/app.js
T

458 lines
19 KiB
JavaScript
Raw Normal View History

2026-09-21 23:17:51 +08:00
// 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");