Files
wo_chovy/frontend/app.js
T
2026-09-21 19:49:26 +08:00

185 lines
29 KiB
JavaScript
Raw 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.
"use strict";
(() => {
const $ = id => document.getElementById(id);
const esc = value => String(value ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const API = "/api/studentsManagement";
const number = value => value == null ? "—" : Number(value).toLocaleString("zh-CN", {maximumFractionDigits:2});
const date = value => value ? String(value).replace("T", " ").slice(0,16) : "—";
const field = (key, label, type="text", required=false, extra={}) => ({key,label,type,required,...extra});
const fields = {
students:[field("student_no","学号","text",true,{max:50,immutable:true}),field("student_name","学生姓名","text",true,{max:50}),field("class_id","所属班级","classes",true),field("advisor_id","所属顾问","advisors",true),field("gender","性别","choice",false,{options:["男","女"]}),field("age","年龄","number",false,{min:1,max:150}),field("native_place","籍贯","text",false,{max:200}),field("school","毕业院校","text",false,{max:100}),field("major","专业","text",false,{max:50}),field("education","学历","text",false,{max:100}),field("enrollment_time","入学日期","date"),field("graduation_time","毕业日期","date"),field("state","学生状态","choice",false,{options:["在读","进入就业","已就业"]})],
classes:[field("class_name","班级名称","text",true,{max:10}),field("start_time","开班时间","datetime-local",true),field("head_teacher","班主任","text",true,{max:10}),field("teacher","任课教师","text",true,{max:15})],
teachers:[field("t_name","教师姓名","text",true,{max:20}),field("phone","手机号","tel",true,{max:11,pattern:"[0-9]{11}"}),field("subject","教学科目","text",false,{max:20}),field("entry_time","入职日期","date")],
advisors:[field("advisor_name","顾问姓名","text",true,{minlength:2,max:10}),field("phone","手机号","tel",false,{max:11,pattern:"[0-9]{11}"}),field("gender","性别","choice",false,{options:["男","女"]})],
scores:[field("student_id","学生","students",true,{immutable:true}),field("exam_id","考试次序","choice",true,{options:[1,2,3],numeric:true,immutable:true}),field("score","考试分数","number",true,{min:0,max:100})],
employment:[field("student_id","所属学生","students",true,{immutable:true}),field("company_name","就业公司","text",false,{max:100}),field("salary","就业薪资(元)","number",false,{min:0,step:"any"}),field("employment_start_time","就业开放时间","datetime-local"),field("offer_time","Offer 下发时间","datetime-local")]
};
const modules = {
students:{title:"学生管理",record:"学生",description:"从入学到就业,记录每位学生的成长轨迹。",id:"sid",name:"student_name",english:"STUDENT WORKSPACE",create:"/api/create/student",update:r=>`/api/update/student/${r.sid}`,remove:r=>`/api/delete/student/${r.sid}`,columns:[["student_name","学生"],["class_name","所属班级"],["advisor_name","顾问"],["gender","性别"],["age","年龄"],["school","毕业院校"],["state","状态"]]},
classes:{title:"班级管理",record:"班级",description:"有序组织教学,让每一个班级稳步向前。",id:"cid",name:"class_name",english:"CLASS WORKSPACE",create:"/add_class",update:r=>`/update_class/${r.cid}`,remove:r=>`/delete_class/${r.cid}`,columns:[["class_name","班级名称"],["cid","班级编号"],["start_time","开班时间"],["head_teacher","班主任"],["teacher","任课教师"]]},
teachers:{title:"教师管理",record:"教师",description:"汇聚教学力量,连接教师、班级与学生。",id:"tid",name:"t_name",english:"TEACHER WORKSPACE",create:"/add/teacher",update:r=>`/update/${r.tid}`,remove:r=>`/delete/${r.tid}`,columns:[["t_name","教师"],["phone","手机号"],["subject","教学科目"],["entry_time","入职日期"]]},
advisors:{title:"顾问管理",record:"顾问",description:"以细致的陪伴,支持学生的每一次选择。",id:"id",name:"advisor_name",english:"ADVISOR WORKSPACE",create:"/createAdvisor",update:r=>`/updateAdvisor/${r.id}`,remove:r=>`/changeAdvisorStatus/${r.id}/status`,columns:[["advisor_name","顾问"],["id","顾问编号"],["phone","手机号"],["gender","性别"]]},
scores:{title:"成绩管理",record:"成绩",description:"看见学习进展,为每一次进步留下记录。",id:"id",name:"student_name",english:"ACADEMIC WORKSPACE",create:"/score/addScore",update:r=>`/score/modifyScore/${r.student_id}`,remove:r=>`/score/removeScore/${r.student_id}/${r.exam_id}`,columns:[["student_name","学生"],["class_name","所属班级"],["exam_id","考试次序"],["score","分数"],["result","学习情况"]]},
employment:{title:"就业管理",record:"就业信息",description:"记录每一次成长,跟进每一份职业机会。",id:"id",name:"student_name",english:"EMPLOYMENT WORKSPACE",create:"/addEmployment",update:r=>`/modifyEmployment/${r.student_id}`,remove:r=>`/removeEmployment/${r.student_id}`,columns:[["student_name","学生"],["company_name","就业公司"],["salary","就业薪资"],["employment_start_time","就业开放时间"],["offer_time","Offer 下发时间"],["progress","就业进度"]]}
};
const labels = Object.fromEntries(Object.values(fields).flat().map(f=>[f.key,f.label]));
Object.assign(labels,{sid:"学生ID",cid:"班级ID",tid:"教师ID",id:"记录ID",class_name:"班级",advisor_name:"顾问",student_name:"学生姓名",student_no:"学号",duration_days:"就业时长(天)",value:"统计值"});
const state = {module:"employment",page:1,pages:1,rows:[],filters:[],overview:null,lookup:{},request:0,statsPage:1,statsPages:1,statsRequest:0,editing:null,deleting:null};
let toastTimer;
function toast(text){$("toast").textContent=text;$("toast").hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>$("toast").hidden=true,3200);}
function error(id,text){$(id).textContent=text || "";$(id).hidden=!text;}
async function request(path, options={}) {
const response=await fetch(API+path,{...options,headers:{"Content-Type":"application/json"},signal:AbortSignal.timeout(20000)});
let data;try{data=await response.json();}catch{throw new Error("服务返回格式异常,请检查后端是否正常运行。");}
if(!response.ok){let detail=data.detail;if(Array.isArray(detail))detail=detail.map(e=>`${labels[e.loc.at(-1)]||e.loc.at(-1)}:${e.msg}`).join(";");throw new Error(typeof detail==="string"?detail:"操作失败,请稍后重试。");}
return data;
}
const query = data => request("/workspace/query",{method:"POST",body:JSON.stringify(data)});
function cell(row,key){
if(key==="student_name"||key==="t_name"||key==="advisor_name")return `<div class="person"><span class="person-icon">${esc((row[key]||"未").slice(0,1))}</span><div><strong>${esc(row[key]||"未填写")}</strong><small>${esc(row.student_no||`ID ${row.sid||row.student_id||row.tid||row.id}`)}</small></div></div>`;
if(key==="salary")return `<span class="salary">${row.salary==null?"未填写":"¥ "+number(row.salary)}</span>`;
if(key.endsWith("time"))return `<span class="date">${esc(date(row[key]))}</span>`;
if(key==="score")return `<strong class="${row.score<60?"score-low":"score-good"}">${esc(row.score)} <small>分</small></strong>`;
if(key==="exam_id")return `第 ${esc(row.exam_id)} 次考试`;
if(key==="result")return `<span class="pill ${row.score<60?"pending":""}">${row.score<60?"待提升":"已及格"}</span>`;
if(key==="progress")return `<span class="pill ${row.offer_time?"":"pending"}">${row.offer_time?"已发 Offer":"跟进中"}</span>`;
if(key==="state")return `<span class="pill neutral">${esc(row.state||"未填写")}</span>`;
return esc(row[key]??"—");
}
async function loadOverview(){
try{state.overview=await request("/workspace/overview");$("connection").textContent="数据已连接";$("connection").className="connection online";renderMetrics();}
catch(e){$("connection").textContent="连接异常";$("connection").className="connection offline";error("page-error",e.message);}
}
function renderMetrics(){
const o=state.overview,c=o?.counts||{};
const metrics=state.module==="employment"?[["在册学生",c.students,"人","当前有效学生"],["就业记录",c.employment,"条","学生及就业记录均有效"],["已发 Offer",o?.offers,"人","已填写 Offer 下发时间"],["平均就业薪资",o?.average_salary,"元",`基于 ${o?.salary_samples??"—"} 条已填写薪资`]]:state.module==="scores"?[["在册学生",c.students,"人","当前有效学生"],["成绩记录",c.scores,"条","每次考试分别计数"],["有效班级",c.classes,"个","正在服务的班级"],["教学团队",c.teachers,"人","当前有效教师"]]:[["在册学生",c.students,"人","陪伴每一段成长"],["有效班级",c.classes,"个","班级教学有序开展"],["教学团队",c.teachers,"人","携手推动学习进步"],["服务顾问",c.advisors,"人","为学生提供成长支持"]];
$("data-metrics").innerHTML=metrics.map((m,i)=>`<article class="metric ${i===3?"accent":""}"><span class="metric-label">${m[0]}<span class="metric-icon">${["◉","▤","✓","↗"][i]}</span></span><div><strong>${number(m[1])}</strong><span class="unit">${m[2]}</span></div><small>${m[3]}</small></article>`).join("");
}
function renderFilters(){
const module=state.module;
let html=`<label>${module==="employment"?"公司名称":"查找"+modules[module].record}<input name="keyword" type="search" placeholder="${module==="employment"?"输入公司名称":"输入姓名或名称"}" maxlength="100"></label>`;
if(module==="scores")html='<label>学生姓名<input name="keyword" type="search" placeholder="输入学生姓名" maxlength="50"></label>';
if(["students","scores"].includes(module))html+='<label>班级名称<input name="class_name" placeholder="输入班级名称"></label>';
if(module==="students")html+='<label>学号<input name="student_no" placeholder="输入完整学号"></label>';
if(module==="employment")html+='<label>学生姓名<input name="student_name" placeholder="输入学生姓名"></label>';
if(module==="teachers")html+='<label>教学科目<input name="subject" placeholder="例如:Python"></label>';
if(module==="scores")html+='<label>考试次序<select name="exam_id"><option value="">全部考试</option><option value="1">第 1 次</option><option value="2">第 2 次</option><option value="3">第 3 次</option></select></label>';
if(module==="employment")html+='<label>薪资范围<span class="range-input"><input name="min" type="number" min="0" placeholder="最低" aria-label="最低薪资"><span>—</span><input name="max" type="number" min="0" placeholder="最高" aria-label="最高薪资"></span></label>';
html+='<div class="filter-actions"><button class="button primary" type="submit">查询</button><button class="button subtle" type="reset">重置</button></div>';
$("filter-form").innerHTML=html;$("filter-form").className="filter-bar "+(["classes","advisors"].includes(module)?"simple":module==="teachers"?"compact":"");
}
async function loadRows(){
const token=++state.request,module=state.module;if(!modules[module])return;
const config=modules[module];$("table-body").innerHTML=`<tr><td colspan="${config.columns.length+1}" class="empty">正在读取${config.record}…</td></tr>`;$("prev-page").disabled=$("next-page").disabled=true;error("page-error","");
try{
const data=await query({module,filters:state.filters,page:state.page,page_size:8});if(token!==state.request||module!==state.module)return;
if(!data.items.length&&state.page>data.pages){state.page=data.pages;return loadRows();}
state.rows=data.items;state.pages=data.pages;$("record-count").textContent=`${data.total} 条记录`;
$("table-head").innerHTML=`<tr>${config.columns.map(c=>`<th>${c[1]}</th>`).join("")}<th class="actions-heading">操作</th></tr>`;
$("table-body").innerHTML=data.items.length?data.items.map((row,index)=>`<tr>${config.columns.map(c=>`<td>${cell(row,c[0])}</td>`).join("")}<td class="row-actions"><button class="text-button" data-action="detail" data-row="${index}">详情</button><button class="text-button" data-action="edit" data-row="${index}">编辑</button><button class="text-button delete" data-action="delete" data-row="${index}">删除</button></td></tr>`).join(""):`<tr><td colspan="${config.columns.length+1}" class="empty"><strong>暂时没有匹配的${config.record}</strong>试试调整筛选条件,或点击右上角新增。</td></tr>`;
$("page-number").textContent=`${state.page} / ${data.pages}`;$("prev-page").disabled=state.page<=1;$("next-page").disabled=state.page>=data.pages;
$("list-summary").textContent=`共 ${data.total} 条有效记录 · 每页 8 条`;$("last-update").textContent=`更新于 ${new Date().toLocaleTimeString("zh-CN")}`;
}catch(e){if(token!==state.request)return;error("page-error",e.message);$("table-body").innerHTML=`<tr><td colspan="${config.columns.length+1}" class="empty">加载失败,请点击刷新重试。</td></tr>`;}
}
function route(){
const module=location.hash.slice(1)||"employment";state.module=modules[module]||["statistics","ai"].includes(module)?module:"employment";state.page=1;state.filters=[];state.request++;
const config=modules[state.module],isAI=state.module==="ai",isStats=state.module==="statistics";
document.querySelectorAll(".nav-item").forEach(a=>{a.classList.toggle("active",a.hash==="#"+state.module);if(a.hash==="#"+state.module)a.setAttribute("aria-current","page");else a.removeAttribute("aria-current");});
$("page-title").textContent=config?.title||(isAI?"AI 助手":"统计分析");$("crumb-current").textContent=$("page-title").textContent;document.title=$("page-title").textContent+" · 沃林学生管理系统";
$("page-description").textContent=config?.description||(isAI?"把问题交给助手,让真实数据给出答案。":"连接学习与就业,让每一个决定有据可依。");$("page-eyebrow").textContent=config?.english||(isAI?"YOUR INTELLIGENT COMPANION":"DATA INSIGHTS");
$("add-button").hidden=!config;$("module-view").hidden=!config;$("statistics-view").hidden=!isStats;$("ai-view").hidden=!isAI;$("data-metrics").hidden=isAI;error("page-error","");renderMetrics();
if(config){$("add-button").textContent="+ 新增"+config.record;$("list-title").textContent=config.record+"列表";renderFilters();loadRows();}if(isStats){state.statsPage=1;loadStats();}
}
$("filter-form").addEventListener("submit",event=>{
event.preventDefault();const values=Object.fromEntries(new FormData(event.target));const module=state.module;const filters=[];
if(values.min&&values.max&&Number(values.min)>Number(values.max)){error("page-error","最低薪资不能高于最高薪资。");return;}
for(const [key,raw] of Object.entries(values)){const value=raw.trim();if(!value)continue;let name=key,op="contains",v=value;
if(key==="keyword")name=module==="employment"?"company_name":modules[module].name;
if(["student_no","exam_id"].includes(key))op="eq";
if(key==="exam_id")v=Number(value);
if(key==="min"||key==="max"){name="salary";op=key==="min"?"ge":"le";v=Number(value);}
filters.push({field:name,op,value:v});}
state.filters=filters;state.page=1;loadRows();
});
$("filter-form").addEventListener("reset",()=>{state.filters=[];state.page=1;loadRows();});
$("refresh-button").onclick=()=>{loadRows();loadOverview();};$("prev-page").onclick=()=>{state.page--;loadRows();};$("next-page").onclick=()=>{state.page++;loadRows();};
async function lookup(module){
const rows=[];let page=1,pages=1;do{const data=await query({module,page,page_size:100});rows.push(...data.items);pages=data.pages;page++;}while(page<=pages);return rows;
}
async function openEdit(row=null){
const module=state.module,config=modules[module];state.editing={module,row};error("edit-error","");$("edit-title").textContent=(row?"编辑":"新增")+config.record;
$("edit-description").textContent=module==="employment"?"就业开放时间不能早于 Offer 下发时间,可与其相同。每位学生只能登记一条就业记录。"+(row?"编辑时留空表示保留原值。":"未确定的信息可留空。"):row&&module==="advisors"?"电话已脱敏;留空表示保留原手机号。":row&&module==="students"?"必填项标有 *;可选项留空表示保留原值。":"填写以下信息,标有 * 的项目为必填项。";
$("edit-fields").innerHTML='<p>正在准备表单…</p>';$("save-button").disabled=true;$("edit-dialog").showModal();
try{
const relations=[...new Set(fields[module].filter(f=>["students","classes","advisors"].includes(f.type)).map(f=>f.type))];
const results=await Promise.all(relations.map(async relation=>[relation,await lookup(relation)]));for(const [key,value] of results)state.lookup[key]=value;
if(!$("edit-dialog").open)return;
$("edit-fields").innerHTML=fields[module].map(f=>{
const value=row?.[f.key]??"";const disabled=!!row&&f.immutable;let control;
if(f.type==="choice"||relations.includes(f.type)){
let choices=f.type==="choice"?f.options.map(v=>[v,v]):state.lookup[f.type].map(r=>[r[modules[f.type].id],`${r[modules[f.type].name]} · ${r.student_no||r[modules[f.type].id]}`]);
if(value!==""&&!choices.some(c=>String(c[0])===String(value)))choices.push([value,`${value}(当前关联)`]);
control=`<select name="${f.key}" id="field-${f.key}" ${f.required?"required":""} ${disabled?"disabled":""}><option value="">请选择</option>${choices.map(([v,label])=>`<option value="${esc(v)}" ${String(value)===String(v)?"selected":""}>${esc(label)}</option>`).join("")}</select>`;
}else{const shown=module==="advisors"&&f.key==="phone"&&row?"":f.type==="date"?String(value).slice(0,10):f.type==="datetime-local"?String(value).replace(" ","T").slice(0,module==="employment"?19:16):value;
control=`<input name="${f.key}" id="field-${f.key}" type="${f.type}" value="${esc(shown)}" ${f.required?"required":""} ${disabled?"disabled":""} ${f.min!=null?`min="${f.min}"`:""} ${f.max!=null?(f.type==="number"?`max="${f.max}"`:`maxlength="${f.max}"`):""} ${f.minlength?`minlength="${f.minlength}"`:""} ${f.pattern?`pattern="${f.pattern}"`:""} step="${f.step||1}">`;}
return `<label>${f.label}${f.required?' <span class="required">*</span>':""}${control}</label>`;}).join("");
$("save-button").disabled=false;
}catch(e){error("edit-error",e.message);}
}
$("add-button").onclick=()=>openEdit();
$("edit-form").addEventListener("submit",async event=>{
event.preventDefault();if($("save-button").disabled)return;const {module,row}=state.editing,config=modules[module];const payload={};const values=Object.fromEntries(new FormData(event.target));
for(const f of fields[module]){if(row&&f.immutable){if(f.key!=="student_no")payload[f.key]=row[f.key];continue;}let v=values[f.key]?.trim();if(!v){if(!row)payload[f.key]=null;continue;}if(f.type==="number"||f.numeric||["students","classes","advisors"].includes(f.type))v=Number(v);payload[f.key]=v;}
if(module==="students")payload.flag=1;
if(payload.graduation_time&&payload.enrollment_time&&payload.graduation_time<payload.enrollment_time){error("edit-error","毕业日期不能早于入学日期。");return;}
if(module==="employment"){
// 更新接口对留空字段保留原值,校验本次保存后实际生效的两个时间。
const start=payload.employment_start_time??row?.employment_start_time;
const offer=payload.offer_time??row?.offer_time;
if(start&&offer&&new Date(String(start).replace(" ","T")).getTime()<new Date(String(offer).replace(" ","T")).getTime()){
error("edit-error","就业开放时间不能早于 Offer 下发时间,请调整后再保存。");
$("field-employment_start_time").focus();
return;
}
}
$("save-button").disabled=true;$("save-button").textContent="正在保存…";error("edit-error","");
try{await request(row?config.update(row):config.create,{method:row?"PUT":"POST",body:JSON.stringify(payload)});$("edit-dialog").close();toast("信息保存成功");await Promise.all([loadRows(),loadOverview()]);}
catch(e){error("edit-error",e.message);}finally{$("save-button").disabled=false;$("save-button").textContent="保存信息";}
});
document.querySelectorAll("[data-close]").forEach(button=>button.addEventListener("click",()=>$(button.dataset.close).close()));
$("table-body").addEventListener("click",event=>{const button=event.target.closest("[data-action]");if(!button)return;const row=state.rows[Number(button.dataset.row)];if(!row)return;
if(button.dataset.action==="edit")openEdit(row);else if(button.dataset.action==="detail")openDetail(row);else{state.deleting={module:state.module,row};$("delete-description").textContent=`即将删除${modules[state.module].record}:${row[modules[state.module].name]||"记录"}(ID ${row[modules[state.module].id]})。`;error("delete-error","");$("delete-dialog").showModal();}});
$("confirm-delete").onclick=async()=>{if($("confirm-delete").disabled)return;const {module,row}=state.deleting;$("confirm-delete").disabled=true;error("delete-error","");
try{await request(modules[module].remove(row),{method:module==="advisors"?"PUT":"DELETE",...(module==="advisors"?{body:JSON.stringify({flag:0})}:{})});$("delete-dialog").close();toast("记录已删除");await Promise.all([loadRows(),loadOverview()]);}catch(e){error("delete-error",e.message);}finally{$("confirm-delete").disabled=false;}};
async function openDetail(row){
const module=state.module;$("detail-title").textContent=modules[module].record+"详情";$("detail-content").innerHTML=`<dl class="detail-grid">${Object.entries(row).map(([key,value])=>`<div><dt>${esc(labels[key]||key)}</dt><dd>${esc(key.endsWith("time")?date(value):value??"未填写")}</dd></div>`).join("")}</dl><div id="related-content"></div>`;$("detail-dialog").showModal();
const related = module==="teachers"?[{module:"teacher_classes",filters:[{field:"tid",value:row.tid}],title:"关联班级"},{module:"teacher_students",filters:[{field:"tid",value:row.tid}],title:"关联学生"}]:module==="classes"?[{module:"students",filters:[{field:"class_id",value:row.cid}],title:"班级学生"}]:module==="advisors"?[{module:"students",filters:[{field:"advisor_id",value:row.id}],title:"名下学生"}]:module==="students"?[{module:"scores",filters:[{field:"student_id",value:row.sid}],title:"考试成绩"},{module:"employment",filters:[{field:"student_id",value:row.sid}],title:"就业记录"}]:[];
const container=$("related-content");
for(const item of related){try{const data=await query({...item,title:undefined,page_size:100});const block=document.createElement("section");block.className="related-section";block.innerHTML=`<h3 class="detail-subtitle">${item.title} · ${data.total} 条</h3><div class="related-list">${data.items.length?data.items.map(r=>`<span>${esc(item.module==="scores"?`第${r.exam_id}次:${r.score}分`:item.module==="employment"?`${r.company_name||"公司未填"} · ${number(r.salary)}元`:r.student_name||r.class_name)}</span>`).join(""):"暂无有效关联记录"}</div>${data.truncated?'<p class="field-hint">此处展示前100条,可在相应模块查询更多。</p>':""}`;container.append(block);}catch(e){const p=document.createElement("p");p.textContent=e.message;container.append(p);}}
}
const statsConfig={
gender:{query:{module:"students",group_by:["class_name","gender"],aggregate:"count"},columns:[["class_name","班级"],["gender","性别"],["value","学生人数"]],note:"按有效学生及其所属班级统计,未填写性别单独列出。"},
class_scores:{query:{module:"scores",group_by:["class_name","exam_id"],aggregate:"avg",aggregate_field:"score"},columns:[["class_name","班级"],["exam_id","考试次序"],["value","平均分"]],note:"按全部有效成绩计算平均值,仅包含有效学生。"},
score_rank:{query:{module:"scores",group_by:["student_id","student_name"],aggregate:"avg",aggregate_field:"score",sort_by:"value",descending:true},columns:[["student_name","学生"],["student_id","学生ID"],["value","平均分"]],note:"每位学生按已登记的有效考试成绩计算平均分,不将缺考视为0分。"},
fails:{query:{module:"scores",filters:[{field:"score",op:"lt",value:60}],group_by:["student_id","student_name"],aggregate:"count",sort_by:"value",descending:true},columns:[["student_name","学生"],["student_id","学生ID"],["value","不及格次数"]],note:"分数低于60视为不及格,仅计算有效成绩记录。"},
salary:{query:{module:"employment",filters:[{field:"salary",op:"ge",value:0}],sort_by:"salary",descending:true},columns:[["student_name","学生"],["class_name","班级"],["company_name","公司"],["salary","薪资(元)"]],note:"按有效就业记录中的非负薪资排名,空薪资不参与。"},
duration:{query:{module:"employment"},columns:[["student_name","学生"],["company_name","公司"],["employment_start_time","就业开放时间"],["offer_time","Offer时间"],["duration_days","就业时长(天)"]],note:"就业时长 = 就业开放时间 − Offer下发时间。日期缺失或先后顺序异常时显示“—”。"},
class_duration:{query:{module:"employment",group_by:["class_name"],aggregate:"avg",aggregate_field:"duration_days"},columns:[["class_name","班级"],["value","平均就业时长(天)"]],note:"按班级计算有效就业时长的平均值,空值及异常日期不参与。"},
teacher_classes:{query:{module:"teacher_classes"},columns:[["t_name","教师"],["tid","教师ID"],["class_name","关联班级"],["cid","班级ID"]],note:"来自教师与班级的实际绑定关系;尚未绑定时列表为空。"}
};
async function loadStats(){
const token=++state.statsRequest;const config=statsConfig[$("stats-kind").value];const body={...config.query,page:state.statsPage,page_size:10};if($("stats-kind").value==="fails")body.having_min=Number($("stats-threshold").value)||1;
$("stats-note").textContent=config.note;$("stats-head").innerHTML=`<tr>${config.columns.map(c=>`<th>${c[1]}</th>`).join("")}</tr>`;$("stats-body").innerHTML=`<tr><td colspan="${config.columns.length}" class="empty">正在计算…</td></tr>`;$("stats-chart").replaceChildren();$("stats-prev").disabled=$("stats-next").disabled=true;error("page-error","");
try{const data=await query(body);if(token!==state.statsRequest)return;state.statsPages=data.pages;
$("stats-body").innerHTML=data.items.length?data.items.map(row=>`<tr>${config.columns.map(([key])=>`<td>${esc(typeof row[key]==="number"?number(row[key]):key.endsWith("time")?date(row[key]):row[key]??"—")}</td>`).join("")}</tr>`).join(""):`<tr><td colspan="${config.columns.length}" class="empty">暂无符合条件的数据</td></tr>`;
$("stats-summary").textContent=`共 ${data.total} 条结果 · 统计基于全部符合条件的数据`;$("stats-page").textContent=`${state.statsPage} / ${data.pages}`;$("stats-prev").disabled=state.statsPage<=1;$("stats-next").disabled=state.statsPage>=data.pages;
const metric=config.query.aggregate?"value":$("stats-kind").value==="salary"?"salary":null;
if(metric){const max=Math.max(1,...data.items.map(r=>Number(r[metric])||0));$("stats-chart").innerHTML=data.items.slice(0,8).map(r=>{const label=config.columns.filter(c=>c[0]!==metric).slice(0,2).map(c=>r[c[0]]??"未填写").join(" · ");return `<div class="bar-line"><label title="${esc(label)}">${esc(label)}</label><div class="bar-track"><div class="bar-fill" style="width:${Math.max(0,(Number(r[metric])||0)/max*100)}%"></div></div><span class="bar-number">${number(r[metric])}</span></div>`;}).join("");}
}catch(e){if(token!==state.statsRequest)return;error("page-error",e.message);$("stats-body").innerHTML=`<tr><td colspan="${config.columns.length}" class="empty">统计加载失败,请刷新重试。</td></tr>`;}
}
$("stats-kind").onchange=()=>{$("stats-threshold-label").hidden=$("stats-kind").value!=="fails";state.statsPage=1;loadStats();};$("stats-form").onsubmit=e=>{e.preventDefault();state.statsPage=1;loadStats();};$("stats-refresh").onclick=loadStats;$("stats-prev").onclick=()=>{state.statsPage--;loadStats();};$("stats-next").onclick=()=>{state.statsPage++;loadStats();};
$("ask-module").onclick=()=>{const title=modules[state.module].title;location.hash="ai";$("ai-question").value=`请查询${title}的有效记录,并说明记录总数。`;$("ai-question").dispatchEvent(new Event("input"));};
async function checkAI(){try{const status=await request("/ai/status");const p=document.createElement("p");p.className="ai-config-status";p.textContent=status.configured?"助手已就绪 · 六大模块数据查询已接入":"助手尚未配置密钥 · 业务页面可正常使用";document.querySelector(".ai-help").prepend(p);}catch(e){error("page-error",e.message);}}
window.addEventListener("hashchange",route);route();loadOverview();checkAI();
})();