187 lines
29 KiB
JavaScript
187 lines
29 KiB
JavaScript
"use strict";
|
||
// 页面目录即应用入口;本地 / 与反向代理 /wolin/ 共用同一套接口地址。
|
||
const WOLIN_API_BASE = new URL("api/studentsManagement", new URL(".", document.baseURI)).href;
|
||
(() => {
|
||
const $ = id => document.getElementById(id);
|
||
const esc = value => String(value ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||
const API = WOLIN_API_BASE;
|
||
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(offer).replace(" ","T")).getTime()<new Date(String(start).replace(" ","T")).getTime()){
|
||
error("edit-error","Offer 下发时间不能早于就业开放时间,请调整后再保存。");
|
||
$("field-offer_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();
|
||
})();
|