2520 lines
112 KiB
TypeScript
2520 lines
112 KiB
TypeScript
/**
|
||
* 武校家校信息发布 · 宿舍通知模块 本地 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;
|