/** * 武校家校信息发布 · 宿舍通知模块 本地 Mock 数据 * (vite-plugin-mock,仅在 `npm run dev:mock`(--mode mock)下生效) * * 契约对齐 src/api/module/dorm.ts: * - /api/notice/{List,SendList,Detail,GetDetail,GetNextPrev,ReadDetail,Create,Edit,Delete, * InitializeRed,UserPull,SetTopmost,Expedite,ReceiveRooms,Statis, * Receipts,ReceiptDetail,Review,Remind,ExportReceipts, * MyNotices,MyNotice,SubmitReceipt,CancelReceipt} * - /api/dorm/{BuildingList,RoomList,AddBuilding,EditBuilding,DelBuilding, * AddRoom,EditRoom,DelRoom,RoomStudents, * PendingStudents,AssignStudent,RemoveStudent,TransferStudent,SetMonitor} * - /api/dict/{List,Sync,ManageList,Add,Update,Delete,Sort} * - /api/base/{GetUserInfo,GetUserMod,GetLayout,SetRole,GetSchoolAll,GetDormTree,FileUpload} * - /api/contacts/* /api/file/{UploadImage,Preview,Down} * 统一响应 {code,msg,data},由 alova-core 解包为 data(code==0)。 * * 武校寄宿制场景关键点: * 1. 收件人是「宿舍房间」而不是「班级」:同一班级的学生住不同楼栋房间, * 按班级下发会造成大量无关触达。receiveList = "ALL" 或 房间 id 逗号串; * 应收人数 / 已读人数按房间**入住床位数**(住宿生数)累加 —— 家长即挂在住宿生档案上的联系人。 * 2. 回执方式 receiptMode: * - none 仅通知(只统计已读,不产生回执记录,家长端「阅读即知悉」) * - confirm 需确认(家长端**打开通知即自动确认**,无需点击) * - fill 需填报(家长须按通知声明的 formFields 填报,如留宿起止、探视时间) * 3. 通知流程 noticeStatus:draft 草稿 → published 已发布 → closed 已截止 → finished 已结束。 * 4. 紧急程度 level:normal 普通 / important 重要 / urgent 紧急(家长端置顶并标红)。 * 5. 当前登录演示身份 = 德育处 王建山(wjs);家长端演示视角 = 1号宿舍楼 305 室的住宿生。 * 6. 房间人员(住宿生)只能通过「安排入住」从待安排池(/api/dorm/PendingStudents)调入, * 或经「退宿 / 床位缩减」退回池;usedBeds 恒等于花名册长度(单一数据源), * 不再由 usedBeds 反向凭空生成学生。宿舍长 isMonitor 显式落在花名册上,每间房至多 1 人。 */ /* ============================ 基础数据 ============================ */ /** 发布部门字典(校办 / 各处室 / 生活管理科) */ let deptDict = [ { SerID: 101, CodeID: "D001", Name: "校办", Enable: 1 }, { SerID: 102, CodeID: "D002", Name: "德育处", Enable: 1 }, { SerID: 103, CodeID: "D003", Name: "生活管理科", Enable: 1 }, { SerID: 104, CodeID: "D004", Name: "教务处", Enable: 1 }, { SerID: 105, CodeID: "D005", Name: "后勤保障部", Enable: 1 }, { SerID: 106, CodeID: "D006", Name: "校医务室", Enable: 1 }, { SerID: 107, CodeID: "D007", Name: "武术训练部", Enable: 1 }, { SerID: 108, CodeID: "D008", Name: "安全管理办公室", Enable: 1 }, ]; let deptSeq = 109; /** 通知类型字典(前端下拉与统计口径共用;发布时从「宿舍设置-通知类型」扩展) */ let noticeTypeDict = [ { SerID: 201, CodeID: "T001", Name: "作息调整", Enable: 1 }, { SerID: 202, CodeID: "T002", Name: "宿舍调整", Enable: 1 }, { SerID: 203, CodeID: "T003", Name: "卫生检查", Enable: 1 }, { SerID: 204, CodeID: "T004", Name: "违规通报", Enable: 1 }, { SerID: 205, CodeID: "T005", Name: "停水停电", Enable: 1 }, { SerID: 206, CodeID: "T006", Name: "安全排查", Enable: 1 }, { SerID: 207, CodeID: "T007", Name: "留宿申请", Enable: 1 }, { SerID: 208, CodeID: "T008", Name: "家长探视", Enable: 1 }, { SerID: 209, CodeID: "T009", Name: "夜间查寝", Enable: 1 }, { SerID: 210, CodeID: "T010", Name: "物品清退", Enable: 1 }, { SerID: 211, CodeID: "T011", Name: "疫情防控", Enable: 1 }, { SerID: 212, CodeID: "T012", Name: "假期安排", Enable: 1 }, { SerID: 213, CodeID: "T013", Name: "其他", Enable: 1 }, ]; let typeSeq = 214; let nextFileId = 9001; /** 当前登录用户(发布端·德育处) */ const ME = { account: "wjs", name: "王建山" }; /* ============================ 宿舍楼栋 / 房间 / 床位 ============================ */ interface BuildingSeed { buildingId: number; buildingName: string; short: string; gender: string; floors: number; roomsPerFloor: number; beds: number; supervisor: string; phone: string; } const buildingSeeds: BuildingSeed[] = [ { buildingId: 1, buildingName: "1号宿舍楼(男生)", short: "1号楼", gender: "男生", floors: 6, roomsPerFloor: 8, beds: 8, supervisor: "秦大力", phone: "13903710011" }, { buildingId: 2, buildingName: "2号宿舍楼(男生)", short: "2号楼", gender: "男生", floors: 5, roomsPerFloor: 8, beds: 8, supervisor: "贺明远", phone: "13903710022" }, { buildingId: 3, buildingName: "3号宿舍楼(女生)", short: "3号楼", gender: "女生", floors: 4, roomsPerFloor: 6, beds: 6, supervisor: "钱秀兰", phone: "13903710033" }, { buildingId: 4, buildingName: "4号宿舍楼(武术特长班)", short: "4号楼", gender: "男生", floors: 3, roomsPerFloor: 6, beds: 4, supervisor: "岑治平", phone: "13903710044" }, ]; /** 确定性伪随机(同种子稳定,刷新不跳变) */ function seededRand(seed: number) { const x = Math.sin(seed) * 10000; return x - Math.floor(x); } const buildings = buildingSeeds.map(b => ({ ...b })); /** roomId = buildingId * 10000 + floor * 100 + index,如 10305 = 1号楼 3层05房 */ let rooms: any[] = []; function buildRooms() { rooms = []; buildings.forEach(b => { for (let f = 1; f <= b.floors; f++) { for (let i = 1; i <= b.roomsPerFloor; i++) { const roomId = b.buildingId * 10000 + f * 100 + i; // 入住率 88%~100%(同种子稳定) const used = Math.max(1, Math.round(b.beds * (0.88 + seededRand(roomId) * 0.12))); rooms.push({ roomId, roomNo: `${f}${String(i).padStart(2, "0")}`, buildingId: b.buildingId, buildingName: b.buildingName, buildingShort: b.short, floor: f, bedCount: b.beds, usedBeds: Math.min(used, b.beds), supervisorName: `${b.supervisor}(${b.short}${f}层宿管)`, supervisorPhone: b.phone, }); } } }); } buildRooms(); const roomMap: Record = {}; rooms.forEach(r => (roomMap[String(r.roomId)] = r)); const ALL_ROOMS = Object.keys(roomMap); /** 全部宿舍床位数(接收对象为「全部宿舍」时的应读/应收人数上限) */ const ALL_BEDS = ALL_ROOMS.reduce((s, id) => s + roomMap[id].bedCount, 0); /** 全部已入住床位数 */ const ALL_USED_BEDS = ALL_ROOMS.reduce((s, id) => s + roomMap[id].usedBeds, 0); /** 楼栋 → 房间树(ztree 结构,供选房间弹窗使用;叶子带 bedCount / usedBeds) */ function buildDormTree() { return buildings.map(b => { const byFloor: Record = {}; rooms .filter(r => r.buildingId === b.buildingId) .forEach(r => { (byFloor[r.floor] = byFloor[r.floor] || []).push({ id: r.roomId, name: `${b.short} ${r.roomNo}(${r.usedBeds}/${r.bedCount} 床)`, isParent: false, bedCount: r.bedCount, usedBeds: r.usedBeds, supervisorName: r.supervisorName, }); }); return { id: b.buildingId, name: b.buildingName, isParent: true, children: Object.keys(byFloor) .map(Number) .sort((x, y) => x - y) .map(f => ({ id: b.buildingId * 10000 + f * 100, name: `${b.short} ${f} 层`, isParent: true, children: byFloor[f], })), }; }); } /* ============================ 住宿生花名册 ============================ */ const SURNAMES = ["王", "李", "张", "刘", "陈", "杨", "赵", "黄", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "何", "林", "高", "罗", "郑", "梁"]; /** 名字池按性别分开:安排入住要按性别校验楼栋,出现「周雨萱(男)」这类错配会让演示很假 */ const GIVEN_M = ["伟", "浩", "鑫", "磊", "洋", "帅", "杰", "涛", "明", "超", "子轩", "浩然", "一鸣", "思远", "俊熙", "振宇", "子豪", "天佑", "嘉豪", "立诚"]; const GIVEN_F = ["梓涵", "欣怡", "雨桐", "梦琪", "雨萱", "诗涵", "语彤", "佳怡", "思彤", "若曦", "欣然", "筱雨"]; /** 按性别取名(seed 稳定决定取池中哪一个) */ function givenName(seed: number, gender: string) { const pool = gender === "女" ? GIVEN_F : GIVEN_M; return pool[Math.abs(seed) % pool.length]; } /** 武校班级(住宿生按文化班 + 武术班混合编排) */ const CLASS_NAMES = ["三年级(1)班", "三年级(2)班", "四年级(1)班", "初一(1)班", "初一(2)班", "套路表演班", "散打竞技班", "拳击班"]; /** roomId → 住宿生列表(床位数即学生数,1 床 1 生) * isMonitor 显式落在数据上(默认首位为宿舍长,可由 /api/dorm/SetMonitor 改派), * 不再靠「数组第一位」隐式约定——否则家长端与回执单里无法标注宿舍长。 */ const roster: Record = {}; rooms.forEach((r, ri) => { const arr: any[] = []; for (let i = 0; i < r.usedBeds; i++) { const sid = r.roomId * 100 + i + 1; const gender = r.buildingName.includes("女生") ? "女" : "男"; arr.push({ studentId: sid, studentName: SURNAMES[(ri * 7 + i) % SURNAMES.length] + givenName(ri * 13 + i * 5, gender), gender, bedNo: `${i + 1}号床`, className: CLASS_NAMES[(ri + i) % CLASS_NAMES.length], guardianName: SURNAMES[(ri * 7 + i) % SURNAMES.length] + (gender === "女" ? "女士" : "先生"), guardianPhone: "13" + String(100000000 + Math.floor(seededRand(sid * 7) * 899999999)).slice(0, 9), relation: seededRand(sid * 11) > 0.55 ? "母亲" : "父亲", /** 宿舍长(每间房至多 1 人) */ isMonitor: i === 0, }); } roster[String(r.roomId)] = arr; }); /* ============================ 待安排床位学生池 ============================ */ /** * 「人员进房间」的唯一入口数据源:学籍在册但尚未分配床位、以及退宿退回的学生。 * 房间里的住宿生不再凭空产生 —— 只能通过 /api/dorm/AssignStudent 从本池调入, * 或经 /api/dorm/RemoveStudent、床位缩减退回本池。 * studentId 段位 900001+,与房内学生(roomId*100+i,最小 1010101)不冲突。 */ const PENDING_SOURCES = ["新生未分配床位", "退宿待重新安排", "楼栋调整待安置", "转学插班待安排"]; let pendingPool: any[] = []; (function seedPendingPool() { for (let i = 0; i < 46; i++) { const sid = 900001 + i * 37; const gender = seededRand(sid * 3) > 0.7 ? "女" : "男"; const surname = SURNAMES[(i * 5 + 3) % SURNAMES.length]; pendingPool.push({ studentId: sid, studentName: surname + givenName(i * 11 + 7, gender), gender, className: CLASS_NAMES[(i * 3) % CLASS_NAMES.length], guardianName: surname + (gender === "女" ? "女士" : "先生"), guardianPhone: "13" + String(100000000 + Math.floor(seededRand(sid * 13) * 899999999)).slice(0, 9), sourceDesc: PENDING_SOURCES[i % PENDING_SOURCES.length], }); } })(); /** 按性别校验楼栋是否可入住(混合楼栋不限制) */ function genderConflict(buildingName: string, gender: string): string | null { if (buildingName.includes("女生")) return gender === "女" ? null : "女生宿舍楼不能安排男生"; if (buildingName.includes("男生")) return gender === "男" ? null : "男生宿舍楼不能安排女生"; return null; } /** 退宿 / 床位缩减:把学生退回待安排池 */ function backToPool(stu: any, sourceDesc: string) { stu.isMonitor = false; pendingPool.unshift({ studentId: stu.studentId, studentName: stu.studentName, gender: stu.gender, className: stu.className, guardianName: stu.guardianName, guardianPhone: stu.guardianPhone, sourceDesc, }); } /** 床位号重排为 1..n 连续,避免退宿后出现空洞床位 */ function renumberBeds(list: any[]) { list.forEach((s, idx) => (s.bedNo = `${idx + 1}号床`)); if (list.length && !list.some(s => s.isMonitor)) list[0].isMonitor = true; } /* ============================ 枚举文案 ============================ */ const NOTICE_STATUS_DESC: Record = { draft: "草稿", published: "已发布", closed: "已截止", finished: "已结束" }; const RECEIPT_MODE_DESC: Record = { none: "仅通知", confirm: "需确认", fill: "需填报" }; const LEVEL_DESC: Record = { normal: "普通", important: "重要", urgent: "紧急" }; const RECEIPT_STATUS_DESC: Record = { none: "未回执", confirmed: "已确认", filled: "已填报", canceled: "已取消" }; /* ============================ 需填报类的表单字段声明 ============================ */ /** * 公共区字段:**所有单据完全一致**的部分(分区口径见 types/dorm.ts)。 * 家长端每次看到的都是这一套;管理端「回执结论」可跨通知合并统计。 * 学生 / 家长姓名 / 联系电话由表单固定项与回执顶层字段(guardianName / guardianPhone)承载, * 不在此重复声明。 */ const COMMON_FIELDS = [ { key: "conclusion", label: "回执结论", type: "select", required: true, group: "common", options: [ { label: "同意 / 确认办理", value: "同意" }, { label: "不同意 / 不参与", value: "不同意" }, { label: "需要电话沟通", value: "需沟通" }, ], }, ]; /** * 单据模板:**系统预置,发布人只能选类型、不能增删字段**。 * 之所以收紧成固定模板:回执没有审批下游,字段结构化的意义只在汇总; * 允许自由拼字段会让每条通知的表头各不相同,导出与统计无法合并口径。 */ const FORM_TEMPLATES: Record = { stay: { type: "stay", name: "留宿申请单", desc: "周末 / 假期学生留宿申请,需注明留宿起止与事由", fields: [ { key: "stayStart", label: "留宿开始日期", type: "date", required: true, group: "specific", placeholder: "选择留宿开始日期" }, { key: "stayEnd", label: "留宿结束日期", type: "date", required: true, group: "specific", placeholder: "选择留宿结束日期" }, { key: "stayCount", label: "留宿人数", type: "number", required: true, group: "specific", unit: "人" }, { key: "stayReason", label: "留宿事由", type: "select", required: true, group: "specific", options: [ { label: "家远不便往返", value: "家远不便往返" }, { label: "赛前集训", value: "赛前集训" }, { label: "伤病康复观察", value: "伤病康复观察" }, { label: "学业补差", value: "学业补差" }, { label: "其他(请在补充说明中写明)", value: "其他" }, ], }, ], }, visit: { type: "visit", name: "探视预约单", desc: "家长到校探视预约,需注明到访日期、时段与人数", fields: [ { key: "visitDate", label: "到访日期", type: "date", required: true, group: "specific", placeholder: "选择到访日期" }, { key: "visitSlot", label: "到访时段", type: "select", required: true, group: "specific", options: [ { label: "上午 09:00-11:30", value: "上午 09:00-11:30" }, { label: "下午 14:00-17:00", value: "下午 14:00-17:00" }, { label: "晚间 18:30-20:00", value: "晚间 18:30-20:00" }, ], }, { key: "visitorCount", label: "到访人数", type: "number", required: true, group: "specific", unit: "人" }, ], }, adjust: { type: "adjust", name: "调宿申请单", desc: "床位调整 / 宿舍调换确认,需注明可配合搬迁的日期", fields: [ { key: "moveDate", label: "可配合搬迁日期", type: "date", required: true, group: "specific", placeholder: "选择可配合搬迁的日期" }, { key: "adjustReason", label: "情况说明", type: "textarea", required: false, group: "specific", placeholder: "如孩子有腰伤需住下铺、需与同训练组同住、恐高等,请在此说明", }, ], }, general: { type: "general", name: "通用回执单", desc: "只需家长给出回执结论并说明情况,无额外登记项", fields: [], }, }; /** 单据模板列表(发布页选型 + 家长端区块展示共用) */ const FORM_TEMPLATE_LIST = ["stay", "visit", "adjust", "general"].map(k => FORM_TEMPLATES[k]); const FORM_TYPE_DESC: Record = { stay: "留宿申请单", visit: "探视预约单", adjust: "调宿申请单", general: "通用回执单" }; /** * 按单据类型拼出整张回执单:**公共区 + 专属区**。 * 通知上的 formFields 一律由后端据此生成,不接受前端上传的字段定义。 */ function buildFormFields(formType: string): any[] { const tpl = FORM_TEMPLATES[formType] || FORM_TEMPLATES.general; return [...COMMON_FIELDS, ...tpl.fields].map(f => ({ ...f, options: f.options ? f.options.map((o: any) => ({ ...o })) : undefined, })); } /* ============================ 通知数据 ============================ */ let notices = [ { id: 1, title: "【紧急】1号宿舍楼夜间作息时间调整(熄灯提前至 21:40)", noticeType: "作息调整", level: "urgent", deptId: "D003", deptName: "生活管理科", publisherAccount: "wjs", publisherName: "王建山", content: "

各位住宿生家长:

进入秋冬季节后天气转冷,为保证学生睡眠时长与次日训练状态,经校务会研究决定:自 10 月 8 日起,全校宿舍晚间熄灯时间由 22:20 提前至 21:40,早训起床时间不变(05:50)。

请家长配合督促孩子提前完成洗漱与作业,晚 21:20 后不再接打非紧急电话。宿舍内禁止使用大功率电器与明火取暖,违者按校规处理。

", effectiveTime: "2026-10-08 21:40:00", deadlineTime: null, receiptMode: "none", needSupervisor: 1, receiveList: "ALL", roomRead: [], files: "9001", topmost: 1, isPulled: 0, smsFlag: 1, timingSendTime: null, noticeStatus: "published", createTime: "2026-09-15 19:20:00", formType: null, formFields: null, }, { id: 2, title: "【紧急】3号宿舍楼东侧主水管抢修临时停水通知", noticeType: "停水停电", level: "urgent", deptId: "D005", deptName: "后勤保障部", publisherAccount: "czq", publisherName: "陈志强", content: "

各位家长:

3号宿舍楼东侧主水管出现渗漏,后勤保障部定于 9 月 19 日(周六)08:00 — 17:00 停水抢修,涉及 3号楼全部楼层。

学校已在各层配备应急储水桶与瓶装饮用水,洗漱用水由宿管统一安排至公共洗漱间。请家长提前告知孩子,无需送水到校。恢复供水后前 10 分钟请勿直接饮用。

", effectiveTime: "2026-09-19 08:00:00", deadlineTime: null, receiptMode: "none", needSupervisor: 1, receiveList: "30000", roomRead: ["30101", "30102", "30201"], files: null, topmost: 1, isPulled: 0, smsFlag: 1, timingSendTime: null, noticeStatus: "published", createTime: "2026-09-16 08:40:00", formType: null, formFields: null, }, { id: 3, title: "2026 年国庆假期学生留宿申请(需家长填报)", noticeType: "留宿申请", level: "important", deptId: "D002", deptName: "德育处", publisherAccount: "wjs", publisherName: "王建山", content: "

各位住宿生家长:

国庆假期(10 月 1 日 — 10 月 7 日)学校实行封楼管理,仅保留 1 号楼一层值班区域供留宿学生集中住宿,宿管与值班教练 24 小时在岗。

如孩子确需留宿,请家长于 9 月 28 日 18:00 前在家长端填写《留宿申请单》,写明留宿起止日期、留宿人数与事由。未提交申请的学生须按规定离校,学校不承担擅自留校期间的看护责任。

留宿期间每天安排 2 次宿舍点名(09:00 / 19:00),请家长保持电话畅通。

", effectiveTime: "2026-10-01 00:00:00", deadlineTime: "2026-09-28 18:00:00", receiptMode: "fill", needSupervisor: 1, receiveList: "ALL", roomRead: ["10305", "10101", "20103"], files: "9002", topmost: 1, isPulled: 0, smsFlag: 1, timingSendTime: null, noticeStatus: "published", createTime: "2026-09-14 15:30:00", formType: "stay", formFields: buildFormFields("stay"), }, { id: 4, title: "初一新生宿舍床位重排方案(需家长确认床位调整)", noticeType: "宿舍调整", level: "normal", deptId: "D003", deptName: "生活管理科", publisherAccount: "lhm", publisherName: "李慧敏", content: "

各位家长:

因初一新生入学后武术班重新编排,原按文化班分配的床位已不适应训练分组,生活管理科对 1 号楼 3 层、2 号楼 2 层的 12 个房间进行床位重排,同训练小组学生安排至同一房间,便于早晚训集合与教练查寝。

请家长确认是否接受床位调整,并选择可配合搬迁的日期(学校统一安排 9 月 26 日、27 日两批次搬迁,宿管协助搬运)。确有困难(如伤病、恐高上铺等)请在备注中说明,学校另行安排。

", effectiveTime: "2026-09-26 09:00:00", deadlineTime: "2026-09-25 12:00:00", receiptMode: "fill", needSupervisor: 1, receiveList: "10301,10302,10303,10304,10305,10306,20201,20202,20203,20204,20205,20206", roomRead: ["10301", "10303", "20201"], files: "9003", topmost: 0, isPulled: 0, smsFlag: 1, timingSendTime: null, noticeStatus: "published", createTime: "2026-09-12 10:15:00", formType: "adjust", formFields: buildFormFields("adjust"), }, { id: 5, title: "家长到校探视预约通知(需填报到访时间)", noticeType: "家长探视", level: "normal", deptId: "D002", deptName: "德育处", publisherAccount: "wjs", publisherName: "王建山", content: "

各位家长:

为兼顾学生训练秩序与家长探视需求,本学期探视实行预约制:每周六、周日 09:00 — 20:00 开放探视,请家长至少提前 1 天在家长端填写《探视预约单》,写明到访日期、时段、人数与与学生关系。

探视区域为宿舍楼一楼会客区与校园广场,请勿进入二楼以上住宿区;携带食品请交宿管登记,禁止携带烟酒、管制刀具及大功率电器。

当日到访请携带身份证,在校门与宿舍楼门禁处配合核验。

", effectiveTime: "2026-09-20 09:00:00", deadlineTime: "2026-12-31 18:00:00", receiptMode: "fill", needSupervisor: 1, receiveList: "ALL", roomRead: ["10302", "10305"], files: "9004", topmost: 0, isPulled: 0, smsFlag: 0, timingSendTime: null, noticeStatus: "published", createTime: "2026-09-10 14:00:00", formType: "visit", formFields: buildFormFields("visit"), }, { id: 6, title: "冬季宿舍用电与消防安全专项排查(需家长确认知悉)", noticeType: "安全排查", level: "important", deptId: "D008", deptName: "安全管理办公室", publisherAccount: "zp", publisherName: "赵鹏", content: "

各位家长:

入冬后宿舍用电负荷上升,学校将于 10 月 15 日 — 10 月 20 日开展宿舍用电与消防专项排查,重点检查:私拉电线、电热毯/电煮锅等违规电器、灭火器与烟感有效性、疏散通道畅通情况。

请家长配合教育孩子:宿舍内严禁使用大功率电器与明火,严禁在床上充电。排查中发现的违规物品由学校暂扣并通知家长领回。

请家长阅读后确认知悉(家长端打开本通知即视为已确认,无需点击)。

", effectiveTime: "2026-10-15 08:00:00", deadlineTime: "2026-10-14 20:00:00", receiptMode: "confirm", needSupervisor: 1, receiveList: "ALL", roomRead: ["10102", "10401", "30102", "40201"], files: null, topmost: 0, isPulled: 0, smsFlag: 0, timingSendTime: null, noticeStatus: "published", createTime: "2026-09-13 16:20:00", formType: null, formFields: null, }, { id: 7, title: "秋季学期第一轮宿舍卫生大检查结果通报", noticeType: "卫生检查", level: "normal", deptId: "D003", deptName: "生活管理科", publisherAccount: "lhm", publisherName: "李慧敏", content: "

各位家长:

9 月 5 日 — 9 月 7 日,生活管理科联合德育处对全校 130 间宿舍开展卫生大检查,评分维度包括地面清洁、床铺整理、物品摆放、通风与异味。

本次检查优秀宿舍 41 间、合格 78 间、不合格 11 间(不合格宿舍已由宿管现场指导整改并复查通过)。1 号楼 305、3 号楼 202、4 号楼 103 等 12 间宿舍获得「文明宿舍」流动红旗。

感谢各位家长对宿舍卫生工作的支持,请继续督促孩子保持个人物品整洁。

", effectiveTime: "2026-09-08 00:00:00", deadlineTime: null, receiptMode: "none", needSupervisor: 0, receiveList: "ALL", roomRead: ALL_ROOMS.slice(), files: "9005", topmost: 0, isPulled: 0, smsFlag: 0, timingSendTime: null, noticeStatus: "closed", createTime: "2026-09-08 09:00:00", formType: null, formFields: null, }, { id: 8, title: "军训期间夜间查寝值守安排(4号宿舍楼)", noticeType: "夜间查寝", level: "important", deptId: "D002", deptName: "德育处", publisherAccount: "wjs", publisherName: "王建山", content: "

各位家长:

新生军训期间(9 月 18 日 — 9 月 30 日),4 号宿舍楼实行夜间加密查寝:由宿管 + 值班教练每 2 小时巡查一次(22:00 / 00:00 / 02:00 / 04:00),重点核查在寝人数、身体状况与用电安全。

巡查结果每日 07:30 前同步至班主任。学生如有身体不适(中暑、肌肉拉伤等)可在巡查时报告,医务室安排次日就诊。

请家长知悉,如有特殊情况需临时离寝,须由家长电话向宿管报备。

", effectiveTime: "2026-09-18 22:00:00", deadlineTime: null, receiptMode: "none", needSupervisor: 1, receiveList: "40000", roomRead: ["40101", "40201"], files: null, topmost: 0, isPulled: 0, smsFlag: 0, timingSendTime: null, noticeStatus: "published", createTime: "2026-09-16 11:10:00", formType: null, formFields: null, }, { id: 9, title: "关于个别宿舍违规使用电煮锅的处理通报", noticeType: "违规通报", level: "normal", deptId: "D008", deptName: "安全管理办公室", publisherAccount: "zp", publisherName: "赵鹏", content: "

各位家长:

9 月 9 日夜间查寝中,2 号楼 401、402 房间发现使用电煮锅与热得快各 1 件,存在严重消防隐患。学校已暂扣相关物品,并对当事学生进行安全警示教育,记入学期宿舍管理考核。

请各位家长引以为戒,配合学校做好孩子的用电安全教育。本学期宿舍考核累计 3 次违规的,将取消其住宿资格,另行安排走读。

", effectiveTime: "2026-09-10 00:00:00", deadlineTime: null, receiptMode: "none", needSupervisor: 0, receiveList: "20401,20402,20403,20404,20405,20406,20407,20408", roomRead: ["20401", "20402", "20403", "20404", "20405", "20406", "20407", "20408"], files: null, topmost: 0, isPulled: 0, smsFlag: 0, timingSendTime: null, noticeStatus: "finished", createTime: "2026-09-10 10:30:00", formType: null, formFields: null, }, { id: 10, title: "学期末宿舍物品清退与寒假寄存安排(需家长确认)", noticeType: "物品清退", level: "important", deptId: "D003", deptName: "生活管理科", publisherAccount: "lhm", publisherName: "李慧敏", content: "

各位家长:

学期末宿舍将进行统一清整,请家长于 1 月 15 日前确认以下事项:

1. 需带回的行李由学生自行打包,宿管协助捆扎;
2. 被褥、脸盆等大件可寄存学校仓库(免费,凭寄存单领取),寄存时间至下学期开学后 15 日内;
3. 贵重物品(手机、平板、现金)一律不得寄存,请家长务必督促孩子随身带回。

逾期未清退的遗留物品,学校统一登记保管 30 日后按无主物品处理。请家长确认知悉(打开即视为已确认)。

", effectiveTime: "2027-01-10 08:00:00", deadlineTime: "2027-01-15 18:00:00", receiptMode: "confirm", needSupervisor: 1, receiveList: "ALL", roomRead: [], files: "9006", topmost: 0, isPulled: 0, smsFlag: 0, timingSendTime: null, noticeStatus: "published", createTime: "2026-09-16 17:45:00", formType: null, formFields: null, }, { id: 11, title: "流感高发期宿舍通风与消杀安排", noticeType: "疫情防控", level: "important", deptId: "D006", deptName: "校医务室", publisherAccount: "lhy", publisherName: "刘海燕", content: "

各位家长:

近期昼夜温差大,流感样病例增多。学校安排 10 月 9 日起每日对宿舍公共区域消杀 1 次,房间内每日通风不少于 3 次、每次 30 分钟(07:00 / 12:30 / 17:30 由宿管统一开窗)。

请家长为学生准备一件保暖外套与备用口罩;如孩子出现发热、咳嗽、咽痛,请及时告知班主任或宿管,医务室将安排隔离观察室并第一时间联系家长。

因病缺勤的学生返校须持医院复课证明。

", effectiveTime: "2026-10-09 00:00:00", deadlineTime: null, receiptMode: "none", needSupervisor: 1, receiveList: "ALL", roomRead: ["10305", "10306"], files: null, topmost: 0, isPulled: 0, smsFlag: 1, timingSendTime: null, noticeStatus: "published", createTime: "2026-09-16 09:25:00", formType: null, formFields: null, }, { id: 12, title: "暑假宿舍楼维修改造告知(不影响本学期住宿)", noticeType: "假期安排", level: "normal", deptId: "D005", deptName: "后勤保障部", publisherAccount: "czq", publisherName: "陈志强", content: "

各位家长:

今年暑假(7 月 15 日 — 8 月 20 日)学校对 1 号、2 号宿舍楼进行整体维修改造,内容含:卫生间翻新、墙面涂刷、上下水管道更换、加装公共饮水机与晾衣区。

改造已于 8 月 25 日通过验收,本学期住宿条件相应改善。改造期间给各位家长带来的不便,敬请谅解。

", effectiveTime: "2026-08-25 00:00:00", deadlineTime: null, receiptMode: "none", needSupervisor: 0, receiveList: "ALL", roomRead: ALL_ROOMS.slice(), files: "9007", topmost: 0, isPulled: 0, smsFlag: 0, timingSendTime: null, noticeStatus: "finished", createTime: "2026-08-26 10:00:00", formType: null, formFields: null, }, { id: 13, title: "春季学期「文明宿舍」评比活动方案(草稿)", noticeType: "卫生检查", level: "normal", deptId: "D003", deptName: "生活管理科", publisherAccount: "wjs", publisherName: "王建山", content: "

各位家长:

为营造整洁文明的宿舍环境,拟于春季学期开展「文明宿舍」评比,评分维度含卫生(40 分)、内务规范(30 分)、文化氛围(20 分)、安全守纪(10 分),每月评选一次,累计 3 次获评的宿舍推荐参加市级评比。

(方案与奖品预算尚在审核,暂未发布。)

", effectiveTime: "2027-03-01 00:00:00", deadlineTime: null, receiptMode: "none", needSupervisor: 0, receiveList: "ALL", roomRead: [], files: null, topmost: 0, isPulled: 0, smsFlag: 0, timingSendTime: null, noticeStatus: "draft", createTime: "2026-09-16 16:30:00", formType: null, formFields: null, }, { id: 14, title: "关于调整女生宿舍归寝时间的补充通知", noticeType: "作息调整", level: "normal", deptId: "D003", deptName: "生活管理科", publisherAccount: "lhm", publisherName: "李慧敏", content: "

各位家长:

经听取家长与学生意见,女生宿舍(3 号楼)晚自习后归寝时间由 21:00 延长至 21:15,熄灯时间与男生宿舍一致(21:40)。该项调整自 9 月 20 日起执行。

请家长督促孩子按时归寝,21:15 后楼门关闭,特殊情况须由家长电话向宿管说明。

", effectiveTime: "2026-09-20 00:00:00", deadlineTime: null, receiptMode: "none", needSupervisor: 1, receiveList: "30000", roomRead: ["30101"], files: null, topmost: 0, isPulled: 0, smsFlag: 0, timingSendTime: null, noticeStatus: "published", createTime: "2026-09-17 08:00:00", formType: null, formFields: null, }, ]; let noticeSeq = 15; const NOTICE_TYPE_NAMES = noticeTypeDict.map(t => t.Name); /* ============================ 回执 / 填报记录 ============================ */ /** 回执记录(住宿生 × 通知)。仅通知类不产生记录;草稿不产生记录。 */ const receipts: any[] = []; let receiptSeq = 1; function noticeOf(id: number) { return notices.find(n => n.id === id); } function receiptsOf(noticeId: number) { return receipts.filter(s => s.noticeId === noticeId); } /** 回执/填报进度派生统计 */ function progressOf(noticeId: number) { const list = receiptsOf(noticeId); const should = list.length; const confirmed = list.filter(s => s.status === "confirmed").length; const filled = list.filter(s => s.status === "filled").length; const canceled = list.filter(s => s.status === "canceled").length; const acted = confirmed + filled; const n = noticeOf(noticeId); return { should, confirmed, filled, canceled, unsigned: Math.max(should - acted - canceled, 0), receiptRate: should ? Math.round((acted / should) * 100) : 0, rooms: n ? targetRoomIds(n).length : 0, beds: n ? allowCountOf(n) : 0, }; } /** 生成某通知的回执记录(按通知状态给不同回执率) */ function seedReceiptsFor(n: any) { if (n.noticeStatus === "draft" || n.receiptMode === "none") return; if (receiptsOf(n.id).length) return; const rate = n.noticeStatus === "published" ? 0.68 : n.noticeStatus === "closed" ? 0.87 : 0.94; targetRoomIds(n).forEach((rid: string) => { const room = roomMap[rid]; if (!room) return; (roster[rid] || []).forEach((stu: any, si: number) => { const r = seededRand(n.id * 1000 + stu.studentId); const r2 = seededRand(stu.studentId * 3 + n.id); let status = "none"; if (r < rate) { // 已回执:需确认类只可能是「已确认」;需填报类一律「已填报」 status = n.receiptMode === "confirm" ? "confirmed" : "filled"; } else if (r > 0.965) { status = "canceled"; } const acted = status === "confirmed" || status === "filled"; receipts.push({ id: receiptSeq++, noticeId: n.id, studentId: stu.studentId, studentName: stu.studentName, roomId: room.roomId, roomNo: `${room.buildingShort} ${room.roomNo}`, buildingId: room.buildingId, buildingName: room.buildingName, floor: room.floor, bedNo: stu.bedNo, className: stu.className, guardianName: acted ? stu.guardianName : "", guardianPhone: acted ? stu.guardianPhone : "", relation: acted ? stu.relation : "", status, read: acted ? true : r2 < 0.8, readAt: null, submittedAt: acted ? `${String(n.createTime).slice(0, 8)}${String(8 + (si % 10)).padStart(2, "0")} ${9 + (si % 9)}:${String((si * 7) % 60).padStart(2, "0")}:00` : null, fillData: status === "filled" ? buildFillData(n, stu, r2) : null, remark: acted && r2 > 0.87 ? pickRemark(n, r2) : "", }); }); }); } /** * 按统一回执单的字段清单造一份合理的填报内容。 * 公共区 conclusion 每条必填,按比例撒出「同意 / 不同意 / 需沟通」三种结论, * 让回执页的结论列有真实分布可看。 */ function buildFillData(n: any, stu: any, r: number) { const out: Record = {}; const fields: any[] = n.formFields || []; fields.forEach(f => { if (f.key === "conclusion") out[f.key] = r > 0.18 ? "同意" : r > 0.06 ? "需沟通" : "不同意"; else if (f.key === "stayStart") out[f.key] = "2026-10-01"; else if (f.key === "stayEnd") out[f.key] = r > 0.6 ? "2026-10-07" : "2026-10-03"; else if (f.key === "stayCount") out[f.key] = "1"; else if (f.key === "stayReason") out[f.key] = (f.options?.[Math.floor(r * (f.options.length - 1))] || {}).value || "家远不便往返"; else if (f.key === "visitDate") out[f.key] = "2026-09-26"; else if (f.key === "visitSlot") out[f.key] = "上午 09:00-11:30"; else if (f.key === "visitorCount") out[f.key] = String(1 + Math.floor(r * 2)); else if (f.key === "moveDate") out[f.key] = "2026-09-26"; else if (f.key === "adjustReason") out[f.key] = r > 0.75 ? "孩子有轻度腰伤,希望安排下铺" : ""; else out[f.key] = ""; }); return out; } function pickRemark(n: any, r: number) { if (n.noticeType === "留宿申请") return "留宿期间需每日服用中药,请宿管代为保管并提醒"; if (n.noticeType === "家长探视") return "需接孩子外出就餐,18:00 前送回宿舍"; if (n.noticeType === "宿舍调整") return "孩子有轻度腰伤,希望安排下铺"; if (n.noticeType === "安全排查") return "已阅读并会配合学校排查"; return r > 0.95 ? "已知悉,感谢老师" : ""; } notices.forEach(n => seedReceiptsFor(n)); /** 家长端演示住宿生:1 号楼 305 室第一位学生 */ const ME_ROOM = "10305"; const DEMO_STUDENT = (() => { const stu = roster[ME_ROOM][0]; const room = roomMap[ME_ROOM]; return { ...stu, roomId: room.roomId, roomNo: room.roomNo, buildingName: room.buildingName, buildingShort: room.buildingShort, floor: room.floor }; })(); /** * 演示便利:固定让演示学生的「待回执」里有三种回执形态各一条—— * 一条「需填报」(留宿申请 / 探视预约)、一条「需确认」(打开即自动确认)。 * 否则随机种子可能导致回执流程演示不到。 */ [3, 5, 6].forEach(id => { receipts .filter(s => s.noticeId === id && s.studentId === DEMO_STUDENT.studentId) .forEach(s => { s.status = "none"; s.fillData = null; s.submittedAt = null; s.guardianName = ""; s.guardianPhone = ""; s.relation = ""; s.remark = ""; s.read = false; s.readAt = null; }); }); // 通知 4(宿舍调整·需填报)落在演示学生所在房间,确保「已填报」形态也能看到 const demoAdjust = receipts.find(s => s.noticeId === 4 && s.studentId === DEMO_STUDENT.studentId); if (demoAdjust) { demoAdjust.status = "filled"; demoAdjust.fillData = { conclusion: "同意", moveDate: "2026-09-26" }; demoAdjust.guardianName = DEMO_STUDENT.guardianName; demoAdjust.guardianPhone = DEMO_STUDENT.guardianPhone; demoAdjust.relation = DEMO_STUDENT.relation; demoAdjust.submittedAt = "2026-09-13 20:12:00"; demoAdjust.read = true; } /* ============================ 附件 / 联系组 / 组织树 ============================ */ const filesStore: Record = { 9001: { id: 9001, filename: "宿舍作息时间表(秋冬).pdf", fileTrueName: "宿舍作息时间表(秋冬).pdf", size: 312320 }, 9002: { id: 9002, filename: "国庆留宿申请表(模板).docx", fileTrueName: "国庆留宿申请表(模板).docx", size: 28672 }, 9003: { id: 9003, filename: "初一新生床位重排方案.xlsx", fileTrueName: "初一新生床位重排方案.xlsx", size: 41984 }, 9004: { id: 9004, filename: "校园探视管理规定.pdf", fileTrueName: "校园探视管理规定.pdf", size: 542720 }, 9005: { id: 9005, filename: "宿舍卫生检查评分细则.pdf", fileTrueName: "宿舍卫生检查评分细则.pdf", size: 233472 }, 9006: { id: 9006, filename: "学期末物品寄存单.docx", fileTrueName: "学期末物品寄存单.docx", size: 22528 }, 9007: { id: 9007, filename: "宿舍楼维修改造验收报告.pdf", fileTrueName: "宿舍楼维修改造验收报告.pdf", size: 1048576 }, }; const groups = [ { serid: 1, GroupName: "全体宿管员", members: [12, 13, 14, 15] }, { serid: 2, GroupName: "生活老师(1-2号楼)", members: [12, 13, 16] }, { serid: 3, GroupName: "夜间值班教练组", members: [1, 17, 18] }, ]; let groupSeq = 4; const idNames: Record = { 1: "王建山", 2: "李慧敏", 3: "张振武", 4: "刘海燕", 5: "陈志强", 6: "赵鹏", 7: "孙丽", 8: "周文博", 9: "吴桂芳", 10: "郑小龙", 11: "马俊", 12: "秦大力", 13: "贺明远", 14: "钱秀兰", 15: "岑治平", 16: "许春燕", 17: "邢铁山", 18: "屈子昂", }; /** 教师组织树(宿舍设置-自定义组选人在用;发布对象为宿舍房间树) */ const schoolTree = [ { id: 301, name: "校办", isParent: true, children: [{ id: 7, name: "孙丽", isParent: false }, { id: 9, name: "吴桂芳", isParent: false }] }, { id: 302, name: "德育处", isParent: true, children: [{ id: 1, name: "王建山", isParent: false }, { id: 8, name: "周文博", isParent: false }] }, { id: 303, name: "生活管理科", isParent: true, children: [{ id: 2, name: "李慧敏", isParent: false }, { id: 12, name: "秦大力", isParent: false }, { id: 13, name: "贺明远", isParent: false }] }, { id: 304, name: "武术训练部", isParent: true, children: [{ id: 3, name: "张振武", isParent: false }, { id: 17, name: "邢铁山", isParent: false }, { id: 18, name: "屈子昂", isParent: false }] }, { id: 305, name: "后勤保障部", isParent: true, children: [{ id: 5, name: "陈志强", isParent: false }] }, { id: 306, name: "校医务室", isParent: true, children: [{ id: 4, name: "刘海燕", isParent: false }] }, { id: 307, name: "安全管理办公室", isParent: true, children: [{ id: 6, name: "赵鹏", isParent: false }] }, ]; /* ============================ 工具 ============================ */ function ok(data: any) { return { code: 0, msg: "操作成功", data }; } function fail(msg: string, code = 4) { return { code, msg, data: null }; } function paginate(content: any[], pageIndex: number, pageSize: number) { const start = (pageIndex - 1) * pageSize; const totalPages = pageSize ? Math.ceil(content.length / pageSize) : 0; return { content: content.slice(start, start + pageSize), total: content.length, pageIndex, pageSize, totalPages, hasNext: pageIndex < totalPages }; } function parseBody(body: any): any { if (body == null) return {}; if (typeof body === "object") return body; const str = String(body).trim(); if (!str) return {}; if (str.startsWith("{") || str.startsWith("[")) { try { return JSON.parse(str); } catch { /* fallthrough */ } } const out: Record = {}; new URLSearchParams(str).forEach((v, k) => (out[k] = v)); return out; } /** 合并取参:query(postForm 已把参数拼入 URL)+ JSON body(Create/Edit/Delete/SubmitReceipt/Review) */ function bodyOf(rawBody: any, query: any): any { const parsed = parseBody(rawBody); if (Array.isArray(parsed)) return parsed; return { ...(query || {}), ...(parsed || {}) }; } /** * 接收房间 id 数组。 * 支持三种写法(与服务端约定一致): * - "ALL" 全部宿舍 * - "30000" 楼栋前缀(xx000)→ 该楼栋全部房间 * - "10301,10305" 具体房间 */ function targetRoomIds(n: any): string[] { const raw = String(n.receiveList ?? "").trim(); if (/^all$/i.test(raw)) return ALL_ROOMS; const out = new Set(); raw.split(",") .map(s => s.trim()) .filter(Boolean) .forEach(id => { if (/^\d+0000$/.test(id) || /^\d0000$/.test(id)) { // 楼栋前缀:如 30000 → buildingId=3 const bid = Math.floor(Number(id) / 10000); ALL_ROOMS.filter(rid => Math.floor(Number(rid) / 10000) === bid).forEach(r => out.add(r)); } else if (roomMap[id]) { out.add(id); } else if (/^\d{5}$/.test(id) && id.endsWith("00")) { // 楼层前缀:如 10300 → 1号楼3层 ALL_ROOMS.filter(rid => String(rid).slice(0, 3) === id.slice(0, 3)).forEach(r => out.add(r)); } }); return Array.from(out); } function isAllTarget(n: any): boolean { const raw = String(n.receiveList ?? "").trim(); return /^all$/i.test(raw) || targetRoomIds(n).length === ALL_ROOMS.length; } /** 应收/应读人数:覆盖房间的**入住床位数**(住宿生数)累加 */ function allowCountOf(n: any): number { if (/^all$/i.test(String(n.receiveList ?? "").trim())) return ALL_USED_BEDS; return targetRoomIds(n).reduce((s, id) => s + (roomMap[id]?.usedBeds || 0), 0); } function readCountOf(n: any): number { const targets = targetRoomIds(n); return (n.roomRead || []).filter((id: string) => targets.includes(id)).reduce((s: number, id: string) => s + (roomMap[id]?.usedBeds || 0), 0); } /** 接收对象可读文本:全部宿舍 / 「楼栋 房间」顿号串(超 12 间折叠) */ function receiveText(n: any): string { if (isAllTarget(n)) return `全部宿舍(${ALL_ROOMS.length} 间 / ${ALL_USED_BEDS} 个床位)`; const ids = targetRoomIds(n); // 整楼覆盖时折叠为楼栋名 for (const b of buildings) { const inB = ids.filter(id => roomMap[id]?.buildingId === b.buildingId); if (inB.length === ALL_ROOMS.filter(id => roomMap[id].buildingId === b.buildingId).length) { return `${b.buildingName}(${inB.length} 间)`; } } const names = ids.map(id => `${roomMap[id]?.buildingShort || ""} ${roomMap[id]?.roomNo || id}`); if (names.length > 12) return `${names.slice(0, 12).join("、")} 等 ${names.length} 间`; return names.join("、"); } /** 当前视角房间(家长端为演示学生所在房间;发布端不参与已读判定) */ let viewerRole: "teacher" | "parent" = "teacher"; function viewerRoom(): string | null { return viewerRole === "parent" ? ME_ROOM : null; } function isReadByMe(n: any): boolean { const c = viewerRoom(); return c ? (n.roomRead || []).includes(c) : true; } /** 是否已发布(家长端可见) */ function isPublished(n: any): boolean { return !n.isPulled && n.noticeStatus !== "draft"; } /** 定时未到的通知,家长端不展示 */ function isScheduledFuture(n: any): boolean { return !!(n.timingSendTime && n.timingSendTime > "2026-09-26"); } function toVo(n: any) { const progress = n.noticeStatus === "draft" ? null : progressOf(n.id); return { id: n.id, title: n.title, content: n.content, noticeType: n.noticeType, level: n.level, levelDesc: LEVEL_DESC[n.level] || "普通", effectiveTime: n.effectiveTime, deadlineTime: n.deadlineTime, receiptMode: n.receiptMode, receiptModeDesc: RECEIPT_MODE_DESC[n.receiptMode] || "仅通知", publisherAccount: n.publisherAccount, publisherName: n.publisherName, deptId: n.deptId, deptName: n.deptName, needSupervisor: n.needSupervisor, status: isReadByMe(n) ? 2 : 1, statusDesc: isReadByMe(n) ? "已读" : "未读", noticeStatus: n.noticeStatus, noticeStatusDesc: NOTICE_STATUS_DESC[n.noticeStatus] || "已发布", topmost: n.topmost, readCount: readCountOf(n), allowCount: allowCountOf(n), read: isReadByMe(n), isPulled: n.isPulled, timingSendTime: n.timingSendTime, smsFlag: n.smsFlag, receiveType: "room", receiveList: n.receiveList, receiveText: receiveText(n), roomCount: targetRoomIds(n).length, bedCount: allowCountOf(n), progress, createBy: n.publisherAccount, createTime: n.createTime, updateBy: n.publisherAccount, updateTime: n.createTime, UpFile: n.files, }; } function fileItems(n: any) { return String(n.files || "") .split(",") .map(Number) .filter(Boolean) .map(id => { const f = filesStore[id]; return { id, name: f?.fileTrueName || "附件", size: f?.size || 0, fileid: id, filename: f?.filename || "" }; }); } function toDetail(n: any) { const progress = n.noticeStatus === "draft" ? null : progressOf(n.id); return { serid: n.id, title: n.title, Contents: n.content, DeptID: n.deptId, DeptName: n.deptName, TeaName: n.publisherName, EditDate: n.createTime, numcount: allowCountOf(n), readnumcount: readCountOf(n), ReceiveList: n.receiveList, DsfsTime: n.timingSendTime, isDsfs: !!n.timingSendTime, noticeType: n.noticeType, level: n.level, levelDesc: LEVEL_DESC[n.level] || "普通", effectiveTime: n.effectiveTime, deadlineTime: n.deadlineTime, receiptMode: n.receiptMode, receiptModeDesc: RECEIPT_MODE_DESC[n.receiptMode] || "仅通知", needSupervisor: n.needSupervisor, noticeStatus: n.noticeStatus, noticeStatusDesc: NOTICE_STATUS_DESC[n.noticeStatus] || "已发布", progress, ChaoSend: fileItems(n), tlist: /^all$/i.test(String(n.receiveList).trim()) ? "all" : targetRoomIds(n), receiveType: "room", receiveText: receiveText(n), formType: n.formType || null, formTypeDesc: n.formType ? FORM_TYPE_DESC[n.formType] || "通用回执单" : "", formFields: n.formFields || null, }; } function sortByTimeDesc(list: any[]) { // 紧急 > 置顶 > 时间倒序 const levelWeight = (l: string) => (l === "urgent" ? 2 : l === "important" ? 1 : 0); return [...list].sort((a, b) => { if (levelWeight(a.level) !== levelWeight(b.level)) return levelWeight(b.level) - levelWeight(a.level); if (a.topmost !== b.topmost) return b.topmost - a.topmost; return b.createTime > a.createTime ? 1 : -1; }); } function applyFilters(list: any[], query: any) { let out = [...list]; const kw = query.keyword || query.Name; if (kw) { out = out.filter( n => n.title.includes(kw) || n.noticeType.includes(kw) || n.publisherName.includes(kw) || n.deptName.includes(kw) || receiveText(n).includes(kw) ); } const dept = query.deptId || query.Depttype; if (dept) out = out.filter(n => n.deptId === dept); if (query.noticeType) out = out.filter(n => n.noticeType === query.noticeType); if (query.level && query.level !== "all") out = out.filter(n => n.level === query.level); if (query.buildingId) { const bid = Number(query.buildingId); out = out.filter(n => targetRoomIds(n).some(id => (roomMap[id]?.buildingId || 0) === bid)); } if (query.noticeStatus && query.noticeStatus !== "all") out = out.filter(n => n.noticeStatus === query.noticeStatus); const st = query.startTime || query.starttime; const et = query.endTime || query.endtime; if (st) out = out.filter(n => String(n.effectiveTime || n.createTime).slice(0, 10) >= String(st).slice(0, 10)); if (et) out = out.filter(n => String(n.effectiveTime || n.createTime).slice(0, 10) <= String(et).slice(0, 10)); const rt = Number(query.readType || 0); if (rt === 1) out = out.filter(n => !isReadByMe(n)); if (rt === 2) out = out.filter(n => isReadByMe(n)); return out; } function nowStr() { const now = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; } /** 家长端通知的「我的回执」态 */ function toParentVo(n: any) { const my = receipts.find(s => s.noticeId === n.id && s.studentId === DEMO_STUDENT.studentId) || null; const deadlinePassed = !!n.deadlineTime && n.deadlineTime < nowStr(); const canReceipt = n.noticeStatus === "published" && !deadlinePassed; return { id: n.id, title: n.title, noticeType: n.noticeType, level: n.level, levelDesc: LEVEL_DESC[n.level] || "普通", effectiveTime: n.effectiveTime, deadlineTime: n.deadlineTime, receiptMode: n.receiptMode, receiptModeDesc: RECEIPT_MODE_DESC[n.receiptMode] || "仅通知", content: n.content, publisherName: n.publisherName, deptName: n.deptName, roomNo: DEMO_STUDENT.roomNo, buildingName: DEMO_STUDENT.buildingName, bedNo: DEMO_STUDENT.bedNo, createTime: n.createTime, noticeStatus: n.noticeStatus, noticeStatusDesc: NOTICE_STATUS_DESC[n.noticeStatus] || "已发布", viewOnly: n.receiptMode === "none", canReceipt: !!canReceipt, my: my ? { receiptId: my.id, status: my.status, statusDesc: RECEIPT_STATUS_DESC[my.status] || my.status, fillData: my.fillData, guardianName: my.guardianName, guardianPhone: my.guardianPhone, relation: my.relation, remark: my.remark, submittedAt: my.submittedAt, } : null, }; } /** 单条回执在名单里的展示形态 */ function toReceiptBrief(s: any) { return { receiptId: s.id, studentId: s.studentId, studentName: s.studentName, roomNo: s.roomNo, buildingName: s.buildingName, bedNo: s.bedNo, guardianName: s.guardianName, guardianPhone: s.guardianPhone, relation: s.relation, status: s.status, statusDesc: RECEIPT_STATUS_DESC[s.status] || s.status, hasReceipt: s.status === "confirmed" || s.status === "filled", submittedAt: s.submittedAt, fillData: s.fillData, remark: s.remark, }; } /* ============================ Mock 定义 ============================ */ export default [ /* ---------- 布局 / 用户 ---------- */ { url: "/api/base/GetUserInfo", method: "get", response: () => viewerRole === "teacher" ? ok({ id: 186532, account: ME.account, name: ME.name, role: "teacher", profilePicture: "", school: { id: 3260, name: "少林文武学校" } }) : ok({ id: DEMO_STUDENT.studentId, account: "stu" + DEMO_STUDENT.studentId, name: DEMO_STUDENT.studentName, role: "parent", studentId: DEMO_STUDENT.studentId, studentName: DEMO_STUDENT.studentName, roomId: DEMO_STUDENT.roomId, roomNo: DEMO_STUDENT.roomNo, buildingName: DEMO_STUDENT.buildingName, bedNo: DEMO_STUDENT.bedNo, profilePicture: "", school: { id: 3260, name: "少林文武学校" }, }), }, { url: "/api/base/GetUserMod", method: "get", response: () => ok( viewerRole === "parent" ? [ { id: 31, modId: 31, name: "宿舍通知", parentId: 0, page: "/par/notices", orderNumber: 1, usable: true, children: [] }, { id: 32, modId: 32, name: "我的回执", parentId: 0, page: "/par/mine", orderNumber: 2, usable: true, children: [] }, { id: 2, modId: 2, name: "发布端(教师)", parentId: 0, page: "/list", orderNumber: 9, usable: true, children: [] }, ] : [ { id: 2, modId: 2, name: "我发布的", parentId: 0, page: "/list", orderNumber: 1, usable: true, children: [] }, { id: 3, modId: 3, name: "全部通知", parentId: 0, page: "/noticelist", orderNumber: 2, usable: true, children: [] }, { id: 25, modId: 25, name: "回执情况", parentId: 0, page: "/receipts", orderNumber: 3, usable: true, children: [] }, { id: 4, modId: 4, name: "通知统计", parentId: 0, page: "/stats", orderNumber: 4, usable: true, children: [] }, { id: 21, modId: 21, name: "宿舍设置", parentId: 0, page: "/setting", orderNumber: 5, usable: true, children: [] }, { id: 31, modId: 31, name: "家长端预览", parentId: 0, page: "/par/notices", orderNumber: 8, usable: true, children: [] }, ] ), }, { url: "/api/base/GetLayout", method: "get", response: () => ok({ ssoUrl: "", username: viewerRole === "teacher" ? ME.name : DEMO_STUDENT.studentName, pathList: [], topName: "宿舍通知", pageUrl: "", logoUrl: "", initUrl: "", id: 0, avatar: "", }), }, { // 端分离入口:按 URL 前缀切换当前视角(teacher 发布端 / parent 家长端) url: "/api/base/SetRole", method: "post", response: ({ query, body }: any) => { const as = String((bodyOf(body, query) || {}).as || query.as || "teacher"); viewerRole = as === "parent" ? "parent" : "teacher"; return ok({ role: viewerRole }); }, }, { url: "/api/base/GetSchoolAll", method: "get", response: () => ok(schoolTree) }, { // 楼栋 → 楼层 → 房间树(通知发布对象选择:到房间一级) url: "/api/base/GetDormTree", method: "get", response: () => ok(buildDormTree()), }, /* ---------- 宿舍楼栋 / 房间 ---------- */ { url: "/api/dorm/BuildingList", method: "get", response: () => ok( buildings.map(b => { const inB = rooms.filter(r => r.buildingId === b.buildingId); return { buildingId: b.buildingId, buildingName: b.buildingName, gender: b.gender, floorCount: b.floors, supervisorName: b.supervisor, supervisorPhone: b.phone, roomCount: inB.length, bedCount: inB.reduce((s, r) => s + r.bedCount, 0), usedBeds: inB.reduce((s, r) => s + r.usedBeds, 0), }; }) ), }, { url: "/api/dorm/RoomList", method: "get", response: ({ query }: any) => { let list = rooms.slice(); if (query.buildingId) list = list.filter(r => r.buildingId === Number(query.buildingId)); if (query.keyword) { const kw = String(query.keyword); list = list.filter(r => r.roomNo.includes(kw) || r.supervisorName.includes(kw)); } return ok( list.map(r => ({ roomId: r.roomId, roomNo: r.roomNo, buildingId: r.buildingId, buildingName: `${r.buildingShort} ${r.roomNo}`, floor: r.floor, bedCount: r.bedCount, usedBeds: r.usedBeds, supervisorName: r.supervisorName, supervisorPhone: r.supervisorPhone, monitorName: roster[String(r.roomId)]?.find((s: any) => s.isMonitor)?.studentName || "", monitorStudentId: roster[String(r.roomId)]?.find((s: any) => s.isMonitor)?.studentId || 0, })) ); }, }, { url: "/api/dorm/RoomStudents", method: "get", response: ({ query }: any) => { const stu = roster[String(query.roomId)] || []; return ok( stu.map(s => ({ studentId: s.studentId, studentName: s.studentName, gender: s.gender, bedNo: s.bedNo, className: s.className, guardianName: s.guardianName, guardianPhone: s.guardianPhone, relation: s.relation, isMonitor: !!s.isMonitor, })) ); }, }, /* ---------- 床位安排 / 宿舍长(「人员进房间」的唯一入口) ---------- */ { // 待安排床位学生池(安排入住的数据源) url: "/api/dorm/PendingStudents", method: "get", response: ({ query }: any) => { let list = pendingPool.slice(); if (query.gender) list = list.filter(s => s.gender === String(query.gender)); if (query.keyword) { const kw = String(query.keyword); list = list.filter(s => s.studentName.includes(kw) || s.className.includes(kw) || String(s.studentId).includes(kw)); } return ok(list); }, }, { // 安排入住:把待安排学生调入指定房间,按空床位顺序落位 url: "/api/dorm/AssignStudent", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const r = roomMap[String(b.roomId)]; if (!r) return fail("房间不存在"); const ids = String(b.studentIds || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean); if (!ids.length) return fail("请选择要安排入住的学生"); if (!roster[String(r.roomId)]) roster[String(r.roomId)] = []; const list = roster[String(r.roomId)]; const empty = r.bedCount - list.length; if (empty <= 0) return fail(`该房间已住满(${list.length}/${r.bedCount} 床)`); if (ids.length > empty) return fail(`该房间仅剩 ${empty} 个空床位,无法安排 ${ids.length} 人`); // 先整体校验再落位,避免"部分成功"的半截数据 const picked: any[] = []; for (const id of ids) { const stu = pendingPool.find(s => String(s.studentId) === id); if (!stu) return fail("所选学生不在待安排名单中,请刷新后重试"); const conflict = genderConflict(r.buildingName, stu.gender); if (conflict) return fail(`${stu.studentName}:${conflict}`); picked.push(stu); } picked.forEach(stu => { const used = new Set(list.map((s: any) => s.bedNo)); let bed = 1; while (used.has(`${bed}号床`)) bed++; list.push({ ...stu, bedNo: `${bed}号床`, isMonitor: list.length === 0 }); delete list[list.length - 1].sourceDesc; pendingPool.splice(pendingPool.indexOf(stu), 1); }); r.usedBeds = list.length; rebuildRoomIndex(); return ok(null); }, }, { // 退宿:学生移出房间并退回待安排池 url: "/api/dorm/RemoveStudent", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const r = roomMap[String(b.roomId)]; if (!r) return fail("房间不存在"); const list = roster[String(r.roomId)] || []; const i = list.findIndex((s: any) => String(s.studentId) === String(b.studentId)); if (i < 0) return fail("该学生不在本房间"); const [stu] = list.splice(i, 1); backToPool(stu, "退宿待重新安排"); renumberBeds(list); r.usedBeds = list.length; rebuildRoomIndex(); return ok(null); }, }, { // 调整宿舍:房内学生转到另一间房(占目标房空床位) url: "/api/dorm/TransferStudent", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const from = roomMap[String(b.fromRoomId)]; const to = roomMap[String(b.toRoomId)]; if (!from || !to) return fail("房间不存在"); if (String(from.roomId) === String(to.roomId)) return fail("原房间与目标房间相同"); const src = roster[String(from.roomId)] || []; const i = src.findIndex((s: any) => String(s.studentId) === String(b.studentId)); if (i < 0) return fail("该学生不在原房间"); if (!roster[String(to.roomId)]) roster[String(to.roomId)] = []; const dst = roster[String(to.roomId)]; if (dst.length >= to.bedCount) return fail(`${to.buildingName} 已住满(${dst.length}/${to.bedCount} 床)`); const stu = src[i]; const conflict = genderConflict(to.buildingName, stu.gender); if (conflict) return fail(`${stu.studentName}:${conflict}`); const wasMonitor = stu.isMonitor; src.splice(i, 1); const used = new Set(dst.map((s: any) => s.bedNo)); let bed = 1; while (used.has(`${bed}号床`)) bed++; dst.push({ ...stu, bedNo: `${bed}号床`, isMonitor: dst.length === 1 }); renumberBeds(src); from.usedBeds = src.length; to.usedBeds = dst.length; rebuildRoomIndex(); return ok({ wasMonitor, bedNo: `${bed}号床` }); }, }, { // 设置 / 取消宿舍长(studentId 传 0 表示取消) url: "/api/dorm/SetMonitor", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const r = roomMap[String(b.roomId)]; if (!r) return fail("房间不存在"); const list = roster[String(r.roomId)] || []; if (!list.length) return fail("该房间暂无住宿生,无法指定宿舍长"); const sid = String(b.studentId ?? ""); const valid = !!sid && sid !== "0"; if (valid && !list.some((s: any) => String(s.studentId) === sid)) return fail("该学生不在本房间"); list.forEach((s: any) => (s.isMonitor = valid && String(s.studentId) === sid)); rebuildRoomIndex(); return ok(null); }, }, { url: "/api/dorm/AddBuilding", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); if (!b.buildingName) return fail("楼栋名称不能为空"); const bid = Math.max(...buildings.map(x => x.buildingId)) + 1; const floors = Number(b.floorCount) || 1; const per = Number(b.roomsPerFloor) || 1; const beds = Number(b.bedCount) || 6; buildings.push({ buildingId: bid, buildingName: b.buildingName, short: `${bid}号楼`, gender: b.gender || "男生", floors, roomsPerFloor: per, beds, supervisor: b.supervisorName || "待指定", phone: b.supervisorPhone || "", }); // 同步铺房间与床位。新楼栋按「空楼」创建(usedBeds=0)—— // 真实流程是「先建楼栋房间,再由床位安排流程写入住宿生」,mock 同理。 for (let f = 1; f <= floors; f++) { for (let i = 1; i <= per; i++) { const roomId = bid * 10000 + f * 100 + i; rooms.push({ roomId, roomNo: `${f}${String(i).padStart(2, "0")}`, buildingId: bid, buildingName: b.buildingName, buildingShort: `${bid}号楼`, floor: f, bedCount: beds, usedBeds: 0, supervisorName: `${b.supervisorName || "待指定"}(${bid}号楼${f}层宿管)`, supervisorPhone: b.supervisorPhone || "", }); roster[String(roomId)] = []; } } rebuildRoomIndex(); return ok(null); }, }, { url: "/api/dorm/EditBuilding", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const item = buildings.find(x => x.buildingId === Number(b.buildingId)); if (!item) return fail("楼栋不存在"); if (b.buildingName) item.buildingName = b.buildingName; if (b.gender) item.gender = b.gender; if (b.supervisorName) { item.supervisor = b.supervisorName; rooms.filter(r => r.buildingId === item.buildingId).forEach(r => (r.supervisorName = `${b.supervisorName}(${item.short}${r.floor}层宿管)`)); } if (b.supervisorPhone) { item.phone = b.supervisorPhone; rooms.filter(r => r.buildingId === item.buildingId).forEach(r => (r.supervisorPhone = b.supervisorPhone)); } rooms.filter(r => r.buildingId === item.buildingId).forEach(r => (r.buildingName = item.buildingName)); rebuildRoomIndex(); return ok(null); }, }, { url: "/api/dorm/DelBuilding", method: "post", response: ({ body, query }: any) => { const bid = Number(bodyOf(body, query).buildingId); const roomCount = rooms.filter(r => r.buildingId === bid).length; if (roomCount) return fail(`该楼栋下仍有 ${roomCount} 间房间,请先清空房间后再删除`); const i = buildings.findIndex(x => x.buildingId === bid); if (i > -1) buildings.splice(i, 1); rebuildRoomIndex(); return ok(null); }, }, { url: "/api/dorm/AddRoom", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const bid = Number(b.buildingId); const bd = buildings.find(x => x.buildingId === bid); if (!bd) return fail("楼栋不存在"); const floors = Number(b.floor) || 1; const count = Number(b.roomCount) || 1; const beds = Number(b.bedCount) || 6; // 从该层已有房间号往后追加,避免房间号重复 for (let k = 0; k < count; k++) { let idx = rooms.filter(r => r.buildingId === bid && r.floor === floors).length + 1 + k; const roomId = bid * 10000 + floors * 100 + idx; rooms.push({ roomId, roomNo: `${floors}${String(idx).padStart(2, "0")}`, buildingId: bid, buildingName: bd.buildingName, buildingShort: bd.short, floor: floors, bedCount: beds, usedBeds: 0, supervisorName: `${bd.supervisor}(${bd.short}${floors}层宿管)`, supervisorPhone: bd.phone, }); roster[String(roomId)] = []; } rebuildRoomIndex(); return ok(null); }, }, { url: "/api/dorm/EditRoom", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const r = roomMap[String(b.roomId)]; if (!r) return fail("房间不存在"); if (b.roomNo) r.roomNo = b.roomNo; if (b.bedCount !== undefined) { const beds = Math.max(1, Number(b.bedCount) || 1); r.bedCount = beds; // 床位收紧时装不下的学生由 rebuildRoomIndex 退回待安排池(不再凭空裁掉) } if (b.supervisorName) r.supervisorName = b.supervisorName; if (b.supervisorPhone) r.supervisorPhone = b.supervisorPhone; rebuildRoomIndex(); return ok(null); }, }, { url: "/api/dorm/DelRoom", method: "post", response: ({ body, query }: any) => { const rid = String(bodyOf(body, query).roomId); const r = roomMap[rid]; if (!r) return fail("房间不存在"); if ((roster[rid] || []).length) return fail("该房间仍有学生入住,请先办理床位调整后再删除"); const i = rooms.findIndex(x => String(x.roomId) === rid); if (i > -1) rooms.splice(i, 1); delete roomMap[rid]; rebuildRoomIndex(); return ok(null); }, }, /* ---------- 字典 ---------- */ { url: "/api/dict/List", method: "get", response: ({ query }: any) => { const type = String(query.type || ""); const src = type === "noticeType" ? noticeTypeDict : deptDict; return ok(src.filter(d => d.Enable === 1).map(d => ({ k: type === "noticeType" ? d.Name : d.CodeID, v: d.Name }))); }, }, { url: "/api/dict/Sync", method: "get", response: () => ok(true) }, { url: "/api/dict/ManageList", method: "get", response: ({ query }: any) => ok((String(query.type || "") === "dept" ? deptDict : noticeTypeDict).map(d => ({ ...d }))), }, { url: "/api/dict/Add", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const name = b.name || "未命名"; if (String(b.type) === "dept") { deptDict.push({ SerID: deptSeq, CodeID: "D" + String(deptSeq).padStart(3, "0"), Name: name, Enable: 1 }); deptSeq++; } else { if (noticeTypeDict.some(t => t.Name === name)) return fail("该通知类型已存在"); noticeTypeDict.push({ SerID: typeSeq, CodeID: "T" + String(typeSeq).padStart(3, "0"), Name: name, Enable: 1 }); typeSeq++; } return ok(null); }, }, { url: "/api/dict/Update", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const src = String(b.type) === "dept" ? deptDict : noticeTypeDict; const item = src.find(d => d.SerID === Number(b.SerID)); if (item) { if (b.Name) item.Name = b.Name; if (b.Enable !== undefined && b.Enable !== null && b.Enable !== "") item.Enable = Number(b.Enable); } return ok(null); }, }, { url: "/api/dict/Delete", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const src = String(b.type) === "dept" ? deptDict : noticeTypeDict; const i = src.findIndex(d => d.SerID === Number(b.serid)); if (i > -1) src.splice(i, 1); return ok(null); }, }, { url: "/api/dict/Sort", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const src = String(b.type) === "dept" ? deptDict : noticeTypeDict; const ids = String(b.ids || "") .split(",") .map(Number) .filter(Boolean); const sorted = ids.map(id => src.find(d => d.SerID === id)).filter(Boolean) as any[]; if (sorted.length === src.length) src.splice(0, src.length, ...sorted); return ok(null); }, }, /* ---------- 通知列表 ---------- */ { // 家长端收件箱:仅已发布、非未来定时、面向本生所在房间 url: "/api/notice/List", method: "get", response: ({ query }: any) => { const pageIndex = Number(query.pageIndex || 1); const pageSize = Number(query.pageSize || 50); let list = notices.filter(n => { if (!isPublished(n) || isScheduledFuture(n)) return false; return /^all$/i.test(String(n.receiveList).trim()) || targetRoomIds(n).includes(ME_ROOM); }); list = sortByTimeDesc(applyFilters(list, query)); const unreadCount = list.filter(n => !isReadByMe(n)).length; return ok({ ...paginate(list.map(toVo), pageIndex, pageSize), unreadCount }); }, }, { // 我发布的(scope=user) / 全部通知(不传) url: "/api/notice/SendList", method: "get", response: ({ query }: any) => { const pageIndex = Number(query.pageIndex || 1); const pageSize = Number(query.pageSize || 10); let list = notices.slice(); if (query.scope === "user") list = list.filter(n => n.publisherAccount === ME.account); list = sortByTimeDesc(applyFilters(list, query)); return ok(paginate(list.map(toVo), pageIndex, pageSize)); }, }, /* ---------- 通知详情 ---------- */ { url: "/api/notice/Detail", method: "get", response: ({ query }: any) => { const n = noticeOf(Number(query.serid)); if (!n) return fail("通知不存在或已被删除"); const c = viewerRoom(); if (c && String(query.type) !== "0" && !(n.roomRead || []).includes(c)) { n.roomRead.push(c); // 同步把该房间学生的回执记录标为已读 receiptsOf(n.id).forEach(s => { if (String(s.roomId) === c) { s.read = true; if (!s.readAt) s.readAt = nowStr(); } }); } return ok(toDetail(n)); }, }, { url: "/api/notice/FormTemplates", method: "get", response: () => ok(FORM_TEMPLATE_LIST), }, { url: "/api/notice/GetDetail", method: "get", response: ({ query }: any) => { const n = noticeOf(Number(query.serid)); if (!n) return fail("通知不存在或已被删除"); return ok(toDetail(n)); }, }, { url: "/api/notice/GetNextPrev", method: "get", response: ({ query }: any) => { const ids = sortByTimeDesc(notices.filter(isPublished)).map(n => n.id); const i = ids.indexOf(Number(query.serid)); return ok({ prevId: i > 0 ? ids[i - 1] : 0, nextId: i > -1 && i < ids.length - 1 ? ids[i + 1] : 0 }); }, }, { // 阅读情况:按楼栋分组,叶子为房间(读/未读) url: "/api/notice/ReadDetail", method: "get", response: ({ query }: any) => { const n = noticeOf(Number(query.serid)); if (!n) return fail("通知不存在或已被删除"); const readSet = new Set(n.roomRead || []); const byBuilding: Record = {}; targetRoomIds(n).forEach(id => { const r = roomMap[id]; if (!r) return; (byBuilding[r.buildingName] = byBuilding[r.buildingName] || []).push({ account: id, teaname: `${r.buildingShort} ${r.roomNo}(${r.usedBeds} 人)`, states: readSet.has(id) ? "read" : "unread", }); }); return ok(Object.keys(byBuilding).map(name => ({ GroupName: name, teaslist: byBuilding[name] }))); }, }, /* ---------- 通知写入操作 ---------- */ { url: "/api/notice/Create", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); if (!b.title) return fail("通知标题不能为空"); const dept = deptDict.find(d => d.CodeID === b.deptId || d.Name === b.deptName); const id = noticeSeq++; const n: any = { id, title: b.title, noticeType: b.noticeType || "其他", level: b.level || "normal", deptId: dept?.CodeID || "D002", deptName: dept?.Name || "德育处", publisherAccount: ME.account, publisherName: ME.name, content: b.content || "", effectiveTime: b.effectiveTime || null, deadlineTime: b.deadlineTime || null, receiptMode: b.receiptMode || "none", needSupervisor: Number(b.needSupervisor) || 0, receiveList: b.receiveList || "ALL", roomRead: [], files: b.fileIds || null, topmost: Number(b.topmost) || 0, isPulled: 0, smsFlag: Number(b.smsFlag) || 0, timingSendTime: b.timingSendTime || null, noticeStatus: b.draft ? "draft" : "published", createTime: nowStr(), formType: b.receiptMode === "fill" ? b.formType || "general" : null, formFields: b.receiptMode === "fill" ? buildFormFields(b.formType) : null, }; notices.unshift(n); seedReceiptsFor(n); return ok(n.id); }, }, { url: "/api/notice/Edit", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const n = noticeOf(Number(b.sourceId || b.id)); if (!n) return fail("通知不存在或已被删除"); if (b.title) n.title = b.title; if (b.noticeType) n.noticeType = b.noticeType; if (b.level) n.level = b.level; if (b.content !== undefined) n.content = b.content; if (b.effectiveTime !== undefined) n.effectiveTime = b.effectiveTime; if (b.deadlineTime !== undefined) n.deadlineTime = b.deadlineTime; if (b.receiptMode) n.receiptMode = b.receiptMode; if (b.needSupervisor !== undefined) n.needSupervisor = Number(b.needSupervisor) || 0; if (b.receiveList) n.receiveList = b.receiveList; if (b.fileIds !== undefined) n.files = b.fileIds || null; // 单据字段一律由后端按 formType 生成,前端不再上传字段定义 if (n.receiptMode === "fill") { n.formType = b.formType || n.formType || "general"; n.formFields = buildFormFields(n.formType); } else { n.formType = null; n.formFields = null; } if (b.deptId) { const d = deptDict.find(x => x.CodeID === b.deptId); n.deptId = b.deptId; n.deptName = d?.Name || n.deptName; } if (b.timingSendTime) n.timingSendTime = b.timingSendTime; n.noticeStatus = b.draft ? "draft" : "published"; n.isPulled = 0; // 回执方式由「仅通知」改成需回执时补齐回执记录 seedReceiptsFor(n); return ok(null); }, }, { url: "/api/notice/Delete", method: "post", response: ({ body }: any) => { const ids: number[] = Array.isArray(body) ? body.map(Number) : parseBody(body).ids || []; ids.forEach(id => { const i = notices.findIndex(n => n.id === Number(id)); if (i > -1) notices.splice(i, 1); for (let k = receipts.length - 1; k >= 0; k--) if (receipts[k].noticeId === Number(id)) receipts.splice(k, 1); }); return ok(null); }, }, { url: "/api/notice/InitializeRed", method: "post", response: () => { let count = 0; notices.forEach(n => { if (!isPublished(n)) return; const targets = targetRoomIds(n); if (targets.includes(ME_ROOM) && !n.roomRead.includes(ME_ROOM)) { n.roomRead.push(ME_ROOM); count++; } }); return ok({ count }); }, }, { url: "/api/notice/UserPull", method: "post", response: ({ body, query }: any) => { const n = noticeOf(Number(bodyOf(body, query).serid)); if (n) { n.isPulled = 1; n.noticeStatus = "closed"; } return ok(null); }, }, { url: "/api/notice/SetTopmost", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const n = noticeOf(Number(b.serid)); if (n) n.topmost = Number(b.topmost) === 0 ? 0 : 1; return ok(null); }, }, { url: "/api/notice/Expedite", method: "post", response: ({ body, query }: any) => { const n = noticeOf(Number(bodyOf(body, query).serid)); if (!n) return fail("通知不存在"); return ok({ reminded: receiptsOf(n.id).filter(s => !s.read).length }); }, }, { url: "/api/notice/ReceiveRooms", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const n = noticeOf(Number(b.serid)); if (!n) return fail("通知不存在"); const merged = new Set([...targetRoomIds(n), ...targetRoomIds({ receiveList: b.rooms })]); n.receiveList = Array.from(merged).join(","); seedReceiptsFor(n); return ok(null); }, }, /* ---------- 回执 / 填报(家校核心闭环) ---------- */ { url: "/api/notice/Receipts", method: "get", response: ({ query }: any) => { const n = noticeOf(Number(query.serid)); if (!n) return fail("通知不存在或已被删除"); const status = String(query.status || "all"); const all = receiptsOf(n.id); const filtered = status === "all" ? all : all.filter(s => s.status === status); const byRoom: Record = {}; filtered.forEach(s => { const key = `${s.buildingName} · ${s.roomNo}`; (byRoom[key] = byRoom[key] || []).push(toReceiptBrief(s)); }); return ok({ noticeId: n.id, title: n.title, noticeType: n.noticeType, level: n.level, levelDesc: LEVEL_DESC[n.level] || "普通", effectiveTime: n.effectiveTime, deadlineTime: n.deadlineTime, noticeStatus: n.noticeStatus, noticeStatusDesc: NOTICE_STATUS_DESC[n.noticeStatus] || "已发布", receiptMode: n.receiptMode, receiptModeDesc: RECEIPT_MODE_DESC[n.receiptMode] || "仅通知", formType: n.formType || null, formTypeDesc: n.formType ? FORM_TYPE_DESC[n.formType] || "通用回执单" : "", formFields: n.formFields || null, summary: progressOf(n.id), groups: Object.keys(byRoom).map(groupName => ({ groupName, roomNo: byRoom[groupName][0]?.roomNo || "", students: byRoom[groupName], })), }); }, }, { url: "/api/notice/ReceiptDetail", method: "get", response: ({ query }: any) => { const s = receipts.find(x => x.id === Number(query.receiptId)); if (!s) return fail("回执记录不存在"); const n = noticeOf(s.noticeId); return ok({ receiptId: s.id, noticeId: s.noticeId, noticeTitle: n?.title || "", noticeType: n?.noticeType || "", studentId: s.studentId, studentName: s.studentName, roomId: s.roomId, roomNo: s.roomNo, buildingName: s.buildingName, bedNo: s.bedNo, guardianName: s.guardianName, guardianPhone: s.guardianPhone, relation: s.relation, status: s.status, statusDesc: RECEIPT_STATUS_DESC[s.status] || s.status, submittedAt: s.submittedAt, read: s.read, readAt: s.readAt, fillData: s.fillData, formType: n?.formType || null, formTypeDesc: n?.formType ? FORM_TYPE_DESC[n.formType] || "通用回执单" : "", formFields: n?.formFields || null, remark: s.remark, }); }, }, { url: "/api/notice/Review", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const s = receipts.find(x => x.id === Number(b.receiptId)); if (!s) return fail("回执记录不存在"); const n = noticeOf(s.noticeId); const action = String(b.action || "confirm"); if (action === "confirm") { if (n?.receiptMode === "fill") { // 代确认:把家长口头给出的结论写进单据,避免出现「已填报但内容为空」的空单 s.fillData = { ...(s.fillData || {}), conclusion: (s.fillData || {}).conclusion || "同意" }; s.status = "filled"; } else { s.status = "confirmed"; } } else if (action === "cancel") s.status = "canceled"; else s.status = "none"; if (b.remark !== undefined) s.remark = b.remark; return ok(null); }, }, { url: "/api/notice/Remind", method: "post", response: ({ body, query }: any) => { const n = noticeOf(Number(bodyOf(body, query).serid)); if (!n) return fail("通知不存在"); const pending = receiptsOf(n.id).filter(s => s.status === "none" || s.status === "canceled").length; return ok({ reminded: pending }); }, }, { // 导出回执名单(返回 CSV 文本,前端 Blob 下载) url: "/api/notice/ExportReceipts", method: "get", response: ({ query }: any) => { const n = noticeOf(Number(query.serid)); if (!n) return fail("通知不存在"); // 表头统一:固定列 → 公共区(所有单据一致)→ 专属区(随单据类型)→ 回执信息。 // 同类单据导出的列完全相同,可直接纵向合并;不同单据的差异只落在专属区那几列。 const fields: any[] = n.formFields || []; const common = fields.filter(f => f.group === "common"); const specific = fields.filter(f => f.group === "specific"); const rows = [ [ "房间", "床位", "学生姓名", "班级", "单据类型", "家长姓名", "与学生关系", "联系电话", ...common.map(f => f.label), ...specific.map(f => f.label), "回执状态", "回执时间", "备注", ], ]; receiptsOf(n.id).forEach(s => { rows.push([ s.roomNo, s.bedNo, s.studentName, s.className, n.formType ? FORM_TYPE_DESC[n.formType] || "通用回执单" : "", s.guardianName, s.relation, s.guardianPhone, ...common.map(f => String((s.fillData || {})[f.key] ?? "")), ...specific.map(f => String((s.fillData || {})[f.key] ?? "")), RECEIPT_STATUS_DESC[s.status] || s.status, s.submittedAt || "", (s.remark || "").replace(/,/g, ","), ]); }); const csv = "\ufeff" + rows.map(r => r.join(",")).join("\r\n"); return ok(csv); }, }, /* ---------- 统计 ---------- */ { url: "/api/notice/Statis", method: "get", response: ({ query }: any) => { let list = notices.slice(); const st = query.startTime || query.starttime; const et = query.endTime || query.endtime; if (st) list = list.filter(n => String(n.createTime).slice(0, 10) >= String(st).slice(0, 10)); if (et) list = list.filter(n => String(n.createTime).slice(0, 10) <= String(et).slice(0, 10)); const published = list.filter(n => n.noticeStatus !== "draft"); const withReceipt = published.filter(n => n.receiptMode !== "none"); const rates = withReceipt.map(n => progressOf(n.id).receiptRate); const byPublisherMap: Record = {}; list.forEach(n => (byPublisherMap[n.publisherName] = (byPublisherMap[n.publisherName] || 0) + 1)); const byMonthMap: Record = {}; list.forEach(n => { const key = String(n.createTime).slice(0, 7); byMonthMap[key] = (byMonthMap[key] || 0) + 1; }); const byTypeMap: Record = {}; list.forEach(n => (byTypeMap[n.noticeType] = (byTypeMap[n.noticeType] || 0) + 1)); const byBuilding = buildings.map(b => { const hit = published.filter(n => targetRoomIds(n).some(id => roomMap[id]?.buildingId === b.buildingId)); const beds = new Set(); hit.forEach(n => targetRoomIds(n).forEach(id => roomMap[id]?.buildingId === b.buildingId && beds.add(id))); return { buildingName: b.buildingName, noticeCount: hit.length, beds: Array.from(beds).reduce((s, id) => s + (roomMap[id]?.usedBeds || 0), 0), }; }); const mine = list.filter(n => n.publisherAccount === ME.account); return ok({ total: list.length, publishedCount: published.length, myNoticeCount: mine.length, urgentCount: list.filter(n => n.level === "urgent" || n.level === "important").length, totalReceipts: receipts.filter(s => list.some(n => n.id === s.noticeId) && (s.status === "confirmed" || s.status === "filled")).length, avgReceiptRate: rates.length ? Math.round(rates.reduce((a, b) => a + b, 0) / rates.length) : 0, totalCovered: published.reduce((s, n) => s + allowCountOf(n), 0), pendingReview: receipts.filter(s => s.status === "none").length, byPublisher: Object.keys(byPublisherMap).map(k => ({ publisherName: k, count: byPublisherMap[k] })), byMonth: Object.keys(byMonthMap) .sort() .map(k => ({ year: Number(k.slice(0, 4)), month: Number(k.slice(5, 7)), count: byMonthMap[k] })), byType: Object.keys(byTypeMap).map(k => ({ label: k, key: k, count: byTypeMap[k] })), byBuilding, byNotice: mine .filter(n => n.noticeStatus !== "draft") .map(n => ({ id: n.id, title: n.title, noticeType: n.noticeType, level: n.level, levelDesc: LEVEL_DESC[n.level] || "普通", noticeStatus: n.noticeStatus, noticeStatusDesc: NOTICE_STATUS_DESC[n.noticeStatus] || "已发布", ...progressOf(n.id), })), }); }, }, /* ---------- 家长端 ---------- */ { url: "/api/notice/MyNotices", method: "get", response: ({ query }: any) => { const list = sortByTimeDesc( applyFilters( notices.filter(n => isPublished(n) && !isScheduledFuture(n) && (/^all$/i.test(String(n.receiveList).trim()) || targetRoomIds(n).includes(ME_ROOM))), query ) ); const vos = list.map(toParentVo); return ok({ buckets: { todo: vos.filter(v => !v.viewOnly && (!v.my || v.my.status === "none" || v.my.status === "canceled") && v.noticeStatus === "published").length, done: vos.filter(v => v.my && (v.my.status === "confirmed" || v.my.status === "filled")).length, finished: vos.filter(v => v.noticeStatus === "finished" || v.noticeStatus === "closed").length, all: vos.length, }, list: vos, }); }, }, { url: "/api/notice/MyNotice", method: "get", response: ({ query }: any) => { const n = noticeOf(Number(query.serid)); if (!n) return fail("通知不存在或已被删除"); if (!(n.roomRead || []).includes(ME_ROOM)) { n.roomRead.push(ME_ROOM); receiptsOf(n.id).forEach(s => { if (String(s.roomId) === ME_ROOM) { s.read = true; if (!s.readAt) s.readAt = nowStr(); } }); } const vo = toParentVo(n); return ok({ notice: { id: vo.id, title: vo.title, noticeType: vo.noticeType, level: vo.level, levelDesc: vo.levelDesc, content: vo.content, effectiveTime: vo.effectiveTime, deadlineTime: vo.deadlineTime, receiptMode: vo.receiptMode, receiptModeDesc: vo.receiptModeDesc, needSupervisor: n.needSupervisor, publisherName: vo.publisherName, deptName: vo.deptName, roomNo: vo.roomNo, buildingName: vo.buildingName, bedNo: vo.bedNo, noticeStatus: vo.noticeStatus, noticeStatusDesc: vo.noticeStatusDesc, files: fileItems(n), }, formType: n.formType || null, formTypeDesc: n.formType ? FORM_TYPE_DESC[n.formType] || "通用回执单" : "", formFields: n.formFields || null, my: vo.my, }); }, }, { // 家长提交回执 / 填报 url: "/api/notice/SubmitReceipt", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const n = noticeOf(Number(b.serid)); if (!n) return fail("通知不存在或已被删除"); if (n.noticeStatus !== "published") return fail("该通知已截止或已撤回,无法提交"); if (n.deadlineTime && n.deadlineTime < nowStr()) return fail("已超过回执截止时间,如需补交请联系宿管"); // 需填报类:校验必填字段 if (n.receiptMode === "fill") { const fd = typeof b.fillData === "string" ? JSON.parse(b.fillData) : b.fillData || {}; const missing = (n.formFields || []).filter((f: any) => f.required && !String(fd[f.key] ?? "").trim()); if (missing.length) return fail(`请填写:${missing.map((f: any) => f.label).join("、")}`); } let s = receipts.find(x => x.noticeId === n.id && x.studentId === DEMO_STUDENT.studentId); if (!s) { s = { id: receiptSeq++, noticeId: n.id, studentId: DEMO_STUDENT.studentId, studentName: DEMO_STUDENT.studentName, roomId: DEMO_STUDENT.roomId, roomNo: `${DEMO_STUDENT.buildingShort} ${DEMO_STUDENT.roomNo}`, buildingId: DEMO_STUDENT.buildingId, buildingName: DEMO_STUDENT.buildingName, floor: DEMO_STUDENT.floor, bedNo: DEMO_STUDENT.bedNo, className: DEMO_STUDENT.className, guardianName: "", guardianPhone: "", relation: "", status: "none", read: true, readAt: nowStr(), submittedAt: null, fillData: null, remark: "", }; receipts.push(s); } s.guardianName = b.guardianName || DEMO_STUDENT.guardianName; s.guardianPhone = b.guardianPhone || DEMO_STUDENT.guardianPhone; s.relation = b.relation || DEMO_STUDENT.relation; s.remark = b.remark !== undefined ? b.remark : s.remark; if (n.receiptMode === "fill") { s.fillData = typeof b.fillData === "string" ? JSON.parse(b.fillData) : b.fillData || {}; s.status = "filled"; } else { s.status = "confirmed"; } s.submittedAt = nowStr(); return ok({ receiptId: s.id, status: s.status }); }, }, { url: "/api/notice/CancelReceipt", method: "post", response: ({ body, query }: any) => { const n = noticeOf(Number(bodyOf(body, query).serid)); if (!n) return fail("通知不存在"); const s = receipts.find(x => x.noticeId === n.id && x.studentId === DEMO_STUDENT.studentId); if (!s) return fail("尚未提交回执"); s.status = "canceled"; return ok(null); }, }, /* ---------- 文件 ---------- */ { url: "/api/base/FileUpload", method: "post", response: () => { const id = nextFileId++; filesStore[id] = { id, filename: `宿舍通知附件_${id}.pdf`, fileTrueName: `宿舍通知附件_${id}.pdf`, size: 122880 }; return ok({ fileid: id, name: filesStore[id].filename, host: "", fileurl: `/api/file/Preview?id=${id}`, type: "pdf", size: 122880 }); }, }, { url: "/api/file/UploadImage", method: "post", response: () => { const id = nextFileId++; filesStore[id] = { id, filename: `image_${id}.png`, fileTrueName: `image_${id}.png`, size: 4096 }; return ok([{ id, fileid: id, fileTrueName: filesStore[id].fileTrueName, fileName: filesStore[id].filename, fileSize: 4096, fileType: "png", url: mockImageDataURL(id) }]); }, }, { url: "/api/file/Preview", method: "get", response: () => ok({ id: 0, fileurl: mockImageDataURL() }) }, { url: "/api/file/Down", method: "get", response: () => ok({ id: 0, fileurl: mockImageDataURL() }) }, { url: "/api/notice/AllFileDown", method: "get", response: () => ok({ id: 0, fileurl: mockImageDataURL() }) }, /* ---------- 联系组(宿舍设置) ---------- */ { url: "/api/contacts/GetGroup", method: "get", response: ({ query }: any) => { const page = Number(query.page || 1); const count = Number(query.count || 10); const all = groups.map(g => ({ serid: g.serid, GroupName: g.GroupName, count: g.members.length })); const start = (page - 1) * count; return ok({ list: all.slice(start, start + count), total: all.length }); }, }, { url: "/api/contacts/AddGroup", method: "post", response: ({ body, query }: any) => { groups.push({ serid: groupSeq, GroupName: bodyOf(body, query).name || "未命名组", members: [] }); groupSeq++; return ok(null); }, }, { url: "/api/contacts/EditGroup", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const g = groups.find(x => x.serid === Number(b.id)); if (g && b.name) g.GroupName = b.name; return ok(null); }, }, { url: "/api/contacts/DelGroup", method: "post", response: ({ body, query }: any) => { const ids = String(bodyOf(body, query).ids || "") .split(",") .map(Number); ids.forEach(id => { const i = groups.findIndex(g => g.serid === id); if (i > -1) groups.splice(i, 1); }); return ok(null); }, }, { url: "/api/contacts/GetContactsTea", method: "get", response: ({ query }: any) => { const g = groups.find(x => x.serid === Number(query.gid)); const members = g?.members || []; const list = members.map(id => ({ account: "t" + id, name: idNames[id] || "未知用户", userId: id, id })); const page = Number(query.page || 0); const count = Number(query.count || 0); if (!page || !count) return ok({ list, total: list.length }); const start = (page - 1) * count; return ok({ list: list.slice(start, start + count), total: list.length }); }, }, { url: "/api/contacts/SetContactsTea", method: "post", response: ({ body, query }: any) => { const b = bodyOf(body, query); const g = groups.find(x => x.serid === Number(b.gid)); if (g) g.members = String(b.ids || "").split(",").map(Number).filter(Boolean); return ok(null); }, }, { url: "/api/contacts/GetGroupTree", method: "get", response: () => ok( groups.map(g => ({ id: 2000 + g.serid, name: g.GroupName, isParent: true, children: g.members.map(id => ({ id, name: idNames[id] || "未知用户", isParent: false })), })) ), }, ]; /** 房间/花名册索引重建(楼栋房间增删改后调用) * 注意:这里**不再按 usedBeds 凭空补学生**。床位占用只能由床位安排接口驱动, * 否则新建房间 / 调大床位数会自动变出根本不存在的学生,与真实学籍流程不符。 * usedBeds 一律以花名册长度为准(单一数据源)。 */ function rebuildRoomIndex() { Object.keys(roomMap).forEach(k => delete roomMap[k]); rooms.forEach(r => (roomMap[String(r.roomId)] = r)); rooms.forEach(r => { const key = String(r.roomId); const list = roster[key] || []; // 床位收紧时裁掉超出部分,被裁学生退回待安排池(而不是凭空消失) if (list.length > r.bedCount) { const cut = list.slice(r.bedCount); roster[key] = list.slice(0, r.bedCount); cut.forEach((s: any) => backToPool(s, "床位缩减待重新安排")); } if (!roster[key]) roster[key] = []; r.usedBeds = roster[key].length; }); } /** 占位图片(dataURL,避免 mock 环境访问网络图片 404) */ function mockImageDataURL(id?: number) { const seed = Number(id) || 1; const hue = (seed * 47) % 360; const svg = ` 宿舍通知附件 ${seed} `; return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); } /* 供页面下拉用的类型清单(避免每页硬编码) */ export const DORM_NOTICE_TYPES = NOTICE_TYPE_NAMES;