/* ===================================================================== * static/app.js —— 码力全开·学生管理系统 前端逻辑 * * 结构: * 一、通用工具 二、API 层 三、通用 UI 组件 * 四、资源视图工厂 五、总览与统计页 六、查询小助手 * 七、路由与启动 * * 说明:本文件为原生 JS,无任何第三方依赖;后端接口全部走 /api 前缀, * 与 index.html 同源,因此不存在跨域问题。 * ===================================================================== */ 'use strict'; /* ===================== 一、通用工具 ===================== */ const $ = (sel, root = document) => root.querySelector(sel); /** 转义 HTML,防止数据中的特殊字符破坏页面结构 */ function esc(v) { if (v === null || v === undefined) return ''; return String(v).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } /** 空值显示为占位符 */ function dash(v) { return (v === null || v === undefined || v === '') ? '—' : esc(v); } /** 小数比率转百分比:0.5556 → 55.6% */ function pct(v) { return (v === null || v === undefined) ? '—' : `${(Number(v) * 100).toFixed(1)}%`; } /** 金额格式化 */ function money(v) { if (v === null || v === undefined) return '—'; return `¥${Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`; } /** 数值固定小数位 */ function fixed(v, digits = 2) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(digits); } /** 构造查询串,自动忽略空值 */ function qs(params) { const p = new URLSearchParams(); for (const [k, v] of Object.entries(params || {})) { if (v === '' || v === null || v === undefined) continue; p.append(k, v); } const s = p.toString(); return s ? `?${s}` : ''; } /** 去除空值字段,契合后端"局部更新"语义(Pydantic exclude_unset) */ function compact(obj) { const out = {}; for (const [k, v] of Object.entries(obj)) { if (v === '' || v === null || v === undefined) continue; out[k] = v; } return out; } /* ===================== 二、API 层 ===================== */ const API_BASE = '/api'; /** 统一请求封装:把后端的 {"detail": "..."} 异常转成可读的 Error */ async function http(method, path, body) { const opt = { method, headers: {} }; if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); } let res; try { res = await fetch(API_BASE + path, opt); } catch (e) { throw new Error('无法连接后端服务,请确认已在项目目录下启动 main.py(默认端口 8002)'); } const text = await res.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch (e) { data = text; } } if (!res.ok) { const detail = data && data.detail; let msg; if (typeof detail === 'string') msg = detail; else if (Array.isArray(detail)) msg = detail.map((d) => d.msg || JSON.stringify(d)).join(';'); else msg = `请求失败(HTTP ${res.status})`; throw new Error(msg); } return data; } const enc = encodeURIComponent; const api = { /* 学生信息 */ stu: { list: (p) => http('GET', `/student${qs(p)}`), get: (id) => http('GET', `/student/${enc(id)}`), create: (b) => http('POST', '/student', b), update: (id, b) => http('PUT', `/student/${enc(id)}`, b), remove: (id) => http('DELETE', `/student/${enc(id)}`), }, /* 班级管理 */ cls: { list: (p) => http('GET', `/classes${qs(p)}`), get: (id) => http('GET', `/classes/${enc(id)}`), create: (b) => http('POST', '/classes', b), update: (id, b) => http('PUT', `/classes/${enc(id)}`, b), remove: (id) => http('DELETE', `/classes/${enc(id)}`), }, /* 教师信息 */ tea: { list: (p) => http('GET', `/teacher${qs(p)}`), get: (id) => http('GET', `/teacher/${enc(id)}`), create: (b) => http('POST', '/teacher', b), update: (id, b) => http('PUT', `/teacher/${enc(id)}`, b), remove: (id) => http('DELETE', `/teacher/${enc(id)}`), }, /* 顾问信息 */ adv: { list: (p) => http('GET', `/advisor${qs(p)}`), get: (id) => http('GET', `/advisor/${enc(id)}`), create: (b) => http('POST', '/advisor', b), update: (id, b) => http('PUT', `/advisor/${enc(id)}`, b), remove: (id) => http('DELETE', `/advisor/${enc(id)}`), }, /* 学生成绩 */ score: { list: (p) => http('GET', `/score${qs(p)}`), get: (id) => http('GET', `/score/${enc(id)}`), byStudent: (stuId) => http('GET', `/score/student/${enc(stuId)}`), create: (b) => http('POST', '/score', b), update: (id, b) => http('PUT', `/score/${enc(id)}`, b), remove: (id) => http('DELETE', `/score/${enc(id)}`), }, /* 学生就业 */ emp: { list: (p) => http('GET', `/employ${qs(p)}`), get: (stuId) => http('GET', `/employ/${enc(stuId)}`), create: (b) => http('POST', '/employ', b), update: (stuId, b) => http('PUT', `/employ/${enc(stuId)}`, b), remove: (stuId) => http('DELETE', `/employ/${enc(stuId)}`), }, /* 统计分析 */ stats: { overview: () => http('GET', '/stats/overview'), gender: () => http('GET', '/stats/student/gender'), education: () => http('GET', '/stats/student/education'), state: () => http('GET', '/stats/student/state'), hometown: (limit = 8) => http('GET', `/stats/student/hometown${qs({ limit })}`), scoreSummary: (clsId) => http('GET', `/stats/score/summary${qs({ cls_id: clsId })}`), scoreDist: () => http('GET', '/stats/score/distribution'), employSummary: () => http('GET', '/stats/employ/summary'), }, }; /* ===================== 三、通用 UI 组件 ===================== */ /** 轻提示 */ function toast(msg, type = '') { const box = $('#toasts'); const t = document.createElement('div'); t.className = `toast ${type}`; t.textContent = msg; box.appendChild(t); setTimeout(() => t.remove(), 2800); } /** 二次确认对话框,返回 Promise */ function confirmDialog(title, message) { return new Promise((resolve) => { const mask = document.createElement('div'); mask.className = 'mask'; mask.innerHTML = ` `; const done = (v) => { mask.remove(); resolve(v); }; mask.addEventListener('click', (e) => { const act = e.target.dataset.act; if (act === 'yes') done(true); else if (act === 'no' || e.target === mask) done(false); }); $('#modalRoot').appendChild(mask); }); } /** 把字段定义的 options 解析成 [{value,label}] 形式 */ function resolveOptions(options) { const raw = typeof options === 'function' ? options() : (options || []); return raw.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); } /** 渲染单个表单字段 */ function fieldHtml(f, value) { const v = (value === null || value === undefined) ? '' : String(value); const wrapCls = f.full ? 'field full' : 'field'; const star = f.required ? ' *' : ''; if (f.type === 'select') { const list = resolveOptions(f.options); let inner = f.allowEmpty ? `` : ''; for (const o of list) { inner += ``; } return `
`; } const attrs = [ f.maxlength ? `maxlength="${f.maxlength}"` : '', f.min !== undefined ? `min="${f.min}"` : '', f.max !== undefined ? `max="${f.max}"` : '', f.step !== undefined ? `step="${f.step}"` : '', ].filter(Boolean).join(' '); const type = ['number', 'date'].includes(f.type) ? f.type : 'text'; return `
`; } /** * 打开新增 / 编辑表单弹窗 * @param {object} cfg {title, fields, values, note, submitText, onSubmit(payload)} */ function openModal(cfg) { const mask = document.createElement('div'); mask.className = 'mask'; const fieldsHtml = cfg.fields.map((f) => fieldHtml(f, cfg.values[f.key])).join(''); mask.innerHTML = ` `; const form = $('form', mask); const errBox = $('.form-err', mask); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target.dataset.act === 'cancel' || e.target === mask) close(); }); form.addEventListener('submit', async (e) => { e.preventDefault(); errBox.hidden = true; const fd = new FormData(form); const raw = {}; for (const [k, v] of fd.entries()) raw[k] = typeof v === 'string' ? v.trim() : v; // 前端必填校验(与后端 Pydantic 约束保持一致) const missing = cfg.fields.filter((f) => f.required && !raw[f.key]).map((f) => f.label); if (missing.length) { errBox.textContent = `请填写:${missing.join('、')}`; errBox.hidden = false; return; } const btn = $('button[type="submit"]', form); btn.disabled = true; try { await cfg.onSubmit(compact(raw)); close(); } catch (err) { errBox.textContent = err.message; errBox.hidden = false; } finally { btn.disabled = false; } }); $('#modalRoot').appendChild(mask); const first = $('input,select', form); if (first) first.focus(); } /** 右侧详情抽屉 */ function openDrawer(title, html) { const mask = document.createElement('div'); mask.className = 'mask right'; mask.innerHTML = `
`; mask.addEventListener('click', (e) => { if (e.target.dataset.act === 'close' || e.target === mask) mask.remove(); }); $('#modalRoot').appendChild(mask); } /** 详情字段列表 */ function detailList(pairs) { return `
${pairs .map(([k, v]) => `
${esc(k)}
${v}
`) .join('')}
`; } /** 状态徽标 */ function stateTag(v) { const map = { '在读': 'info', '毕业': 'ok' }; return `${esc(v || '—')}`; } /** 成绩等级徽标 */ function levelTag(v) { const map = { '优秀': 'ok', '良好': 'info', '普通': '', '及格': 'warn', '不及格': 'bad' }; return `${esc(v || '—')}`; } /** 横向条形图(纯 DOM 实现,无第三方图表库) */ function barChart(rows, showPct = false) { if (!rows.length) return `
暂无数据
`; const max = Math.max(1, ...rows.map((r) => Number(r.value) || 0)); const total = rows.reduce((s, r) => s + (Number(r.value) || 0), 0); return rows.map((r) => { const w = ((Number(r.value) || 0) / max * 100).toFixed(1); const tail = showPct && total ? `${r.value} · ${((r.value / total) * 100).toFixed(0)}%` : `${r.value}`; return `
${esc(r.label)}
${esc(tail)}
`; }).join(''); } /* ===================== 四、资源视图工厂 ===================== */ const STATE_OPTS = ['在读', '毕业']; const GENDER_OPTS = ['男', '女']; const EDU_OPTS = ['大专', '本科', '硕士', '博士']; const LEVEL_OPTS = ['优秀', '良好', '普通', '及格', '不及格']; const TEA_TYPE_OPTS = ['班主任', '主讲']; /** 下拉数据源缓存(页面初始化时加载一次) */ const selects = { classes: [], teachers: [], advisors: [], students: [], studentName: {}, teacherName: {}, }; async function loadSelects() { const [c, t, a, s] = await Promise.all([ api.cls.list({ page: 1, size: 100 }), api.tea.list({ page: 1, size: 100 }), api.adv.list({ page: 1, size: 100 }), api.stu.list({ page: 1, size: 100 }), ]); selects.classes = (c.items || []).map((x) => ({ value: x.id, label: `${x.id} · ${x.cls_start_date}` })); selects.teachers = (t.items || []).map((x) => ({ value: x.id, label: `${x.id} · ${x.name}` })); selects.advisors = (a.items || []).map((x) => ({ value: x.id, label: `${x.id} · ${x.name}` })); selects.students = (s.items || []).map((x) => ({ value: x.id, label: `${x.id} · ${x.name}` })); selects.studentName = Object.fromEntries((s.items || []).map((x) => [x.id, x.name])); selects.teacherName = Object.fromEntries((t.items || []).map((x) => [x.id, x.name])); } /** 各资源的视图状态:分页与筛选条件 */ const viewState = {}; function stateOf(key) { if (!viewState[key]) viewState[key] = { page: 1, size: 10, filters: {} }; return viewState[key]; } /* 资源定义:列 / 筛选 / 表单 / 详情,六个模块共用一套渲染流程 */ const RESOURCES = { students: { key: 'students', title: '学生管理', desc: '学生基本信息,关联班级、顾问、成绩与就业', idKey: 'id', idLabel: '学号', api: api.stu, columns: [ { key: 'id', label: '学号', cls: 'mono' }, { key: 'name', label: '姓名' }, { key: 'gender', label: '性别' }, { key: 'cls_id', label: '班级', cls: 'mono' }, { key: 'education', label: '学历', render: dash }, { key: 'hometown', label: '籍贯', render: dash }, { key: 'grad_school', label: '毕业院校', render: dash }, { key: 'state', label: '状态', render: stateTag }, ], filters: [ { key: 'name', label: '姓名', type: 'text', placeholder: '模糊匹配' }, { key: 'cls_id', label: '班级', type: 'select', options: () => selects.classes, allowEmpty: true }, { key: 'state', label: '状态', type: 'select', options: () => STATE_OPTS, allowEmpty: true }, ], form: [ { key: 'id', label: '学号', type: 'text', required: true, maxlength: 15, createOnly: true }, { key: 'cls_id', label: '所属班级', type: 'select', required: true, options: () => selects.classes }, { key: 'name', label: '姓名', type: 'text', required: true, maxlength: 20 }, { key: 'gender', label: '性别', type: 'select', options: () => GENDER_OPTS }, { key: 'age', label: '年龄', type: 'number', min: 0 }, { key: 'hometown', label: '籍贯', type: 'text', maxlength: 50 }, { key: 'grad_school', label: '毕业院校', type: 'text', maxlength: 50 }, { key: 'major', label: '专业', type: 'text', maxlength: 50 }, { key: 'education', label: '学历', type: 'select', options: () => EDU_OPTS, allowEmpty: true }, { key: 'enr_date', label: '入学日期', type: 'date' }, { key: 'grad_date', label: '毕业日期', type: 'date' }, { key: 'advisor_id', label: '顾问', type: 'select', options: () => selects.advisors, allowEmpty: true }, { key: 'state', label: '状态', type: 'select', options: () => STATE_OPTS }, ], }, classes: { key: 'classes', title: '班级管理', desc: '班级开课信息与班主任 / 主讲老师配置', idKey: 'id', idLabel: '班级编号', api: api.cls, columns: [ { key: 'id', label: '班级编号', cls: 'mono' }, { key: 'cls_start_date', label: '开课时间' }, { key: 'head_tea_id', label: '班主任', cls: 'mono', render: (v) => teacherCell(v) }, { key: 'lecturer_id', label: '主讲老师', cls: 'mono', render: (v) => teacherCell(v) }, ], filters: [ { key: 'head_tea_id', label: '班主任', type: 'select', options: () => selects.teachers, allowEmpty: true }, { key: 'lecturer_id', label: '主讲老师', type: 'select', options: () => selects.teachers, allowEmpty: true }, ], form: [ { key: 'id', label: '班级编号', type: 'text', required: true, maxlength: 15, createOnly: true }, { key: 'cls_start_date', label: '开课时间', type: 'date', required: true }, { key: 'head_tea_id', label: '班主任', type: 'select', required: true, options: () => selects.teachers }, { key: 'lecturer_id', label: '主讲老师', type: 'select', required: true, options: () => selects.teachers }, ], }, teachers: { key: 'teachers', title: '教师管理', desc: '教师基本信息与所带班级', idKey: 'id', idLabel: '教师编号', api: api.tea, columns: [ { key: 'id', label: '教师编号', cls: 'mono' }, { key: 'name', label: '姓名' }, { key: 'phone', label: '电话', cls: 'mono' }, { key: 'type', label: '职位' }, ], filters: [ { key: 'name', label: '姓名', type: 'text', placeholder: '模糊匹配' }, { key: 'type', label: '职位', type: 'select', options: () => TEA_TYPE_OPTS, allowEmpty: true }, ], form: [ { key: 'id', label: '教师编号', type: 'text', required: true, maxlength: 15, createOnly: true }, { key: 'name', label: '姓名', type: 'text', required: true, maxlength: 20 }, { key: 'phone', label: '电话', type: 'text', required: true, maxlength: 20 }, { key: 'type', label: '职位', type: 'select', options: () => TEA_TYPE_OPTS }, ], }, advisors: { key: 'advisors', title: '顾问管理', desc: '顾问基本信息与所负责学生', idKey: 'id', idLabel: '顾问编号', api: api.adv, columns: [ { key: 'id', label: '顾问编号', cls: 'mono' }, { key: 'name', label: '姓名' }, { key: 'phone', label: '电话', cls: 'mono' }, ], filters: [ { key: 'name', label: '姓名', type: 'text', placeholder: '模糊匹配' }, ], form: [ { key: 'id', label: '顾问编号', type: 'text', required: true, maxlength: 15, createOnly: true }, { key: 'name', label: '姓名', type: 'text', required: true, maxlength: 20 }, { key: 'phone', label: '电话', type: 'text', required: true, maxlength: 20 }, ], }, scores: { key: 'scores', title: '成绩管理', desc: '考试轮次与成绩,等级可留空由系统自动判定', idKey: 'id', idLabel: '记录 ID', api: api.score, columns: [ { key: 'id', label: 'ID', cls: 'mono' }, { key: 'stu_id', label: '学生', cls: 'mono', render: (v) => studentCell(v) }, { key: 'exam_attempt', label: '考试轮次', render: (v) => `第 ${esc(v)} 次` }, { key: 'exam_score', label: '成绩', render: (v) => fixed(v, 2) }, { key: 'score_level', label: '等级', render: levelTag }, ], filters: [ { key: 'stu_id', label: '学生编号', type: 'text', placeholder: '如 S0001' }, { key: 'score_level', label: '成绩等级', type: 'select', options: () => LEVEL_OPTS, allowEmpty: true }, ], form: [ { key: 'stu_id', label: '学生', type: 'select', required: true, options: () => selects.students }, { key: 'exam_attempt', label: '考试轮次', type: 'number', min: 1, required: true }, { key: 'exam_score', label: '成绩(0-100)', type: 'number', min: 0, max: 100, step: '0.01', required: true }, { key: 'score_level', label: '成绩等级', type: 'select', options: () => LEVEL_OPTS, allowEmpty: true }, ], }, employ: { key: 'employ', title: '就业管理', desc: '学生就业记录(与学生一对一)', idKey: 'stu_id', idLabel: '学号', api: api.emp, columns: [ { key: 'stu_id', label: '学生', cls: 'mono', render: (v) => studentCell(v) }, { key: 'emp_company', label: '就业公司', render: dash }, { key: 'salary', label: '月薪', render: (v) => money(v) }, { key: 'emp_open_time', label: '就业开放时间', render: dash }, { key: 'send_offer_time', label: '收到 Offer 时间', render: dash }, ], filters: [ { key: 'emp_company', label: '公司名称', type: 'text', placeholder: '模糊匹配' }, { key: 'employed', label: '就业状态', type: 'select', allowEmpty: true, options: () => [{ value: 'true', label: '已就业(含公司)' }, { value: 'false', label: '未就业' }], }, ], form: [ { key: 'stu_id', label: '学生', type: 'select', required: true, options: () => selects.students, createOnly: true }, { key: 'emp_company', label: '就业公司', type: 'text', maxlength: 50 }, { key: 'salary', label: '月薪(元)', type: 'number', min: 0, step: '0.01' }, { key: 'emp_open_time', label: '就业开放时间', type: 'date' }, { key: 'send_offer_time', label: '收到 Offer 时间', type: 'date' }, ], }, }; /** 学生单元格:编号 + 姓名 */ function studentCell(id) { const name = selects.studentName[id]; return `${esc(id)}${name ? ` ${esc(name)}` : ''}`; } /** 教师单元格:编号 + 姓名 */ function teacherCell(id) { const name = selects.teacherName[id]; return `${esc(id)}${name ? ` ${esc(name)}` : ''}`; } /** 渲染筛选栏 */ function filtersHtml(r, st) { const items = r.filters.map((f) => { const v = st.filters[f.key] || ''; if (f.type === 'select') { const list = resolveOptions(f.options); let inner = f.allowEmpty ? `` : ''; for (const o of list) { inner += ``; } return `
`; } return `
`; }).join(''); return `
${items}
`; } /** 渲染表格主体 */ function tableHtml(r, items) { if (!items.length) return `
没有符合条件的数据
`; const head = r.columns.map((c) => `${esc(c.label)}`).join('') + '操作'; const body = items.map((row, idx) => { const tds = r.columns.map((c) => { const raw = row[c.key]; const content = c.render ? c.render(raw, row) : (raw === null || raw === undefined || raw === '' ? '—' : esc(raw)); return `${content}`; }).join(''); return `${tds} · · `; }).join(''); return `
${head}${body}
`; } /** 渲染分页器 */ function pagerHtml(total, st) { const pages = Math.max(1, Math.ceil(total / st.size)); const from = total === 0 ? 0 : (st.page - 1) * st.size + 1; const to = Math.min(total, st.page * st.size); return `
共 ${total} 条,当前 ${from}-${to}
${st.page} / ${pages}
`; } /* 当前视图的数据快照,供操作(编辑 / 详情)取用 */ let currentItems = []; /** 渲染资源视图 */ async function renderResource(key) { const r = RESOURCES[key]; const st = stateOf(key); const main = $('#main'); main.innerHTML = `
加载中…
`; let data; try { data = await r.api.list({ page: st.page, size: st.size, ...st.filters }); } catch (e) { main.innerHTML = `
加载失败:${esc(e.message)}
`; return; } currentItems = data.items || []; main.innerHTML = `

${esc(r.title)}${esc(r.desc)}

${filtersHtml(r, st)}
${tableHtml(r, currentItems)} ${pagerHtml(data.total, st)}
`; } /* ---------- 资源视图的增删改查交互 ---------- */ /** 取表单字段定义(新增时全部,编辑时排除主键与 createOnly) */ function formFields(r, isCreate) { return r.form.filter((f) => isCreate || (!f.createOnly && f.key !== r.idKey)); } function openCreateForm(key) { const r = RESOURCES[key]; openModal({ title: `新增${r.title.replace('管理', '')}`, fields: formFields(r, true), values: {}, submitText: '创建', onSubmit: async (payload) => { await r.api.create(payload); toast('创建成功', 'ok'); await loadSelects(); await renderResource(key); }, }); } function openEditForm(key, item) { const r = RESOURCES[key]; const values = {}; for (const f of formFields(r, false)) values[f.key] = item[f.key]; openModal({ title: `编辑${r.title.replace('管理', '')} · ${item[r.idKey]}`, fields: formFields(r, false), values, submitText: '保存', onSubmit: async (payload) => { await r.api.update(item[r.idKey], payload); toast('保存成功', 'ok'); await loadSelects(); await renderResource(key); }, }); } async function doDelete(key, item) { const r = RESOURCES[key]; const ok = await confirmDialog( '确认删除', `将软删除 ${r.idLabel} ${item[r.idKey]},数据仍保留在数据库中(is_deleted 置位)。是否继续?` ); if (!ok) return; try { await r.api.remove(item[r.idKey]); toast('已删除', 'ok'); await renderResource(key); } catch (e) { toast(e.message, 'err'); } } /* ---------- 详情:按模块组装抽屉内容 ---------- */ async function showDetail(key, item) { try { if (key === 'students') return openDrawer('学生详情', stuDetailHtml(await api.stu.get(item.id))); if (key === 'classes') return openDrawer('班级详情', clsDetailHtml(await api.cls.get(item.id))); if (key === 'teachers') return openDrawer('教师详情', teaDetailHtml(await api.tea.get(item.id))); if (key === 'advisors') return openDrawer('顾问详情', advDetailHtml(await api.adv.get(item.id))); if (key === 'scores') { const s = await api.score.get(item.id); return openDrawer('成绩详情', detailList([ ['记录 ID', esc(s.id)], ['学生', studentCell(s.stu_id)], ['考试轮次', `第 ${esc(s.exam_attempt)} 次`], ['成绩', fixed(s.exam_score, 2)], ['等级', levelTag(s.score_level)], ])); } if (key === 'employ') { const e = await api.emp.get(item.stu_id); return openDrawer('就业详情', detailList([ ['学生', `${studentCell(e.stu_id)}`], ['就业公司', dash(e.emp_company)], ['月薪', money(e.salary)], ['就业开放时间', dash(e.emp_open_time)], ['收到 Offer 时间', dash(e.send_offer_time)], ])); } } catch (e) { toast(e.message, 'err'); } } function stuDetailHtml(d) { const base = detailList([ ['学号', `${esc(d.id)}`], ['姓名', esc(d.name)], ['性别', dash(d.gender)], ['年龄', dash(d.age)], ['状态', stateTag(d.state)], ['所属班级', d.cls ? `${esc(d.cls.id)}(开课 ${esc(d.cls.cls_start_date)})` : '—'], ['顾问', d.advisor ? `${esc(d.advisor.name)} ${esc(d.advisor.id)}` : '—'], ['籍贯', dash(d.hometown)], ['毕业院校', dash(d.grad_school)], ['专业', dash(d.major)], ['学历', dash(d.education)], ['入学日期', dash(d.enr_date)], ['毕业日期', dash(d.grad_date)], ]); const scores = (d.scores || []).length ? `
${d.scores.map((s) => ``).join('')}
轮次成绩等级
第 ${esc(s.exam_attempt)} 次 ${fixed(s.exam_score, 2)}${levelTag(s.score_level)}
` : `
暂无成绩记录
`; const emp = d.employ ? detailList([ ['就业公司', dash(d.employ.emp_company)], ['月薪', money(d.employ.salary)], ['就业开放时间', dash(d.employ.emp_open_time)], ['收到 Offer 时间', dash(d.employ.send_offer_time)], ]) : `
暂无就业记录
`; return `${base}

成绩记录

${scores}

就业信息

${emp}
`; } function clsDetailHtml(d) { return detailList([ ['班级编号', `${esc(d.id)}`], ['开课时间', dash(d.cls_start_date)], ['班主任', d.head_teacher_name ? `${esc(d.head_teacher_name)} ${esc(d.head_tea_id)}` : '—'], ['主讲老师', d.lecturer_name ? `${esc(d.lecturer_name)} ${esc(d.lecturer_id)}` : '—'], ['在班学生数', `${esc(d.student_count)} 人`], ]); } function teaDetailHtml(d) { const list = (arr) => (arr && arr.length ? arr.map((x) => `${esc(x)}`).join(' ') : '无'); return `${detailList([ ['教师编号', `${esc(d.id)}`], ['姓名', esc(d.name)], ['电话', `${esc(d.phone)}`], ['职位', dash(d.type)], ])}

担任班主任的班级

${list(d.head_classes)}

担任主讲老师的班级

${list(d.lecturer_classes)}
`; } function advDetailHtml(d) { const ids = d.student_ids || []; return `${detailList([ ['顾问编号', `${esc(d.id)}`], ['姓名', esc(d.name)], ['电话', `${esc(d.phone)}`], ['负责学生数', `${esc(d.student_count)} 人`], ])}

负责的学生

${ ids.length ? `
${ids.map((id) => `${esc(id)}`).join('')}
` : '
暂无学生
' }
`; } /* ===================== 五、总览与统计页 ===================== */ /** 数据总览页 */ async function renderOverview() { const main = $('#main'); main.innerHTML = `
加载中…
`; let ov, gender, score, emp; try { [ov, gender, score, emp] = await Promise.all([ api.stats.overview(), api.stats.gender(), api.stats.scoreSummary(), api.stats.employSummary(), ]); } catch (e) { main.innerHTML = `
加载失败:${esc(e.message)}
`; return; } const genderRows = gender.map((g) => ({ label: g.gender, value: g.count })); main.innerHTML = `

数据总览实时读取 MySQL,数据每次刷新更新

学生总数
${ov.student_count}
未删除记录
班级数
${ov.class_count}
在册班级
教师数
${ov.teacher_count}
班主任 / 主讲
顾问数
${ov.advisor_count}
在册顾问
就业率
${pct(ov.employment_rate)}
已就业 ${ov.employed_count} / ${ov.student_count} 人
学生性别分布/api/stats/student/gender
${barChart(genderRows, true)}
成绩概览全部班级
${detailList([ ['成绩记录', `${score.total} 条`], ['平均分', `${fixed(score.avg_score)}`], ['最高分', fixed(score.max_score)], ['最低分', fixed(score.min_score)], ['及格率', `${pct(score.pass_rate)}(${score.pass_count} 人)`], ])}
就业概览/api/stats/employ/summary
${detailList([ ['就业率', `${pct(emp.employment_rate)}`], ['已就业', `${emp.employed_count} 人`], ['平均月薪', money(emp.avg_salary)], ['最高月薪', money(emp.max_salary)], ['最低月薪', money(emp.min_salary)], ])}
`; } /** 统计分析页 */ let statsClsFilter = ''; async function renderStats() { const main = $('#main'); main.innerHTML = `
加载中…
`; let gender, edu, state, home, sd, emp, sc; try { [gender, edu, state, home, sd, emp, sc] = await Promise.all([ api.stats.gender(), api.stats.education(), api.stats.state(), api.stats.hometown(8), api.stats.scoreDist(), api.stats.employSummary(), api.stats.scoreSummary(statsClsFilter), ]); } catch (e) { main.innerHTML = `
加载失败:${esc(e.message)}
`; return; } const clsOpts = selects.classes .map((c) => ``) .join(''); main.innerHTML = `

统计分析聚合、分组与关联查询

性别分布student/gender
${barChart(gender.map((x) => ({ label: x.gender, value: x.count })), true)}
学历分布student/education
${barChart(edu.map((x) => ({ label: x.education, value: x.count })), true)}
就读状态分布student/state
${barChart(state.map((x) => ({ label: x.state, value: x.count })), true)}
生源地 Top 8student/hometown
${barChart(home.map((x) => ({ label: x.hometown, value: x.count })), true)}
成绩等级分布score/distribution
${barChart(sd.map((x) => ({ label: x.score_level, value: x.count })), true)}
就业薪资概览employ/summary
${detailList([ ['就业率', `${pct(emp.employment_rate)}`], ['已就业人数', `${emp.employed_count} 人`], ['平均月薪', money(emp.avg_salary)], ['最高月薪', money(emp.max_salary)], ['最低月薪', money(emp.min_salary)], ])}
成绩综合统计score/summary,可按班级动态过滤
成绩记录
${sc.total}
条
平均分
${fixed(sc.avg_score)}
满分 100
最高分
${fixed(sc.max_score)}
—
最低分
${fixed(sc.min_score)}
—
及格率
${pct(sc.pass_rate)}
及格 ${sc.pass_count} 条
`; } /* ===================== 六、查询小助手 ===================== */ const assistant = { snap: null, snapAt: 0, /** 拉取一份本地快照用于条件筛选(30 秒内复用) */ async snapshot(force = false) { if (!force && this.snap && Date.now() - this.snapAt < 30000) return this.snap; const [stu, cls, tea, adv, emp] = await Promise.all([ api.stu.list({ page: 1, size: 100 }), api.cls.list({ page: 1, size: 100 }), api.tea.list({ page: 1, size: 100 }), api.adv.list({ page: 1, size: 100 }), api.emp.list({ page: 1, size: 100 }), ]); this.snap = { students: stu.items || [], classes: cls.items || [], teachers: tea.items || [], advisors: adv.items || [], employ: emp.items || [], truncated: (stu.total || 0) > 100, }; this.snapAt = Date.now(); return this.snap; }, /** 从快照里推导出可识别的词表(城市 / 专业 / 院校 / 公司) */ vocab(snap) { const uniq = (arr) => [...new Set(arr.filter(Boolean))].sort((a, b) => b.length - a.length); return { hometown: uniq(snap.students.map((s) => s.hometown)), major: uniq(snap.students.map((s) => s.major)), gradSchool: uniq(snap.students.map((s) => s.grad_school)), company: uniq(snap.employ.map((e) => e.emp_company)), }; }, }; /** 统计类意图规则:按顺序匹配,越具体越靠前 */ const STAT_RULES = [ { test: (q) => /就业率|就业情况|就业统计|多少人就业/.test(q), run: async () => { const d = await api.stats.employSummary(); return { html: `当前就业率 ${pct(d.employment_rate)},即 ${d.employed_count} / ${d.student_count} 人已就业。`, rows: [ ['已就业人数', `${d.employed_count} 人`], ['学生总数', `${d.student_count} 人`], ['平均月薪', money(d.avg_salary)], ['最高月薪', money(d.max_salary)], ['最低月薪', money(d.min_salary)], ], jump: { route: 'stats', label: '查看就业统计' }, }; }, }, { test: (q) => /平均分|平均成绩|成绩平均|分数平均/.test(q), run: async () => { const d = await api.stats.scoreSummary(); return { html: `全部成绩记录的平均分为 ${fixed(d.avg_score)} 分。`, rows: [['成绩记录', `${d.total} 条`], ['最高分', fixed(d.max_score)], ['最低分', fixed(d.min_score)], ['及格率', pct(d.pass_rate)]], jump: { route: 'stats', label: '查看成绩统计' }, }; }, }, { test: (q) => /最高分|最高成绩/.test(q), run: async () => { const d = await api.stats.scoreSummary(); return { html: `最高分为 ${fixed(d.max_score)} 分。`, rows: [], jump: { route: 'scores', label: '去成绩管理' } }; }, }, { test: (q) => /最低分|最低成绩/.test(q), run: async () => { const d = await api.stats.scoreSummary(); return { html: `最低分为 ${fixed(d.min_score)} 分。`, rows: [], jump: { route: 'scores', label: '去成绩管理' } }; }, }, { test: (q) => /及格率|通过率/.test(q), run: async () => { const d = await api.stats.scoreSummary(); return { html: `及格率为 ${pct(d.pass_rate)}(及格 ${d.pass_count} / ${d.total} 条)。`, rows: [], jump: { route: 'stats', label: '查看成绩统计' }, }; }, }, { test: (q) => /薪资|工资|月薪/.test(q), run: async () => { const d = await api.stats.employSummary(); return { html: `已就业学生的平均月薪为 ${money(d.avg_salary)}。`, rows: [['最高月薪', money(d.max_salary)], ['最低月薪', money(d.min_salary)], ['就业人数', `${d.employed_count} 人`]], jump: { route: 'stats', label: '查看就业统计' }, }; }, }, { test: (q) => /生源地|籍贯|哪里人|哪个城市|哪个地方/.test(q), run: async () => { const d = await api.stats.hometown(5); if (!d.length) return { html: '暂无生源地数据。', rows: [] }; return { html: `生源地人数最多的是 ${esc(d[0].hometown)},共 ${d[0].count} 人。`, rows: d.map((x) => [x.hometown, `${x.count} 人`]), jump: { route: 'stats', label: '查看统计页' }, }; }, }, { test: (q) => /学历|本科|硕士|大专|博士/.test(q), run: async () => { const d = await api.stats.education(); return { html: '学生学历分布如下:', rows: d.sort((a, b) => b.count - a.count).map((x) => [x.education, `${x.count} 人`]), jump: { route: 'stats', label: '查看统计页' }, }; }, }, { test: (q) => /性别|男女|男生女生/.test(q), run: async () => { const d = await api.stats.gender(); return { html: '学生性别分布如下:', rows: d.map((x) => [x.gender, `${x.count} 人`]), jump: { route: 'stats', label: '查看统计页' }, }; }, }, { test: (q) => /成绩等级|等级分布|优良率/.test(q), run: async () => { const d = await api.stats.scoreDist(); return { html: '成绩等级分布如下:', rows: d.sort((a, b) => b.count - a.count).map((x) => [x.score_level, `${x.count} 条`]), jump: { route: 'stats', label: '查看统计页' }, }; }, }, { test: (q) => /几个班|多少班|班级数|班级数量/.test(q), run: async () => { const d = await api.stats.overview(); return { html: `系统中共有 ${d.class_count} 个班级。`, rows: [], jump: { route: 'classes', label: '去班级管理' } }; }, }, { test: (q) => /多少老师|几个老师|教师数|教师数量|多少教师/.test(q), run: async () => { const d = await api.stats.overview(); return { html: `系统中共有 ${d.teacher_count} 名教师。`, rows: [], jump: { route: 'teachers', label: '去教师管理' } }; }, }, { test: (q) => /多少顾问|几个顾问|顾问数|顾问数量/.test(q), run: async () => { const d = await api.stats.overview(); return { html: `系统中共有 ${d.advisor_count} 名顾问。`, rows: [], jump: { route: 'advisors', label: '去顾问管理' } }; }, }, { test: (q) => /多少学生|几个学生|学生总数|学生数量|有多少人|总人数/.test(q), run: async () => { const d = await api.stats.overview(); return { html: `系统中共有 ${d.student_count} 名学生,已就业 ${d.employed_count} 人。`, rows: [['班级数', `${d.class_count} 个`], ['教师数', `${d.teacher_count} 人`], ['顾问数', `${d.advisor_count} 人`], ['就业率', pct(d.employment_rate)]], jump: { route: 'students', label: '去学生管理' }, }; }, }, ]; /** 从问句中提取筛选槽位 */ function extractSlots(q, snap) { const vocab = assistant.vocab(snap); const hit = {}; const label = {}; // 学生自身字段 if (/在读|在校/.test(q)) { hit.state = '在读'; label.state = '在读'; } else if (/已毕业|毕业的|毕业了/.test(q)) { hit.state = '毕业'; label.state = '毕业'; } if (/男生|男性|男孩|男的/.test(q)) { hit.gender = '男'; label.gender = '男'; } else if (/女生|女性|女孩|女的/.test(q)) { hit.gender = '女'; label.gender = '女'; } for (const e of EDU_OPTS) { if (q.includes(e)) { hit.education = e; label.education = e; break; } } if (!hit.education && /研究生/.test(q)) { hit.education = '硕士'; label.education = '硕士'; } // 词表匹配(长词优先,避免"北京大学"被"北京"截断) for (const [field, words] of [['hometown', vocab.hometown], ['grad_school', vocab.gradSchool], ['major', vocab.major]]) { for (const w of words) { if (q.includes(w)) { hit[field] = w; label[field] = w; break; } } } // 班级 / 教师 / 顾问编号 const clsM = q.match(/C\d{2,4}/i); if (clsM) { hit.cls_id = clsM[0].toUpperCase(); label.cls_id = `班级 ${hit.cls_id}`; } const advM = q.match(/A\d{2,4}/i); if (advM) { hit.advisor_id = advM[0].toUpperCase(); label.advisor_id = `顾问 ${hit.advisor_id}`; } // 派生条件:成绩等级 / 就业 const scoreLevel = LEVEL_OPTS.find((lv) => q.includes(lv)) || (/不及格/.test(q) ? '不及格' : null); if (scoreLevel) { hit.score_level = scoreLevel; label.score_level = `成绩${scoreLevel}`; } if (/已就业|有工作|拿到offer|入职/.test(q)) { hit.employed = true; label.employed = '已就业'; } else if (/未就业|没就业|还没就业|待业/.test(q)) { hit.employed = false; label.employed = '未就业'; } for (const c of vocab.company) { if (q.includes(c)) { hit.company = c; label.company = `公司 ${c}`; break; } } return { hit, label }; } /** 按槽位过滤学生(派生条件会先查关联表) */ async function filterStudents(hit) { const snap = await assistant.snapshot(); let pool = snap.students; // 成绩等级 → 先取符合条件的成绩记录,再映射到学生 if (hit.score_level) { const r = await api.score.list({ page: 1, size: 100, score_level: hit.score_level }); const ids = new Set((r.items || []).map((s) => s.stu_id)); pool = pool.filter((s) => ids.has(s.id)); } // 就业状态 / 公司 if (hit.employed !== undefined || hit.company) { const p = { page: 1, size: 100 }; if (hit.company) p.emp_company = hit.company; const r = await api.emp.list(p); const ids = new Set((r.items || []) .filter((e) => (hit.employed === undefined ? true : (hit.employed ? !!e.emp_company : !e.emp_company))) .map((e) => e.stu_id)); pool = pool.filter((s) => ids.has(s.id)); } // 学生自身字段过滤 const selfKeys = ['state', 'gender', 'education', 'hometown', 'grad_school', 'major', 'cls_id', 'advisor_id']; for (const k of selfKeys) { if (hit[k] !== undefined) pool = pool.filter((s) => String(s[k] || '') === String(hit[k])); } return pool; } /** 解析并回答一个问题 */ async function answer(q, pushUser = true) { if (pushUser) pushMsg('user', esc(q)); const snap = await assistant.snapshot(); // 1) 编号直达 const idM = q.match(/([SCAT])[\s-]?(\d{2,4})/i); if (idM) { const id = (idM[1] + idM[2]).toUpperCase(); const prefix = id[0]; try { if (prefix === 'S') { const d = await api.stu.get(id); pushMsg('bot', `找到学生 ${esc(d.name)}(${esc(d.id)})
班级 ${esc(d.cls_id)} · ${esc(d.gender || '—')} · ${esc(d.state || '—')} · 籍贯 ${esc(d.hometown || '—')} 在学生列表中查看 →`); } else if (prefix === 'C') { const d = await api.cls.get(id); pushMsg('bot', `班级 ${esc(d.id)}:开课 ${esc(d.cls_start_date)},班主任 ${esc(d.head_teacher_name || d.head_tea_id)},主讲 ${esc(d.lecturer_name || d.lecturer_id)},在班 ${d.student_count} 人。
去班级管理 →`); } else if (prefix === 'T') { const d = await api.tea.get(id); pushMsg('bot', `教师 ${esc(d.name)}(${esc(d.id)}),电话 ${esc(d.phone)},职位 ${esc(d.type)}。
班主任班级:${(d.head_classes || []).join('、') || '无'}|主讲班级:${(d.lecturer_classes || []).join('、') || '无'}
去教师管理 →`); } else { const d = await api.adv.get(id); pushMsg('bot', `顾问 ${esc(d.name)}(${esc(d.id)}),电话 ${esc(d.phone)},负责 ${d.student_count} 名学生。
去顾问管理 →`); } return; } catch (e) { pushMsg('bot', `没有查到编号为 ${esc(id)} 的记录(${esc(e.message)})。可试试学号 S0001、班级 C001、教师 T001、顾问 A001。`); return; } } // 2) 条件筛选 const { hit, label } = extractSlots(q, snap); if (Object.keys(hit).length) { const list = await filterStudents(hit); const cond = Object.values(label).join(' · '); if (!list.length) { pushMsg('bot', `没有找到满足「${esc(cond)}」的学生。`); return; } const show = list.slice(0, 6).map((s) => `
  • ${esc(s.id)}${esc(s.name)} · ${esc(s.cls_id)}
  • `).join(''); const more = list.length > 6 ? `
  • 其余 ${list.length - 6} 人…
  • ` : ''; pushMsg('bot', `筛选条件「${esc(cond)}」命中 ${list.length} 名学生。 ${snap.truncated ? '注:数据超过 100 条,统计基于前 100 条。' : ''} 在学生列表中查看 →`); return; } // 3) 统计类问答 for (const rule of STAT_RULES) { if (rule.test(q)) { const r = await rule.run(); const rows = (r.rows || []).length ? `` : ''; const jump = r.jump ? `${esc(r.jump.label)} →` : ''; pushMsg('bot', `${r.html}${rows}${jump}`); return; } } // 4) 姓名匹配 const words = q.replace(/[的|学生|查一下|查询|查|找|是谁|信息|资料|情况|看看]/g, ' ').trim().split(/\s+/).filter((w) => w.length >= 2); for (const w of words) { const hitStu = snap.students.filter((s) => s.name.includes(w)); if (hitStu.length) { pushMsg('bot', `按姓名找到 ${hitStu.length} 人: 在学生列表中查看 →`); return; } } // 5) 兜底 pushMsg('bot', `暂时没听懂这句话。我目前支持:按学号/编号查详情、按条件筛学生(性别、学历、生源地、班级、成绩等级、就业状态)、以及就业率/平均分/分布类统计问题。`, true); } /** 取学生自身可映射到列表接口的筛选键(列表接口仅支持 name / cls_id / state) */ function supportedFilters(hit) { const out = {}; for (const k of ['name', 'cls_id', 'state']) if (hit[k] !== undefined) out[k] = hit[k]; return out; } /* ---------- 助手消息渲染 ---------- */ function pushMsg(role, html) { const box = $('#assistantBody'); const d = document.createElement('div'); d.className = `msg ${role}`; d.innerHTML = html; box.appendChild(d); box.scrollTop = box.scrollHeight; } function suggestChips() { const snap = assistant.snap; const chips = [ '在读的女生有多少人', '就业率是多少', '平均分是多少', '哪个生源地人最多', '成绩优秀的名单', '已就业的学生', ]; if (snap && snap.students.length) chips.push(`查学生 ${snap.students[0].id}`); if (snap && snap.classes.length) chips.push(`${snap.classes[0].id} 班有多少人`); return `
    ${chips.map((c) => ``).join('')}
    `; } async function initAssistant() { const box = $('#assistantBody'); box.innerHTML = `
    你好,我是查询小助手。
    直接用中文问我就行:查学号、筛学生、问统计。
    试试这些:
    正在准备示例…
    `; try { await assistant.snapshot(); const slot = $('#suggestSlot'); if (slot) slot.outerHTML = suggestChips(); } catch (e) { const slot = $('#suggestSlot'); if (slot) slot.textContent = '(后端未就绪,示例问句加载失败)'; } box.addEventListener('click', async (e) => { const sug = e.target.closest('[data-suggest]'); if (sug) { await answer(sug.dataset.suggest); return; } const jump = e.target.closest('[data-jump-route]'); if (!jump) return; // 助手内的跳转:切到目标视图,尽量带上筛选条件 const route = jump.dataset.jumpRoute; const fRaw = jump.dataset.jumpFilters; if (fRaw) { try { const st = stateOf(route); Object.assign(st.filters, JSON.parse(fRaw)); st.page = 1; } catch (err) { /* 忽略解析失败 */ } } const id = jump.dataset.jumpId; if (id) { // 带具体 ID 的跳转:切到目标视图后直接打开详情抽屉 await navigate(route); const r = RESOURCES[route]; if (r) return showDetail(route, { [r.idKey]: id }); } await navigate(route); // navigate 内部会同步地址栏,无需手动改 hash box.scrollTop = box.scrollHeight; }); $('#assistantForm').addEventListener('submit', async (e) => { e.preventDefault(); const input = $('#assistantInput'); const q = input.value.trim(); if (!q) return; input.value = ''; try { await answer(q); } catch (err) { pushMsg('bot', `出错了:${esc(err.message)}`); } }); } /* ===================== 七、路由与启动 ===================== */ const NAV = [ { group: '概览' }, { key: 'overview', label: '数据总览' }, { key: 'stats', label: '统计分析' }, { group: '基础数据' }, { key: 'students', label: '学生管理' }, { key: 'classes', label: '班级管理' }, { key: 'teachers', label: '教师管理' }, { key: 'advisors', label: '顾问管理' }, { group: '教学与就业' }, { key: 'scores', label: '成绩管理' }, { key: 'employ', label: '就业管理' }, ]; function renderNav(active) { $('#nav').innerHTML = NAV.map((n) => { if (n.group) return ``; return ``; }).join(''); } /** 当前激活的路由:作为单一数据源,避免与地址栏 hash 不同步 */ let activeRoute = 'overview'; /** 从地址栏 hash 解析出合法路由 */ function routeFromHash() { const key = location.hash.replace(/^#\/?/, '') || 'overview'; return (key === 'overview' || key === 'stats' || RESOURCES[key]) ? key : 'overview'; } /** 当前路由(供各操作读取,保证与实际渲染的视图一致) */ function currentRoute() { return activeRoute; } /** 按路由渲染主区,并同步地址栏 */ async function navigate(route) { activeRoute = route; // 同步地址栏;随后触发的 hashchange 会因路由相同而跳过,不会重复渲染 const target = `#/${route}`; if (location.hash !== target) location.hash = target; renderNav(route); if (route === 'overview') return renderOverview(); if (route === 'stats') return renderStats(); if (RESOURCES[route]) return renderResource(route); return renderOverview(); } /** 全局事件委托:导航、筛选、增删改查、分页 */ function bindEvents() { $('#nav').addEventListener('click', (e) => { const btn = e.target.closest('[data-route]'); if (btn) navigate(btn.dataset.route); }); $('#main').addEventListener('click', async (e) => { const btn = e.target.closest('[data-act]'); if (!btn) return; const act = btn.dataset.act; const route = currentRoute(); const r = RESOURCES[route]; const st = r ? stateOf(route) : null; if (act === 'refresh') { await loadSelects(); return navigate(route); } if (!r) return; if (act === 'search') { for (const el of document.querySelectorAll('[data-filter]')) st.filters[el.dataset.filter] = el.value.trim(); st.page = 1; return renderResource(route); } if (act === 'reset') { st.filters = {}; st.page = 1; return renderResource(route); } if (act === 'create') return openCreateForm(route); if (act === 'edit') return openEditForm(route, currentItems[Number(btn.dataset.idx)]); if (act === 'delete') return doDelete(route, currentItems[Number(btn.dataset.idx)]); if (act === 'detail') return showDetail(route, currentItems[Number(btn.dataset.idx)]); if (act === 'page') { const p = Number(btn.dataset.page); if (p >= 1) { st.page = p; return renderResource(route); } } }); // 分页大小 / 统计页班级过滤 $('#main').addEventListener('change', async (e) => { const el = e.target; if (el.dataset.act === 'size') { const st = stateOf(currentRoute()); st.size = Number(el.value); st.page = 1; return renderResource(currentRoute()); } if (el.dataset.act === 'stats-cls') { statsClsFilter = el.value; return renderStats(); } }); // 筛选栏输入框支持回车查询 $('#main').addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.dataset.filter !== undefined) { const route = currentRoute(); const st = stateOf(route); for (const el of document.querySelectorAll('[data-filter]')) st.filters[el.dataset.filter] = el.value.trim(); st.page = 1; renderResource(route); } }); // 助手栏收起 / 展开 $('#assistantToggle').addEventListener('click', () => { const layout = $('#layout'); const hidden = layout.classList.toggle('assistant-hidden'); layout.classList.toggle('assistant-open', !hidden); $('#assistantToggle').textContent = hidden ? '展开' : '收起'; }); // hash 变化(浏览器前进/后退或手改地址)时同步视图 window.addEventListener('hashchange', () => { const r = routeFromHash(); if (r !== activeRoute) navigate(r); }); } /** 启动 */ (async function boot() { bindEvents(); try { await loadSelects(); } catch (e) { toast(e.message, 'err'); } await initAssistant(); await navigate(currentRoute()); })();