Files

458 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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");