"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 `
${esc((row[key]||"未").slice(0,1))}
${esc(row[key]||"未填写")}${esc(row.student_no||`ID ${row.sid||row.student_id||row.tid||row.id}`)}
`; if(key==="salary")return `${row.salary==null?"未填写":"¥ "+number(row.salary)}`; if(key.endsWith("time"))return `${esc(date(row[key]))}`; if(key==="score")return `${esc(row.score)} 分`; if(key==="exam_id")return `第 ${esc(row.exam_id)} 次考试`; if(key==="result")return `${row.score<60?"待提升":"已及格"}`; if(key==="progress")return `${row.offer_time?"已发 Offer":"跟进中"}`; if(key==="state")return `${esc(row.state||"未填写")}`; 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)=>`
${m[0]}${["◉","▤","✓","↗"][i]}
${number(m[1])}${m[2]}
${m[3]}
`).join(""); } function renderFilters(){ const module=state.module; let html=``; if(module==="scores")html=''; if(["students","scores"].includes(module))html+=''; if(module==="students")html+=''; if(module==="employment")html+=''; if(module==="teachers")html+=''; if(module==="scores")html+=''; if(module==="employment")html+=''; html+='
'; $("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=`正在读取${config.record}…`;$("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=`${config.columns.map(c=>`${c[1]}`).join("")}操作`; $("table-body").innerHTML=data.items.length?data.items.map((row,index)=>`${config.columns.map(c=>`${cell(row,c[0])}`).join("")}`).join(""):`暂时没有匹配的${config.record}试试调整筛选条件,或点击右上角新增。`; $("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=`加载失败,请点击刷新重试。`;} } 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='

正在准备表单…

';$("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=``; }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=``;} return ``;}).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_timebutton.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=`
${Object.entries(row).map(([key,value])=>`
${esc(labels[key]||key)}
${esc(key.endsWith("time")?date(value):value??"未填写")}
`).join("")}
`;$("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=`

${item.title} · ${data.total} 条

${data.truncated?'

此处展示前100条,可在相应模块查询更多。

':""}`;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=`${config.columns.map(c=>`${c[1]}`).join("")}`;$("stats-body").innerHTML=`正在计算…`;$("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=>`${config.columns.map(([key])=>`${esc(typeof row[key]==="number"?number(row[key]):key.endsWith("time")?date(row[key]):row[key]??"—")}`).join("")}`).join(""):`暂无符合条件的数据`; $("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 `
${number(r[metric])}
`;}).join("");} }catch(e){if(token!==state.statsRequest)return;error("page-error",e.message);$("stats-body").innerHTML=`统计加载失败,请刷新重试。`;} } $("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(); })();