Files
DormitoryNotice/.backup/dormnotice-frontend-removed/dormapi.ts
T
2026-09-20 08:52:02 +08:00

2520 lines
112 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 武校家校信息发布 · 宿舍通知模块 本地 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<string, any> = {};
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<number, any[]> = {};
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<string, any[]> = {};
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<string, string> = { draft: "草稿", published: "已发布", closed: "已截止", finished: "已结束" };
const RECEIPT_MODE_DESC: Record<string, string> = { none: "仅通知", confirm: "需确认", fill: "需填报" };
const LEVEL_DESC: Record<string, string> = { normal: "普通", important: "重要", urgent: "紧急" };
const RECEIPT_STATUS_DESC: Record<string, string> = { 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<string, { type: string; name: string; desc: string; fields: any[] }> = {
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<string, string> = { 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:
"<p>各位住宿生家长:</p><p>进入秋冬季节后天气转冷,为保证学生睡眠时长与次日训练状态,经校务会研究决定:自 10 月 8 日起,全校宿舍晚间熄灯时间由 22:20 提前至 <b>21:40</b>,早训起床时间不变(05:50)。</p><p>请家长配合督促孩子提前完成洗漱与作业,晚 21:20 后不再接打非紧急电话。宿舍内禁止使用大功率电器与明火取暖,违者按校规处理。</p>",
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:
"<p>各位家长:</p><p>3号宿舍楼东侧主水管出现渗漏,后勤保障部定于 9 月 19 日(周六)08:00 — 17:00 停水抢修,涉及 3号楼全部楼层。</p><p>学校已在各层配备应急储水桶与瓶装饮用水,洗漱用水由宿管统一安排至公共洗漱间。请家长提前告知孩子,无需送水到校。恢复供水后前 10 分钟请勿直接饮用。</p>",
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:
"<p>各位住宿生家长:</p><p>国庆假期(10 月 1 日 — 10 月 7 日)学校实行封楼管理,仅保留 1 号楼一层值班区域供留宿学生集中住宿,宿管与值班教练 24 小时在岗。</p><p>如孩子确需留宿,请家长于 <b>9 月 28 日 18:00 前</b>在家长端填写《留宿申请单》,写明留宿起止日期、留宿人数与事由。未提交申请的学生须按规定离校,学校不承担擅自留校期间的看护责任。</p><p>留宿期间每天安排 2 次宿舍点名(09:00 / 19:00),请家长保持电话畅通。</p>",
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:
"<p>各位家长:</p><p>因初一新生入学后武术班重新编排,原按文化班分配的床位已不适应训练分组,生活管理科对 1 号楼 3 层、2 号楼 2 层的 12 个房间进行床位重排,同训练小组学生安排至同一房间,便于早晚训集合与教练查寝。</p><p>请家长确认是否接受床位调整,并选择可配合搬迁的日期(学校统一安排 9 月 26 日、27 日两批次搬迁,宿管协助搬运)。确有困难(如伤病、恐高上铺等)请在备注中说明,学校另行安排。</p>",
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:
"<p>各位家长:</p><p>为兼顾学生训练秩序与家长探视需求,本学期探视实行预约制:每周六、周日 09:00 — 20:00 开放探视,请家长至少提前 1 天在家长端填写《探视预约单》,写明到访日期、时段、人数与与学生关系。</p><p>探视区域为宿舍楼一楼会客区与校园广场,请勿进入二楼以上住宿区;携带食品请交宿管登记,禁止携带烟酒、管制刀具及大功率电器。</p><p>当日到访请携带身份证,在校门与宿舍楼门禁处配合核验。</p>",
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:
"<p>各位家长:</p><p>入冬后宿舍用电负荷上升,学校将于 10 月 15 日 — 10 月 20 日开展宿舍用电与消防专项排查,重点检查:私拉电线、电热毯/电煮锅等违规电器、灭火器与烟感有效性、疏散通道畅通情况。</p><p>请家长配合教育孩子:宿舍内严禁使用大功率电器与明火,严禁在床上充电。排查中发现的违规物品由学校暂扣并通知家长领回。</p><p>请家长阅读后确认知悉(家长端打开本通知即视为已确认,无需点击)。</p>",
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:
"<p>各位家长:</p><p>9 月 5 日 — 9 月 7 日,生活管理科联合德育处对全校 130 间宿舍开展卫生大检查,评分维度包括地面清洁、床铺整理、物品摆放、通风与异味。</p><p>本次检查优秀宿舍 41 间、合格 78 间、不合格 11 间(不合格宿舍已由宿管现场指导整改并复查通过)。1 号楼 305、3 号楼 202、4 号楼 103 等 12 间宿舍获得「文明宿舍」流动红旗。</p><p>感谢各位家长对宿舍卫生工作的支持,请继续督促孩子保持个人物品整洁。</p>",
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:
"<p>各位家长:</p><p>新生军训期间(9 月 18 日 — 9 月 30 日),4 号宿舍楼实行夜间加密查寝:由宿管 + 值班教练每 2 小时巡查一次(22:00 / 00:00 / 02:00 / 04:00),重点核查在寝人数、身体状况与用电安全。</p><p>巡查结果每日 07:30 前同步至班主任。学生如有身体不适(中暑、肌肉拉伤等)可在巡查时报告,医务室安排次日就诊。</p><p>请家长知悉,如有特殊情况需临时离寝,须由家长电话向宿管报备。</p>",
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:
"<p>各位家长:</p><p>9 月 9 日夜间查寝中,2 号楼 401、402 房间发现使用电煮锅与热得快各 1 件,存在严重消防隐患。学校已暂扣相关物品,并对当事学生进行安全警示教育,记入学期宿舍管理考核。</p><p>请各位家长引以为戒,配合学校做好孩子的用电安全教育。本学期宿舍考核累计 3 次违规的,将取消其住宿资格,另行安排走读。</p>",
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:
"<p>各位家长:</p><p>学期末宿舍将进行统一清整,请家长于 1 月 15 日前确认以下事项:</p><p>1. 需带回的行李由学生自行打包,宿管协助捆扎;<br/>2. 被褥、脸盆等大件可寄存学校仓库(免费,凭寄存单领取),寄存时间至下学期开学后 15 日内;<br/>3. 贵重物品(手机、平板、现金)一律不得寄存,请家长务必督促孩子随身带回。</p><p>逾期未清退的遗留物品,学校统一登记保管 30 日后按无主物品处理。请家长确认知悉(打开即视为已确认)。</p>",
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:
"<p>各位家长:</p><p>近期昼夜温差大,流感样病例增多。学校安排 10 月 9 日起每日对宿舍公共区域消杀 1 次,房间内每日通风不少于 3 次、每次 30 分钟(07:00 / 12:30 / 17:30 由宿管统一开窗)。</p><p>请家长为学生准备一件保暖外套与备用口罩;如孩子出现发热、咳嗽、咽痛,请及时告知班主任或宿管,医务室将安排隔离观察室并第一时间联系家长。</p><p>因病缺勤的学生返校须持医院复课证明。</p>",
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:
"<p>各位家长:</p><p>今年暑假(7 月 15 日 — 8 月 20 日)学校对 1 号、2 号宿舍楼进行整体维修改造,内容含:卫生间翻新、墙面涂刷、上下水管道更换、加装公共饮水机与晾衣区。</p><p>改造已于 8 月 25 日通过验收,本学期住宿条件相应改善。改造期间给各位家长带来的不便,敬请谅解。</p>",
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:
"<p>各位家长:</p><p>为营造整洁文明的宿舍环境,拟于春季学期开展「文明宿舍」评比,评分维度含卫生(40 分)、内务规范(30 分)、文化氛围(20 分)、安全守纪(10 分),每月评选一次,累计 3 次获评的宿舍推荐参加市级评比。</p><p>(方案与奖品预算尚在审核,暂未发布。)</p>",
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:
"<p>各位家长:</p><p>经听取家长与学生意见,女生宿舍(3 号楼)晚自习后归寝时间由 21:00 延长至 21:15,熄灯时间与男生宿舍一致(21:40)。该项调整自 9 月 20 日起执行。</p><p>请家长督促孩子按时归寝,21:15 后楼门关闭,特殊情况须由家长电话向宿管说明。</p>",
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<string, string> = {};
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<number, { id: number; filename: string; fileTrueName: string; size: number }> = {
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<number, string> = {
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<string, any> = {};
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<string>();
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<string, any[]> = {};
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<string, any[]> = {};
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<string, number> = {};
list.forEach(n => (byPublisherMap[n.publisherName] = (byPublisherMap[n.publisherName] || 0) + 1));
const byMonthMap: Record<string, number> = {};
list.forEach(n => {
const key = String(n.createTime).slice(0, 7);
byMonthMap[key] = (byMonthMap[key] || 0) + 1;
});
const byTypeMap: Record<string, number> = {};
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<string>();
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180">
<rect width="240" height="180" fill="hsl(${hue},62%,86%)"/>
<text x="120" y="96" font-size="16" font-family="sans-serif" fill="hsl(${hue},55%,34%)" text-anchor="middle">宿舍通知附件 ${seed}</text>
</svg>`;
return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
}
/* 供页面下拉用的类型清单(避免每页硬编码) */
export const DORM_NOTICE_TYPES = NOTICE_TYPE_NAMES;