فهرست منبع

Initial commit: keyissue prototype

zhensolid 1 هفته پیش
کامیت
b6e5a33623

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+node_modules/
+.DS_Store
+*.log
+dist/
+.env

+ 444 - 0
data/app-state.json

@@ -0,0 +1,444 @@
+{
+  "version": 1,
+  "savedAt": "2026-07-10T01:41:09.915Z",
+  "users": [
+    {
+      "id": "U001",
+      "username": "admin",
+      "displayName": "肖总",
+      "role": "admin",
+      "status": "启用",
+      "passwordHash": "pbkdf2:sha256:120000:0e3de8c69b1e05c63345bb288b9a5c1c:475fa6dd4a0d214a3860610885cc5dfb90e67bfe456e6ab6c3c33c41b3e27713"
+    },
+    {
+      "id": "U002",
+      "username": "executive",
+      "displayName": "管理层",
+      "role": "executive",
+      "status": "启用",
+      "passwordHash": "pbkdf2:sha256:120000:df41724ef000cb5d00587e86ff639eae:a680d366ea6dbc0cb78ed6412aef6246c433feda7eeaa7edd9b167de3bd91791"
+    },
+    {
+      "id": "U003",
+      "username": "delivery",
+      "displayName": "交付助理",
+      "role": "delivery",
+      "status": "启用",
+      "passwordHash": "pbkdf2:sha256:120000:5d37f48e77d49cd95dc39f9c9be78bd5:c7ab1a3ca596dab624151821faf2a1816f389f47c7bea94786094f4e0f5a4c1e"
+    },
+    {
+      "id": "U004",
+      "username": "manager",
+      "displayName": "项目经理",
+      "role": "manager",
+      "status": "启用",
+      "passwordHash": "pbkdf2:sha256:120000:db0d5bbab7d4a8cc7e12e9930d863a8e:7b7ab1b34ba3af84e822adb0f94d64e825adc32661566d9e4d469cfb253e82f6"
+    },
+    {
+      "id": "U005",
+      "username": "auditor",
+      "displayName": "监察部",
+      "role": "auditor",
+      "status": "启用",
+      "passwordHash": "pbkdf2:sha256:120000:1d8334dd3fe97d93afc67126982bee0c:3e6c7af9667c98f9b14b72d55235456ad48cd1b1b5e735a03f4263d9526e3e1d"
+    },
+    {
+      "id": "U006",
+      "username": "designer",
+      "displayName": "设计师",
+      "role": "designer",
+      "status": "启用",
+      "passwordHash": "pbkdf2:sha256:120000:a6b78d45781a4f75ca54772d427be740:1316b4a6526f493bf15259c379f2d3ecda4ac6d9351deeb821fb55a5f4f183e3"
+    },
+    {
+      "id": "U007",
+      "username": "design_manager",
+      "displayName": "设计经理",
+      "role": "designManager",
+      "status": "启用",
+      "passwordHash": "pbkdf2:sha256:120000:fb98a272248674f5c80b5ddcb96bfe9e:0f7ea2fab6abd600b0d5daa829dd3c809e21f29ccc36f1cb129e67fa953d527e"
+    },
+    {
+      "id": "U008",
+      "username": "18685125668",
+      "displayName": "杨洁",
+      "phone": "18685125668",
+      "department": "总经办",
+      "position": "运营副总",
+      "role": "executive",
+      "status": "启用",
+      "registerSource": "roster",
+      "passwordHash": "pbkdf2:sha256:120000:7adbc72d665509a721f9cd76f952408f:7f700179986069b337708eb3a64bafc595e4611f83e8c162e1664d393118e79a"
+    },
+    {
+      "id": "U009",
+      "username": "15085983248",
+      "displayName": "张芮",
+      "phone": "15085983248",
+      "department": "蘭堂设计工作室",
+      "position": "设计经理",
+      "role": "designManager",
+      "status": "启用",
+      "registerSource": "roster",
+      "passwordHash": "pbkdf2:sha256:120000:725e2e19002f9e4cdae0a8735af2fdb9:f9d697df17a9eeeee2bde10d3115ddb1cb053361e31653bab1ed87dde1585bbe"
+    },
+    {
+      "id": "U010",
+      "username": "13195216117",
+      "displayName": "肖奎",
+      "phone": "13195216117",
+      "department": "董事办",
+      "position": "董事长",
+      "role": "executive",
+      "status": "启用",
+      "registerSource": "roster",
+      "passwordHash": "pbkdf2:sha256:120000:3150a1ab531c078fba0929057a96f8e9:d877cf3461b42ec8a052d05eeb872273df4061a25e3511574c70457fe12770c5"
+    },
+    {
+      "id": "U011",
+      "username": "manager15",
+      "displayName": "杨森",
+      "phone": "15286077417",
+      "department": "交付部",
+      "position": "杨森",
+      "role": "manager",
+      "status": "停用",
+      "passwordHash": "pbkdf2:sha256:120000:27f857efdeb3bd6317b7b3e4d8e0f5ba:44f1df554e0a00aecd809b1cf43ccbb9fbf6b7b08a69147bd0e07aefdbb6d0ef"
+    },
+    {
+      "id": "U012",
+      "username": "15185113054",
+      "displayName": "白斌",
+      "phone": "15185113054",
+      "department": "麓居设计工作室",
+      "position": "设计经理",
+      "role": "designManager",
+      "status": "启用",
+      "registerSource": "roster",
+      "passwordHash": "pbkdf2:sha256:120000:b479152c5aa77832af594dcd50fb62dd:e7f5583d982109a44b2a0e1c0b10cade1fa7e40f6d30aaa7089dcf02e7c41a8d"
+    }
+  ],
+  "roles": {
+    "admin": {
+      "roleName": "系统管理员",
+      "summary": "全量功能、规则配置、后台权限管理",
+      "pages": [
+        "dashboard",
+        "customers",
+        "inspections",
+        "issues",
+        "audit",
+        "analysis",
+        "rules",
+        "admin"
+      ],
+      "permissions": [
+        "*",
+        "admin:manage"
+      ]
+    },
+    "executive": {
+      "roleName": "管理层",
+      "summary": "全局查看、督办、指派责任人",
+      "pages": [
+        "dashboard",
+        "customers",
+        "inspections",
+        "issues",
+        "audit",
+        "analysis"
+      ],
+      "permissions": [
+        "issue:view",
+        "issue:assign",
+        "inspection:view",
+        "customer:submit"
+      ]
+    },
+    "delivery": {
+      "roleName": "交付助理",
+      "summary": "提交每日巡查、问题反馈、查看客户与问题",
+      "pages": [
+        "dashboard",
+        "customers",
+        "inspections",
+        "issues",
+        "analysis"
+      ],
+      "permissions": [
+        "issue:view",
+        "issue:create",
+        "inspection:view",
+        "inspection:create",
+        "customer:submit"
+      ]
+    },
+    "manager": {
+      "roleName": "项目经理",
+      "summary": "问题处置、提交巡查、提交核查、补充根因",
+      "pages": [
+        "dashboard",
+        "customers",
+        "inspections",
+        "issues",
+        "analysis"
+      ],
+      "permissions": [
+        "issue:view",
+        "issue:handle",
+        "inspection:view",
+        "inspection:create",
+        "customer:submit"
+      ]
+    },
+    "auditor": {
+      "roleName": "监察部",
+      "summary": "查看巡查、监察核查、通过或驳回整改",
+      "pages": [
+        "dashboard",
+        "inspections",
+        "issues",
+        "audit",
+        "analysis",
+        "customers"
+      ],
+      "permissions": [
+        "issue:view",
+        "audit:review",
+        "inspection:view"
+      ]
+    },
+    "designer": {
+      "roleName": "设计师",
+      "summary": "查看客户与问题、提交设计相关反馈、补充处置记录",
+      "pages": [
+        "dashboard",
+        "customers",
+        "inspections",
+        "issues",
+        "analysis"
+      ],
+      "permissions": [
+        "issue:view",
+        "issue:create",
+        "issue:handle",
+        "inspection:view",
+        "inspection:create",
+        "customer:submit"
+      ]
+    },
+    "designManager": {
+      "roleName": "设计经理",
+      "summary": "设计问题统筹、责任指派、处置跟进与客户入池提交",
+      "pages": [
+        "dashboard",
+        "customers",
+        "inspections",
+        "issues",
+        "analysis"
+      ],
+      "permissions": [
+        "issue:view",
+        "issue:create",
+        "issue:assign",
+        "issue:handle",
+        "inspection:view",
+        "inspection:create",
+        "customer:submit"
+      ]
+    },
+    "deliveryManager": {
+      "roleName": "交付经理",
+      "summary": "交付线全量查看、问题督办与客户入池提交",
+      "pages": [
+        "dashboard",
+        "customers",
+        "inspections",
+        "issues",
+        "audit",
+        "analysis"
+      ],
+      "permissions": [
+        "issue:view",
+        "issue:create",
+        "issue:assign",
+        "issue:handle",
+        "inspection:view",
+        "inspection:create",
+        "customer:submit"
+      ]
+    },
+    "customerOwner": {
+      "roleName": "客管人员",
+      "summary": "仅查看本人负责客户、提交反馈与客户入池申请",
+      "pages": [
+        "dashboard",
+        "customers",
+        "inspections",
+        "issues",
+        "analysis"
+      ],
+      "permissions": [
+        "issue:view",
+        "issue:create",
+        "issue:handle",
+        "inspection:view",
+        "inspection:create",
+        "customer:submit"
+      ]
+    },
+    "customerManager": {
+      "roleName": "客管经理",
+      "summary": "查看本部门客管人员名下客户、督办反馈与客户入池提交",
+      "pages": [
+        "dashboard",
+        "customers",
+        "inspections",
+        "issues",
+        "analysis"
+      ],
+      "permissions": [
+        "issue:view",
+        "issue:create",
+        "issue:assign",
+        "issue:handle",
+        "inspection:view",
+        "inspection:create",
+        "customer:submit"
+      ]
+    },
+    "supplyChain": {
+      "roleName": "供应链",
+      "summary": "供应链相关岗位全量查看客户与问题反馈",
+      "pages": [
+        "dashboard",
+        "customers",
+        "inspections",
+        "issues",
+        "analysis"
+      ],
+      "permissions": [
+        "issue:view",
+        "issue:create",
+        "inspection:view",
+        "customer:submit"
+      ]
+    }
+  },
+  "dictionaries": {
+    "departments": [
+      "光宸",
+      "澜光",
+      "蘭堂",
+      "焕境"
+    ],
+    "customerSources": [
+      "转介绍",
+      "公司特殊关系",
+      "标杆项目",
+      "风险关注"
+    ],
+    "issueSources": [
+      "线下巡检",
+      "客户群反馈",
+      "每日线上巡查",
+      "客户主动反馈",
+      "管理层转办",
+      "监察部发现"
+    ],
+    "issueTypes": [
+      "施工质量",
+      "设计变更",
+      "沟通响应",
+      "材料交付",
+      "客户满意度"
+    ],
+    "progressStatuses": [
+      "正常",
+      "延期"
+    ]
+  },
+  "customers": [
+    {
+      "id": "C002",
+      "name": "张先生(测试)",
+      "tier": "A+",
+      "source": "转介绍",
+      "keyCustomerReason": "多少分很快就发货",
+      "customerOwner": "小信",
+      "address": "兴隆语录付",
+      "department": "蘭堂",
+      "designer": "白斌",
+      "manager": "马亚林",
+      "stage": "木作",
+      "startDate": "2026-07-11",
+      "plannedFinishDate": "-",
+      "progressStatus": "正常",
+      "satisfaction": 5,
+      "approvedBy": "肖总",
+      "approvedAt": "2026-07-10 09:41",
+      "inspectionStatus": "未巡查",
+      "inspectionAt": "-",
+      "inspector": "-",
+      "inspectionSummary": "新建客户,等待首次每日线上巡查。"
+    },
+    {
+      "id": "C001",
+      "name": "李女士(测试客户)",
+      "tier": "A+",
+      "source": "转介绍",
+      "keyCustomerReason": "测试测试测试",
+      "customerOwner": "李娟",
+      "address": "中铁阅花溪",
+      "department": "光宸",
+      "designer": "方舒",
+      "manager": "马亚林",
+      "stage": "字段(下拉选项)",
+      "startDate": "2026-07-22",
+      "plannedFinishDate": "-",
+      "progressStatus": "正常",
+      "satisfaction": 5,
+      "approvedBy": "肖总",
+      "approvedAt": "2026-07-10 08:36",
+      "inspectionStatus": "未巡查",
+      "inspectionAt": "-",
+      "inspector": "-",
+      "inspectionSummary": "新建客户,等待首次每日线上巡查。"
+    }
+  ],
+  "customerDrafts": [
+    {
+      "id": "CR-240701",
+      "status": "已通过",
+      "submittedBy": "白斌",
+      "submittedRole": "设计经理",
+      "submittedAt": "2026-07-10 09:39",
+      "reviewedBy": "肖总",
+      "reviewedAt": "2026-07-10 09:41",
+      "reviewNote": "",
+      "customerId": "C002",
+      "customer": {
+        "name": "张先生(测试)",
+        "tier": "A+",
+        "source": "转介绍",
+        "keyCustomerReason": "多少分很快就发货",
+        "customerOwner": "小信",
+        "address": "兴隆语录付",
+        "department": "蘭堂",
+        "designer": "白斌",
+        "manager": "马亚林",
+        "stage": "木作",
+        "startDate": "2026-07-11",
+        "plannedFinishDate": "-",
+        "progressStatus": "正常",
+        "satisfaction": 5
+      }
+    }
+  ],
+  "inspections": [],
+  "issues": [],
+  "rulesState": {
+    "updatedAt": "2026-07-08T20:59:21.706Z",
+    "updatedBy": "肖总"
+  }
+}

+ 540 - 0
data/roster-members.json

@@ -0,0 +1,540 @@
+{
+  "source": "部分岗位花名册.xlsx",
+  "count": 89,
+  "members": [
+    {
+      "name": "肖奎",
+      "department": "董事办",
+      "position": "董事长",
+      "phone": "13195216117"
+    },
+    {
+      "name": "郭光莹",
+      "department": "总经办",
+      "position": "总经理",
+      "phone": "18984336435"
+    },
+    {
+      "name": "杨洁",
+      "department": "",
+      "position": "运营副总",
+      "phone": "18685125668"
+    },
+    {
+      "name": "罗薇",
+      "department": "",
+      "position": "总经理助理(管理型)",
+      "phone": "15885085090"
+    },
+    {
+      "name": "吴昊",
+      "department": "",
+      "position": "AI工程师",
+      "phone": "15885321099"
+    },
+    {
+      "name": "葛亚萍",
+      "department": "用户服务部",
+      "position": "用户服务经理",
+      "phone": "18153113804"
+    },
+    {
+      "name": "余福芬",
+      "department": "",
+      "position": "家访师",
+      "phone": "15285126860"
+    },
+    {
+      "name": "陈思桐",
+      "department": "",
+      "position": "家访师",
+      "phone": "18785092925"
+    },
+    {
+      "name": "吴学勤",
+      "department": "监察部",
+      "position": "监察经理",
+      "phone": "17785695393"
+    },
+    {
+      "name": "杨险峰",
+      "department": "",
+      "position": "售后主管",
+      "phone": "13985146995"
+    },
+    {
+      "name": "郭荣维",
+      "department": "",
+      "position": "售后专员",
+      "phone": "18786670485"
+    },
+    {
+      "name": "陈晓莉",
+      "department": "",
+      "position": "监察专员",
+      "phone": "17885484561"
+    },
+    {
+      "name": "覃稀",
+      "department": "客管一部",
+      "position": "客管经理",
+      "phone": "18786755090"
+    },
+    {
+      "name": "郭青",
+      "department": "",
+      "position": "客户经理",
+      "phone": "18798066872"
+    },
+    {
+      "name": "罗绍娜",
+      "department": "",
+      "position": "客户经理",
+      "phone": "18685058917"
+    },
+    {
+      "name": "陈钰秋",
+      "department": "客管二部",
+      "position": "客管经理",
+      "phone": "18302551585"
+    },
+    {
+      "name": "李娟",
+      "department": "",
+      "position": "客户经理",
+      "phone": "18593847249"
+    },
+    {
+      "name": "张静",
+      "department": "",
+      "position": "客户经理",
+      "phone": "18585196314"
+    },
+    {
+      "name": "董娟",
+      "department": "",
+      "position": "客户经理",
+      "phone": "18285075297"
+    },
+    {
+      "name": "蔡菁",
+      "department": "光宸筑境设计工作室",
+      "position": "设计总监",
+      "phone": "13594001006"
+    },
+    {
+      "name": "何志伟",
+      "department": "",
+      "position": "深化设计师",
+      "phone": "18786724215"
+    },
+    {
+      "name": "肖诚",
+      "department": "熹言设计工作室",
+      "position": "设计经理",
+      "phone": "15185257007"
+    },
+    {
+      "name": "孙晨威",
+      "department": "",
+      "position": "设计师",
+      "phone": "15185143361"
+    },
+    {
+      "name": "杜雅兰",
+      "department": "",
+      "position": "设计师",
+      "phone": "18585004499"
+    },
+    {
+      "name": "陈军",
+      "department": "",
+      "position": "设计师",
+      "phone": "15108518512"
+    },
+    {
+      "name": "张鸿",
+      "department": "",
+      "position": "设计师",
+      "phone": "15608501399"
+    },
+    {
+      "name": "刘露",
+      "department": "",
+      "position": "设计师",
+      "phone": "18798852139"
+    },
+    {
+      "name": "白斌",
+      "department": "麓居设计工作室",
+      "position": "设计经理",
+      "phone": "15185113054"
+    },
+    {
+      "name": "李雪",
+      "department": "",
+      "position": "设计师",
+      "phone": "13983179721"
+    },
+    {
+      "name": "申佳艺",
+      "department": "",
+      "position": "设计师",
+      "phone": "15885039002"
+    },
+    {
+      "name": "黄紫菱",
+      "department": "",
+      "position": "设计师",
+      "phone": "13659855672"
+    },
+    {
+      "name": "车夏灵",
+      "department": "",
+      "position": "设计师",
+      "phone": "18111881817"
+    },
+    {
+      "name": "周金滔",
+      "department": "",
+      "position": "设计师",
+      "phone": "17785147879"
+    },
+    {
+      "name": "简婕",
+      "department": "",
+      "position": "设计师",
+      "phone": "18386869898"
+    },
+    {
+      "name": "赵耀",
+      "department": "",
+      "position": "设计师",
+      "phone": "13312229978"
+    },
+    {
+      "name": "张芮",
+      "department": "蘭堂设计工作室",
+      "position": "设计经理",
+      "phone": "15085983248"
+    },
+    {
+      "name": "陈叶婷",
+      "department": "",
+      "position": "设计师",
+      "phone": "18275026495"
+    },
+    {
+      "name": "王飞",
+      "department": "",
+      "position": "设计师",
+      "phone": "18275307141"
+    },
+    {
+      "name": "胡华玉",
+      "department": "",
+      "position": "设计师",
+      "phone": "19112814491"
+    },
+    {
+      "name": "陈璧昀",
+      "department": "",
+      "position": "设计师",
+      "phone": "15285548521"
+    },
+    {
+      "name": "杨国涛",
+      "department": "",
+      "position": "设计师",
+      "phone": "18212009548"
+    },
+    {
+      "name": "陈明先",
+      "department": "",
+      "position": "设计师",
+      "phone": "15885537787"
+    },
+    {
+      "name": "荘健",
+      "department": "",
+      "position": "设计师",
+      "phone": "18685049992"
+    },
+    {
+      "name": "方舒",
+      "department": "澜光设计工作室",
+      "position": "设计经理",
+      "phone": "18685049993"
+    },
+    {
+      "name": "陈艳琴",
+      "department": "",
+      "position": "设计师",
+      "phone": "18798770975"
+    },
+    {
+      "name": "林煜贤",
+      "department": "",
+      "position": "设计师",
+      "phone": "18586802288"
+    },
+    {
+      "name": "周梦梅",
+      "department": "",
+      "position": "设计师",
+      "phone": "13047606160"
+    },
+    {
+      "name": "张军",
+      "department": "",
+      "position": "设计师",
+      "phone": "13385502991"
+    },
+    {
+      "name": "陈晓丽",
+      "department": "",
+      "position": "设计师",
+      "phone": "17785930792"
+    },
+    {
+      "name": "马仲恒",
+      "department": "叁川设计工作室",
+      "position": "设计总监",
+      "phone": "18798898632"
+    },
+    {
+      "name": "贾九利",
+      "department": "",
+      "position": "设计师",
+      "phone": "18798799042"
+    },
+    {
+      "name": "陈真平",
+      "department": "",
+      "position": "设计师",
+      "phone": "18285001294"
+    },
+    {
+      "name": "王祖婷",
+      "department": "",
+      "position": "设计师",
+      "phone": "18286175625"
+    },
+    {
+      "name": "韦士荣",
+      "department": "",
+      "position": "设计师",
+      "phone": "18286023953"
+    },
+    {
+      "name": "吴珂",
+      "department": "",
+      "position": "设计师",
+      "phone": "15286086079"
+    },
+    {
+      "name": "李厚伦",
+      "department": "",
+      "position": "设计师",
+      "phone": "13809473686"
+    },
+    {
+      "name": "崔晟豪",
+      "department": "",
+      "position": "设计师",
+      "phone": "15286041002"
+    },
+    {
+      "name": "刘玉阳",
+      "department": "交付部",
+      "position": "交付总监",
+      "phone": "18798032359"
+    },
+    {
+      "name": "李继丹",
+      "department": "",
+      "position": "交付助理",
+      "phone": "13639123890"
+    },
+    {
+      "name": "黄文静",
+      "department": "",
+      "position": "产业交付助理",
+      "phone": "18786127845"
+    },
+    {
+      "name": "王海清",
+      "department": "",
+      "position": "水电工种主管",
+      "phone": "18786683490"
+    },
+    {
+      "name": "陶醉",
+      "department": "",
+      "position": "泥作工种主管",
+      "phone": "15885088915"
+    },
+    {
+      "name": "胡登洪",
+      "department": "",
+      "position": "瓷粉工种主管",
+      "phone": "13985102770"
+    },
+    {
+      "name": "蔡天林",
+      "department": "橙子验房",
+      "position": "质检经理",
+      "phone": "18134507121"
+    },
+    {
+      "name": "杜飞",
+      "department": "",
+      "position": "质检员",
+      "phone": "18185000686"
+    },
+    {
+      "name": "范瑞晨",
+      "department": "",
+      "position": "质检员",
+      "phone": "18586645113"
+    },
+    {
+      "name": "幸艳",
+      "department": "供应链中心",
+      "position": "供应链总监兼董事长助理",
+      "phone": "18984040853"
+    },
+    {
+      "name": "毛永艳",
+      "department": "供应链中心-硬供部",
+      "position": "主材采购专员",
+      "phone": "13721582092"
+    },
+    {
+      "name": "杨秀玲",
+      "department": "",
+      "position": "主材采购专员",
+      "phone": "15285547404"
+    },
+    {
+      "name": "简阮叶",
+      "department": "",
+      "position": "辅材采购专员",
+      "phone": "18786119156"
+    },
+    {
+      "name": "王利梅",
+      "department": "",
+      "position": "主辅材结算专员",
+      "phone": "15761460941"
+    },
+    {
+      "name": "王姣",
+      "department": "",
+      "position": "库管员",
+      "phone": "18224741305"
+    },
+    {
+      "name": "芦丽娟",
+      "department": "供应链中心-定制部",
+      "position": "定制设计主管",
+      "phone": "18744762020"
+    },
+    {
+      "name": "墙斌",
+      "department": "",
+      "position": "定制设计师",
+      "phone": "13688509676"
+    },
+    {
+      "name": "李威",
+      "department": "",
+      "position": "定制设计师",
+      "phone": "17680155210"
+    },
+    {
+      "name": "郭兴博",
+      "department": "",
+      "position": "定制设计师",
+      "phone": "15761656727"
+    },
+    {
+      "name": "王新华",
+      "department": "",
+      "position": "定制设计师",
+      "phone": "13385115211"
+    },
+    {
+      "name": "刘燕",
+      "department": "",
+      "position": "定制采购主管",
+      "phone": "13608588942"
+    },
+    {
+      "name": "叶梅",
+      "department": "",
+      "position": "定制跟单员",
+      "phone": "15885067714"
+    },
+    {
+      "name": "粟瑞璐",
+      "department": "",
+      "position": "定制采购专员",
+      "phone": "18585038566"
+    },
+    {
+      "name": "刘凤云",
+      "department": "",
+      "position": "定制采购专员",
+      "phone": "17785460415"
+    },
+    {
+      "name": "杨正顺",
+      "department": "",
+      "position": "定制安装售后主管",
+      "phone": "18085005809"
+    },
+    {
+      "name": "姚正权",
+      "department": "",
+      "position": "定制安装技师",
+      "phone": "18984107284"
+    },
+    {
+      "name": "吴远亮",
+      "department": "",
+      "position": "定制安装技师",
+      "phone": "13087830618"
+    },
+    {
+      "name": "王腾",
+      "department": "",
+      "position": "定制安装技师",
+      "phone": "18886210809"
+    },
+    {
+      "name": "陈浪涛",
+      "department": "供应链中心-全案配套销售部",
+      "position": "销售经理",
+      "phone": "18111868161"
+    },
+    {
+      "name": "赵甜",
+      "department": "",
+      "position": "销售专员",
+      "phone": "15329606612"
+    },
+    {
+      "name": "周曾园",
+      "department": "",
+      "position": "销售专员",
+      "phone": "18798084987"
+    },
+    {
+      "name": "倪志超",
+      "department": "",
+      "position": "灯光设计师",
+      "phone": "15185000630"
+    }
+  ]
+}

+ 50 - 0
docs/delivery-index.md

@@ -0,0 +1,50 @@
+# A+在建客户动态管理系统上线交付索引
+
+## 交付物
+
+| 类型 | 路径 | 说明 |
+| --- | --- | --- |
+| 产品说明 | `docs/product-prototype.md` | 产品定位、A/A+ 客户边界、角色权限、数据实体、每日线上巡查、问题预警分析、上线范围 |
+| 本地后台服务 | `server.js` | 提供登录、会话、A/A+ 客户、客户入池审核、每日巡查、问题、处置、核查、规则保存、账号权限、基础字典 API;权限由后台统一校验 |
+| Web 正式版 | `prototype/index.html` | 多页面后台:动态驾驶舱、A/A+ 客户库、每日线上巡查、问题反馈池、监察核查、问题分析、预警规则、系统后台;含新增客户、客户入池审核、提交巡查、新增反馈、详情抽屉、指派、处置满意度、通过/驳回核查、账号管理、角色授权和字典配置按钮 |
+| 小程序静态版 | `miniapp-prototype/index.html` | 手机端完整流程:首页、提交每日巡查、问题详情、处置记录、问题分析;含日查反馈、保存处置、满意度回访、核查通过、驳回整改按钮 |
+| Web UI 概念图 | `ui-mockups/web-issue-warning-ui.png` | Web 问题反馈预警驾驶舱视觉方向图 |
+| 小程序 UI 概念图 | `ui-mockups/miniapp-issue-warning-ui.png` | 小程序问题预警首页视觉方向图 |
+| Excel 数据样本 | `source_customer_grading.xlsx` | 从微信文件复制到项目内的分析副本,原文件未改动 |
+
+## 建议验收顺序
+
+1. 先看 `docs/product-prototype.md`,确认业务范围、字段和流程。
+2. 运行 `node server.js`,再打开 `http://127.0.0.1:8787/prototype/index.html`,验收管理层 Web 端驾驶舱与客户详情。
+3. 再打开 `miniapp-prototype/index.html`,验收移动端每日巡查、反馈提交、处置记录和核查效率。
+4. 最后看 `ui-mockups/` 两张图片,确认后续 UI 视觉方向。
+
+## 当前上线重点
+
+- 系统只保留 A / A+ 客户,新增客户和后台接口都会拒绝其他等级。
+- 管理员新增客户直接进入客户库;非管理员新增客户先进入“系统后台”的管理员审核池,通过后才入库。
+- 把原表中的“日巡查情况、问题反馈、跟进情况、监察部核查”改造成“每日线上巡查、问题反馈预警、根因分析、责任处置、满意度回访、监察核查”的闭环流程。
+- Web 端优先服务管理层查看问题预警、问题类型分布、责任部门归因和重点问题详情。
+- 小程序端优先服务一线人员快速提交每日巡查、补充问题反馈、记录处置、提交核查和查看简版分析。
+- 所有客户姓名、地址字段默认按敏感信息处理。
+
+## 可点击按钮
+
+- Web:新增客户、提交客户入池申请、客户入池审核通过/驳回、保存客户档案、提交每日巡查、新增问题反馈、提交并进入预警池、查看问题详情、指派责任人、提交处置满意度、核查通过、驳回整改、保存预警规则、新增后台账号、保存账号、停启用账号、保存角色权限、保存基础字典。
+- 小程序:提交日查、保存并触发预警、进入处置、保存处置并提交核查、核查通过、驳回整改、底部 Tab 切换。
+
+## 登录与后台权限
+
+Web 正式版已接入后台,前端不再保存角色权限表。登录、页面授权、按钮授权和操作放行都由 `server.js` 控制;无权限操作会被 API 返回 `403` 拒绝。
+
+系统管理员可在 Web 端“系统后台”审核非管理员提交的客户入池申请,维护账号、启停用账号、调整账号角色、配置角色可访问页面和可执行操作,并维护责任部门、客户来源、问题来源、问题类型、当前进度等下拉字典。
+
+正式账号和初始密码不在页面或文档中展示,由系统管理员在“系统后台”统一新增、停启用和重置。
+
+无权限按钮会按后台返回的权限自动置灰;即使通过浏览器控制台直接调用接口,后台也会拒绝未授权的新增客户、提交反馈、处置、核查、规则保存等操作。
+
+## 正式运行数据
+
+服务端已接入本地数据文件持久化,新增客户、客户入池审核、每日巡查、问题反馈、责任人指派、处置记录、监察核查、预警规则、账号权限和基础字典会由 `server.js` 自动保存。
+
+Windows 本地默认保存到 `data/app-state.json`;云服务器默认保存到 `/var/lib/keyissue-prototype/app-state.json`。如果需要迁移或备份正式数据,优先备份该文件。

+ 322 - 0
docs/product-prototype.md

@@ -0,0 +1,322 @@
+# A+在建客户动态管理系统产品说明
+
+## 1. 产品定位
+
+面向公司管理层、监察部、交付助理、设计师、项目经理的 A+ 在建客户动态管理系统。当前 Excel《重要客户分级共享表》和会议纪要是系统的数据起点,系统目标是把静态共享表升级为“每日线上巡查 + 问题反馈预警 + 问题分析 + 责任处置 + 满意度回访 + 监察核查”的管理工具。
+
+本次升级后的客户等级只保留 A / A+。其他客户等级不进入本系统,新建客户时后台会直接拒绝非 A/A+ 等级。
+
+## 2. 原表信息抽取
+
+原表名称:重要客户分级共享表(记录模版)
+
+保留等级:
+
+- A+ 类:高端大平层、别墅整装全案客户;愿意推荐公司或已经推荐公司;高净值人脉客户;具备极强口碑传播及转介绍潜力;公司重点标杆项目客户。
+- A 类:公司特殊关系或特别要求关注的重要客户;出现问题后需要重点跟进并做风险控制管理的客户。
+
+原表字段:
+
+| 模块 | 字段 |
+| --- | --- |
+| 客户基础 | 序号、等级、客户信息来源、重要客户姓名、客户地址(新居)、客户地址(旧居) |
+| 组织协同 | 部门、设计师、项目经理 |
+| 项目状态 | 施工阶段、开工日期、计划完工日期、当前进度、客户满意度 |
+| 每日巡查 | 日巡查情况(交付助理或内部人员填写) |
+| 问题与处置 | 问题反馈、问题描述、跟进情况、跟进时间 |
+| 监察闭环 | 监察部核查、完成时间、备注 |
+
+## 3. 产品目标
+
+- 统一录入 A/A+ 客户档案,减少多版本 Excel 传递。
+- 让管理层快速看到每日巡查覆盖、异常巡查、红色预警问题、待核查问题。
+- 将“客户行内反馈问题”直接转成问题反馈单,并自动绑定客户档案。
+- 把“问题反馈 -> 预警分级 -> 根因分析 -> 责任处置 -> 处置后满意度 -> 监察核查 -> 完成复盘”做成闭环。
+- 支持小程序随手提交每日巡查、补充问题反馈、记录处置和查看简版分析。
+
+## 4. 用户角色
+
+| 角色 | 主要诉求 | 核心权限 |
+| --- | --- | --- |
+| 董事长/总经理/高管 | 看 A/A+ 客户态势、异常巡查、问题风险、责任进展 | 全局看板、客户详情、巡查查看、督办 |
+| 客户管理负责人 | 维护客户档案、分派责任、审核关键变更 | 客户增改、任务分派、规则维护 |
+| 监察部 | 核查问题是否真实解决、形成闭环 | 核查记录、通过/驳回、问题复发统计 |
+| 交付助理 | 每日线上巡查、补充现场情况和客户群反馈 | 巡查记录、问题反馈、图片/语音补充 |
+| 设计师/项目经理 | 处置客户问题、反馈处理进度和满意度 | 负责客户、处置记录、完成申请 |
+
+## 5. 数据实体
+
+### 客户 Customer
+
+- customer_id
+- customer_name
+- grade:A / A+
+- source:转介绍、特殊关系、标杆项目、风险关注等
+- key_customer_reason:重点客户入选说明
+- customer_owner:客管人员负责人
+- new_address / old_address
+- owner_department
+- designer_id
+- project_manager_id
+- construction_stage
+- start_date
+- planned_finish_date
+- progress_status:正常、延期
+- satisfaction_score
+- inspection_status:已巡查、未巡查、异常
+- latest_inspection_at
+- latest_inspection_summary
+- created_by / created_at
+
+### 客户入池审核 CustomerReview
+
+- review_id
+- customer_snapshot:客户提交时的完整字段快照
+- submitted_by / submitted_at
+- status:待审核、已通过、已驳回
+- reviewed_by / reviewed_at
+- review_note
+
+### 每日巡查 Inspection
+
+- inspection_id
+- customer_id
+- status:已巡查、未巡查、异常
+- inspector_id
+- site_quality:施工质量观察
+- civility:文明施工观察
+- group_feedback:客户群反馈
+- summary:巡查结论
+- photos / voice_notes
+- created_at
+
+### 问题 Issue
+
+- issue_id
+- customer_id
+- source:线下巡检、客户群反馈、每日线上巡查、客户主动反馈、管理层转办、监察部发现等
+- issue_type:施工质量、设计变更、沟通响应、材料交付、文明施工等
+- issue_summary:问题描述 / 客户反馈原文
+- root_cause
+- warning_level:红色预警、橙色预警、蓝色关注
+- owner_id
+- status:新反馈、红色预警、橙色预警、处理中、待核查、已处理、已复发
+- post_satisfaction:满意、一般、不满意、待回访
+- created_at
+
+### 处置记录 HandlingRecord
+
+- record_id
+- issue_id
+- handler_id
+- action_detail
+- customer_feedback
+- post_satisfaction
+- handled_at
+
+### 核查记录 Audit
+
+- audit_id
+- issue_id
+- auditor_id
+- result:通过、驳回、需补充
+- audit_note
+- audited_at
+
+## 6. Web 端信息架构
+
+1. 动态驾驶舱
+   - 今日已巡查、异常巡查、红色预警问题、待核查问题
+   - 高频问题分析
+   - 部门/人员问题归因排名
+   - 重点异常问题表
+
+2. A/A+ 客户库
+   - 客户列表
+   - 新增客户
+   - 客户行内提交问题反馈
+   - 客户行内提交每日巡查
+   - 客户详情
+
+3. 每日线上巡查
+   - 今日巡查记录
+   - 异常巡查
+   - 未巡查客户
+   - 施工质量、文明施工、客户群反馈三类观察项
+
+4. 问题反馈池
+   - 新反馈
+   - 红色预警
+   - 处理中
+   - 待核查
+   - 已处理
+
+5. 监察核查
+   - 待核查问题
+   - 核查通过
+   - 驳回整改
+   - 复发问题记录
+
+6. 问题分析
+   - 问题类型分布
+   - 责任部门分布
+   - 巡查异常来源
+   - 满意度回访结果
+
+7. 预警规则
+   - 预警触发规则
+   - 反馈来源字典
+   - 提醒规则
+
+8. 系统后台
+   - 账号新增、停用、启用、角色调整、密码重置
+   - 客户入池审核池,通过后进入 A/A+ 客户库,驳回后不入库
+   - 角色可访问页面配置
+   - 角色可执行操作配置
+   - 责任部门、客户来源、问题来源、问题类型、当前进度等基础字典维护
+
+## 7. 小程序信息架构
+
+1. 首页
+   - 今日巡查预警
+   - 异常提醒
+   - 待核查提醒
+   - A/A+ 客户快捷入口
+
+2. 提交日查
+   - 选择客户
+   - 选择巡查状态
+   - 填写施工质量、文明施工、客户群反馈
+   - 形成问题描述或巡查结论
+
+3. 问题详情
+   - 客户档案
+   - 问题描述
+   - 问题链路
+   - 处置记录
+
+4. 处置记录
+   - 新增处置说明
+   - 记录处置后满意度
+   - 提交监察核查
+
+5. 问题分析
+   - 本周巡查
+   - 异常预警
+   - 高频问题类型
+   - 部门闭环率
+
+## 8. 核心流程
+
+### 流程 A:录入 A/A+ 客户
+
+1. 有权限人员点击“新增客户”。
+2. 填写客户姓名、等级、来源、客管人员负责人、重点客户入选说明、地址、部门、设计师、项目经理、施工阶段、开工日期、计划完工日期、当前进度。
+3. 只能选择 A 或 A+,后台接口同时校验等级。
+4. 管理员提交时直接进入客户库,并生成“等待首次每日线上巡查”的初始状态。
+5. 非管理员提交时进入“系统后台 > 管理员审核池”,管理员通过后才进入客户库,驳回则不入库。
+
+### 流程 B:每日线上巡查
+
+1. 交付助理或内部负责人选择 A/A+ 客户。
+2. 填写巡查状态:已巡查、未巡查、异常。
+3. 记录施工质量、文明施工、客户群反馈和巡查结论。
+4. 异常巡查进入管理层驾驶舱,并可转成问题反馈。
+
+### 流程 C:问题反馈预警与分析闭环
+
+1. 在客户行或问题反馈池提交问题反馈,系统自动绑定客户。
+2. 填写问题描述、问题类型、反馈来源、初步根因。
+3. 系统根据客户等级、问题类型、满意度、重复反馈次数生成预警级别。
+4. 责任人提交处置记录,并记录处置后客户是否满意。
+5. 完成后提交“待核查”。
+6. 监察部核查:通过后标记已处理,驳回后重新进入处理中;复发问题自动升级预警。
+7. 看板同步展示问题类型、预警级别、责任部门、根因分布、满意度和闭环情况。
+
+### 流程 D:管理层看板巡检
+
+1. 管理层进入动态驾驶舱。
+2. 优先查看:异常巡查、红色预警问题、重复反馈问题、处置后不满意、待核查问题。
+3. 点击预警卡片进入问题详情。
+4. 在详情页查看客户背景、问题描述、处置记录、满意度回访和监察核查状态。
+5. 可直接发起督办或调整责任人。
+
+## 9. MVP 页面清单
+
+| 端 | 页面 | 必做能力 |
+| --- | --- | --- |
+| Web | 动态驾驶舱 | KPI、每日巡查、问题预警、问题类型分析、重点问题表 |
+| Web | A/A+ 客户库 | 筛选、搜索、等级标签、客管负责人、入选说明、开工日期、计划完工日期、当前进度、日查情况、行内反馈 |
+| Web | 每日线上巡查 | 巡查提交、异常巡查、施工质量、文明施工、客户群反馈 |
+| Web | 问题反馈池 | 新反馈、红色预警、处理中、待核查、已处理 |
+| Web | 问题详情 | 基础信息、问题描述、问题链路、处置流水、满意度 |
+| Web | 新增/编辑客户 | 按原表字段结构化录入,仅允许 A/A+ |
+| Web | 系统后台 | 客户入池审核、账号管理、角色页面授权、角色操作授权、基础字典配置 |
+| 小程序 | 首页 | 今日巡查、异常预警、待核查、快捷日查 |
+| 小程序 | 提交日查 | 日查状态、客户群反馈、问题描述 |
+| 小程序 | 处置记录 | 文字、图片、语音、满意度、完成申请 |
+| 小程序 | 简版看板 | 巡查数、异常预警、问题类型、部门闭环率 |
+
+## 10. 看板指标
+
+- A/A+ 客户总数
+- A 客户数、A+ 客户数
+- 今日已巡查客户数
+- 未巡查客户数
+- 异常巡查客户数
+- 问题反馈总数
+- 红色/橙色/蓝色预警数量
+- 重复反馈客户数
+- 处置后不满意问题数
+- 高频问题类型 Top 5
+- 部门问题反馈量
+- 部门问题闭环率
+- 待监察核查任务数
+- 平均问题闭环时长
+- 问题复发率
+- 设计师/项目经理待办排行
+
+## 11. 关键交互规则
+
+- 系统只出现 A / A+ 两类等级。
+- 客户列表中点击客户姓名或“反馈”即可提交问题反馈,并自动绑定该客户。
+- 客户列表中点击“日查”即可提交每日线上巡查,并自动绑定该客户。
+- 问题类型必须配套展示详细问题描述,不能只显示类型标签。
+- 处置动作必须形成多条处置记录卡片,展示处理人、时间、说明和处置后满意度。
+- 问题预警状态要比普通跟进更醒目,因为管理层最关心“问题是什么、严重到什么程度、根因在哪、谁来解决、客户是否满意、是否复发”。
+- 小程序优先减少输入负担:常用字段做选择器,问题反馈和处置记录支持语音和图片。
+
+## 12. UI 风格方向
+
+- Web:克制、密集、可扫描的经营管理后台;白底、深色文字、少量蓝绿状态色;8px 以内圆角;导航和表格优先。
+- 小程序:轻量、卡片化但不过度装饰;底部 Tab;核心信息一屏内可读;大按钮只用于“提交日查”“保存处置”和“完成申请”。
+- 信息层级:异常状态 > 问题描述 > 责任人 > 时间 > 客户基础信息。
+- 色彩语义:
+  - A/A+:深蓝或金色小标签
+  - 异常/风险:红色
+  - 待核查:橙色
+  - 已处理:绿色
+
+## 13. 后续迭代
+
+- 企业微信/微信服务通知提醒。
+- Excel 导入导出与字段映射。
+- 客户满意度打分历史。
+- 项目节点自动同步。
+- 管理层批注与督办。
+- 转介绍价值跟踪。
+- 按会议建议拆出“问题客户动态管理”和“收尾客户立项管理”两个独立模块入口。
+- AI 周报:自动总结本周巡查异常、高频问题、根因分布、复发问题和下周重点。
+
+## 14. Excel 导入与数据质量规则
+
+- 导入时忽略空白工作表,当前模板中 Sheet2、Sheet3 为空。
+- 导入时以第 3 行作为表头,从第 4 行开始读取数据。
+- 导入时清洗姓名、人员字段中的不可见字符。
+- 序号仅作为导入顺序,不作为客户主键。
+- 新增客户时必须生成客户 ID、项目 ID、创建人、创建时间、更新时间。
+- “跟进情况”不能覆盖式更新,应落成多条问题处置流水。
+- “日巡查情况”拆成巡查日期、巡查人、巡查状态、施工质量、文明施工、客户群反馈、巡查结论。
+- “监察部核查”建议用待核查、通过、驳回、延期四种状态。
+- 客户姓名、新居地址、旧居地址默认按敏感字段处理,按角色控制可见范围。
+- Excel 中如存在下拉校验范围错位,系统导入时只使用字段名和单元格值,不直接继承校验范围。

+ 122 - 0
miniapp-prototype/app.js

@@ -0,0 +1,122 @@
+const pages = document.querySelectorAll(".page");
+const tabs = document.querySelectorAll("[data-tab]");
+const jumpers = document.querySelectorAll("[data-jump]");
+const toast = document.querySelector("#toast");
+
+let feedbackCount = 18;
+let redCount = 5;
+let auditCount = 11;
+let warningCount = 4;
+let weeklyCount = 55;
+let closureRate = 82;
+
+function activatePage(name) {
+  pages.forEach((page) => {
+    page.classList.toggle("active", page.dataset.page === name);
+    if (page.dataset.page === name) page.scrollTop = 0;
+  });
+
+  tabs.forEach((tab) => {
+    tab.classList.toggle("active", tab.dataset.tab === name);
+  });
+}
+
+function showToast(message) {
+  toast.textContent = message;
+  toast.classList.add("show");
+  window.clearTimeout(showToast.timer);
+  showToast.timer = window.setTimeout(() => {
+    toast.textContent = "";
+    toast.classList.remove("show");
+  }, 2200);
+}
+
+function updateMetrics() {
+  document.querySelector("#mobileFeedbackCount").textContent = feedbackCount;
+  document.querySelector("#mobileRedCount").textContent = redCount;
+  document.querySelector("#mobileAuditCount").textContent = auditCount;
+  document.querySelector("#warningCount").textContent = `${warningCount} 项`;
+  document.querySelector("#pendingAuditText").textContent = `${Math.max(0, auditCount - 8)} 个`;
+  document.querySelector("#weeklyCount").textContent = weeklyCount;
+  document.querySelector("#closureRate").textContent = `${closureRate}%`;
+}
+
+tabs.forEach((tab) => {
+  tab.addEventListener("click", () => activatePage(tab.dataset.tab));
+});
+
+jumpers.forEach((button) => {
+  button.addEventListener("click", () => activatePage(button.dataset.jump));
+});
+
+document.querySelectorAll(".segmented button").forEach((button) => {
+  button.addEventListener("click", () => {
+    button.parentElement.querySelectorAll("button").forEach((item) => {
+      item.classList.toggle("selected", item === button);
+    });
+  });
+});
+
+document.querySelector("#feedbackForm").addEventListener("submit", (event) => {
+  event.preventDefault();
+  const data = new FormData(event.currentTarget);
+  const customer = data.get("customer") || "新客户";
+  const level = data.get("level");
+  const type = data.get("type");
+  const status = data.get("status") || "已巡查";
+
+  feedbackCount += 1;
+  weeklyCount += 1;
+  warningCount += 1;
+  if (level === "红色预警" || status === "异常") redCount += 1;
+
+  document.querySelector("#warningList").insertAdjacentHTML("afterbegin", `
+    <li>
+      <b>${level.replace("预警", "")}</b>
+      <div>
+        <strong>${customer} · ${type}</strong>
+        <p>${status}:${data.get("summary")}</p>
+      </div>
+    </li>
+  `);
+  document.querySelector("#detailCustomer").textContent = customer;
+  document.querySelector("#detailLevel").textContent = level;
+  document.querySelector("#detailType").textContent = type;
+  document.querySelector("#detailTimeline").insertAdjacentHTML("afterbegin", `
+    <li>
+      <strong>新日查反馈</strong>
+      <p>${data.get("summary")}</p>
+      <span>刚刚</span>
+    </li>
+  `);
+
+  updateMetrics();
+  showToast("每日巡查已提交,异常项已同步预警。");
+  activatePage("home");
+});
+
+document.querySelector("#actionForm").addEventListener("submit", (event) => {
+  event.preventDefault();
+  auditCount += 1;
+  document.querySelector("#auditIssueName").textContent = "肖女士木作收口整改";
+  document.querySelector("#auditIssueDesc").textContent = "处置记录已提交,等待监察部核查。";
+  updateMetrics();
+  showToast("处置记录和满意度已保存,状态已同步到待核查。");
+});
+
+document.querySelector("#auditPassBtn").addEventListener("click", () => {
+  auditCount = Math.max(0, auditCount - 1);
+  closureRate = Math.min(99, closureRate + 1);
+  updateMetrics();
+  showToast("核查通过,问题已处理并进入复盘。");
+});
+
+document.querySelector("#auditRejectBtn").addEventListener("click", () => {
+  redCount += 1;
+  auditCount = Math.max(0, auditCount - 1);
+  warningCount += 1;
+  updateMetrics();
+  showToast("已驳回整改,问题升级为红色预警。");
+});
+
+updateMetrics();

+ 351 - 0
miniapp-prototype/index.html

@@ -0,0 +1,351 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>A+在建客户动态管理小程序</title>
+  <link rel="stylesheet" href="styles.css">
+</head>
+<body>
+  <main class="preview-shell">
+    <section class="phone" aria-label="微信小程序手机预览">
+      <header class="status-bar">
+        <span>13:15</span>
+        <span class="status-icons">5G 100%</span>
+      </header>
+
+      <div class="miniapp-top">
+        <button class="icon-btn" type="button" aria-label="返回">‹</button>
+        <strong>A+在建客户动态管理</strong>
+        <div class="capsule" aria-hidden="true">
+          <span></span>
+          <i></i>
+        </div>
+      </div>
+
+      <div class="page-stack">
+        <section class="page active" data-page="home">
+          <div class="hero-panel">
+            <p>A/A+ 客户日报</p>
+            <h1>今日巡查预警</h1>
+            <div class="metric-row">
+              <article>
+                <strong id="mobileFeedbackCount">18</strong>
+                <span>今日巡查</span>
+              </article>
+              <article>
+                <strong id="mobileRedCount">5</strong>
+                <span>异常预警</span>
+              </article>
+              <article>
+                <strong id="mobileAuditCount">11</strong>
+                <span>待核查</span>
+              </article>
+            </div>
+          </div>
+
+          <div class="quick-actions">
+            <button type="button" data-jump="entry">提交日查</button>
+            <button type="button" data-jump="follow">处置记录</button>
+            <button type="button" data-jump="board">问题分析</button>
+          </div>
+
+          <section class="content-block">
+            <div class="block-title">
+              <h2>预警提醒</h2>
+              <span id="warningCount">4 项</span>
+            </div>
+            <ul class="task-list" id="warningList">
+              <li>
+                <b>红色</b>
+                <div>
+                  <strong>肖女士木作收口不一致</strong>
+                  <p>A+ 客户日查异常,需设计师和项目经理共同到场说明。</p>
+                </div>
+              </li>
+              <li>
+                <b>核查</b>
+                <div>
+                  <strong>陈先生墙面返修完成</strong>
+                  <p>项目经理已提交完成申请,等待监察部现场复验。</p>
+                </div>
+              </li>
+            </ul>
+          </section>
+
+          <section class="content-block">
+            <div class="block-title">
+              <h2>问题动态</h2>
+              <span>实时</span>
+            </div>
+            <div class="feed-item">
+              <span class="avatar">交</span>
+              <div>
+                <strong>交付助理新增 3 条每日巡查</strong>
+                <p>其中 1 条由客户群反馈触发异常预警。</p>
+              </div>
+            </div>
+            <div class="feed-item">
+              <span class="avatar green">监</span>
+              <div>
+                <strong>监察部通过 2 条核查</strong>
+                <p>已处理问题同步进入部门质量复盘池。</p>
+              </div>
+            </div>
+          </section>
+        </section>
+
+        <section class="page" data-page="entry">
+          <div class="page-heading">
+            <h1>提交每日巡查</h1>
+            <p>快速记录 A/A+ 客户日查情况、问题描述和客户群反馈。</p>
+          </div>
+
+          <form class="form-card" id="feedbackForm">
+            <label>
+              <span>客户姓名</span>
+              <input name="customer" type="text" value="肖女士">
+            </label>
+            <label>
+              <span>客户等级</span>
+              <select name="tier">
+                <option>A+</option>
+                <option>A</option>
+              </select>
+            </label>
+            <label>
+              <span>巡查状态</span>
+              <select name="status">
+                <option>异常</option>
+                <option>已巡查</option>
+                <option>未巡查</option>
+              </select>
+            </label>
+            <label>
+              <span>问题类型 / 观察项</span>
+              <select name="type">
+                <option>施工质量</option>
+                <option>文明施工</option>
+                <option>客户群反馈</option>
+                <option>设计变更</option>
+                <option>沟通响应</option>
+                <option>材料交付</option>
+              </select>
+            </label>
+            <label>
+              <span>预警级别</span>
+              <select name="level">
+                <option>红色预警</option>
+                <option>橙色预警</option>
+                <option>蓝色关注</option>
+              </select>
+            </label>
+            <label>
+              <span>施工质量</span>
+              <select name="siteQuality">
+                <option>存在质量疑问</option>
+                <option>正常</option>
+                <option>需复核</option>
+              </select>
+            </label>
+            <label>
+              <span>文明施工</span>
+              <select name="civility">
+                <option>现场材料堆放偏乱</option>
+                <option>正常</option>
+                <option>成品保护不足</option>
+                <option>发现抽烟情况</option>
+              </select>
+            </label>
+            <label>
+              <span>客户群反馈</span>
+              <textarea name="groupFeedback" rows="3">客户群反馈木作收口与效果图差异明显,希望项目经理今天到场确认整改方案。</textarea>
+            </label>
+            <label>
+              <span>问题描述 / 巡查结论</span>
+              <textarea name="summary" rows="4">客户对收口差异较敏感,建议今天完成现场复核并同步整改清单。</textarea>
+            </label>
+            <button class="primary-btn" type="submit">保存日查并触发预警</button>
+          </form>
+        </section>
+
+        <section class="page" data-page="detail">
+          <div class="customer-card">
+            <div class="customer-main">
+              <span class="avatar large">肖</span>
+              <div>
+                <h1 id="detailCustomer">肖女士</h1>
+                <p id="detailAddress">阅花溪 9 栋 B 座 1702</p>
+              </div>
+              <em>A+</em>
+            </div>
+            <div class="tag-row">
+              <span id="detailLevel">红色预警</span>
+              <span id="detailType">施工质量</span>
+              <span>二次反馈</span>
+            </div>
+          </div>
+
+          <section class="content-block">
+            <div class="block-title">
+              <h2>巡查与问题摘要</h2>
+              <span>影响 91</span>
+            </div>
+            <dl class="info-list">
+              <div><dt>责任部门</dt><dd>光宸</dd></div>
+              <div><dt>施工阶段</dt><dd>木作安装</dd></div>
+              <div><dt>设计师</dt><dd>蔡菁</dd></div>
+              <div><dt>项目经理</dt><dd>刘江斌</dd></div>
+              <div><dt>日查情况</dt><dd>异常</dd></div>
+              <div><dt>处置后满意度</dt><dd>待回访</dd></div>
+            </dl>
+          </section>
+
+          <section class="content-block">
+            <div class="block-title">
+              <h2>问题链路</h2>
+              <button type="button" data-jump="follow">去处置</button>
+            </div>
+            <ol class="timeline" id="detailTimeline">
+              <li>
+              <strong>客户反馈</strong>
+                <p>木作收口与效果图差异明显,要求今天给出整改方案。</p>
+                <span>今天 09:42</span>
+              </li>
+              <li>
+                <strong>初步归因</strong>
+                <p>设计交底与现场深化尺寸未完全同步,需联合复核。</p>
+                <span>今天 10:20</span>
+              </li>
+            </ol>
+          </section>
+        </section>
+
+        <section class="page" data-page="follow">
+          <div class="page-heading">
+            <h1>处置记录</h1>
+            <p>记录问题处理动作、根因分析、客户回访和提交核查申请。</p>
+          </div>
+
+          <form class="form-card" id="actionForm">
+            <label>
+              <span>选择问题</span>
+              <select name="issue">
+                <option>肖女士 · 木作收口不一致</option>
+                <option>周先生 · 沟通响应慢</option>
+                <option>林女士 · 材料到货延期</option>
+              </select>
+            </label>
+            <div class="segmented" role="group" aria-label="处置动作">
+              <button class="selected" type="button">现场复核</button>
+              <button type="button">整改中</button>
+              <button type="button">提交核查</button>
+            </div>
+            <label>
+              <span>处置说明</span>
+              <textarea name="note" rows="4">已约设计师、项目经理 16:00 到场,现场确认差异点并同步整改清单。</textarea>
+            </label>
+            <label>
+              <span>处置后满意度</span>
+              <select name="satisfaction">
+                <option>待回访</option>
+                <option>满意</option>
+                <option>一般</option>
+                <option>不满意</option>
+              </select>
+            </label>
+            <label>
+              <span>建议核查时间</span>
+              <input name="auditTime" type="datetime-local" value="2026-07-02T10:30">
+            </label>
+            <button class="primary-btn" type="submit">保存处置并提交核查</button>
+          </form>
+
+          <section class="content-block compact">
+            <div class="block-title">
+              <h2>待核查</h2>
+              <span id="pendingAuditText">3 个</span>
+            </div>
+            <div class="reminder-row">
+              <b>明天</b>
+              <div>
+                <strong id="auditIssueName">陈先生墙面返修</strong>
+                <p id="auditIssueDesc">监察部复验通过后可标记为已处理。</p>
+              </div>
+            </div>
+            <div class="quick-actions">
+              <button type="button" id="auditPassBtn">核查通过</button>
+              <button type="button" id="auditRejectBtn">驳回整改</button>
+            </div>
+          </section>
+        </section>
+
+        <section class="page" data-page="board">
+          <div class="page-heading">
+            <h1>问题分析</h1>
+            <p>按日查异常、问题类型、部门和预警等级查看 A/A+ 客户反馈趋势。</p>
+          </div>
+
+          <div class="board-summary">
+            <article>
+              <span>本周巡查</span>
+              <strong id="weeklyCount">55</strong>
+              <p>异常预警 8 件</p>
+            </article>
+            <article>
+              <span>闭环率</span>
+              <strong id="closureRate">82%</strong>
+              <p>较上周 +6%</p>
+            </article>
+          </div>
+
+          <section class="pipeline">
+            <article>
+              <div class="stage-head"><b>施工质量</b><span>26</span></div>
+              <div class="bar"><i style="width: 86%"></i></div>
+              <p>主要集中在收口、返修、成品保护。</p>
+            </article>
+            <article>
+              <div class="stage-head"><b>设计变更</b><span>19</span></div>
+              <div class="bar"><i style="width: 72%"></i></div>
+              <p>变更确认链路需要固化。</p>
+            </article>
+            <article>
+              <div class="stage-head"><b>文明施工</b><span>15</span></div>
+              <div class="bar"><i style="width: 48%"></i></div>
+              <p>现场杂乱、成品保护和抽烟情况需要每日复核。</p>
+            </article>
+            <article>
+              <div class="stage-head"><b>材料交付</b><span>9</span></div>
+              <div class="bar"><i style="width: 36%"></i></div>
+              <p>进口材料需提前建立替代池。</p>
+            </article>
+          </section>
+
+          <section class="content-block">
+            <div class="block-title">
+              <h2>部门预警排行</h2>
+              <span>周榜</span>
+            </div>
+            <div class="rank-row"><b>1</b><span>光宸</span><strong>12 件</strong></div>
+            <div class="rank-row"><b>2</b><span>澜光</span><strong>9 件</strong></div>
+            <div class="rank-row"><b>3</b><span>蘭堂</span><strong>7 件</strong></div>
+          </section>
+        </section>
+      </div>
+
+      <nav class="tabbar" aria-label="底部导航">
+        <button class="active" type="button" data-tab="home"><span>⌂</span>首页</button>
+        <button type="button" data-tab="entry"><span>+</span>日查</button>
+        <button type="button" data-tab="detail"><span>□</span>详情</button>
+        <button type="button" data-tab="follow"><span>✓</span>处置</button>
+        <button type="button" data-tab="board"><span>▦</span>分析</button>
+      </nav>
+
+      <p class="toast" id="toast" aria-live="polite"></p>
+    </section>
+  </main>
+
+  <script src="app.js"></script>
+</body>
+</html>

+ 718 - 0
miniapp-prototype/styles.css

@@ -0,0 +1,718 @@
+:root {
+  color-scheme: light;
+  --bg: #eef2f7;
+  --phone: #f7f9fc;
+  --text: #172033;
+  --muted: #758197;
+  --line: #e4e9f0;
+  --primary: #0b8f71;
+  --primary-dark: #087158;
+  --accent: #f2b84b;
+  --soft: #e8f6f1;
+  --card: #ffffff;
+  --danger: #d85f45;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
+  color: var(--text);
+  background:
+    linear-gradient(135deg, rgba(11, 143, 113, 0.10), rgba(242, 184, 75, 0.14)),
+    var(--bg);
+}
+
+button,
+input,
+select,
+textarea {
+  font: inherit;
+}
+
+button {
+  cursor: pointer;
+}
+
+.preview-shell {
+  min-height: 100vh;
+  display: grid;
+  place-items: center;
+  padding: 24px;
+}
+
+.phone {
+  position: relative;
+  width: min(390px, 100%);
+  height: min(844px, calc(100vh - 48px));
+  min-height: 700px;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 10px solid #111827;
+  border-radius: 34px;
+  background: var(--phone);
+  box-shadow: 0 28px 80px rgba(17, 24, 39, 0.22);
+}
+
+.status-bar {
+  height: 34px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 20px;
+  font-size: 12px;
+  font-weight: 700;
+  background: #ffffff;
+}
+
+.status-icons {
+  letter-spacing: 0;
+}
+
+.miniapp-top {
+  height: 48px;
+  display: grid;
+  grid-template-columns: 40px 1fr 72px;
+  align-items: center;
+  gap: 6px;
+  padding: 0 12px;
+  border-bottom: 1px solid var(--line);
+  background: #ffffff;
+}
+
+.miniapp-top strong {
+  text-align: center;
+  font-size: 16px;
+}
+
+.icon-btn {
+  width: 32px;
+  height: 32px;
+  border: 0;
+  border-radius: 50%;
+  background: transparent;
+  color: var(--text);
+  font-size: 26px;
+  line-height: 1;
+}
+
+.capsule {
+  justify-self: end;
+  width: 66px;
+  height: 30px;
+  display: flex;
+  align-items: center;
+  justify-content: space-around;
+  border: 1px solid var(--line);
+  border-radius: 18px;
+  background: #fff;
+}
+
+.capsule span,
+.capsule i {
+  width: 5px;
+  height: 5px;
+  border-radius: 50%;
+  background: #111827;
+}
+
+.capsule i {
+  width: 14px;
+  height: 14px;
+  border: 2px solid #111827;
+  background: transparent;
+}
+
+.page-stack {
+  position: relative;
+  flex: 1;
+  overflow: hidden;
+}
+
+.page {
+  position: absolute;
+  inset: 0;
+  display: none;
+  overflow-y: auto;
+  padding: 16px 14px 96px;
+}
+
+.page.active {
+  display: block;
+}
+
+.hero-panel {
+  padding: 20px;
+  border-radius: 20px;
+  color: #fff;
+  background:
+    linear-gradient(145deg, rgba(8, 113, 88, 0.96), rgba(17, 38, 67, 0.94)),
+    #0b8f71;
+}
+
+.hero-panel p,
+.page-heading p,
+.content-block p,
+.customer-card p,
+.pipeline p,
+.board-summary p,
+.form-card .toast {
+  margin: 0;
+  color: var(--muted);
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.hero-panel p {
+  color: rgba(255, 255, 255, 0.78);
+}
+
+.hero-panel h1,
+.page-heading h1,
+.customer-main h1 {
+  margin: 6px 0 0;
+  font-size: 24px;
+  line-height: 1.2;
+}
+
+.metric-row {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 10px;
+  margin-top: 18px;
+}
+
+.metric-row article {
+  min-width: 0;
+  padding: 12px 8px;
+  border-radius: 14px;
+  background: rgba(255, 255, 255, 0.14);
+}
+
+.metric-row strong {
+  display: block;
+  font-size: 22px;
+}
+
+.metric-row span {
+  display: block;
+  margin-top: 2px;
+  color: rgba(255, 255, 255, 0.76);
+  font-size: 12px;
+}
+
+.quick-actions {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+  margin: 14px 0;
+}
+
+.quick-actions button,
+.block-title button,
+.segmented button {
+  min-height: 38px;
+  border: 1px solid var(--line);
+  border-radius: 12px;
+  background: #ffffff;
+  color: var(--text);
+  font-size: 13px;
+}
+
+.content-block,
+.form-card,
+.customer-card,
+.pipeline article,
+.board-summary article {
+  border: 1px solid var(--line);
+  border-radius: 16px;
+  background: var(--card);
+  box-shadow: 0 8px 24px rgba(31, 41, 55, 0.05);
+}
+
+.content-block {
+  padding: 14px;
+  margin-top: 12px;
+}
+
+.content-block.compact {
+  margin-top: 14px;
+}
+
+.block-title,
+.stage-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.block-title h2 {
+  margin: 0;
+  font-size: 16px;
+}
+
+.block-title span,
+.stage-head span {
+  color: var(--primary);
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.task-list {
+  display: grid;
+  gap: 12px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.task-list li,
+.feed-item,
+.reminder-row,
+.rank-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+}
+
+.task-list b,
+.reminder-row b,
+.rank-row b {
+  flex: 0 0 auto;
+  min-width: 46px;
+  padding: 5px 8px;
+  border-radius: 10px;
+  background: var(--soft);
+  color: var(--primary-dark);
+  text-align: center;
+  font-size: 12px;
+}
+
+.task-list strong,
+.feed-item strong,
+.reminder-row strong,
+.timeline strong {
+  display: block;
+  margin-bottom: 3px;
+  font-size: 14px;
+}
+
+.feed-item + .feed-item,
+.rank-row + .rank-row {
+  margin-top: 12px;
+}
+
+.avatar {
+  flex: 0 0 auto;
+  width: 34px;
+  height: 34px;
+  display: grid;
+  place-items: center;
+  border-radius: 50%;
+  background: #e9f0ff;
+  color: #31579f;
+  font-weight: 800;
+}
+
+.avatar.green {
+  background: var(--soft);
+  color: var(--primary-dark);
+}
+
+.avatar.large {
+  width: 52px;
+  height: 52px;
+  font-size: 20px;
+}
+
+.page-heading {
+  padding: 4px 4px 14px;
+}
+
+.form-card,
+.customer-card {
+  padding: 16px;
+}
+
+.form-card {
+  display: grid;
+  gap: 13px;
+}
+
+label {
+  display: grid;
+  gap: 7px;
+}
+
+label span {
+  color: #4a5568;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+input,
+select,
+textarea {
+  width: 100%;
+  border: 1px solid var(--line);
+  border-radius: 12px;
+  background: #fbfcfe;
+  color: var(--text);
+  padding: 11px 12px;
+  outline: none;
+}
+
+textarea {
+  resize: vertical;
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+  border-color: var(--primary);
+  box-shadow: 0 0 0 3px rgba(11, 143, 113, 0.12);
+}
+
+.primary-btn {
+  min-height: 46px;
+  border: 0;
+  border-radius: 14px;
+  background: var(--primary);
+  color: #fff;
+  font-weight: 800;
+}
+
+.customer-main {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.customer-main > div {
+  flex: 1;
+  min-width: 0;
+}
+
+.customer-main em {
+  padding: 5px 9px;
+  border-radius: 999px;
+  background: #fff4d8;
+  color: #94620c;
+  font-size: 12px;
+  font-style: normal;
+  font-weight: 800;
+}
+
+.tag-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 14px;
+}
+
+.tag-row span {
+  padding: 6px 10px;
+  border-radius: 999px;
+  background: #f1f4f8;
+  color: #526071;
+  font-size: 12px;
+}
+
+.info-list {
+  display: grid;
+  gap: 10px;
+  margin: 0;
+}
+
+.info-list div {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.info-list dt {
+  color: var(--muted);
+  font-size: 13px;
+}
+
+.info-list dd {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.timeline {
+  position: relative;
+  display: grid;
+  gap: 14px;
+  margin: 0;
+  padding-left: 20px;
+}
+
+.timeline li::marker {
+  color: var(--primary);
+}
+
+.timeline span {
+  display: block;
+  margin-top: 4px;
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.segmented {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 8px;
+}
+
+.segmented button.selected {
+  border-color: var(--primary);
+  background: var(--soft);
+  color: var(--primary-dark);
+  font-weight: 800;
+}
+
+.toast {
+  position: absolute;
+  left: 18px;
+  right: 18px;
+  bottom: 84px;
+  z-index: 10;
+  min-height: 18px;
+  padding: 10px 12px;
+  border-radius: 12px;
+  background: rgba(17, 24, 39, 0.9);
+  color: #fff;
+  text-align: center;
+  opacity: 0;
+  transform: translateY(8px);
+  pointer-events: none;
+  transition: 0.18s ease;
+}
+
+.toast.show {
+  opacity: 1;
+  transform: translateY(0);
+  color: #fff;
+  font-weight: 700;
+}
+
+.board-summary {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.board-summary article {
+  padding: 14px;
+}
+
+.board-summary span {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.board-summary strong {
+  display: block;
+  margin: 6px 0 2px;
+  font-size: 20px;
+}
+
+.pipeline {
+  display: grid;
+  gap: 10px;
+}
+
+.pipeline article {
+  padding: 14px;
+}
+
+.stage-head {
+  margin-bottom: 9px;
+}
+
+.bar {
+  height: 8px;
+  overflow: hidden;
+  border-radius: 999px;
+  background: #edf1f5;
+}
+
+.bar i {
+  display: block;
+  height: 100%;
+  border-radius: inherit;
+  background: linear-gradient(90deg, var(--primary), var(--accent));
+}
+
+.pipeline p {
+  margin-top: 8px;
+}
+
+.rank-row {
+  align-items: center;
+}
+
+.rank-row span {
+  flex: 1;
+  font-size: 14px;
+}
+
+.rank-row strong {
+  color: var(--primary-dark);
+  font-size: 14px;
+}
+
+.tabbar {
+  height: 76px;
+  display: grid;
+  grid-template-columns: repeat(5, 1fr);
+  gap: 2px;
+  padding: 8px 8px 10px;
+  border-top: 1px solid var(--line);
+  background: rgba(255, 255, 255, 0.96);
+}
+
+.tabbar button {
+  display: grid;
+  place-items: center;
+  gap: 2px;
+  border: 0;
+  border-radius: 12px;
+  background: transparent;
+  color: #7b8797;
+  font-size: 11px;
+}
+
+.tabbar span {
+  font-size: 19px;
+  line-height: 1;
+}
+
+.tabbar button.active {
+  background: var(--soft);
+  color: var(--primary-dark);
+  font-weight: 800;
+}
+
+@media (max-width: 430px) {
+  .preview-shell {
+    padding: 0;
+  }
+
+  .phone {
+    width: 100vw;
+    height: 100svh;
+    min-height: 0;
+    border: 0;
+    border-radius: 0;
+  }
+
+  .status-bar {
+    height: 28px;
+    padding: 0 16px;
+  }
+
+  .miniapp-top {
+    height: 44px;
+  }
+
+  .page {
+    padding: 12px 12px 86px;
+  }
+
+  .hero-panel {
+    padding: 16px;
+    border-radius: 16px;
+  }
+
+  .hero-panel h1,
+  .page-heading h1,
+  .customer-main h1 {
+    font-size: 22px;
+  }
+
+  .metric-row {
+    gap: 8px;
+    margin-top: 14px;
+  }
+
+  .metric-row article {
+    padding: 10px 6px;
+    border-radius: 12px;
+  }
+
+  .metric-row strong {
+    font-size: 20px;
+  }
+
+  .quick-actions {
+    gap: 8px;
+    margin: 12px 0;
+  }
+
+  .quick-actions button,
+  .block-title button,
+  .segmented button {
+    min-height: 42px;
+  }
+
+  .content-block,
+  .form-card,
+  .customer-card {
+    border-radius: 14px;
+  }
+
+  .content-block {
+    padding: 12px;
+  }
+
+  .form-card,
+  .customer-card {
+    padding: 14px;
+  }
+
+  .task-list {
+    gap: 10px;
+  }
+
+  .task-list b,
+  .reminder-row b,
+  .rank-row b {
+    min-width: 42px;
+  }
+
+  .customer-main {
+    align-items: flex-start;
+  }
+
+  .board-summary {
+    gap: 8px;
+  }
+
+  .pipeline {
+    gap: 8px;
+  }
+
+  .tabbar {
+    height: 68px;
+    padding: 6px 6px 8px;
+  }
+
+  .tabbar button {
+    border-radius: 10px;
+  }
+
+  .toast {
+    bottom: 76px;
+  }
+}

+ 1223 - 0
prototype/app.js

@@ -0,0 +1,1223 @@
+let currentUser = null;
+let authToken = window.localStorage.getItem("keyissueToken") || "";
+let customers = [];
+let issues = [];
+let inspections = [];
+let adminConfig = null;
+let activePage = "dashboard";
+let activeStatus = "all";
+let selectedIssueId = null;
+let selectedCustomerId = null;
+let dictionaries = {
+  departments: ["光宸", "澜光", "蘭堂", "焕境"],
+  customerSources: ["转介绍", "公司特殊关系", "标杆项目", "风险关注"],
+  issueSources: ["线下巡检", "客户群反馈", "每日线上巡查", "客户主动反馈", "管理层转办", "监察部发现"],
+  issueTypes: ["施工质量", "设计变更", "沟通响应", "材料交付", "客户满意度"],
+  progressStatuses: ["正常", "延期"]
+};
+
+const pageTitles = {
+  dashboard: "A+在建客户动态管理驾驶舱",
+  customers: "A/A+客户库",
+  inspections: "每日线上巡查",
+  issues: "问题反馈池",
+  audit: "监察核查",
+  analysis: "问题分析",
+  rules: "预警规则",
+  admin: "系统后台"
+};
+
+const pageTitle = document.querySelector("#pageTitle");
+const searchInput = document.querySelector("#searchInput");
+const tierFilter = document.querySelector("#tierFilter");
+const departmentFilter = document.querySelector("#departmentFilter");
+const statusButtons = document.querySelectorAll("[data-status]");
+const drawer = document.querySelector("#detailDrawer");
+const customerDrawer = document.querySelector("#customerDrawer");
+const drawerBackdrop = document.querySelector("#drawerBackdrop");
+const modalBackdrop = document.querySelector("#modalBackdrop");
+const toast = document.querySelector("#toast");
+const loginScreen = document.querySelector("#loginScreen");
+const appShell = document.querySelector("#appShell");
+const currentUserName = document.querySelector("#currentUserName");
+const currentRoleName = document.querySelector("#currentRoleName");
+const logoutBtn = document.querySelector("#logoutBtn");
+const loginForm = document.querySelector("#loginForm");
+const loginUsername = document.querySelector("#loginUsername");
+const loginPassword = document.querySelector("#loginPassword");
+const loginMessage = document.querySelector("#loginMessage");
+const registerForm = document.querySelector("#registerForm");
+const registerName = document.querySelector("#registerName");
+const registerPhone = document.querySelector("#registerPhone");
+const registerPassword = document.querySelector("#registerPassword");
+const registerPasswordConfirm = document.querySelector("#registerPasswordConfirm");
+const registerMessage = document.querySelector("#registerMessage");
+const issueCustomerSearch = document.querySelector("#issueCustomerSearch");
+
+function escapeHtml(value) {
+  return String(value ?? "").replace(/[&<>"']/g, (char) => ({
+    "&": "&amp;",
+    "<": "&lt;",
+    ">": "&gt;",
+    '"': "&quot;",
+    "'": "&#039;"
+  })[char]);
+}
+
+function showToast(message) {
+  toast.textContent = message;
+  toast.classList.add("show");
+  window.clearTimeout(showToast.timer);
+  showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
+}
+
+async function apiRequest(path, options = {}) {
+  const headers = {
+    Accept: "application/json",
+    ...(options.headers || {})
+  };
+  const request = { ...options, headers };
+
+  if (authToken) headers.Authorization = `Bearer ${authToken}`;
+  if (options.body && typeof options.body !== "string") {
+    headers["Content-Type"] = "application/json";
+    request.body = JSON.stringify(options.body);
+  }
+
+  const response = await fetch(path, request);
+  const contentType = response.headers.get("content-type") || "";
+  const payload = contentType.includes("application/json") ? await response.json() : await response.text();
+
+  if (!response.ok) {
+    const error = new Error(payload.message || `请求失败:${response.status}`);
+    error.status = response.status;
+    error.payload = payload;
+    throw error;
+  }
+  return payload;
+}
+
+function hasPermission(permission) {
+  if (!currentUser) return false;
+  if (currentUser.permissions.includes("*")) return true;
+  if (permission.startsWith("page:")) {
+    return currentUser.pages.includes(permission.replace("page:", ""));
+  }
+  return currentUser.permissions.includes(permission);
+}
+
+function requirePermission(permission, actionName = "执行该操作") {
+  if (hasPermission(permission)) return true;
+  showToast(`当前登录人无权${actionName}`);
+  return false;
+}
+
+function applyPermissions() {
+  document.querySelectorAll("[data-page]").forEach((button) => {
+    const allowed = hasPermission(`page:${button.dataset.page}`);
+    button.disabled = !allowed;
+    button.title = allowed ? "" : "后台未授权访问该页面";
+  });
+
+  document.querySelectorAll("[data-requires]").forEach((element) => {
+    const allowed = hasPermission(element.dataset.requires);
+    if (!element.matches("button")) element.disabled = !allowed;
+    if (element.matches("button")) element.disabled = false;
+    element.toggleAttribute("aria-disabled", !allowed);
+    element.classList.toggle("is-disabled", !allowed);
+    element.title = allowed ? "" : "后台未授权执行该操作";
+  });
+
+  document.querySelectorAll(".permission-list div").forEach((item) => {
+    const label = item.querySelector("strong")?.textContent || "";
+    item.classList.toggle("is-current", currentUser && label === currentUser.roleName);
+  });
+}
+
+function handleApiError(error) {
+  if (error.status === 401) {
+    clearSession();
+    showToast("登录已失效,请重新登录。");
+    return;
+  }
+  showToast(error.message || "操作失败,请稍后再试。");
+}
+
+function beginFormSubmit(form) {
+  if (form.dataset.submitting === "true") return false;
+  form.dataset.submitting = "true";
+  form.querySelectorAll('button[type="submit"]').forEach((button) => {
+    button.dataset.wasDisabled = button.disabled ? "true" : "false";
+    button.disabled = true;
+  });
+  return true;
+}
+
+function endFormSubmit(form) {
+  delete form.dataset.submitting;
+  form.querySelectorAll('button[type="submit"]').forEach((button) => {
+    button.disabled = button.dataset.wasDisabled === "true";
+    delete button.dataset.wasDisabled;
+  });
+}
+
+function getCustomer(id) {
+  return customers.find((customer) => customer.id === id) || {
+    id,
+    name: "未匹配客户",
+    tier: "-",
+    source: "-",
+    keyCustomerReason: "-",
+    customerOwner: "-",
+    address: "-",
+    department: "-",
+    designer: "-",
+    manager: "-",
+    stage: "-",
+    startDate: "-",
+    plannedFinishDate: "-",
+    progressStatus: "正常",
+    satisfaction: "-",
+    inspectionStatus: "-",
+    inspectionAt: "-",
+    inspector: "-",
+    inspectionSummary: "-"
+  };
+}
+
+function statusClass(status) {
+  if (status === "红色预警") return "risk";
+  if (status === "橙色预警") return "orange";
+  if (status === "蓝色关注") return "blue";
+  if (status === "待核查") return "audit";
+  if (status === "已处理") return "closed";
+  return "normal";
+}
+
+function inspectionClass(status) {
+  if (status === "异常") return "risk";
+  if (status === "未巡查" || status === "-" || !status) return "orange";
+  return "closed";
+}
+
+function inspectionFlag(status) {
+  return status === "未巡查" || status === "-" || !status ? "未巡查" : "已巡查";
+}
+
+function inspectionFlagClass(status) {
+  return status === "未巡查" || status === "-" || !status ? "orange" : "closed";
+}
+
+function progressClass(status) {
+  return status === "延期" ? "risk" : "closed";
+}
+
+function cell(label, content) {
+  return `<td data-label="${escapeHtml(label)}">${content}</td>`;
+}
+
+function dataTagBadge(item) {
+  return item?.dataTag ? `<span class="data-tag">${escapeHtml(item.dataTag)}</span>` : "";
+}
+
+function dictionaryValues(key) {
+  const values = dictionaries[key];
+  return Array.isArray(values) && values.length ? values : [];
+}
+
+function optionList(values, selected = "") {
+  return values.map((value) => {
+    const text = String(value);
+    const isSelected = selected === text ? " selected" : "";
+    return `<option value="${escapeHtml(text)}"${isSelected}>${escapeHtml(text)}</option>`;
+  }).join("");
+}
+
+function setSelectOptions(select, values, selectedValue) {
+  if (!select) return;
+  const currentValue = selectedValue ?? select.value;
+  const safeValues = values.length ? values : [currentValue].filter(Boolean);
+  select.innerHTML = optionList(safeValues, currentValue);
+  if (safeValues.includes(currentValue)) select.value = currentValue;
+}
+
+function renderDictionaryOptions() {
+  const departments = dictionaryValues("departments");
+  const customerSources = dictionaryValues("customerSources");
+  const issueSources = dictionaryValues("issueSources");
+  const issueTypes = dictionaryValues("issueTypes");
+  const progressStatuses = dictionaryValues("progressStatuses");
+  const activeDepartment = departmentFilter.value || "all";
+
+  departmentFilter.innerHTML = `<option value="all">全部部门</option>${optionList(departments, activeDepartment)}`;
+  departmentFilter.value = departments.includes(activeDepartment) ? activeDepartment : "all";
+  setSelectOptions(document.querySelector('#customerForm select[name="source"]'), customerSources);
+  setSelectOptions(document.querySelector('#customerForm select[name="department"]'), departments);
+  setSelectOptions(document.querySelector('#customerForm select[name="progressStatus"]'), progressStatuses);
+  setSelectOptions(document.querySelector('#issueForm select[name="source"]'), issueSources);
+  setSelectOptions(document.querySelector('#issueForm select[name="type"]'), issueTypes);
+}
+
+function customerCell(customer) {
+  return `
+    <button class="customer-cell-button" type="button" data-view-customer="${escapeHtml(customer.id)}">
+      <strong>${escapeHtml(customer.name)}${dataTagBadge(customer)}</strong>
+      <small>${escapeHtml(customer.address)}</small>
+      <span class="customer-cell-meta">
+        <span class="badge">${escapeHtml(customer.tier)}</span>
+        <span class="status-badge ${progressClass(customer.progressStatus)}">${escapeHtml(customer.progressStatus || "正常")}</span>
+        <span class="status-badge ${inspectionFlagClass(customer.inspectionStatus)}">${escapeHtml(inspectionFlag(customer.inspectionStatus))}</span>
+      </span>
+    </button>
+  `;
+}
+
+function filteredIssues() {
+  const keyword = searchInput.value.trim().toLowerCase();
+  const tier = tierFilter.value;
+  const department = departmentFilter.value;
+
+  return issues.filter((issue) => {
+    const customer = getCustomer(issue.customerId);
+    const targetText = [
+      customer.name,
+      customer.address,
+      customer.stage,
+      customer.startDate,
+      customer.plannedFinishDate,
+      customer.progressStatus,
+      issue.type,
+      issue.source,
+      issue.rootCause,
+      issue.owner,
+      issue.action
+    ].join(" ").toLowerCase();
+    const matchesKeyword = !keyword || targetText.includes(keyword);
+    const matchesTier = tier === "all" || customer.tier === tier;
+    const matchesDepartment = department === "all" || customer.department === department;
+    const matchesStatus = activeStatus === "all" || issue.status === activeStatus;
+    return matchesKeyword && matchesTier && matchesDepartment && matchesStatus;
+  });
+}
+
+function countBy(list, getter) {
+  return list.reduce((acc, item) => {
+    const key = getter(item);
+    acc[key] = (acc[key] || 0) + 1;
+    return acc;
+  }, {});
+}
+
+function renderKpis() {
+  const visible = filteredIssues();
+  const red = visible.filter((issue) => issue.status === "红色预警").length;
+  const audit = visible.filter((issue) => issue.status === "待核查").length;
+  const inspectionDone = customers.filter((customer) => customer.inspectionStatus === "已巡查" || customer.inspectionStatus === "异常").length;
+  const abnormalInspection = customers.filter((customer) => customer.inspectionStatus === "异常").length;
+
+  document.querySelector("#kpiInspectionDone").textContent = inspectionDone;
+  document.querySelector("#kpiAbnormalInspections").textContent = abnormalInspection;
+  document.querySelector("#kpiRedIssues").textContent = red;
+  document.querySelector("#kpiAuditIssues").textContent = audit;
+  document.querySelector("#funnelRed").textContent = `${issues.filter((issue) => issue.status === "红色预警").length} 件`;
+  document.querySelector("#funnelOrange").textContent = `${issues.filter((issue) => issue.status === "橙色预警").length} 件`;
+  document.querySelector("#funnelBlue").textContent = `${issues.filter((issue) => issue.status === "蓝色关注").length} 件`;
+  document.querySelector("#sideAlertCount").textContent = `${issues.filter((issue) => issue.status === "红色预警").length} 件`;
+}
+
+function renderBars() {
+  const typeCounts = countBy(issues, (issue) => issue.type);
+  const typeMax = Math.max(1, ...Object.values(typeCounts));
+  document.querySelector("#issueTypeBars").innerHTML = Object.entries(typeCounts).map(([type, count]) => `
+    <div class="bar-item">
+      <span>${escapeHtml(type)}</span>
+      <div><i style="width: ${Math.max(22, Math.round((count / typeMax) * 100))}%;"></i></div>
+      <strong>${count}</strong>
+    </div>
+  `).join("");
+
+  const departmentCounts = countBy(issues, (issue) => getCustomer(issue.customerId).department);
+  document.querySelector("#departmentRanks").innerHTML = Object.entries(departmentCounts)
+    .sort((a, b) => b[1] - a[1])
+    .map(([department, count], index) => `<div><strong>${index + 1}. ${escapeHtml(department)}</strong><span>${count} 件</span></div>`)
+    .join("");
+}
+
+function issueRow(issue, compact = false) {
+  const customer = getCustomer(issue.customerId);
+  const cells = compact ? `
+    ${cell("客户", customerCell(customer))}
+    ${cell("等级", `<span class="badge">${escapeHtml(customer.tier)}</span>`)}
+    ${cell("部门", escapeHtml(customer.department))}
+    ${cell("问题来源", escapeHtml(issue.source || "-"))}
+    ${cell("问题类型", escapeHtml(issue.type))}
+    ${cell("预警状态", `<span class="status-badge ${statusClass(issue.status)}">${escapeHtml(issue.status)}</span>`)}
+    ${cell("责任人", escapeHtml(issue.owner))}
+    ${cell("当前处置", escapeHtml(issue.action))}
+  ` : `
+    ${cell("反馈编号", `${escapeHtml(issue.id)}${dataTagBadge(issue)}`)}
+    ${cell("客户", customerCell(customer))}
+    ${cell("问题来源", escapeHtml(issue.source || "-"))}
+    ${cell("问题类型", escapeHtml(issue.type))}
+    ${cell("问题描述", escapeHtml(issue.summary || "未填写"))}
+    ${cell("预警状态", `<span class="status-badge ${statusClass(issue.status)}">${escapeHtml(issue.status)}</span>`)}
+    ${cell("责任人", escapeHtml(issue.owner))}
+    ${cell("根因", escapeHtml(issue.rootCause))}
+    ${cell("当前处置", escapeHtml(issue.action))}
+    ${cell("处置后满意度", `<span class="status-badge ${statusClass(issue.postSatisfaction)}">${escapeHtml(issue.postSatisfaction || "待处置")}</span>`)}
+  `;
+
+  return `<tr>${cells}${cell("操作", `<div class="action-cell"><button class="small-button" type="button" data-view-issue="${escapeHtml(issue.id)}" data-requires="issue:view">查看</button><button class="small-button" type="button" data-quick-handle="${escapeHtml(issue.id)}" data-requires="issue:handle">处置</button></div>`)}</tr>`;
+}
+
+function renderIssueTables() {
+  const visible = filteredIssues();
+  const highPriority = [...visible].sort((a, b) => b.score - a.score).slice(0, 5);
+
+  document.querySelector("#dashboardRows").innerHTML = highPriority.map((issue) => issueRow(issue, true)).join("") ||
+    `<tr><td colspan="9">暂无符合条件的问题。</td></tr>`;
+
+  document.querySelector("#issueRows").innerHTML = visible.map((issue) => issueRow(issue)).join("") ||
+    `<tr><td colspan="11">暂无符合条件的问题反馈。</td></tr>`;
+
+  const auditIssues = issues.filter((issue) => issue.status === "待核查");
+  document.querySelector("#auditCount").textContent = `${auditIssues.length} 件`;
+  document.querySelector("#auditRows").innerHTML = auditIssues.map((issue) => {
+    const customer = getCustomer(issue.customerId);
+    return `
+      <tr>
+        ${cell("客户", customerCell(customer))}
+        ${cell("问题", escapeHtml(issue.type))}
+        ${cell("处置说明", escapeHtml(issue.handlerNote))}
+        ${cell("提交人", escapeHtml(issue.submitter))}
+        ${cell("操作", `<div class="action-cell"><button class="small-button" type="button" data-view-issue="${escapeHtml(issue.id)}" data-requires="issue:view">查看</button><button class="small-button" type="button" data-audit-pass="${escapeHtml(issue.id)}" data-requires="audit:review">通过</button><button class="small-button" type="button" data-audit-reject="${escapeHtml(issue.id)}" data-requires="audit:review">驳回</button></div>`)}
+      </tr>
+    `;
+  }).join("") || `<tr><td colspan="5">当前没有待核查问题。</td></tr>`;
+}
+
+function renderCustomers() {
+  document.querySelector("#customerRows").innerHTML = customers.map((customer) => {
+    const count = issues.filter((issue) => issue.customerId === customer.id).length;
+    return `
+      <tr>
+        ${cell("客户", customerCell(customer))}
+        ${cell("等级", `<span class="badge">${escapeHtml(customer.tier)}</span>`)}
+        ${cell("新居地址", escapeHtml(customer.address))}
+        ${cell("客管负责人", escapeHtml(customer.customerOwner || "-"))}
+        ${cell("入选说明", escapeHtml(customer.keyCustomerReason || "-"))}
+        ${cell("部门", escapeHtml(customer.department))}
+        ${cell("设计师", escapeHtml(customer.designer))}
+        ${cell("项目经理", escapeHtml(customer.manager))}
+        ${cell("施工阶段", escapeHtml(customer.stage))}
+        ${cell("开工日期", escapeHtml(customer.startDate || "-"))}
+        ${cell("计划完工日期", escapeHtml(customer.plannedFinishDate || "-"))}
+        ${cell("当前进度", `<span class="status-badge ${progressClass(customer.progressStatus)}">${escapeHtml(customer.progressStatus || "正常")}</span>`)}
+        ${cell("巡查与否", `<span class="status-badge ${inspectionFlagClass(customer.inspectionStatus)}">${escapeHtml(inspectionFlag(customer.inspectionStatus))}</span>`)}
+        ${cell("巡查状态", `<span class="status-badge ${inspectionClass(customer.inspectionStatus)}">${escapeHtml(customer.inspectionStatus || "未巡查")}</span>`)}
+        ${cell("最近巡查", `${escapeHtml(customer.inspectionAt || "-")}<br><small>${escapeHtml(customer.inspectionSummary || "")}</small>`)}
+        ${cell("问题数", count)}
+        ${cell("操作", `<div class="action-cell"><button class="small-button" type="button" data-open-modal="inspectionModal" data-inspection-customer="${escapeHtml(customer.id)}" data-requires="inspection:create">日查</button><button class="small-button" type="button" data-open-modal="issueModal" data-customer="${escapeHtml(customer.id)}" data-requires="issue:create">反馈</button></div>`)}
+      </tr>
+    `;
+  }).join("");
+
+  renderCustomerSelects();
+}
+
+function renderCustomerSelects(filter = "") {
+  const keyword = filter.trim().toLowerCase();
+  const visibleCustomers = customers.filter((customer) => {
+    const text = `${customer.name} ${customer.address} ${customer.manager} ${customer.designer} ${customer.stage} ${customer.startDate} ${customer.plannedFinishDate} ${customer.progressStatus}`.toLowerCase();
+    return !keyword || text.includes(keyword);
+  });
+  const options = visibleCustomers.map((customer) => `<option value="${escapeHtml(customer.id)}">${escapeHtml(customer.name)} · ${escapeHtml(customer.address)}</option>`).join("");
+  const issueSelect = document.querySelector("#issueCustomerSelect");
+  const inspectionSelect = document.querySelector("#inspectionCustomerSelect");
+  if (issueSelect) issueSelect.innerHTML = options;
+  if (inspectionSelect) inspectionSelect.innerHTML = customers.map((customer) => `<option value="${escapeHtml(customer.id)}">${escapeHtml(customer.name)} · ${escapeHtml(customer.address)}</option>`).join("");
+}
+
+function renderInspections() {
+  const done = customers.filter((customer) => customer.inspectionStatus === "已巡查" || customer.inspectionStatus === "异常").length;
+  const abnormal = customers.filter((customer) => customer.inspectionStatus === "异常").length;
+  const pending = customers.filter((customer) => customer.inspectionStatus === "未巡查").length;
+  document.querySelector("#inspectionSummaryCards").innerHTML = `
+    <article><span>今日已巡查</span><strong>${done}</strong></article>
+    <article><span>异常巡查</span><strong>${abnormal}</strong></article>
+    <article><span>未巡查</span><strong>${pending}</strong></article>
+  `;
+
+  document.querySelector("#inspectionRows").innerHTML = inspections.map((item) => `
+    <tr>
+      ${cell("巡查编号", `${escapeHtml(item.id)}${dataTagBadge(item)}`)}
+      ${cell("客户", customerCell(getCustomer(item.customerId)))}
+      ${cell("巡查与否", `<span class="status-badge ${inspectionFlagClass(item.status)}">${escapeHtml(inspectionFlag(item.status))}</span>`)}
+      ${cell("巡查状态", `<span class="status-badge ${inspectionClass(item.status)}">${escapeHtml(item.status)}</span>`)}
+      ${cell("巡查人", escapeHtml(item.inspector))}
+      ${cell("巡查时间", escapeHtml(item.createdAt))}
+      ${cell("施工质量", escapeHtml(item.siteQuality))}
+      ${cell("文明施工", escapeHtml(item.civility))}
+      ${cell("客户群反馈", escapeHtml(item.groupFeedback))}
+      ${cell("巡查结论", escapeHtml(item.summary))}
+    </tr>
+  `).join("") || `<tr><td colspan="10">暂无每日线上巡查记录。</td></tr>`;
+}
+
+function adminRoleOptions(selectedRole) {
+  if (!adminConfig) return "";
+  return Object.entries(adminConfig.roles).map(([roleKey, role]) => {
+    const selected = roleKey === selectedRole ? " selected" : "";
+    return `<option value="${escapeHtml(roleKey)}"${selected}>${escapeHtml(role.roleName)}</option>`;
+  }).join("");
+}
+
+function renderAdminRoleCards() {
+  return Object.entries(adminConfig.roles).map(([roleKey, role]) => {
+    const isAdmin = roleKey === "admin";
+    const allPermissions = role.permissions.includes("*");
+    const pageControls = adminConfig.pageCatalog.map((page) => `
+      <label>
+        <input type="checkbox" data-role-page="${escapeHtml(roleKey)}" value="${escapeHtml(page.key)}" ${role.pages.includes(page.key) || isAdmin ? "checked" : ""} ${isAdmin ? "disabled" : ""}>
+        <span>${escapeHtml(page.name)}</span>
+      </label>
+    `).join("");
+    const permissionControls = adminConfig.permissionCatalog.map((permission) => `
+      <label>
+        <input type="checkbox" data-role-permission="${escapeHtml(roleKey)}" value="${escapeHtml(permission.key)}" ${allPermissions || role.permissions.includes(permission.key) ? "checked" : ""} ${isAdmin ? "disabled" : ""}>
+        <span>${escapeHtml(permission.name)}</span>
+      </label>
+    `).join("");
+
+    return `
+      <article class="role-card">
+        <div class="role-card-head">
+          <div>
+            <strong>${escapeHtml(role.roleName)}</strong>
+            <span>${escapeHtml(role.summary)}</span>
+          </div>
+          <button class="small-button" type="button" data-admin-save-role="${escapeHtml(roleKey)}" ${isAdmin ? "disabled" : ""}>保存角色权限</button>
+        </div>
+        <div class="role-card-section">
+          <b>可访问页面</b>
+          <div class="checkbox-grid">${pageControls}</div>
+        </div>
+        <div class="role-card-section">
+          <b>可执行操作</b>
+          <div class="checkbox-grid">${permissionControls}</div>
+        </div>
+      </article>
+    `;
+  }).join("");
+}
+
+function renderCustomerReviewRows() {
+  const drafts = adminConfig.customerDrafts || [];
+  return drafts.map((draft) => {
+    const customer = draft.customer || {};
+    const canReview = draft.status === "待审核";
+    return `
+      <tr>
+        ${cell("审核单", `${escapeHtml(draft.id)}${dataTagBadge(draft)}`)}
+        ${cell("客户", `<strong>${escapeHtml(customer.name)}</strong><br><small>${escapeHtml(customer.address || "-")}</small>`)}
+        ${cell("等级", `<span class="badge">${escapeHtml(customer.tier || "-")}</span>`)}
+        ${cell("提交人", `${escapeHtml(draft.submittedBy || "-")}<br><small>${escapeHtml(draft.submittedRole || "-")}</small>`)}
+        ${cell("客管负责人", escapeHtml(customer.customerOwner || "-"))}
+        ${cell("入选说明", escapeHtml(customer.keyCustomerReason || "-"))}
+        ${cell("提交时间", escapeHtml(draft.submittedAt || "-"))}
+        ${cell("状态", `<span class="status-badge ${draft.status === "待审核" ? "orange" : draft.status === "已通过" ? "closed" : "risk"}">${escapeHtml(draft.status)}</span>`)}
+        ${cell("操作", canReview ? `<div class="action-cell"><button class="small-button" type="button" data-admin-customer-approve="${escapeHtml(draft.id)}">通过</button><button class="small-button" type="button" data-admin-customer-reject="${escapeHtml(draft.id)}">驳回</button></div>` : escapeHtml(draft.reviewNote || draft.customerId || "-"))}
+      </tr>
+    `;
+  }).join("") || `<tr><td colspan="9">暂无客户入池审核单。</td></tr>`;
+}
+
+function renderAdmin() {
+  if (!adminConfig) {
+    document.querySelector("#adminUserRows").innerHTML = `<tr><td colspan="9">当前登录人没有系统后台管理权限。</td></tr>`;
+    document.querySelector("#adminRoleCards").innerHTML = `<p class="muted-text">后台配置仅系统管理员可维护。</p>`;
+    return;
+  }
+
+  const activeUsers = adminConfig.users.filter((user) => user.status !== "停用").length;
+  const pendingDrafts = (adminConfig.customerDrafts || []).filter((draft) => draft.status === "待审核").length;
+  document.querySelector("#adminUserCount").textContent = adminConfig.users.length;
+  document.querySelector("#adminActiveUserCount").textContent = activeUsers;
+  document.querySelector("#adminRoleCount").textContent = Object.keys(adminConfig.roles).length;
+  document.querySelector("#adminPermissionCount").textContent = adminConfig.permissionCatalog.length;
+  document.querySelector("#adminPendingCustomerCount").textContent = pendingDrafts;
+  document.querySelector("#adminCustomerReviewCount").textContent = `${pendingDrafts} 单待审核`;
+  document.querySelector("#adminUserRoleSelect").innerHTML = adminRoleOptions("delivery");
+  document.querySelector("#customerReviewRows").innerHTML = renderCustomerReviewRows();
+
+  document.querySelector("#adminUserRows").innerHTML = adminConfig.users.map((user) => `
+    <tr>
+      ${cell("账号", `<strong>${escapeHtml(user.username)}</strong>`)}
+      ${cell("姓名", `<input class="inline-input" data-admin-user-name="${escapeHtml(user.id)}" value="${escapeHtml(user.displayName)}">`)}
+      ${cell("手机号", `<input class="inline-input" data-admin-user-phone="${escapeHtml(user.id)}" value="${escapeHtml(user.phone || "")}" placeholder="可选">`)}
+      ${cell("部门", `<input class="inline-input" data-admin-user-department="${escapeHtml(user.id)}" value="${escapeHtml(user.department || "")}" placeholder="用于范围">`)}
+      ${cell("岗位", `<input class="inline-input" data-admin-user-position="${escapeHtml(user.id)}" value="${escapeHtml(user.position || "")}" placeholder="用于范围">`)}
+      ${cell("角色", `<select data-admin-user-role="${escapeHtml(user.id)}">${adminRoleOptions(user.role)}</select>`)}
+      ${cell("状态", `<span class="status-badge ${user.status === "停用" ? "orange" : "closed"}">${escapeHtml(user.status)}</span>`)}
+      ${cell("重置密码", `<input class="inline-input" type="password" data-admin-user-password="${escapeHtml(user.id)}" value="" placeholder="留空不修改">`)}
+      ${cell("操作", `<div class="action-cell"><button class="small-button" type="button" data-admin-save-user="${escapeHtml(user.id)}">保存账号</button><button class="small-button" type="button" data-admin-toggle-user="${escapeHtml(user.id)}">${user.status === "停用" ? "启用" : "停用"}</button></div>`)}
+    </tr>
+  `).join("");
+
+  document.querySelector("#adminRoleCards").innerHTML = renderAdminRoleCards();
+  const dictionaryForm = document.querySelector("#adminDictionaryForm");
+  Object.entries(adminConfig.dictionaries || {}).forEach(([key, values]) => {
+    if (dictionaryForm.elements[key]) dictionaryForm.elements[key].value = values.join("\n");
+  });
+}
+
+function renderAll() {
+  renderKpis();
+  renderBars();
+  renderIssueTables();
+  renderCustomers();
+  renderInspections();
+  if (activePage === "admin") renderAdmin();
+  applyPermissions();
+}
+
+async function loadData() {
+  const issueRequest = hasPermission("issue:view") ? apiRequest("/api/issues") : Promise.resolve({ issues: [] });
+  const inspectionRequest = hasPermission("inspection:view") ? apiRequest("/api/inspections") : Promise.resolve({ inspections: [] });
+  const adminRequest = hasPermission("admin:manage") ? apiRequest("/api/admin/config") : Promise.resolve(null);
+  const [customerPayload, issuePayload, inspectionPayload, metaPayload, adminPayload] = await Promise.all([
+    apiRequest("/api/customers"),
+    issueRequest,
+    inspectionRequest,
+    apiRequest("/api/meta"),
+    adminRequest
+  ]);
+  customers = customerPayload.customers;
+  issues = issuePayload.issues;
+  inspections = inspectionPayload.inspections;
+  dictionaries = { ...dictionaries, ...(metaPayload.dictionaries || {}) };
+  adminConfig = adminPayload;
+  renderDictionaryOptions();
+}
+
+function switchPage(page) {
+  if (!hasPermission(`page:${page}`)) {
+    showToast("后台未授权访问该页面。");
+    return;
+  }
+  activePage = page;
+  pageTitle.textContent = pageTitles[page] || pageTitles.dashboard;
+  document.querySelectorAll(".page-section").forEach((section) => {
+    section.classList.toggle("active", section.dataset.section === page);
+  });
+  document.querySelectorAll("[data-page]").forEach((button) => {
+    button.classList.toggle("active", button.dataset.page === page);
+  });
+  renderAll();
+}
+
+function openDrawer(issueId) {
+  const issue = issues.find((item) => item.id === issueId);
+  if (!issue) return;
+  const customer = getCustomer(issue.customerId);
+  selectedIssueId = issueId;
+  selectedCustomerId = null;
+  customerDrawer.classList.remove("open");
+  customerDrawer.setAttribute("aria-hidden", "true");
+
+  document.querySelector("#drawerName").textContent = `${customer.name} · ${issue.type}`;
+  document.querySelector("#drawerScore").textContent = issue.score;
+  document.querySelector("#drawerStatus").textContent = issue.status;
+  document.querySelector("#drawerDepartment").textContent = customer.department;
+  document.querySelector("#drawerType").textContent = issue.type;
+  document.querySelector("#drawerOwner").textContent = issue.owner;
+  document.querySelector("#drawerPostSatisfaction").textContent = issue.postSatisfaction || "待处置";
+  document.querySelector("#drawerLatestHandle").textContent = issue.handlerNote || "待提交处置记录";
+  document.querySelector("#drawerSummary").textContent = issue.summary || "未填写问题描述。";
+  const records = issue.handlingRecords || [];
+  document.querySelector("#drawerHandlingRecords").innerHTML = records.length ? records.map((record) => `
+    <article class="record-card">
+      <div><strong>${escapeHtml(record.handler)}</strong><span>${escapeHtml(record.handledAt)}</span></div>
+      <p>${escapeHtml(record.note)}</p>
+      <small>处置后满意度:${escapeHtml(record.satisfaction || "待回访")};客户反馈:${escapeHtml(record.feedback || "待补充")}</small>
+    </article>
+  `).join("") : `<p class="muted-text">暂无处置记录,责任人提交后会按时间形成卡片流水。</p>`;
+  document.querySelector("#drawerTimeline").innerHTML = issue.timeline.map((item) => `<li>${escapeHtml(item)}</li>`).join("");
+
+  drawerBackdrop.hidden = false;
+  drawer.classList.add("open");
+  drawer.setAttribute("aria-hidden", "false");
+  applyPermissions();
+}
+
+function openCustomerDrawer(customerId) {
+  const customer = getCustomer(customerId);
+  selectedCustomerId = customer.id;
+  selectedIssueId = null;
+  drawer.classList.remove("open");
+  drawer.setAttribute("aria-hidden", "true");
+
+  const relatedIssues = issues.filter((issue) => issue.customerId === customer.id);
+  document.querySelector("#customerDrawerName").textContent = customer.name;
+  document.querySelector("#customerDrawerInspectionFlag").textContent = inspectionFlag(customer.inspectionStatus);
+  document.querySelector("#customerDrawerTier").textContent = customer.tier;
+  document.querySelector("#customerDrawerSource").textContent = customer.source || "-";
+  document.querySelector("#customerDrawerCustomerOwner").textContent = customer.customerOwner || "-";
+  document.querySelector("#customerDrawerDepartment").textContent = customer.department;
+  document.querySelector("#customerDrawerDesigner").textContent = customer.designer;
+  document.querySelector("#customerDrawerManager").textContent = customer.manager;
+  document.querySelector("#customerDrawerStage").textContent = customer.stage;
+  document.querySelector("#customerDrawerStartDate").textContent = customer.startDate || "-";
+  document.querySelector("#customerDrawerPlannedFinishDate").textContent = customer.plannedFinishDate || "-";
+  document.querySelector("#customerDrawerProgressStatus").innerHTML = `<span class="status-badge ${progressClass(customer.progressStatus)}">${escapeHtml(customer.progressStatus || "正常")}</span>`;
+  document.querySelector("#customerDrawerSatisfaction").textContent = `${customer.satisfaction || "-"} 分`;
+  document.querySelector("#customerDrawerInspectionStatus").innerHTML = `<span class="status-badge ${inspectionClass(customer.inspectionStatus)}">${escapeHtml(customer.inspectionStatus || "未巡查")}</span>`;
+  document.querySelector("#customerDrawerInspector").textContent = customer.inspector || "-";
+  document.querySelector("#customerDrawerInspectionAt").textContent = customer.inspectionAt || "-";
+  document.querySelector("#customerDrawerAddress").textContent = customer.address || "-";
+  document.querySelector("#customerDrawerKeyReason").textContent = customer.keyCustomerReason || "暂无重点客户入选说明。";
+  document.querySelector("#customerDrawerInspectionSummary").textContent = customer.inspectionSummary || "暂无巡查结论。";
+  document.querySelector("#customerDrawerIssues").innerHTML = relatedIssues.length ? relatedIssues.map((issue) => `
+    <article class="record-card">
+      <div><strong>${escapeHtml(issue.type)}</strong><span class="status-badge ${statusClass(issue.status)}">${escapeHtml(issue.status)}</span></div>
+      <p>${escapeHtml(issue.summary || "未填写问题描述。")}</p>
+      <small>责任人:${escapeHtml(issue.owner)};处置后满意度:${escapeHtml(issue.postSatisfaction || "待处置")}</small>
+    </article>
+  `).join("") : `<p class="muted-text">暂无关联问题反馈。</p>`;
+
+  drawerBackdrop.hidden = false;
+  customerDrawer.classList.add("open");
+  customerDrawer.setAttribute("aria-hidden", "false");
+  applyPermissions();
+}
+
+function closeDrawer() {
+  drawer.classList.remove("open");
+  drawer.setAttribute("aria-hidden", "true");
+  customerDrawer.classList.remove("open");
+  customerDrawer.setAttribute("aria-hidden", "true");
+  drawerBackdrop.hidden = true;
+  selectedIssueId = null;
+  selectedCustomerId = null;
+}
+
+function openModal(id, customerId) {
+  const modal = document.querySelector(`#${id}`);
+  if (!modal) return;
+
+  if ((id === "issueModal" || id === "inspectionModal") && customers.length === 0) {
+    showToast("当前账号暂无可操作客户,请先新增客户或联系管理员分配客户。");
+    return;
+  }
+
+  if ((id === "issueModal" || id === "inspectionModal") && customerId && !customers.some((customer) => customer.id === customerId)) {
+    showToast("当前登录人无权操作该客户。");
+    return;
+  }
+
+  if (id === "issueModal" && customerId) {
+    if (issueCustomerSearch) issueCustomerSearch.value = "";
+    renderCustomerSelects();
+    document.querySelector("#issueCustomerSelect").value = customerId;
+  }
+  if (id === "issueModal" && !customerId) {
+    if (issueCustomerSearch) issueCustomerSearch.value = "";
+    renderCustomerSelects();
+  }
+  if (id === "inspectionModal" && customerId) {
+    document.querySelector("#inspectionCustomerSelect").value = customerId;
+  }
+  modalBackdrop.hidden = false;
+  modal.classList.add("open");
+  modal.setAttribute("aria-hidden", "false");
+}
+
+function closeModal() {
+  document.querySelectorAll(".modal.open").forEach((modal) => {
+    modal.classList.remove("open");
+    modal.setAttribute("aria-hidden", "true");
+  });
+  modalBackdrop.hidden = true;
+}
+
+async function refreshAfterMutation(issueId, message) {
+  await loadData();
+  renderAll();
+  if (issueId && drawer.classList.contains("open")) openDrawer(issueId);
+  if (message) showToast(message);
+}
+
+async function refreshAdminConfig(message) {
+  adminConfig = await apiRequest("/api/admin/config");
+  dictionaries = { ...dictionaries, ...(adminConfig.dictionaries || {}) };
+  renderDictionaryOptions();
+  renderAdmin();
+  applyPermissions();
+  if (message) showToast(message);
+}
+
+async function mutateIssue(issueId, action, body = {}) {
+  try {
+    const payload = await apiRequest(`/api/issues/${encodeURIComponent(issueId)}/${action}`, {
+      method: "PATCH",
+      body
+    });
+    await refreshAfterMutation(issueId, payload.message);
+  } catch (error) {
+    handleApiError(error);
+  }
+}
+
+async function enterApp(user, token, silent = false) {
+  currentUser = user;
+  authToken = token;
+  window.localStorage.setItem("keyissueToken", token);
+  loginScreen.hidden = true;
+  appShell.hidden = false;
+  currentUserName.textContent = currentUser.userName;
+  currentRoleName.textContent = currentUser.roleName;
+  activePage = currentUser.pages[0] || "dashboard";
+  await loadData();
+  switchPage(activePage);
+  if (!silent) showToast(`已通过后台登录:${currentUser.roleName}`);
+}
+
+function clearSession() {
+  currentUser = null;
+  authToken = "";
+  adminConfig = null;
+  customers = [];
+  issues = [];
+  inspections = [];
+  window.localStorage.removeItem("keyissueToken");
+  appShell.hidden = true;
+  loginScreen.hidden = false;
+  currentUserName.textContent = "未登录";
+  currentRoleName.textContent = "-";
+  loginMessage.textContent = "";
+  if (registerMessage) registerMessage.textContent = "";
+  closeDrawer();
+  closeModal();
+}
+
+async function restoreSession() {
+  if (!authToken) return;
+  try {
+    const payload = await apiRequest("/api/auth/me");
+    await enterApp(payload.user, authToken, true);
+  } catch {
+    clearSession();
+  }
+}
+
+document.querySelectorAll("[data-page]").forEach((button) => {
+  button.addEventListener("click", () => switchPage(button.dataset.page));
+});
+
+document.querySelectorAll("[data-page-jump]").forEach((button) => {
+  button.addEventListener("click", () => switchPage(button.dataset.pageJump));
+});
+
+[searchInput, tierFilter, departmentFilter].forEach((control) => {
+  control.addEventListener("input", renderAll);
+  control.addEventListener("change", renderAll);
+});
+
+statusButtons.forEach((button) => {
+  button.addEventListener("click", () => {
+    activeStatus = button.dataset.status;
+    statusButtons.forEach((item) => item.classList.toggle("active", item === button));
+    renderAll();
+  });
+});
+
+document.addEventListener("click", async (event) => {
+  const saveUserButton = event.target.closest("[data-admin-save-user]");
+  if (saveUserButton) {
+    if (!requirePermission("admin:manage", "保存后台账号")) return;
+    const userId = saveUserButton.dataset.adminSaveUser;
+    try {
+      const payload = await apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, {
+        method: "PATCH",
+        body: {
+          displayName: document.querySelector(`[data-admin-user-name="${userId}"]`)?.value,
+          phone: document.querySelector(`[data-admin-user-phone="${userId}"]`)?.value,
+          department: document.querySelector(`[data-admin-user-department="${userId}"]`)?.value,
+          position: document.querySelector(`[data-admin-user-position="${userId}"]`)?.value,
+          password: document.querySelector(`[data-admin-user-password="${userId}"]`)?.value,
+          role: document.querySelector(`[data-admin-user-role="${userId}"]`)?.value
+        }
+      });
+      await refreshAdminConfig(payload.message);
+    } catch (error) {
+      handleApiError(error);
+    }
+    return;
+  }
+
+  const toggleUserButton = event.target.closest("[data-admin-toggle-user]");
+  if (toggleUserButton) {
+    if (!requirePermission("admin:manage", "停启用后台账号")) return;
+    const userId = toggleUserButton.dataset.adminToggleUser;
+    const user = adminConfig?.users.find((item) => item.id === userId);
+    if (!user) return;
+    try {
+      const payload = await apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, {
+        method: "PATCH",
+        body: { status: user.status === "停用" ? "启用" : "停用" }
+      });
+      await refreshAdminConfig(payload.message);
+    } catch (error) {
+      handleApiError(error);
+    }
+    return;
+  }
+
+  const saveRoleButton = event.target.closest("[data-admin-save-role]");
+  if (saveRoleButton) {
+    if (!requirePermission("admin:manage", "保存角色权限")) return;
+    const roleKey = saveRoleButton.dataset.adminSaveRole;
+    let pages = [...document.querySelectorAll(`[data-role-page="${roleKey}"]`)].filter((input) => input.checked).map((input) => input.value);
+    const permissions = [...document.querySelectorAll(`[data-role-permission="${roleKey}"]`)].filter((input) => input.checked).map((input) => input.value);
+    if (!permissions.includes("admin:manage")) pages = pages.filter((page) => page !== "admin");
+    try {
+      const payload = await apiRequest(`/api/admin/roles/${encodeURIComponent(roleKey)}`, {
+        method: "PATCH",
+        body: { pages, permissions }
+      });
+      await refreshAdminConfig(payload.message);
+    } catch (error) {
+      handleApiError(error);
+    }
+    return;
+  }
+
+  const approveCustomerButton = event.target.closest("[data-admin-customer-approve]");
+  if (approveCustomerButton) {
+    if (!requirePermission("customer:approve", "审核客户入池")) return;
+    try {
+      const payload = await apiRequest(`/api/admin/customer-drafts/${encodeURIComponent(approveCustomerButton.dataset.adminCustomerApprove)}`, {
+        method: "PATCH",
+        body: { action: "approve" }
+      });
+      await loadData();
+      renderAll();
+      showToast(payload.message);
+    } catch (error) {
+      handleApiError(error);
+    }
+    return;
+  }
+
+  const rejectCustomerButton = event.target.closest("[data-admin-customer-reject]");
+  if (rejectCustomerButton) {
+    if (!requirePermission("customer:approve", "驳回客户入池")) return;
+    try {
+      const payload = await apiRequest(`/api/admin/customer-drafts/${encodeURIComponent(rejectCustomerButton.dataset.adminCustomerReject)}`, {
+        method: "PATCH",
+        body: { action: "reject", reviewNote: "管理员驳回,需补充入选说明或责任信息。" }
+      });
+      await loadData();
+      renderAll();
+      showToast(payload.message);
+    } catch (error) {
+      handleApiError(error);
+    }
+    return;
+  }
+
+  const openButton = event.target.closest("[data-open-modal]");
+  if (openButton) {
+    const modalName = openButton.dataset.openModal;
+    const permissionMap = {
+      customerModal: ["customer:submit", "提交客户入池申请"],
+      issueModal: ["issue:create", "新增问题反馈"],
+      inspectionModal: ["inspection:create", "提交每日巡查"],
+      handleModal: ["issue:handle", "提交处置"]
+    };
+    const [required, actionName] = permissionMap[modalName] || ["issue:view", "打开表单"];
+    if (!requirePermission(required, actionName)) return;
+    openModal(modalName, openButton.dataset.customer || openButton.dataset.inspectionCustomer);
+    return;
+  }
+
+  const customerButton = event.target.closest("[data-view-customer]");
+  if (customerButton) {
+    if (!requirePermission("issue:view", "查看客户详情")) return;
+    openCustomerDrawer(customerButton.dataset.viewCustomer);
+    return;
+  }
+
+  const viewButton = event.target.closest("[data-view-issue]");
+  if (viewButton) {
+    if (!requirePermission("issue:view", "查看问题详情")) return;
+    openDrawer(viewButton.dataset.viewIssue);
+    return;
+  }
+
+  const handleButton = event.target.closest("[data-quick-handle]");
+  if (handleButton) {
+    if (!requirePermission("issue:handle", "处置问题")) return;
+    selectedIssueId = handleButton.dataset.quickHandle;
+    openModal("handleModal");
+    return;
+  }
+
+  const passButton = event.target.closest("[data-audit-pass]");
+  if (passButton) {
+    if (!requirePermission("audit:review", "核查通过")) return;
+    mutateIssue(passButton.dataset.auditPass, "audit-pass");
+    return;
+  }
+
+  const rejectButton = event.target.closest("[data-audit-reject]");
+  if (rejectButton) {
+    if (!requirePermission("audit:review", "驳回整改")) return;
+    mutateIssue(rejectButton.dataset.auditReject, "audit-reject");
+  }
+});
+
+document.querySelectorAll("[data-close-modal]").forEach((button) => button.addEventListener("click", closeModal));
+modalBackdrop.addEventListener("click", closeModal);
+document.querySelector("[data-close-drawer]").addEventListener("click", closeDrawer);
+document.querySelector("[data-close-customer-drawer]").addEventListener("click", closeDrawer);
+drawerBackdrop.addEventListener("click", closeDrawer);
+
+document.querySelectorAll("[data-customer-drawer-action]").forEach((button) => {
+  button.addEventListener("click", () => {
+    if (!selectedCustomerId) return;
+    if (button.dataset.customerDrawerAction === "inspection") {
+      if (!requirePermission("inspection:create", "提交每日巡查")) return;
+      openModal("inspectionModal", selectedCustomerId);
+    }
+    if (button.dataset.customerDrawerAction === "feedback") {
+      if (!requirePermission("issue:create", "提交问题反馈")) return;
+      openModal("issueModal", selectedCustomerId);
+    }
+  });
+});
+
+document.querySelectorAll("[data-drawer-action]").forEach((button) => {
+  button.addEventListener("click", () => {
+    if (!selectedIssueId) return;
+    const action = button.dataset.drawerAction;
+    if (action === "assign") {
+      if (!requirePermission("issue:assign", "指派责任人")) return;
+      mutateIssue(selectedIssueId, "assign", { owner: "部门负责人" });
+    }
+    if (action === "handle") {
+      if (!requirePermission("issue:handle", "提交处置")) return;
+      openModal("handleModal");
+    }
+    if (action === "approve") {
+      if (!requirePermission("audit:review", "核查通过")) return;
+      mutateIssue(selectedIssueId, "audit-pass");
+    }
+    if (action === "reject") {
+      if (!requirePermission("audit:review", "驳回整改")) return;
+      mutateIssue(selectedIssueId, "audit-reject");
+    }
+  });
+});
+
+document.querySelector("#issueForm").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!requirePermission("issue:create", "提交问题反馈")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest("/api/issues", { method: "POST", body: data });
+    form.reset();
+    closeModal();
+    await refreshAfterMutation(payload.issue.id, payload.message);
+    switchPage("issues");
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+document.querySelector("#inspectionForm").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!requirePermission("inspection:create", "提交每日线上巡查")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest("/api/inspections", { method: "POST", body: data });
+    form.reset();
+    closeModal();
+    await loadData();
+    switchPage("inspections");
+    showToast(payload.message);
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+document.querySelector("#handleForm").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!selectedIssueId) return;
+  if (!requirePermission("issue:handle", "提交处置")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest(`/api/issues/${encodeURIComponent(selectedIssueId)}/handle`, {
+      method: "PATCH",
+      body: data
+    });
+    form.reset();
+    closeModal();
+    await refreshAfterMutation(selectedIssueId, payload.message);
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+document.querySelector("#customerForm").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!requirePermission("customer:submit", "提交客户入池申请")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest("/api/customers", { method: "POST", body: data });
+    form.reset();
+    closeModal();
+    await loadData();
+    if (payload.customer) switchPage("customers");
+    if (payload.draft && hasPermission("page:admin")) switchPage("admin");
+    showToast(payload.message);
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+document.querySelector("#adminUserForm")?.addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!requirePermission("admin:manage", "新增后台账号")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest("/api/admin/users", { method: "POST", body: data });
+    form.reset();
+    await refreshAdminConfig(payload.message);
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+document.querySelector("#adminDictionaryForm")?.addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!requirePermission("admin:manage", "保存基础字典")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest("/api/admin/dictionaries", { method: "PATCH", body: data });
+    dictionaries = { ...dictionaries, ...(payload.dictionaries || {}) };
+    await refreshAdminConfig(payload.message);
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+issueCustomerSearch?.addEventListener("input", () => {
+  renderCustomerSelects(issueCustomerSearch.value);
+});
+
+document.querySelector("#saveRulesBtn").addEventListener("click", async () => {
+  if (!requirePermission("rules:edit", "保存预警规则")) return;
+  try {
+    const payload = await apiRequest("/api/rules", { method: "POST", body: { source: "official" } });
+    showToast(payload.message);
+  } catch (error) {
+    handleApiError(error);
+  }
+});
+
+loginForm.addEventListener("submit", async (event) => {
+  event.preventDefault();
+  loginMessage.textContent = "正在请求后台登录...";
+  try {
+    const payload = await apiRequest("/api/auth/login", {
+      method: "POST",
+      body: {
+        username: loginUsername.value.trim(),
+        password: loginPassword.value
+      }
+    });
+    loginMessage.textContent = "";
+    await enterApp(payload.user, payload.token);
+  } catch (error) {
+    loginMessage.textContent = error.message;
+  }
+});
+
+registerForm?.addEventListener("submit", async (event) => {
+  event.preventDefault();
+  const form = event.currentTarget;
+  const password = registerPassword.value.trim();
+  const confirmPassword = registerPasswordConfirm.value.trim();
+  if (password.length < 6) {
+    registerMessage.textContent = "密码至少需要 6 位。";
+    return;
+  }
+  if (password !== confirmPassword) {
+    registerMessage.textContent = "两次输入的密码不一致。";
+    return;
+  }
+  if (!beginFormSubmit(form)) return;
+  registerMessage.textContent = "正在核对花名册并创建账号...";
+  try {
+    const payload = await apiRequest("/api/auth/register", {
+      method: "POST",
+      body: {
+        name: registerName.value.trim(),
+        phone: registerPhone.value.trim(),
+        password,
+        confirmPassword
+      }
+    });
+    registerMessage.textContent = "";
+    form.reset();
+    await enterApp(payload.user, payload.token);
+    showToast(payload.message);
+  } catch (error) {
+    registerMessage.textContent = error.message;
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+logoutBtn.addEventListener("click", async () => {
+  try {
+    await apiRequest("/api/auth/logout", { method: "POST" });
+  } catch {
+    // 即使后台会话已经失效,也要清理本地登录态。
+  }
+  clearSession();
+});
+
+document.addEventListener("keydown", (event) => {
+  if (event.key === "Escape") {
+    closeDrawer();
+    closeModal();
+  }
+});
+
+restoreSession();

+ 1209 - 0
prototype/app.js.before-feedback-button-20260709153000

@@ -0,0 +1,1209 @@
+let currentUser = null;
+let authToken = window.localStorage.getItem("keyissueToken") || "";
+let customers = [];
+let issues = [];
+let inspections = [];
+let adminConfig = null;
+let activePage = "dashboard";
+let activeStatus = "all";
+let selectedIssueId = null;
+let selectedCustomerId = null;
+let dictionaries = {
+  departments: ["光宸", "澜光", "蘭堂", "焕境"],
+  customerSources: ["转介绍", "公司特殊关系", "标杆项目", "风险关注"],
+  issueSources: ["线下巡检", "客户群反馈", "每日线上巡查", "客户主动反馈", "管理层转办", "监察部发现"],
+  issueTypes: ["施工质量", "设计变更", "沟通响应", "材料交付", "客户满意度"],
+  progressStatuses: ["正常", "延期"]
+};
+
+const pageTitles = {
+  dashboard: "A+在建客户动态管理驾驶舱",
+  customers: "A/A+客户库",
+  inspections: "每日线上巡查",
+  issues: "问题反馈池",
+  audit: "监察核查",
+  analysis: "问题分析",
+  rules: "预警规则",
+  admin: "系统后台"
+};
+
+const pageTitle = document.querySelector("#pageTitle");
+const searchInput = document.querySelector("#searchInput");
+const tierFilter = document.querySelector("#tierFilter");
+const departmentFilter = document.querySelector("#departmentFilter");
+const statusButtons = document.querySelectorAll("[data-status]");
+const drawer = document.querySelector("#detailDrawer");
+const customerDrawer = document.querySelector("#customerDrawer");
+const drawerBackdrop = document.querySelector("#drawerBackdrop");
+const modalBackdrop = document.querySelector("#modalBackdrop");
+const toast = document.querySelector("#toast");
+const loginScreen = document.querySelector("#loginScreen");
+const appShell = document.querySelector("#appShell");
+const currentUserName = document.querySelector("#currentUserName");
+const currentRoleName = document.querySelector("#currentRoleName");
+const logoutBtn = document.querySelector("#logoutBtn");
+const loginForm = document.querySelector("#loginForm");
+const loginUsername = document.querySelector("#loginUsername");
+const loginPassword = document.querySelector("#loginPassword");
+const loginMessage = document.querySelector("#loginMessage");
+const registerForm = document.querySelector("#registerForm");
+const registerName = document.querySelector("#registerName");
+const registerPhone = document.querySelector("#registerPhone");
+const registerPassword = document.querySelector("#registerPassword");
+const registerPasswordConfirm = document.querySelector("#registerPasswordConfirm");
+const registerMessage = document.querySelector("#registerMessage");
+const issueCustomerSearch = document.querySelector("#issueCustomerSearch");
+
+function escapeHtml(value) {
+  return String(value ?? "").replace(/[&<>"']/g, (char) => ({
+    "&": "&amp;",
+    "<": "&lt;",
+    ">": "&gt;",
+    '"': "&quot;",
+    "'": "&#039;"
+  })[char]);
+}
+
+function showToast(message) {
+  toast.textContent = message;
+  toast.classList.add("show");
+  window.clearTimeout(showToast.timer);
+  showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
+}
+
+async function apiRequest(path, options = {}) {
+  const headers = {
+    Accept: "application/json",
+    ...(options.headers || {})
+  };
+  const request = { ...options, headers };
+
+  if (authToken) headers.Authorization = `Bearer ${authToken}`;
+  if (options.body && typeof options.body !== "string") {
+    headers["Content-Type"] = "application/json";
+    request.body = JSON.stringify(options.body);
+  }
+
+  const response = await fetch(path, request);
+  const contentType = response.headers.get("content-type") || "";
+  const payload = contentType.includes("application/json") ? await response.json() : await response.text();
+
+  if (!response.ok) {
+    const error = new Error(payload.message || `请求失败:${response.status}`);
+    error.status = response.status;
+    error.payload = payload;
+    throw error;
+  }
+  return payload;
+}
+
+function hasPermission(permission) {
+  if (!currentUser) return false;
+  if (currentUser.permissions.includes("*")) return true;
+  if (permission.startsWith("page:")) {
+    return currentUser.pages.includes(permission.replace("page:", ""));
+  }
+  return currentUser.permissions.includes(permission);
+}
+
+function requirePermission(permission, actionName = "执行该操作") {
+  if (hasPermission(permission)) return true;
+  showToast(`当前登录人无权${actionName}`);
+  return false;
+}
+
+function applyPermissions() {
+  document.querySelectorAll("[data-page]").forEach((button) => {
+    const allowed = hasPermission(`page:${button.dataset.page}`);
+    button.disabled = !allowed;
+    button.title = allowed ? "" : "后台未授权访问该页面";
+  });
+
+  document.querySelectorAll("[data-requires]").forEach((element) => {
+    const allowed = hasPermission(element.dataset.requires);
+    element.disabled = !allowed;
+    element.classList.toggle("is-disabled", !allowed);
+    element.title = allowed ? "" : "后台未授权执行该操作";
+  });
+
+  document.querySelectorAll(".permission-list div").forEach((item) => {
+    const label = item.querySelector("strong")?.textContent || "";
+    item.classList.toggle("is-current", currentUser && label === currentUser.roleName);
+  });
+}
+
+function handleApiError(error) {
+  if (error.status === 401) {
+    clearSession();
+    showToast("登录已失效,请重新登录。");
+    return;
+  }
+  showToast(error.message || "操作失败,请稍后再试。");
+}
+
+function beginFormSubmit(form) {
+  if (form.dataset.submitting === "true") return false;
+  form.dataset.submitting = "true";
+  form.querySelectorAll('button[type="submit"]').forEach((button) => {
+    button.dataset.wasDisabled = button.disabled ? "true" : "false";
+    button.disabled = true;
+  });
+  return true;
+}
+
+function endFormSubmit(form) {
+  delete form.dataset.submitting;
+  form.querySelectorAll('button[type="submit"]').forEach((button) => {
+    button.disabled = button.dataset.wasDisabled === "true";
+    delete button.dataset.wasDisabled;
+  });
+}
+
+function getCustomer(id) {
+  return customers.find((customer) => customer.id === id) || {
+    id,
+    name: "未匹配客户",
+    tier: "-",
+    source: "-",
+    keyCustomerReason: "-",
+    customerOwner: "-",
+    address: "-",
+    department: "-",
+    designer: "-",
+    manager: "-",
+    stage: "-",
+    startDate: "-",
+    plannedFinishDate: "-",
+    progressStatus: "正常",
+    satisfaction: "-",
+    inspectionStatus: "-",
+    inspectionAt: "-",
+    inspector: "-",
+    inspectionSummary: "-"
+  };
+}
+
+function statusClass(status) {
+  if (status === "红色预警") return "risk";
+  if (status === "橙色预警") return "orange";
+  if (status === "蓝色关注") return "blue";
+  if (status === "待核查") return "audit";
+  if (status === "已处理") return "closed";
+  return "normal";
+}
+
+function inspectionClass(status) {
+  if (status === "异常") return "risk";
+  if (status === "未巡查" || status === "-" || !status) return "orange";
+  return "closed";
+}
+
+function inspectionFlag(status) {
+  return status === "未巡查" || status === "-" || !status ? "未巡查" : "已巡查";
+}
+
+function inspectionFlagClass(status) {
+  return status === "未巡查" || status === "-" || !status ? "orange" : "closed";
+}
+
+function progressClass(status) {
+  return status === "延期" ? "risk" : "closed";
+}
+
+function cell(label, content) {
+  return `<td data-label="${escapeHtml(label)}">${content}</td>`;
+}
+
+function dataTagBadge(item) {
+  return item?.dataTag ? `<span class="data-tag">${escapeHtml(item.dataTag)}</span>` : "";
+}
+
+function dictionaryValues(key) {
+  const values = dictionaries[key];
+  return Array.isArray(values) && values.length ? values : [];
+}
+
+function optionList(values, selected = "") {
+  return values.map((value) => {
+    const text = String(value);
+    const isSelected = selected === text ? " selected" : "";
+    return `<option value="${escapeHtml(text)}"${isSelected}>${escapeHtml(text)}</option>`;
+  }).join("");
+}
+
+function setSelectOptions(select, values, selectedValue) {
+  if (!select) return;
+  const currentValue = selectedValue ?? select.value;
+  const safeValues = values.length ? values : [currentValue].filter(Boolean);
+  select.innerHTML = optionList(safeValues, currentValue);
+  if (safeValues.includes(currentValue)) select.value = currentValue;
+}
+
+function renderDictionaryOptions() {
+  const departments = dictionaryValues("departments");
+  const customerSources = dictionaryValues("customerSources");
+  const issueSources = dictionaryValues("issueSources");
+  const issueTypes = dictionaryValues("issueTypes");
+  const progressStatuses = dictionaryValues("progressStatuses");
+  const activeDepartment = departmentFilter.value || "all";
+
+  departmentFilter.innerHTML = `<option value="all">全部部门</option>${optionList(departments, activeDepartment)}`;
+  departmentFilter.value = departments.includes(activeDepartment) ? activeDepartment : "all";
+  setSelectOptions(document.querySelector('#customerForm select[name="source"]'), customerSources);
+  setSelectOptions(document.querySelector('#customerForm select[name="department"]'), departments);
+  setSelectOptions(document.querySelector('#customerForm select[name="progressStatus"]'), progressStatuses);
+  setSelectOptions(document.querySelector('#issueForm select[name="source"]'), issueSources);
+  setSelectOptions(document.querySelector('#issueForm select[name="type"]'), issueTypes);
+}
+
+function customerCell(customer) {
+  return `
+    <button class="customer-cell-button" type="button" data-view-customer="${escapeHtml(customer.id)}">
+      <strong>${escapeHtml(customer.name)}${dataTagBadge(customer)}</strong>
+      <small>${escapeHtml(customer.address)}</small>
+      <span class="customer-cell-meta">
+        <span class="badge">${escapeHtml(customer.tier)}</span>
+        <span class="status-badge ${progressClass(customer.progressStatus)}">${escapeHtml(customer.progressStatus || "正常")}</span>
+        <span class="status-badge ${inspectionFlagClass(customer.inspectionStatus)}">${escapeHtml(inspectionFlag(customer.inspectionStatus))}</span>
+      </span>
+    </button>
+  `;
+}
+
+function filteredIssues() {
+  const keyword = searchInput.value.trim().toLowerCase();
+  const tier = tierFilter.value;
+  const department = departmentFilter.value;
+
+  return issues.filter((issue) => {
+    const customer = getCustomer(issue.customerId);
+    const targetText = [
+      customer.name,
+      customer.address,
+      customer.stage,
+      customer.startDate,
+      customer.plannedFinishDate,
+      customer.progressStatus,
+      issue.type,
+      issue.source,
+      issue.rootCause,
+      issue.owner,
+      issue.action
+    ].join(" ").toLowerCase();
+    const matchesKeyword = !keyword || targetText.includes(keyword);
+    const matchesTier = tier === "all" || customer.tier === tier;
+    const matchesDepartment = department === "all" || customer.department === department;
+    const matchesStatus = activeStatus === "all" || issue.status === activeStatus;
+    return matchesKeyword && matchesTier && matchesDepartment && matchesStatus;
+  });
+}
+
+function countBy(list, getter) {
+  return list.reduce((acc, item) => {
+    const key = getter(item);
+    acc[key] = (acc[key] || 0) + 1;
+    return acc;
+  }, {});
+}
+
+function renderKpis() {
+  const visible = filteredIssues();
+  const red = visible.filter((issue) => issue.status === "红色预警").length;
+  const audit = visible.filter((issue) => issue.status === "待核查").length;
+  const inspectionDone = customers.filter((customer) => customer.inspectionStatus === "已巡查" || customer.inspectionStatus === "异常").length;
+  const abnormalInspection = customers.filter((customer) => customer.inspectionStatus === "异常").length;
+
+  document.querySelector("#kpiInspectionDone").textContent = inspectionDone;
+  document.querySelector("#kpiAbnormalInspections").textContent = abnormalInspection;
+  document.querySelector("#kpiRedIssues").textContent = red;
+  document.querySelector("#kpiAuditIssues").textContent = audit;
+  document.querySelector("#funnelRed").textContent = `${issues.filter((issue) => issue.status === "红色预警").length} 件`;
+  document.querySelector("#funnelOrange").textContent = `${issues.filter((issue) => issue.status === "橙色预警").length} 件`;
+  document.querySelector("#funnelBlue").textContent = `${issues.filter((issue) => issue.status === "蓝色关注").length} 件`;
+  document.querySelector("#sideAlertCount").textContent = `${issues.filter((issue) => issue.status === "红色预警").length} 件`;
+}
+
+function renderBars() {
+  const typeCounts = countBy(issues, (issue) => issue.type);
+  const typeMax = Math.max(1, ...Object.values(typeCounts));
+  document.querySelector("#issueTypeBars").innerHTML = Object.entries(typeCounts).map(([type, count]) => `
+    <div class="bar-item">
+      <span>${escapeHtml(type)}</span>
+      <div><i style="width: ${Math.max(22, Math.round((count / typeMax) * 100))}%;"></i></div>
+      <strong>${count}</strong>
+    </div>
+  `).join("");
+
+  const departmentCounts = countBy(issues, (issue) => getCustomer(issue.customerId).department);
+  document.querySelector("#departmentRanks").innerHTML = Object.entries(departmentCounts)
+    .sort((a, b) => b[1] - a[1])
+    .map(([department, count], index) => `<div><strong>${index + 1}. ${escapeHtml(department)}</strong><span>${count} 件</span></div>`)
+    .join("");
+}
+
+function issueRow(issue, compact = false) {
+  const customer = getCustomer(issue.customerId);
+  const cells = compact ? `
+    ${cell("客户", customerCell(customer))}
+    ${cell("等级", `<span class="badge">${escapeHtml(customer.tier)}</span>`)}
+    ${cell("部门", escapeHtml(customer.department))}
+    ${cell("问题来源", escapeHtml(issue.source || "-"))}
+    ${cell("问题类型", escapeHtml(issue.type))}
+    ${cell("预警状态", `<span class="status-badge ${statusClass(issue.status)}">${escapeHtml(issue.status)}</span>`)}
+    ${cell("责任人", escapeHtml(issue.owner))}
+    ${cell("当前处置", escapeHtml(issue.action))}
+  ` : `
+    ${cell("反馈编号", `${escapeHtml(issue.id)}${dataTagBadge(issue)}`)}
+    ${cell("客户", customerCell(customer))}
+    ${cell("问题来源", escapeHtml(issue.source || "-"))}
+    ${cell("问题类型", escapeHtml(issue.type))}
+    ${cell("问题描述", escapeHtml(issue.summary || "未填写"))}
+    ${cell("预警状态", `<span class="status-badge ${statusClass(issue.status)}">${escapeHtml(issue.status)}</span>`)}
+    ${cell("责任人", escapeHtml(issue.owner))}
+    ${cell("根因", escapeHtml(issue.rootCause))}
+    ${cell("当前处置", escapeHtml(issue.action))}
+    ${cell("处置后满意度", `<span class="status-badge ${statusClass(issue.postSatisfaction)}">${escapeHtml(issue.postSatisfaction || "待处置")}</span>`)}
+  `;
+
+  return `<tr>${cells}${cell("操作", `<div class="action-cell"><button class="small-button" type="button" data-view-issue="${escapeHtml(issue.id)}" data-requires="issue:view">查看</button><button class="small-button" type="button" data-quick-handle="${escapeHtml(issue.id)}" data-requires="issue:handle">处置</button></div>`)}</tr>`;
+}
+
+function renderIssueTables() {
+  const visible = filteredIssues();
+  const highPriority = [...visible].sort((a, b) => b.score - a.score).slice(0, 5);
+
+  document.querySelector("#dashboardRows").innerHTML = highPriority.map((issue) => issueRow(issue, true)).join("") ||
+    `<tr><td colspan="9">暂无符合条件的问题。</td></tr>`;
+
+  document.querySelector("#issueRows").innerHTML = visible.map((issue) => issueRow(issue)).join("") ||
+    `<tr><td colspan="11">暂无符合条件的问题反馈。</td></tr>`;
+
+  const auditIssues = issues.filter((issue) => issue.status === "待核查");
+  document.querySelector("#auditCount").textContent = `${auditIssues.length} 件`;
+  document.querySelector("#auditRows").innerHTML = auditIssues.map((issue) => {
+    const customer = getCustomer(issue.customerId);
+    return `
+      <tr>
+        ${cell("客户", customerCell(customer))}
+        ${cell("问题", escapeHtml(issue.type))}
+        ${cell("处置说明", escapeHtml(issue.handlerNote))}
+        ${cell("提交人", escapeHtml(issue.submitter))}
+        ${cell("操作", `<div class="action-cell"><button class="small-button" type="button" data-view-issue="${escapeHtml(issue.id)}" data-requires="issue:view">查看</button><button class="small-button" type="button" data-audit-pass="${escapeHtml(issue.id)}" data-requires="audit:review">通过</button><button class="small-button" type="button" data-audit-reject="${escapeHtml(issue.id)}" data-requires="audit:review">驳回</button></div>`)}
+      </tr>
+    `;
+  }).join("") || `<tr><td colspan="5">当前没有待核查问题。</td></tr>`;
+}
+
+function renderCustomers() {
+  document.querySelector("#customerRows").innerHTML = customers.map((customer) => {
+    const count = issues.filter((issue) => issue.customerId === customer.id).length;
+    return `
+      <tr>
+        ${cell("客户", customerCell(customer))}
+        ${cell("等级", `<span class="badge">${escapeHtml(customer.tier)}</span>`)}
+        ${cell("新居地址", escapeHtml(customer.address))}
+        ${cell("客管负责人", escapeHtml(customer.customerOwner || "-"))}
+        ${cell("入选说明", escapeHtml(customer.keyCustomerReason || "-"))}
+        ${cell("部门", escapeHtml(customer.department))}
+        ${cell("设计师", escapeHtml(customer.designer))}
+        ${cell("项目经理", escapeHtml(customer.manager))}
+        ${cell("施工阶段", escapeHtml(customer.stage))}
+        ${cell("开工日期", escapeHtml(customer.startDate || "-"))}
+        ${cell("计划完工日期", escapeHtml(customer.plannedFinishDate || "-"))}
+        ${cell("当前进度", `<span class="status-badge ${progressClass(customer.progressStatus)}">${escapeHtml(customer.progressStatus || "正常")}</span>`)}
+        ${cell("巡查与否", `<span class="status-badge ${inspectionFlagClass(customer.inspectionStatus)}">${escapeHtml(inspectionFlag(customer.inspectionStatus))}</span>`)}
+        ${cell("巡查状态", `<span class="status-badge ${inspectionClass(customer.inspectionStatus)}">${escapeHtml(customer.inspectionStatus || "未巡查")}</span>`)}
+        ${cell("最近巡查", `${escapeHtml(customer.inspectionAt || "-")}<br><small>${escapeHtml(customer.inspectionSummary || "")}</small>`)}
+        ${cell("问题数", count)}
+        ${cell("操作", `<div class="action-cell"><button class="small-button" type="button" data-open-modal="inspectionModal" data-inspection-customer="${escapeHtml(customer.id)}" data-requires="inspection:create">日查</button><button class="small-button" type="button" data-open-modal="issueModal" data-customer="${escapeHtml(customer.id)}" data-requires="issue:create">反馈</button></div>`)}
+      </tr>
+    `;
+  }).join("");
+
+  renderCustomerSelects();
+}
+
+function renderCustomerSelects(filter = "") {
+  const keyword = filter.trim().toLowerCase();
+  const visibleCustomers = customers.filter((customer) => {
+    const text = `${customer.name} ${customer.address} ${customer.manager} ${customer.designer} ${customer.stage} ${customer.startDate} ${customer.plannedFinishDate} ${customer.progressStatus}`.toLowerCase();
+    return !keyword || text.includes(keyword);
+  });
+  const options = visibleCustomers.map((customer) => `<option value="${escapeHtml(customer.id)}">${escapeHtml(customer.name)} · ${escapeHtml(customer.address)}</option>`).join("");
+  const issueSelect = document.querySelector("#issueCustomerSelect");
+  const inspectionSelect = document.querySelector("#inspectionCustomerSelect");
+  if (issueSelect) issueSelect.innerHTML = options;
+  if (inspectionSelect) inspectionSelect.innerHTML = customers.map((customer) => `<option value="${escapeHtml(customer.id)}">${escapeHtml(customer.name)} · ${escapeHtml(customer.address)}</option>`).join("");
+}
+
+function renderInspections() {
+  const done = customers.filter((customer) => customer.inspectionStatus === "已巡查" || customer.inspectionStatus === "异常").length;
+  const abnormal = customers.filter((customer) => customer.inspectionStatus === "异常").length;
+  const pending = customers.filter((customer) => customer.inspectionStatus === "未巡查").length;
+  document.querySelector("#inspectionSummaryCards").innerHTML = `
+    <article><span>今日已巡查</span><strong>${done}</strong></article>
+    <article><span>异常巡查</span><strong>${abnormal}</strong></article>
+    <article><span>未巡查</span><strong>${pending}</strong></article>
+  `;
+
+  document.querySelector("#inspectionRows").innerHTML = inspections.map((item) => `
+    <tr>
+      ${cell("巡查编号", `${escapeHtml(item.id)}${dataTagBadge(item)}`)}
+      ${cell("客户", customerCell(getCustomer(item.customerId)))}
+      ${cell("巡查与否", `<span class="status-badge ${inspectionFlagClass(item.status)}">${escapeHtml(inspectionFlag(item.status))}</span>`)}
+      ${cell("巡查状态", `<span class="status-badge ${inspectionClass(item.status)}">${escapeHtml(item.status)}</span>`)}
+      ${cell("巡查人", escapeHtml(item.inspector))}
+      ${cell("巡查时间", escapeHtml(item.createdAt))}
+      ${cell("施工质量", escapeHtml(item.siteQuality))}
+      ${cell("文明施工", escapeHtml(item.civility))}
+      ${cell("客户群反馈", escapeHtml(item.groupFeedback))}
+      ${cell("巡查结论", escapeHtml(item.summary))}
+    </tr>
+  `).join("") || `<tr><td colspan="10">暂无每日线上巡查记录。</td></tr>`;
+}
+
+function adminRoleOptions(selectedRole) {
+  if (!adminConfig) return "";
+  return Object.entries(adminConfig.roles).map(([roleKey, role]) => {
+    const selected = roleKey === selectedRole ? " selected" : "";
+    return `<option value="${escapeHtml(roleKey)}"${selected}>${escapeHtml(role.roleName)}</option>`;
+  }).join("");
+}
+
+function renderAdminRoleCards() {
+  return Object.entries(adminConfig.roles).map(([roleKey, role]) => {
+    const isAdmin = roleKey === "admin";
+    const allPermissions = role.permissions.includes("*");
+    const pageControls = adminConfig.pageCatalog.map((page) => `
+      <label>
+        <input type="checkbox" data-role-page="${escapeHtml(roleKey)}" value="${escapeHtml(page.key)}" ${role.pages.includes(page.key) || isAdmin ? "checked" : ""} ${isAdmin ? "disabled" : ""}>
+        <span>${escapeHtml(page.name)}</span>
+      </label>
+    `).join("");
+    const permissionControls = adminConfig.permissionCatalog.map((permission) => `
+      <label>
+        <input type="checkbox" data-role-permission="${escapeHtml(roleKey)}" value="${escapeHtml(permission.key)}" ${allPermissions || role.permissions.includes(permission.key) ? "checked" : ""} ${isAdmin ? "disabled" : ""}>
+        <span>${escapeHtml(permission.name)}</span>
+      </label>
+    `).join("");
+
+    return `
+      <article class="role-card">
+        <div class="role-card-head">
+          <div>
+            <strong>${escapeHtml(role.roleName)}</strong>
+            <span>${escapeHtml(role.summary)}</span>
+          </div>
+          <button class="small-button" type="button" data-admin-save-role="${escapeHtml(roleKey)}" ${isAdmin ? "disabled" : ""}>保存角色权限</button>
+        </div>
+        <div class="role-card-section">
+          <b>可访问页面</b>
+          <div class="checkbox-grid">${pageControls}</div>
+        </div>
+        <div class="role-card-section">
+          <b>可执行操作</b>
+          <div class="checkbox-grid">${permissionControls}</div>
+        </div>
+      </article>
+    `;
+  }).join("");
+}
+
+function renderCustomerReviewRows() {
+  const drafts = adminConfig.customerDrafts || [];
+  return drafts.map((draft) => {
+    const customer = draft.customer || {};
+    const canReview = draft.status === "待审核";
+    return `
+      <tr>
+        ${cell("审核单", `${escapeHtml(draft.id)}${dataTagBadge(draft)}`)}
+        ${cell("客户", `<strong>${escapeHtml(customer.name)}</strong><br><small>${escapeHtml(customer.address || "-")}</small>`)}
+        ${cell("等级", `<span class="badge">${escapeHtml(customer.tier || "-")}</span>`)}
+        ${cell("提交人", `${escapeHtml(draft.submittedBy || "-")}<br><small>${escapeHtml(draft.submittedRole || "-")}</small>`)}
+        ${cell("客管负责人", escapeHtml(customer.customerOwner || "-"))}
+        ${cell("入选说明", escapeHtml(customer.keyCustomerReason || "-"))}
+        ${cell("提交时间", escapeHtml(draft.submittedAt || "-"))}
+        ${cell("状态", `<span class="status-badge ${draft.status === "待审核" ? "orange" : draft.status === "已通过" ? "closed" : "risk"}">${escapeHtml(draft.status)}</span>`)}
+        ${cell("操作", canReview ? `<div class="action-cell"><button class="small-button" type="button" data-admin-customer-approve="${escapeHtml(draft.id)}">通过</button><button class="small-button" type="button" data-admin-customer-reject="${escapeHtml(draft.id)}">驳回</button></div>` : escapeHtml(draft.reviewNote || draft.customerId || "-"))}
+      </tr>
+    `;
+  }).join("") || `<tr><td colspan="9">暂无客户入池审核单。</td></tr>`;
+}
+
+function renderAdmin() {
+  if (!adminConfig) {
+    document.querySelector("#adminUserRows").innerHTML = `<tr><td colspan="9">当前登录人没有系统后台管理权限。</td></tr>`;
+    document.querySelector("#adminRoleCards").innerHTML = `<p class="muted-text">后台配置仅系统管理员可维护。</p>`;
+    return;
+  }
+
+  const activeUsers = adminConfig.users.filter((user) => user.status !== "停用").length;
+  const pendingDrafts = (adminConfig.customerDrafts || []).filter((draft) => draft.status === "待审核").length;
+  document.querySelector("#adminUserCount").textContent = adminConfig.users.length;
+  document.querySelector("#adminActiveUserCount").textContent = activeUsers;
+  document.querySelector("#adminRoleCount").textContent = Object.keys(adminConfig.roles).length;
+  document.querySelector("#adminPermissionCount").textContent = adminConfig.permissionCatalog.length;
+  document.querySelector("#adminPendingCustomerCount").textContent = pendingDrafts;
+  document.querySelector("#adminCustomerReviewCount").textContent = `${pendingDrafts} 单待审核`;
+  document.querySelector("#adminUserRoleSelect").innerHTML = adminRoleOptions("delivery");
+  document.querySelector("#customerReviewRows").innerHTML = renderCustomerReviewRows();
+
+  document.querySelector("#adminUserRows").innerHTML = adminConfig.users.map((user) => `
+    <tr>
+      ${cell("账号", `<strong>${escapeHtml(user.username)}</strong>`)}
+      ${cell("姓名", `<input class="inline-input" data-admin-user-name="${escapeHtml(user.id)}" value="${escapeHtml(user.displayName)}">`)}
+      ${cell("手机号", `<input class="inline-input" data-admin-user-phone="${escapeHtml(user.id)}" value="${escapeHtml(user.phone || "")}" placeholder="可选">`)}
+      ${cell("部门", `<input class="inline-input" data-admin-user-department="${escapeHtml(user.id)}" value="${escapeHtml(user.department || "")}" placeholder="用于范围">`)}
+      ${cell("岗位", `<input class="inline-input" data-admin-user-position="${escapeHtml(user.id)}" value="${escapeHtml(user.position || "")}" placeholder="用于范围">`)}
+      ${cell("角色", `<select data-admin-user-role="${escapeHtml(user.id)}">${adminRoleOptions(user.role)}</select>`)}
+      ${cell("状态", `<span class="status-badge ${user.status === "停用" ? "orange" : "closed"}">${escapeHtml(user.status)}</span>`)}
+      ${cell("重置密码", `<input class="inline-input" type="password" data-admin-user-password="${escapeHtml(user.id)}" value="" placeholder="留空不修改">`)}
+      ${cell("操作", `<div class="action-cell"><button class="small-button" type="button" data-admin-save-user="${escapeHtml(user.id)}">保存账号</button><button class="small-button" type="button" data-admin-toggle-user="${escapeHtml(user.id)}">${user.status === "停用" ? "启用" : "停用"}</button></div>`)}
+    </tr>
+  `).join("");
+
+  document.querySelector("#adminRoleCards").innerHTML = renderAdminRoleCards();
+  const dictionaryForm = document.querySelector("#adminDictionaryForm");
+  Object.entries(adminConfig.dictionaries || {}).forEach(([key, values]) => {
+    if (dictionaryForm.elements[key]) dictionaryForm.elements[key].value = values.join("\n");
+  });
+}
+
+function renderAll() {
+  renderKpis();
+  renderBars();
+  renderIssueTables();
+  renderCustomers();
+  renderInspections();
+  if (activePage === "admin") renderAdmin();
+  applyPermissions();
+}
+
+async function loadData() {
+  const issueRequest = hasPermission("issue:view") ? apiRequest("/api/issues") : Promise.resolve({ issues: [] });
+  const inspectionRequest = hasPermission("inspection:view") ? apiRequest("/api/inspections") : Promise.resolve({ inspections: [] });
+  const adminRequest = hasPermission("admin:manage") ? apiRequest("/api/admin/config") : Promise.resolve(null);
+  const [customerPayload, issuePayload, inspectionPayload, metaPayload, adminPayload] = await Promise.all([
+    apiRequest("/api/customers"),
+    issueRequest,
+    inspectionRequest,
+    apiRequest("/api/meta"),
+    adminRequest
+  ]);
+  customers = customerPayload.customers;
+  issues = issuePayload.issues;
+  inspections = inspectionPayload.inspections;
+  dictionaries = { ...dictionaries, ...(metaPayload.dictionaries || {}) };
+  adminConfig = adminPayload;
+  renderDictionaryOptions();
+}
+
+function switchPage(page) {
+  if (!hasPermission(`page:${page}`)) {
+    showToast("后台未授权访问该页面。");
+    return;
+  }
+  activePage = page;
+  pageTitle.textContent = pageTitles[page] || pageTitles.dashboard;
+  document.querySelectorAll(".page-section").forEach((section) => {
+    section.classList.toggle("active", section.dataset.section === page);
+  });
+  document.querySelectorAll("[data-page]").forEach((button) => {
+    button.classList.toggle("active", button.dataset.page === page);
+  });
+  renderAll();
+}
+
+function openDrawer(issueId) {
+  const issue = issues.find((item) => item.id === issueId);
+  if (!issue) return;
+  const customer = getCustomer(issue.customerId);
+  selectedIssueId = issueId;
+  selectedCustomerId = null;
+  customerDrawer.classList.remove("open");
+  customerDrawer.setAttribute("aria-hidden", "true");
+
+  document.querySelector("#drawerName").textContent = `${customer.name} · ${issue.type}`;
+  document.querySelector("#drawerScore").textContent = issue.score;
+  document.querySelector("#drawerStatus").textContent = issue.status;
+  document.querySelector("#drawerDepartment").textContent = customer.department;
+  document.querySelector("#drawerType").textContent = issue.type;
+  document.querySelector("#drawerOwner").textContent = issue.owner;
+  document.querySelector("#drawerPostSatisfaction").textContent = issue.postSatisfaction || "待处置";
+  document.querySelector("#drawerLatestHandle").textContent = issue.handlerNote || "待提交处置记录";
+  document.querySelector("#drawerSummary").textContent = issue.summary || "未填写问题描述。";
+  const records = issue.handlingRecords || [];
+  document.querySelector("#drawerHandlingRecords").innerHTML = records.length ? records.map((record) => `
+    <article class="record-card">
+      <div><strong>${escapeHtml(record.handler)}</strong><span>${escapeHtml(record.handledAt)}</span></div>
+      <p>${escapeHtml(record.note)}</p>
+      <small>处置后满意度:${escapeHtml(record.satisfaction || "待回访")};客户反馈:${escapeHtml(record.feedback || "待补充")}</small>
+    </article>
+  `).join("") : `<p class="muted-text">暂无处置记录,责任人提交后会按时间形成卡片流水。</p>`;
+  document.querySelector("#drawerTimeline").innerHTML = issue.timeline.map((item) => `<li>${escapeHtml(item)}</li>`).join("");
+
+  drawerBackdrop.hidden = false;
+  drawer.classList.add("open");
+  drawer.setAttribute("aria-hidden", "false");
+  applyPermissions();
+}
+
+function openCustomerDrawer(customerId) {
+  const customer = getCustomer(customerId);
+  selectedCustomerId = customer.id;
+  selectedIssueId = null;
+  drawer.classList.remove("open");
+  drawer.setAttribute("aria-hidden", "true");
+
+  const relatedIssues = issues.filter((issue) => issue.customerId === customer.id);
+  document.querySelector("#customerDrawerName").textContent = customer.name;
+  document.querySelector("#customerDrawerInspectionFlag").textContent = inspectionFlag(customer.inspectionStatus);
+  document.querySelector("#customerDrawerTier").textContent = customer.tier;
+  document.querySelector("#customerDrawerSource").textContent = customer.source || "-";
+  document.querySelector("#customerDrawerCustomerOwner").textContent = customer.customerOwner || "-";
+  document.querySelector("#customerDrawerDepartment").textContent = customer.department;
+  document.querySelector("#customerDrawerDesigner").textContent = customer.designer;
+  document.querySelector("#customerDrawerManager").textContent = customer.manager;
+  document.querySelector("#customerDrawerStage").textContent = customer.stage;
+  document.querySelector("#customerDrawerStartDate").textContent = customer.startDate || "-";
+  document.querySelector("#customerDrawerPlannedFinishDate").textContent = customer.plannedFinishDate || "-";
+  document.querySelector("#customerDrawerProgressStatus").innerHTML = `<span class="status-badge ${progressClass(customer.progressStatus)}">${escapeHtml(customer.progressStatus || "正常")}</span>`;
+  document.querySelector("#customerDrawerSatisfaction").textContent = `${customer.satisfaction || "-"} 分`;
+  document.querySelector("#customerDrawerInspectionStatus").innerHTML = `<span class="status-badge ${inspectionClass(customer.inspectionStatus)}">${escapeHtml(customer.inspectionStatus || "未巡查")}</span>`;
+  document.querySelector("#customerDrawerInspector").textContent = customer.inspector || "-";
+  document.querySelector("#customerDrawerInspectionAt").textContent = customer.inspectionAt || "-";
+  document.querySelector("#customerDrawerAddress").textContent = customer.address || "-";
+  document.querySelector("#customerDrawerKeyReason").textContent = customer.keyCustomerReason || "暂无重点客户入选说明。";
+  document.querySelector("#customerDrawerInspectionSummary").textContent = customer.inspectionSummary || "暂无巡查结论。";
+  document.querySelector("#customerDrawerIssues").innerHTML = relatedIssues.length ? relatedIssues.map((issue) => `
+    <article class="record-card">
+      <div><strong>${escapeHtml(issue.type)}</strong><span class="status-badge ${statusClass(issue.status)}">${escapeHtml(issue.status)}</span></div>
+      <p>${escapeHtml(issue.summary || "未填写问题描述。")}</p>
+      <small>责任人:${escapeHtml(issue.owner)};处置后满意度:${escapeHtml(issue.postSatisfaction || "待处置")}</small>
+    </article>
+  `).join("") : `<p class="muted-text">暂无关联问题反馈。</p>`;
+
+  drawerBackdrop.hidden = false;
+  customerDrawer.classList.add("open");
+  customerDrawer.setAttribute("aria-hidden", "false");
+  applyPermissions();
+}
+
+function closeDrawer() {
+  drawer.classList.remove("open");
+  drawer.setAttribute("aria-hidden", "true");
+  customerDrawer.classList.remove("open");
+  customerDrawer.setAttribute("aria-hidden", "true");
+  drawerBackdrop.hidden = true;
+  selectedIssueId = null;
+  selectedCustomerId = null;
+}
+
+function openModal(id, customerId) {
+  const modal = document.querySelector(`#${id}`);
+  if (id === "issueModal" && customerId) {
+    if (issueCustomerSearch) issueCustomerSearch.value = "";
+    renderCustomerSelects();
+    document.querySelector("#issueCustomerSelect").value = customerId;
+  }
+  if (id === "issueModal" && !customerId) {
+    if (issueCustomerSearch) issueCustomerSearch.value = "";
+    renderCustomerSelects();
+  }
+  if (id === "inspectionModal" && customerId) {
+    document.querySelector("#inspectionCustomerSelect").value = customerId;
+  }
+  modalBackdrop.hidden = false;
+  modal.classList.add("open");
+  modal.setAttribute("aria-hidden", "false");
+}
+
+function closeModal() {
+  document.querySelectorAll(".modal.open").forEach((modal) => {
+    modal.classList.remove("open");
+    modal.setAttribute("aria-hidden", "true");
+  });
+  modalBackdrop.hidden = true;
+}
+
+async function refreshAfterMutation(issueId, message) {
+  await loadData();
+  renderAll();
+  if (issueId && drawer.classList.contains("open")) openDrawer(issueId);
+  if (message) showToast(message);
+}
+
+async function refreshAdminConfig(message) {
+  adminConfig = await apiRequest("/api/admin/config");
+  dictionaries = { ...dictionaries, ...(adminConfig.dictionaries || {}) };
+  renderDictionaryOptions();
+  renderAdmin();
+  applyPermissions();
+  if (message) showToast(message);
+}
+
+async function mutateIssue(issueId, action, body = {}) {
+  try {
+    const payload = await apiRequest(`/api/issues/${encodeURIComponent(issueId)}/${action}`, {
+      method: "PATCH",
+      body
+    });
+    await refreshAfterMutation(issueId, payload.message);
+  } catch (error) {
+    handleApiError(error);
+  }
+}
+
+async function enterApp(user, token, silent = false) {
+  currentUser = user;
+  authToken = token;
+  window.localStorage.setItem("keyissueToken", token);
+  loginScreen.hidden = true;
+  appShell.hidden = false;
+  currentUserName.textContent = currentUser.userName;
+  currentRoleName.textContent = currentUser.roleName;
+  activePage = currentUser.pages[0] || "dashboard";
+  await loadData();
+  switchPage(activePage);
+  if (!silent) showToast(`已通过后台登录:${currentUser.roleName}`);
+}
+
+function clearSession() {
+  currentUser = null;
+  authToken = "";
+  adminConfig = null;
+  customers = [];
+  issues = [];
+  inspections = [];
+  window.localStorage.removeItem("keyissueToken");
+  appShell.hidden = true;
+  loginScreen.hidden = false;
+  currentUserName.textContent = "未登录";
+  currentRoleName.textContent = "-";
+  loginMessage.textContent = "";
+  if (registerMessage) registerMessage.textContent = "";
+  closeDrawer();
+  closeModal();
+}
+
+async function restoreSession() {
+  if (!authToken) return;
+  try {
+    const payload = await apiRequest("/api/auth/me");
+    await enterApp(payload.user, authToken, true);
+  } catch {
+    clearSession();
+  }
+}
+
+document.querySelectorAll("[data-page]").forEach((button) => {
+  button.addEventListener("click", () => switchPage(button.dataset.page));
+});
+
+document.querySelectorAll("[data-page-jump]").forEach((button) => {
+  button.addEventListener("click", () => switchPage(button.dataset.pageJump));
+});
+
+[searchInput, tierFilter, departmentFilter].forEach((control) => {
+  control.addEventListener("input", renderAll);
+  control.addEventListener("change", renderAll);
+});
+
+statusButtons.forEach((button) => {
+  button.addEventListener("click", () => {
+    activeStatus = button.dataset.status;
+    statusButtons.forEach((item) => item.classList.toggle("active", item === button));
+    renderAll();
+  });
+});
+
+document.addEventListener("click", async (event) => {
+  const saveUserButton = event.target.closest("[data-admin-save-user]");
+  if (saveUserButton) {
+    if (!requirePermission("admin:manage", "保存后台账号")) return;
+    const userId = saveUserButton.dataset.adminSaveUser;
+    try {
+      const payload = await apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, {
+        method: "PATCH",
+        body: {
+          displayName: document.querySelector(`[data-admin-user-name="${userId}"]`)?.value,
+          phone: document.querySelector(`[data-admin-user-phone="${userId}"]`)?.value,
+          department: document.querySelector(`[data-admin-user-department="${userId}"]`)?.value,
+          position: document.querySelector(`[data-admin-user-position="${userId}"]`)?.value,
+          password: document.querySelector(`[data-admin-user-password="${userId}"]`)?.value,
+          role: document.querySelector(`[data-admin-user-role="${userId}"]`)?.value
+        }
+      });
+      await refreshAdminConfig(payload.message);
+    } catch (error) {
+      handleApiError(error);
+    }
+    return;
+  }
+
+  const toggleUserButton = event.target.closest("[data-admin-toggle-user]");
+  if (toggleUserButton) {
+    if (!requirePermission("admin:manage", "停启用后台账号")) return;
+    const userId = toggleUserButton.dataset.adminToggleUser;
+    const user = adminConfig?.users.find((item) => item.id === userId);
+    if (!user) return;
+    try {
+      const payload = await apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, {
+        method: "PATCH",
+        body: { status: user.status === "停用" ? "启用" : "停用" }
+      });
+      await refreshAdminConfig(payload.message);
+    } catch (error) {
+      handleApiError(error);
+    }
+    return;
+  }
+
+  const saveRoleButton = event.target.closest("[data-admin-save-role]");
+  if (saveRoleButton) {
+    if (!requirePermission("admin:manage", "保存角色权限")) return;
+    const roleKey = saveRoleButton.dataset.adminSaveRole;
+    let pages = [...document.querySelectorAll(`[data-role-page="${roleKey}"]`)].filter((input) => input.checked).map((input) => input.value);
+    const permissions = [...document.querySelectorAll(`[data-role-permission="${roleKey}"]`)].filter((input) => input.checked).map((input) => input.value);
+    if (!permissions.includes("admin:manage")) pages = pages.filter((page) => page !== "admin");
+    try {
+      const payload = await apiRequest(`/api/admin/roles/${encodeURIComponent(roleKey)}`, {
+        method: "PATCH",
+        body: { pages, permissions }
+      });
+      await refreshAdminConfig(payload.message);
+    } catch (error) {
+      handleApiError(error);
+    }
+    return;
+  }
+
+  const approveCustomerButton = event.target.closest("[data-admin-customer-approve]");
+  if (approveCustomerButton) {
+    if (!requirePermission("customer:approve", "审核客户入池")) return;
+    try {
+      const payload = await apiRequest(`/api/admin/customer-drafts/${encodeURIComponent(approveCustomerButton.dataset.adminCustomerApprove)}`, {
+        method: "PATCH",
+        body: { action: "approve" }
+      });
+      await loadData();
+      renderAll();
+      showToast(payload.message);
+    } catch (error) {
+      handleApiError(error);
+    }
+    return;
+  }
+
+  const rejectCustomerButton = event.target.closest("[data-admin-customer-reject]");
+  if (rejectCustomerButton) {
+    if (!requirePermission("customer:approve", "驳回客户入池")) return;
+    try {
+      const payload = await apiRequest(`/api/admin/customer-drafts/${encodeURIComponent(rejectCustomerButton.dataset.adminCustomerReject)}`, {
+        method: "PATCH",
+        body: { action: "reject", reviewNote: "管理员驳回,需补充入选说明或责任信息。" }
+      });
+      await loadData();
+      renderAll();
+      showToast(payload.message);
+    } catch (error) {
+      handleApiError(error);
+    }
+    return;
+  }
+
+  const openButton = event.target.closest("[data-open-modal]");
+  if (openButton) {
+    const modalName = openButton.dataset.openModal;
+    const permissionMap = {
+      customerModal: ["customer:submit", "提交客户入池申请"],
+      issueModal: ["issue:create", "新增问题反馈"],
+      inspectionModal: ["inspection:create", "提交每日巡查"],
+      handleModal: ["issue:handle", "提交处置"]
+    };
+    const [required, actionName] = permissionMap[modalName] || ["issue:view", "打开表单"];
+    if (!requirePermission(required, actionName)) return;
+    openModal(modalName, openButton.dataset.customer || openButton.dataset.inspectionCustomer);
+    return;
+  }
+
+  const customerButton = event.target.closest("[data-view-customer]");
+  if (customerButton) {
+    if (!requirePermission("issue:view", "查看客户详情")) return;
+    openCustomerDrawer(customerButton.dataset.viewCustomer);
+    return;
+  }
+
+  const viewButton = event.target.closest("[data-view-issue]");
+  if (viewButton) {
+    if (!requirePermission("issue:view", "查看问题详情")) return;
+    openDrawer(viewButton.dataset.viewIssue);
+    return;
+  }
+
+  const handleButton = event.target.closest("[data-quick-handle]");
+  if (handleButton) {
+    if (!requirePermission("issue:handle", "处置问题")) return;
+    selectedIssueId = handleButton.dataset.quickHandle;
+    openModal("handleModal");
+    return;
+  }
+
+  const passButton = event.target.closest("[data-audit-pass]");
+  if (passButton) {
+    if (!requirePermission("audit:review", "核查通过")) return;
+    mutateIssue(passButton.dataset.auditPass, "audit-pass");
+    return;
+  }
+
+  const rejectButton = event.target.closest("[data-audit-reject]");
+  if (rejectButton) {
+    if (!requirePermission("audit:review", "驳回整改")) return;
+    mutateIssue(rejectButton.dataset.auditReject, "audit-reject");
+  }
+});
+
+document.querySelectorAll("[data-close-modal]").forEach((button) => button.addEventListener("click", closeModal));
+modalBackdrop.addEventListener("click", closeModal);
+document.querySelector("[data-close-drawer]").addEventListener("click", closeDrawer);
+document.querySelector("[data-close-customer-drawer]").addEventListener("click", closeDrawer);
+drawerBackdrop.addEventListener("click", closeDrawer);
+
+document.querySelectorAll("[data-customer-drawer-action]").forEach((button) => {
+  button.addEventListener("click", () => {
+    if (!selectedCustomerId) return;
+    if (button.dataset.customerDrawerAction === "inspection") {
+      if (!requirePermission("inspection:create", "提交每日巡查")) return;
+      openModal("inspectionModal", selectedCustomerId);
+    }
+    if (button.dataset.customerDrawerAction === "feedback") {
+      if (!requirePermission("issue:create", "提交问题反馈")) return;
+      openModal("issueModal", selectedCustomerId);
+    }
+  });
+});
+
+document.querySelectorAll("[data-drawer-action]").forEach((button) => {
+  button.addEventListener("click", () => {
+    if (!selectedIssueId) return;
+    const action = button.dataset.drawerAction;
+    if (action === "assign") {
+      if (!requirePermission("issue:assign", "指派责任人")) return;
+      mutateIssue(selectedIssueId, "assign", { owner: "部门负责人" });
+    }
+    if (action === "handle") {
+      if (!requirePermission("issue:handle", "提交处置")) return;
+      openModal("handleModal");
+    }
+    if (action === "approve") {
+      if (!requirePermission("audit:review", "核查通过")) return;
+      mutateIssue(selectedIssueId, "audit-pass");
+    }
+    if (action === "reject") {
+      if (!requirePermission("audit:review", "驳回整改")) return;
+      mutateIssue(selectedIssueId, "audit-reject");
+    }
+  });
+});
+
+document.querySelector("#issueForm").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!requirePermission("issue:create", "提交问题反馈")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest("/api/issues", { method: "POST", body: data });
+    form.reset();
+    closeModal();
+    await refreshAfterMutation(payload.issue.id, payload.message);
+    switchPage("issues");
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+document.querySelector("#inspectionForm").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!requirePermission("inspection:create", "提交每日线上巡查")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest("/api/inspections", { method: "POST", body: data });
+    form.reset();
+    closeModal();
+    await loadData();
+    switchPage("inspections");
+    showToast(payload.message);
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+document.querySelector("#handleForm").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!selectedIssueId) return;
+  if (!requirePermission("issue:handle", "提交处置")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest(`/api/issues/${encodeURIComponent(selectedIssueId)}/handle`, {
+      method: "PATCH",
+      body: data
+    });
+    form.reset();
+    closeModal();
+    await refreshAfterMutation(selectedIssueId, payload.message);
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+document.querySelector("#customerForm").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!requirePermission("customer:submit", "提交客户入池申请")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest("/api/customers", { method: "POST", body: data });
+    form.reset();
+    closeModal();
+    await loadData();
+    if (payload.customer) switchPage("customers");
+    if (payload.draft && hasPermission("page:admin")) switchPage("admin");
+    showToast(payload.message);
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+document.querySelector("#adminUserForm")?.addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!requirePermission("admin:manage", "新增后台账号")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest("/api/admin/users", { method: "POST", body: data });
+    form.reset();
+    await refreshAdminConfig(payload.message);
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+document.querySelector("#adminDictionaryForm")?.addEventListener("submit", async (event) => {
+  event.preventDefault();
+  if (!requirePermission("admin:manage", "保存基础字典")) return;
+  const form = event.currentTarget;
+  if (!beginFormSubmit(form)) return;
+  try {
+    const data = Object.fromEntries(new FormData(form).entries());
+    const payload = await apiRequest("/api/admin/dictionaries", { method: "PATCH", body: data });
+    dictionaries = { ...dictionaries, ...(payload.dictionaries || {}) };
+    await refreshAdminConfig(payload.message);
+  } catch (error) {
+    handleApiError(error);
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+issueCustomerSearch?.addEventListener("input", () => {
+  renderCustomerSelects(issueCustomerSearch.value);
+});
+
+document.querySelector("#saveRulesBtn").addEventListener("click", async () => {
+  if (!requirePermission("rules:edit", "保存预警规则")) return;
+  try {
+    const payload = await apiRequest("/api/rules", { method: "POST", body: { source: "official" } });
+    showToast(payload.message);
+  } catch (error) {
+    handleApiError(error);
+  }
+});
+
+loginForm.addEventListener("submit", async (event) => {
+  event.preventDefault();
+  loginMessage.textContent = "正在请求后台登录...";
+  try {
+    const payload = await apiRequest("/api/auth/login", {
+      method: "POST",
+      body: {
+        username: loginUsername.value.trim(),
+        password: loginPassword.value
+      }
+    });
+    loginMessage.textContent = "";
+    await enterApp(payload.user, payload.token);
+  } catch (error) {
+    loginMessage.textContent = error.message;
+  }
+});
+
+registerForm?.addEventListener("submit", async (event) => {
+  event.preventDefault();
+  const form = event.currentTarget;
+  const password = registerPassword.value.trim();
+  const confirmPassword = registerPasswordConfirm.value.trim();
+  if (password.length < 6) {
+    registerMessage.textContent = "密码至少需要 6 位。";
+    return;
+  }
+  if (password !== confirmPassword) {
+    registerMessage.textContent = "两次输入的密码不一致。";
+    return;
+  }
+  if (!beginFormSubmit(form)) return;
+  registerMessage.textContent = "正在核对花名册并创建账号...";
+  try {
+    const payload = await apiRequest("/api/auth/register", {
+      method: "POST",
+      body: {
+        name: registerName.value.trim(),
+        phone: registerPhone.value.trim(),
+        password,
+        confirmPassword
+      }
+    });
+    registerMessage.textContent = "";
+    form.reset();
+    await enterApp(payload.user, payload.token);
+    showToast(payload.message);
+  } catch (error) {
+    registerMessage.textContent = error.message;
+  } finally {
+    endFormSubmit(form);
+  }
+});
+
+logoutBtn.addEventListener("click", async () => {
+  try {
+    await apiRequest("/api/auth/logout", { method: "POST" });
+  } catch {
+    // 即使后台会话已经失效,也要清理本地登录态。
+  }
+  clearSession();
+});
+
+document.addEventListener("keydown", (event) => {
+  if (event.key === "Escape") {
+    closeDrawer();
+    closeModal();
+  }
+});
+
+restoreSession();

+ 933 - 0
prototype/index.html

@@ -0,0 +1,933 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>A+在建客户动态管理系统</title>
+  <link rel="stylesheet" href="styles.css">
+</head>
+<body>
+  <section class="login-screen" id="loginScreen" aria-label="系统登录">
+    <div class="login-card">
+      <div class="login-copy">
+        <p class="eyebrow">正式运行版</p>
+        <h1>A+在建客户动态管理系统</h1>
+        <p>通过后台账号密码登录,权限由服务器统一校验。账号信息由系统管理员统一发放和维护。</p>
+      </div>
+      <div class="login-panel">
+        <form class="login-form" id="loginForm">
+          <label>
+            <span>账号</span>
+            <input id="loginUsername" name="username" type="text" autocomplete="username" placeholder="输入后台账号" required>
+          </label>
+          <label>
+            <span>密码</span>
+            <input id="loginPassword" name="password" type="password" autocomplete="current-password" placeholder="输入密码" required>
+          </label>
+          <button class="primary-button" type="submit">登录系统</button>
+          <p class="login-message" id="loginMessage" role="status"></p>
+        </form>
+        <form class="login-form register-form" id="registerForm">
+          <div class="form-heading">
+            <strong>名册注册</strong>
+            <small>仅限花名册内姓名与手机号一致的人员注册</small>
+          </div>
+          <label>
+            <span>姓名</span>
+            <input id="registerName" name="name" type="text" autocomplete="name" placeholder="输入花名册姓名" required>
+          </label>
+          <label>
+            <span>手机号</span>
+            <input id="registerPhone" name="phone" type="tel" inputmode="numeric" autocomplete="tel" placeholder="输入花名册手机号" required>
+          </label>
+          <div class="register-password-grid">
+            <label>
+              <span>设置密码</span>
+              <input id="registerPassword" name="password" type="password" autocomplete="new-password" placeholder="至少 6 位" minlength="6" required>
+            </label>
+            <label>
+              <span>确认密码</span>
+              <input id="registerPasswordConfirm" name="confirmPassword" type="password" autocomplete="new-password" placeholder="再次输入密码" minlength="6" required>
+            </label>
+          </div>
+          <button class="ghost-button" type="submit">注册并进入系统</button>
+          <p class="login-message" id="registerMessage" role="status"></p>
+        </form>
+      </div>
+    </div>
+  </section>
+
+  <div class="app-shell" id="appShell" hidden>
+    <aside class="sidebar" aria-label="主导航">
+      <div class="brand">
+        <span class="brand-mark">KI</span>
+        <div>
+          <strong>A+在建客户</strong>
+          <small>动态管理系统</small>
+        </div>
+      </div>
+
+      <nav class="nav-list">
+        <button class="nav-item active" type="button" data-page="dashboard"><span>动态驾驶舱</span><b>01</b></button>
+        <button class="nav-item" type="button" data-page="customers"><span>A/A+客户库</span><b>02</b></button>
+        <button class="nav-item" type="button" data-page="inspections"><span>每日线上巡查</span><b>03</b></button>
+        <button class="nav-item" type="button" data-page="issues"><span>问题反馈池</span><b>04</b></button>
+        <button class="nav-item" type="button" data-page="audit"><span>监察核查</span><b>05</b></button>
+        <button class="nav-item" type="button" data-page="analysis"><span>问题分析</span><b>06</b></button>
+        <button class="nav-item" type="button" data-page="rules"><span>预警规则</span><b>07</b></button>
+        <button class="nav-item" type="button" data-page="admin"><span>系统后台</span><b>08</b></button>
+      </nav>
+
+      <div class="sidebar-card">
+        <span class="status-dot"></span>
+        <p>今日需高层关注</p>
+        <strong id="sideAlertCount">5 件</strong>
+      </div>
+    </aside>
+
+    <main class="main">
+      <header class="topbar">
+        <div>
+          <p class="eyebrow">监察部 / 交付中心</p>
+          <h1 id="pageTitle">A+在建客户动态管理驾驶舱</h1>
+        </div>
+        <div class="top-actions">
+          <div class="user-panel" aria-label="当前登录人">
+            <span>当前登录</span>
+            <strong id="currentUserName">未登录</strong>
+            <em id="currentRoleName">-</em>
+            <button class="small-button" type="button" id="logoutBtn">退出</button>
+          </div>
+          <button class="ghost-button" type="button" data-open-modal="customerModal" data-requires="customer:submit">新增客户</button>
+          <button class="ghost-button" type="button" data-open-modal="inspectionModal" data-requires="inspection:create">每日巡查</button>
+          <button class="primary-button" type="button" data-open-modal="issueModal" data-requires="issue:create">新增问题反馈</button>
+        </div>
+      </header>
+
+      <section class="filter-bar" aria-label="筛选条件">
+        <label>
+          <span>时间范围</span>
+          <select id="periodFilter">
+            <option>本周</option>
+            <option>本月</option>
+            <option>近 30 天</option>
+            <option>全年</option>
+          </select>
+        </label>
+        <label>
+          <span>客户等级</span>
+          <select id="tierFilter">
+            <option value="all">全部等级</option>
+            <option value="A">A 客户</option>
+            <option value="A+">A+ 客户</option>
+          </select>
+        </label>
+        <label>
+          <span>责任部门</span>
+          <select id="departmentFilter">
+            <option value="all">全部部门</option>
+            <option value="光宸">光宸</option>
+            <option value="澜光">澜光</option>
+            <option value="蘭堂">蘭堂</option>
+            <option value="焕境">焕境</option>
+          </select>
+        </label>
+        <label class="search-field">
+          <span>搜索</span>
+          <input id="searchInput" type="search" placeholder="客户、地址、问题关键词">
+        </label>
+      </section>
+
+      <section class="page-section active" data-section="dashboard">
+        <div class="kpi-grid" aria-label="核心指标">
+          <article class="metric-card">
+            <span>今日已巡查</span>
+            <strong id="kpiInspectionDone">0</strong>
+            <em>每日线上巡查覆盖</em>
+          </article>
+          <article class="metric-card">
+            <span>异常巡查</span>
+            <strong id="kpiAbnormalInspections">0</strong>
+            <em>需转入问题处置</em>
+          </article>
+          <article class="metric-card">
+            <span>红色预警问题</span>
+            <strong id="kpiRedIssues">0</strong>
+            <em>需管理层介入</em>
+          </article>
+          <article class="metric-card">
+            <span>待监察核查</span>
+            <strong id="kpiAuditIssues">0</strong>
+            <em>处置后等待复核</em>
+          </article>
+        </div>
+
+        <div class="insight-grid">
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">问题分层</p>
+                <h2>预警级别分布</h2>
+              </div>
+              <button class="ghost-button" type="button" data-page-jump="rules" data-requires="page:rules">查看规则</button>
+            </div>
+            <div class="funnel">
+              <div class="funnel-row tier-a" style="--w: 100%;"><span>红色预警</span><strong id="funnelRed">0 件</strong></div>
+              <div class="funnel-row tier-b" style="--w: 78%;"><span>橙色预警</span><strong id="funnelOrange">0 件</strong></div>
+              <div class="funnel-row tier-c" style="--w: 52%;"><span>蓝色关注</span><strong id="funnelBlue">0 件</strong></div>
+            </div>
+          </article>
+
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">问题归因</p>
+                <h2>高频问题类型</h2>
+              </div>
+            </div>
+            <div class="bar-list" id="issueTypeBars" aria-label="问题类型分布"></div>
+          </article>
+
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">预警雷达</p>
+                <h2>今日需关注</h2>
+              </div>
+            </div>
+            <div class="risk-list">
+              <div class="risk-item danger">
+                <b>重复反馈未解决</b>
+                <span>同一客户同类问题超过 2 次自动升级红色预警。</span>
+              </div>
+              <div class="risk-item warning">
+                <b>满意度下滑</b>
+                <span>满意度低于 3 分进入管理层关注池。</span>
+              </div>
+              <div class="risk-item info">
+                <b>核查待确认</b>
+                <span>处置完成后必须经监察部核查才可标记已处理。</span>
+              </div>
+            </div>
+          </article>
+        </div>
+
+        <article class="panel">
+          <div class="panel-heading">
+            <div>
+              <p class="eyebrow">重点问题</p>
+              <h2>高优先级问题清单</h2>
+            </div>
+            <button class="primary-button" type="button" data-page-jump="issues" data-requires="page:issues">进入问题池</button>
+          </div>
+          <div class="table-wrap">
+            <table>
+              <thead>
+                <tr>
+                  <th>客户</th>
+                  <th>等级</th>
+                  <th>部门</th>
+                  <th>问题来源</th>
+                  <th>问题类型</th>
+                  <th>预警状态</th>
+                  <th>责任人</th>
+                  <th>当前处置</th>
+                  <th>操作</th>
+                </tr>
+              </thead>
+              <tbody id="dashboardRows"></tbody>
+            </table>
+          </div>
+        </article>
+      </section>
+
+      <section class="page-section" data-section="customers">
+        <div class="section-grid">
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">客户库</p>
+                <h2>A/A+ 客户动态库</h2>
+              </div>
+              <button class="primary-button" type="button" data-open-modal="customerModal" data-requires="customer:submit">新增客户</button>
+            </div>
+            <div class="table-wrap">
+              <table>
+                <thead>
+                  <tr>
+                    <th>客户</th>
+                    <th>等级</th>
+                    <th>新居地址</th>
+                    <th>客管负责人</th>
+                    <th>入选说明</th>
+                    <th>部门</th>
+                    <th>设计师</th>
+                    <th>项目经理</th>
+                    <th>施工阶段</th>
+                    <th>开工日期</th>
+                    <th>计划完工日期</th>
+                    <th>当前进度</th>
+                    <th>巡查与否</th>
+                    <th>巡查状态</th>
+                    <th>最近巡查</th>
+                    <th>问题数</th>
+                    <th>操作</th>
+                  </tr>
+                </thead>
+                <tbody id="customerRows"></tbody>
+              </table>
+            </div>
+          </article>
+        </div>
+      </section>
+
+      <section class="page-section" data-section="inspections">
+        <article class="panel">
+          <div class="panel-heading">
+            <div>
+              <p class="eyebrow">每日线上巡查</p>
+              <h2>A/A+ 客户日查情况</h2>
+            </div>
+            <button class="primary-button" type="button" data-open-modal="inspectionModal" data-requires="inspection:create">提交巡查</button>
+          </div>
+          <div class="inspection-summary" id="inspectionSummaryCards"></div>
+          <div class="table-wrap">
+            <table>
+              <thead>
+                <tr>
+                  <th>巡查编号</th>
+                  <th>客户</th>
+                  <th>巡查与否</th>
+                  <th>巡查状态</th>
+                  <th>巡查人</th>
+                  <th>巡查时间</th>
+                  <th>施工质量</th>
+                  <th>文明施工</th>
+                  <th>客户群反馈</th>
+                  <th>巡查结论</th>
+                </tr>
+              </thead>
+              <tbody id="inspectionRows"></tbody>
+            </table>
+          </div>
+        </article>
+      </section>
+
+      <section class="page-section" data-section="issues">
+        <article class="panel">
+          <div class="panel-heading">
+            <div>
+              <p class="eyebrow">反馈池</p>
+              <h2>问题反馈与预警清单</h2>
+            </div>
+            <div class="segmented" role="tablist" aria-label="预警状态">
+              <button class="active" type="button" data-status="all">全部</button>
+              <button type="button" data-status="红色预警">红色</button>
+              <button type="button" data-status="处理中">处理中</button>
+              <button type="button" data-status="待核查">待核查</button>
+            </div>
+          </div>
+          <div class="table-wrap">
+            <table>
+              <thead>
+                <tr>
+                  <th>反馈编号</th>
+                  <th>客户</th>
+                  <th>问题来源</th>
+                  <th>问题类型</th>
+                  <th>问题描述</th>
+                  <th>预警状态</th>
+                  <th>责任人</th>
+                  <th>根因</th>
+                  <th>当前处置</th>
+                  <th>处置后满意度</th>
+                  <th>操作</th>
+                </tr>
+              </thead>
+              <tbody id="issueRows"></tbody>
+            </table>
+          </div>
+        </article>
+      </section>
+
+      <section class="page-section" data-section="audit">
+        <div class="section-grid two">
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">监察核查</p>
+                <h2>待核查问题</h2>
+              </div>
+              <span class="panel-chip" id="auditCount">0 件</span>
+            </div>
+            <div class="table-wrap">
+              <table>
+                <thead>
+                  <tr>
+                    <th>客户</th>
+                    <th>问题</th>
+                    <th>处置说明</th>
+                    <th>提交人</th>
+                    <th>操作</th>
+                  </tr>
+                </thead>
+                <tbody id="auditRows"></tbody>
+              </table>
+            </div>
+          </article>
+
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">闭环标准</p>
+                <h2>核查判定</h2>
+              </div>
+            </div>
+            <div class="checklist">
+              <label><input type="checkbox" checked> 客户反馈的问题点已逐项回应</label>
+              <label><input type="checkbox" checked> 现场整改或解释材料已上传</label>
+              <label><input type="checkbox"> 客户满意度回升到 4 分以上</label>
+              <label><input type="checkbox"> 同类问题 7 天内未复发</label>
+            </div>
+          </article>
+        </div>
+      </section>
+
+      <section class="page-section" data-section="analysis">
+        <div class="analysis-grid">
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">趋势</p>
+                <h2>近 7 日反馈趋势</h2>
+              </div>
+            </div>
+            <div class="trend" aria-label="近 7 日反馈趋势">
+              <span style="--h: 44%"><b>周一</b></span>
+              <span style="--h: 62%"><b>周二</b></span>
+              <span style="--h: 48%"><b>周三</b></span>
+              <span style="--h: 78%"><b>周四</b></span>
+              <span style="--h: 70%"><b>周五</b></span>
+              <span style="--h: 36%"><b>周六</b></span>
+              <span style="--h: 52%"><b>周日</b></span>
+            </div>
+          </article>
+
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">部门</p>
+                <h2>部门预警排行</h2>
+              </div>
+            </div>
+            <div id="departmentRanks" class="rank-list"></div>
+          </article>
+
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">复盘</p>
+                <h2>本周问题洞察</h2>
+              </div>
+            </div>
+            <div class="insight-note">
+              <p>施工质量、文明施工和客户群反馈是每日线上巡查的三个重点观察项。</p>
+              <p>建议把“日查异常转问题反馈”和“处置后满意度回访”作为下周重点动作。</p>
+            </div>
+          </article>
+        </div>
+      </section>
+
+      <section class="page-section" data-section="rules">
+        <div class="section-grid two">
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">规则配置</p>
+                <h2>预警触发条件</h2>
+              </div>
+              <button class="primary-button" type="button" id="saveRulesBtn" data-requires="rules:edit">保存规则</button>
+            </div>
+            <form class="settings-form">
+              <label>
+                <span>红色预警条件</span>
+                <select>
+                  <option>A/A+ 客户重复反馈 2 次以上</option>
+                  <option>满意度低于 3 分</option>
+                  <option>客户明确投诉或停工风险</option>
+                </select>
+              </label>
+              <label>
+                <span>橙色预警条件</span>
+                <select>
+                  <option>同类问题未形成处置方案</option>
+                  <option>材料/设计变更影响节点</option>
+                </select>
+              </label>
+              <label>
+                <span>核查要求</span>
+                <select>
+                  <option>所有红色预警必须监察部核查</option>
+                  <option>A/A+ 客户所有反馈必须核查</option>
+                </select>
+              </label>
+            </form>
+          </article>
+
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">后台权限</p>
+                <h2>角色操作授权</h2>
+              </div>
+            </div>
+            <div class="permission-list">
+              <div><strong>管理层</strong><span>查看全局、督办、指派责任人</span></div>
+              <div><strong>交付助理</strong><span>提交反馈、上传现场证据</span></div>
+              <div><strong>项目经理</strong><span>补充根因、提交处置完成</span></div>
+              <div><strong>监察部</strong><span>通过/驳回核查、标记已处理</span></div>
+            </div>
+          </article>
+        </div>
+      </section>
+
+      <section class="page-section" data-section="admin">
+        <div class="kpi-grid admin-kpis" aria-label="后台管理指标">
+          <article class="metric-card">
+            <span>后台账号</span>
+            <strong id="adminUserCount">0</strong>
+            <em>可登录系统账号</em>
+          </article>
+          <article class="metric-card">
+            <span>启用账号</span>
+            <strong id="adminActiveUserCount">0</strong>
+            <em>当前允许登录</em>
+          </article>
+          <article class="metric-card">
+            <span>角色数量</span>
+            <strong id="adminRoleCount">0</strong>
+            <em>后台权限角色</em>
+          </article>
+          <article class="metric-card">
+            <span>操作权限</span>
+            <strong id="adminPermissionCount">0</strong>
+            <em>由服务器统一校验</em>
+          </article>
+          <article class="metric-card">
+            <span>待审核客户</span>
+            <strong id="adminPendingCustomerCount">0</strong>
+            <em>非管理员提交后进入审核池</em>
+          </article>
+        </div>
+
+        <article class="panel">
+          <div class="panel-heading">
+            <div>
+              <p class="eyebrow">客户入池审核</p>
+              <h2>管理员审核池</h2>
+            </div>
+            <span class="panel-chip" id="adminCustomerReviewCount">0 单待审核</span>
+          </div>
+          <div class="table-wrap admin-review-wrap">
+            <table class="admin-review-table">
+              <thead>
+                <tr>
+                  <th>审核单</th>
+                  <th>客户</th>
+                  <th>等级</th>
+                  <th>提交人</th>
+                  <th>客管负责人</th>
+                  <th>入选说明</th>
+                  <th>提交时间</th>
+                  <th>状态</th>
+                  <th>操作</th>
+                </tr>
+              </thead>
+              <tbody id="customerReviewRows"></tbody>
+            </table>
+          </div>
+        </article>
+
+        <div class="section-grid two admin-grid">
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">账号管理</p>
+                <h2>登录账号与角色</h2>
+              </div>
+            </div>
+            <form class="settings-form compact-form" id="adminUserForm">
+              <label><span>登录账号</span><input name="username" type="text" placeholder="例如:service01" required></label>
+              <label><span>登录密码</span><input name="password" type="password" placeholder="初始密码" required></label>
+              <label><span>姓名/显示名</span><input name="displayName" type="text" placeholder="例如:服务部主管" required></label>
+              <label><span>手机号</span><input name="phone" type="tel" inputmode="numeric" placeholder="用于名册匹配,可选"></label>
+              <label><span>所属部门</span><input name="department" type="text" placeholder="例如:光宸筑境设计工作室"></label>
+              <label><span>岗位</span><input name="position" type="text" placeholder="例如:设计经理"></label>
+              <label><span>角色</span><select name="role" id="adminUserRoleSelect"></select></label>
+              <label><span>状态</span><select name="status"><option>启用</option><option>停用</option></select></label>
+              <div class="form-actions">
+                <button class="primary-button" type="submit">新增账号</button>
+              </div>
+            </form>
+            <div class="table-wrap admin-table-wrap">
+              <table class="admin-table">
+                <thead>
+                  <tr>
+                    <th>账号</th>
+                    <th>姓名</th>
+                    <th>手机号</th>
+                    <th>部门</th>
+                    <th>岗位</th>
+                    <th>角色</th>
+                    <th>状态</th>
+                    <th>重置密码</th>
+                    <th>操作</th>
+                  </tr>
+                </thead>
+                <tbody id="adminUserRows"></tbody>
+              </table>
+            </div>
+          </article>
+
+          <article class="panel">
+            <div class="panel-heading">
+              <div>
+                <p class="eyebrow">后台权限</p>
+                <h2>角色页面与操作授权</h2>
+              </div>
+            </div>
+            <div class="role-card-list" id="adminRoleCards"></div>
+          </article>
+        </div>
+
+        <article class="panel">
+          <div class="panel-heading">
+            <div>
+              <p class="eyebrow">基础配置</p>
+              <h2>字典与下拉项</h2>
+            </div>
+            <button class="primary-button" type="submit" form="adminDictionaryForm">保存字典</button>
+          </div>
+          <form class="dictionary-form" id="adminDictionaryForm">
+            <label>
+              <span>责任部门</span>
+              <textarea name="departments" rows="5" placeholder="每行一个部门"></textarea>
+            </label>
+            <label>
+              <span>客户来源</span>
+              <textarea name="customerSources" rows="5" placeholder="每行一个来源"></textarea>
+            </label>
+            <label>
+              <span>问题来源</span>
+              <textarea name="issueSources" rows="5" placeholder="线下巡检&#10;客户群反馈"></textarea>
+            </label>
+            <label>
+              <span>问题类型</span>
+              <textarea name="issueTypes" rows="5" placeholder="每行一个问题类型"></textarea>
+            </label>
+            <label>
+              <span>当前进度</span>
+              <textarea name="progressStatuses" rows="5" placeholder="正常&#10;延期"></textarea>
+            </label>
+          </form>
+        </article>
+      </section>
+    </main>
+  </div>
+
+  <div class="drawer-backdrop" id="drawerBackdrop" hidden></div>
+  <aside class="detail-drawer" id="detailDrawer" aria-label="问题详情" aria-hidden="true">
+    <div class="drawer-header">
+      <div>
+        <p class="eyebrow">问题详情</p>
+        <h2 id="drawerName">客户姓名</h2>
+      </div>
+      <button class="icon-button" type="button" data-close-drawer aria-label="关闭详情">×</button>
+    </div>
+    <div class="drawer-body">
+      <div class="detail-score">
+        <span>问题影响评分</span>
+        <strong id="drawerScore">88</strong>
+      </div>
+      <dl class="detail-list">
+        <div><dt>预警状态</dt><dd id="drawerStatus"></dd></div>
+        <div><dt>责任部门</dt><dd id="drawerDepartment"></dd></div>
+        <div><dt>问题类型</dt><dd id="drawerType"></dd></div>
+        <div><dt>责任人</dt><dd id="drawerOwner"></dd></div>
+        <div><dt>处置后满意度</dt><dd id="drawerPostSatisfaction"></dd></div>
+        <div><dt>最近处置</dt><dd id="drawerLatestHandle"></dd></div>
+      </dl>
+      <section class="detail-block">
+        <h3>问题描述</h3>
+        <p id="drawerSummary"></p>
+      </section>
+      <section class="detail-block">
+        <h3>处置记录</h3>
+        <div class="record-list" id="drawerHandlingRecords"></div>
+      </section>
+      <section class="timeline">
+        <h3>问题链路与分析</h3>
+        <ol id="drawerTimeline"></ol>
+      </section>
+      <div class="drawer-actions">
+        <button class="ghost-button" type="button" data-drawer-action="assign" data-requires="issue:assign">指派责任人</button>
+        <button class="primary-button" type="button" data-drawer-action="handle" data-requires="issue:handle">提交处置</button>
+        <button class="success-button" type="button" data-drawer-action="approve" data-requires="audit:review">核查通过</button>
+        <button class="danger-button" type="button" data-drawer-action="reject" data-requires="audit:review">驳回整改</button>
+      </div>
+    </div>
+  </aside>
+
+  <aside class="detail-drawer" id="customerDrawer" aria-label="客户详情" aria-hidden="true">
+    <div class="drawer-header">
+      <div>
+        <p class="eyebrow">客户详情</p>
+        <h2 id="customerDrawerName">客户姓名</h2>
+      </div>
+      <button class="icon-button" type="button" data-close-customer-drawer aria-label="关闭客户详情">×</button>
+    </div>
+    <div class="drawer-body">
+      <div class="detail-score">
+        <span>巡查与否</span>
+        <strong id="customerDrawerInspectionFlag">未巡查</strong>
+      </div>
+      <dl class="detail-list">
+        <div><dt>客户等级</dt><dd id="customerDrawerTier"></dd></div>
+        <div><dt>客户来源</dt><dd id="customerDrawerSource"></dd></div>
+        <div><dt>客管负责人</dt><dd id="customerDrawerCustomerOwner"></dd></div>
+        <div><dt>责任部门</dt><dd id="customerDrawerDepartment"></dd></div>
+        <div><dt>设计师</dt><dd id="customerDrawerDesigner"></dd></div>
+        <div><dt>项目经理</dt><dd id="customerDrawerManager"></dd></div>
+        <div><dt>施工阶段</dt><dd id="customerDrawerStage"></dd></div>
+        <div><dt>开工日期</dt><dd id="customerDrawerStartDate"></dd></div>
+        <div><dt>计划完工日期</dt><dd id="customerDrawerPlannedFinishDate"></dd></div>
+        <div><dt>当前进度</dt><dd id="customerDrawerProgressStatus"></dd></div>
+        <div><dt>满意度</dt><dd id="customerDrawerSatisfaction"></dd></div>
+        <div><dt>巡查状态</dt><dd id="customerDrawerInspectionStatus"></dd></div>
+        <div><dt>巡查人</dt><dd id="customerDrawerInspector"></dd></div>
+        <div><dt>最近巡查</dt><dd id="customerDrawerInspectionAt"></dd></div>
+      </dl>
+      <section class="detail-block">
+        <h3>新居地址</h3>
+        <p id="customerDrawerAddress"></p>
+      </section>
+      <section class="detail-block">
+        <h3>重点客户入选说明</h3>
+        <p id="customerDrawerKeyReason"></p>
+      </section>
+      <section class="detail-block">
+        <h3>巡查结论</h3>
+        <p id="customerDrawerInspectionSummary"></p>
+      </section>
+      <section class="detail-block">
+        <h3>关联问题</h3>
+        <div class="record-list" id="customerDrawerIssues"></div>
+      </section>
+      <div class="drawer-actions">
+        <button class="ghost-button" type="button" data-customer-drawer-action="inspection" data-requires="inspection:create">提交日查</button>
+        <button class="primary-button" type="button" data-customer-drawer-action="feedback" data-requires="issue:create">提交反馈</button>
+      </div>
+    </div>
+  </aside>
+
+  <div class="modal-backdrop" id="modalBackdrop" hidden></div>
+
+  <section class="modal" id="inspectionModal" aria-label="每日线上巡查" aria-hidden="true">
+    <div class="modal-header">
+      <div>
+        <p class="eyebrow">内部巡查入口</p>
+        <h2>提交每日线上巡查</h2>
+      </div>
+      <button class="icon-button" type="button" data-close-modal aria-label="关闭">×</button>
+    </div>
+    <form id="inspectionForm" class="modal-form">
+      <label>
+        <span>巡查客户</span>
+        <select name="customerId" id="inspectionCustomerSelect"></select>
+      </label>
+      <label>
+        <span>巡查状态</span>
+        <select name="status">
+          <option>已巡查</option>
+          <option>异常</option>
+          <option>未巡查</option>
+        </select>
+      </label>
+      <label>
+        <span>巡查人</span>
+        <input name="inspector" type="text" placeholder="默认使用当前登录人">
+      </label>
+      <label>
+        <span>施工质量</span>
+        <select name="siteQuality">
+          <option>正常</option>
+          <option>存在施工质量疑问</option>
+          <option>需现场复核</option>
+        </select>
+      </label>
+      <label>
+        <span>文明施工</span>
+        <select name="civility">
+          <option>现场整洁</option>
+          <option>现场杂乱</option>
+          <option>材料堆放需整改</option>
+          <option>抽烟/卫生问题</option>
+        </select>
+      </label>
+      <label>
+        <span>客户群反馈</span>
+        <select name="groupFeedback">
+          <option>暂无新增反馈</option>
+          <option>客户群有疑问待回复</option>
+          <option>客户明确表达不满</option>
+          <option>需设计师/项目经理介入</option>
+        </select>
+      </label>
+      <label class="full">
+        <span>巡查结论</span>
+        <textarea name="summary" rows="3" placeholder="记录线上巡查看到的客户群情况、现场照片反馈、需要转办的问题"></textarea>
+      </label>
+      <div class="form-actions full">
+        <button class="ghost-button" type="button" data-close-modal>取消</button>
+        <button class="primary-button" type="submit">保存日查情况</button>
+      </div>
+    </form>
+  </section>
+
+  <section class="modal" id="issueModal" aria-label="新增问题反馈" aria-hidden="true">
+    <div class="modal-header">
+      <div>
+        <p class="eyebrow">表单提交界面</p>
+        <h2>新增问题反馈</h2>
+      </div>
+      <button class="icon-button" type="button" data-close-modal aria-label="关闭">×</button>
+    </div>
+    <form id="issueForm" class="modal-form">
+      <label>
+        <span>客户搜索</span>
+        <input id="issueCustomerSearch" type="search" placeholder="输入客户姓名或地址">
+      </label>
+      <label>
+        <span>关联客户</span>
+        <select name="customerId" id="issueCustomerSelect"></select>
+      </label>
+      <label>
+        <span>问题来源</span>
+        <select name="source">
+          <option>线下巡检</option>
+          <option>客户群反馈</option>
+          <option>每日线上巡查</option>
+          <option>客户主动反馈</option>
+          <option>管理层转办</option>
+          <option>监察部发现</option>
+        </select>
+      </label>
+      <label>
+        <span>问题类型</span>
+        <select name="type">
+          <option>施工质量</option>
+          <option>设计变更</option>
+          <option>沟通响应</option>
+          <option>材料交付</option>
+          <option>客户满意度</option>
+        </select>
+      </label>
+      <label>
+        <span>预警级别</span>
+        <select name="status">
+          <option>红色预警</option>
+          <option>橙色预警</option>
+          <option>蓝色关注</option>
+        </select>
+      </label>
+      <label>
+        <span>责任人</span>
+        <input name="owner" type="text" placeholder="输入责任人姓名" required>
+      </label>
+      <label>
+        <span>预计核查时间</span>
+        <input name="auditDue" type="date" required>
+      </label>
+      <label class="full">
+        <span>问题描述 / 客户反馈原文</span>
+        <textarea name="summary" rows="3" placeholder="记录客户原话、现场情况、影响程度" required></textarea>
+      </label>
+      <label class="full">
+        <span>初步根因分析</span>
+        <textarea name="rootCause" rows="3" placeholder="例如:设计交底不清、材料延期、现场沟通断点"></textarea>
+      </label>
+      <div class="form-actions full">
+        <button class="ghost-button" type="button" data-close-modal>取消</button>
+        <button class="primary-button" type="submit">提交并进入预警池</button>
+      </div>
+    </form>
+  </section>
+
+  <section class="modal" id="handleModal" aria-label="提交处置记录" aria-hidden="true">
+    <div class="modal-header">
+      <div>
+        <p class="eyebrow">处置流水</p>
+        <h2>提交处置记录</h2>
+      </div>
+      <button class="icon-button" type="button" data-close-modal aria-label="关闭">×</button>
+    </div>
+    <form id="handleForm" class="modal-form">
+      <label class="full">
+        <span>处置说明</span>
+        <textarea name="handlerNote" rows="3" placeholder="记录处理方案、已完成动作、下一步安排" required></textarea>
+      </label>
+      <label>
+        <span>处置后满意度</span>
+        <select name="satisfaction">
+          <option>满意</option>
+          <option>一般</option>
+          <option>不满意</option>
+          <option>待回访</option>
+        </select>
+      </label>
+      <label>
+        <span>客户回访结果</span>
+        <input name="customerFeedback" type="text" placeholder="例如:客户认可整改,要求持续观察">
+      </label>
+      <div class="form-actions full">
+        <button class="ghost-button" type="button" data-close-modal>取消</button>
+        <button class="primary-button" type="submit">提交并转监察核查</button>
+      </div>
+    </form>
+  </section>
+
+  <section class="modal" id="customerModal" aria-label="新增客户" aria-hidden="true">
+    <div class="modal-header">
+      <div>
+        <p class="eyebrow">客户录入</p>
+        <h2>新增重要客户</h2>
+      </div>
+      <button class="icon-button" type="button" data-close-modal aria-label="关闭">×</button>
+    </div>
+    <form id="customerForm" class="modal-form">
+      <label><span>客户姓名</span><input name="name" type="text" placeholder="例如:李女士" required></label>
+      <label><span>客户等级</span><select name="tier"><option>A</option><option>A+</option></select></label>
+      <label><span>客户来源</span><select name="source"><option>转介绍</option><option>公司特殊关系</option><option>标杆项目</option><option>风险关注</option></select></label>
+      <label><span>责任部门</span><select name="department"><option>光宸</option><option>澜光</option><option>蘭堂</option><option>焕境</option></select></label>
+      <label><span>客管人员负责人</span><input name="customerOwner" type="text" placeholder="例如:客管负责人姓名" required></label>
+      <label><span>设计师</span><input name="designer" type="text" required></label>
+      <label><span>项目经理</span><input name="manager" type="text" required></label>
+      <label class="full"><span>新居地址</span><input name="address" type="text" placeholder="楼盘/楼栋/房号" required></label>
+      <label><span>施工阶段</span><input name="stage" type="text" placeholder="例如:木作安装"></label>
+      <label><span>开工日期</span><input name="startDate" type="date"></label>
+      <label><span>计划完工日期</span><input name="plannedFinishDate" type="date"></label>
+      <label><span>当前进度</span><select name="progressStatus"><option>正常</option><option>延期</option></select></label>
+      <label><span>满意度</span><select name="satisfaction"><option>5</option><option>4</option><option>3</option><option>2</option><option>1</option></select></label>
+      <label class="full"><span>重点客户入选说明</span><textarea name="keyCustomerReason" rows="3" placeholder="说明推荐关系、标杆价值、特殊关注原因或问题风险" required></textarea></label>
+      <div class="form-actions full">
+        <button class="ghost-button" type="button" data-close-modal>取消</button>
+        <button class="primary-button" type="submit">提交客户入池</button>
+      </div>
+    </form>
+  </section>
+
+  <div class="toast" id="toast" role="status" aria-live="polite"></div>
+
+  <script src="app.js"></script>
+</body>
+</html>

+ 1710 - 0
prototype/styles.css

@@ -0,0 +1,1710 @@
+:root {
+  color-scheme: light;
+  --bg: #f5f7fb;
+  --surface: #ffffff;
+  --surface-soft: #f8fafc;
+  --line: #dbe3ee;
+  --line-strong: #c6d1df;
+  --text: #172033;
+  --muted: #64748b;
+  --brand: #1d4ed8;
+  --brand-dark: #143b8f;
+  --brand-soft: #e7efff;
+  --success: #0f8f6d;
+  --warning: #b7791f;
+  --danger: #c2413d;
+  --info: #2363b8;
+  --shadow: 0 16px 40px rgba(23, 32, 51, 0.08);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  min-width: 320px;
+  background: var(--bg);
+  color: var(--text);
+  font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
+  letter-spacing: 0;
+}
+
+[hidden] {
+  display: none !important;
+}
+
+button,
+input,
+select,
+textarea {
+  font: inherit;
+}
+
+button {
+  cursor: pointer;
+}
+
+button:disabled,
+button.is-disabled {
+  cursor: not-allowed;
+  opacity: 0.45;
+}
+
+.login-screen {
+  min-height: 100vh;
+  display: grid;
+  place-items: center;
+  padding: 32px;
+  background:
+    linear-gradient(135deg, rgba(29, 78, 216, 0.08), rgba(15, 143, 109, 0.08)),
+    var(--bg);
+}
+
+.login-card {
+  width: min(980px, 100%);
+  display: grid;
+  grid-template-columns: minmax(0, 0.9fr) minmax(360px, 1.1fr);
+  gap: 28px;
+  padding: 28px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface);
+  box-shadow: var(--shadow);
+}
+
+.login-copy {
+  display: grid;
+  align-content: center;
+}
+
+.login-copy h1 {
+  margin-bottom: 16px;
+  font-size: 34px;
+}
+
+.login-copy p:last-child {
+  margin: 0;
+  color: var(--muted);
+  line-height: 1.7;
+}
+
+.login-panel {
+  display: grid;
+  gap: 12px;
+}
+
+.login-form {
+  display: grid;
+  gap: 12px;
+  padding: 18px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface-soft);
+}
+
+.register-form {
+  background: var(--surface);
+}
+
+.register-password-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.form-heading {
+  display: grid;
+  gap: 4px;
+}
+
+.form-heading strong {
+  font-size: 16px;
+}
+
+.form-heading small {
+  color: var(--muted);
+  line-height: 1.5;
+}
+
+.login-form label {
+  display: grid;
+  gap: 7px;
+}
+
+.login-form label span {
+  color: var(--muted);
+  font-size: 13px;
+}
+
+.login-form input {
+  width: 100%;
+  min-height: 44px;
+  padding: 0 12px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface);
+}
+
+.login-message {
+  min-height: 18px;
+  margin: 0;
+  color: var(--danger);
+  font-size: 13px;
+}
+
+.demo-accounts {
+  display: grid;
+  gap: 10px;
+}
+
+.account-fill-button {
+  min-height: 74px;
+  padding: 14px 16px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface-soft);
+  text-align: left;
+}
+
+.account-fill-button:hover {
+  border-color: var(--brand);
+  box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.1);
+}
+
+.account-fill-button strong,
+.account-fill-button span,
+.account-fill-button em {
+  display: block;
+}
+
+.account-fill-button span {
+  margin-top: 5px;
+  color: var(--text);
+  font-size: 13px;
+}
+
+.account-fill-button em {
+  margin-top: 4px;
+  color: var(--muted);
+  font-size: 13px;
+  font-style: normal;
+}
+
+.app-shell {
+  display: grid;
+  grid-template-columns: 260px minmax(0, 1fr);
+  min-height: 100vh;
+}
+
+.sidebar {
+  position: sticky;
+  top: 0;
+  height: 100vh;
+  padding: 22px 18px;
+  background: #0f172a;
+  color: #f8fafc;
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+}
+
+.brand {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+}
+
+.brand-mark {
+  width: 42px;
+  height: 42px;
+  display: grid;
+  place-items: center;
+  border-radius: 8px;
+  background: #2f80ed;
+  font-weight: 800;
+}
+
+.brand strong,
+.brand small {
+  display: block;
+}
+
+.brand small {
+  margin-top: 4px;
+  color: #a9b7cc;
+  font-size: 12px;
+}
+
+.nav-list {
+  display: grid;
+  gap: 8px;
+}
+
+.nav-item {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 44px;
+  padding: 0 12px;
+  border: 0;
+  border-radius: 8px;
+  background: transparent;
+  color: #d7deea;
+  text-align: left;
+}
+
+.nav-item b {
+  color: #718096;
+  font-size: 12px;
+}
+
+.nav-item:hover,
+.nav-item.active {
+  background: rgba(255, 255, 255, 0.1);
+  color: #ffffff;
+}
+
+.sidebar-card {
+  margin-top: auto;
+  padding: 16px;
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.06);
+}
+
+.sidebar-card p {
+  margin: 10px 0 6px;
+  color: #cbd5e1;
+}
+
+.sidebar-card strong {
+  font-size: 28px;
+}
+
+.status-dot {
+  width: 9px;
+  height: 9px;
+  display: inline-block;
+  border-radius: 50%;
+  background: #4ade80;
+  box-shadow: 0 0 0 5px rgba(74, 222, 128, 0.14);
+}
+
+.main {
+  min-width: 0;
+  padding: 24px;
+}
+
+.topbar,
+.panel-heading,
+.top-actions,
+.form-actions,
+.modal-header,
+.drawer-actions {
+  display: flex;
+  align-items: center;
+}
+
+.topbar,
+.panel-heading,
+.modal-header {
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.topbar {
+  margin-bottom: 18px;
+}
+
+.eyebrow {
+  margin: 0 0 6px;
+  color: var(--muted);
+  font-size: 12px;
+  font-weight: 700;
+}
+
+h1,
+h2,
+h3,
+p {
+  margin-top: 0;
+}
+
+h1 {
+  margin-bottom: 0;
+  font-size: 28px;
+  line-height: 1.25;
+}
+
+h2 {
+  margin-bottom: 0;
+  font-size: 18px;
+}
+
+h3 {
+  font-size: 15px;
+}
+
+.top-actions,
+.drawer-actions {
+  gap: 10px;
+}
+
+.user-panel {
+  display: grid;
+  grid-template-columns: auto auto auto auto;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 8px 6px 12px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface);
+}
+
+.user-panel span {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.user-panel strong {
+  font-size: 13px;
+}
+
+.user-panel em {
+  padding: 3px 7px;
+  border-radius: 999px;
+  background: var(--brand-soft);
+  color: var(--brand);
+  font-size: 12px;
+  font-style: normal;
+  font-weight: 700;
+}
+
+.primary-button,
+.ghost-button,
+.icon-button,
+.success-button,
+.danger-button,
+.small-button {
+  border: 1px solid transparent;
+  border-radius: 8px;
+  min-height: 38px;
+  padding: 0 14px;
+  background: var(--surface);
+  color: var(--text);
+}
+
+.primary-button {
+  background: var(--brand);
+  color: #ffffff;
+}
+
+.primary-button:hover {
+  background: var(--brand-dark);
+}
+
+.ghost-button {
+  border-color: var(--line);
+}
+
+.success-button {
+  background: var(--success);
+  color: #ffffff;
+}
+
+.danger-button {
+  background: var(--danger);
+  color: #ffffff;
+}
+
+.small-button {
+  min-height: 30px;
+  padding: 0 10px;
+  border-color: var(--line);
+  font-size: 12px;
+}
+
+.link-button {
+  padding: 0;
+  border: 0;
+  background: transparent;
+  color: var(--brand);
+  font: inherit;
+  font-weight: 800;
+  text-align: left;
+}
+
+.customer-cell-button {
+  width: 100%;
+  min-width: 210px;
+  display: grid;
+  gap: 5px;
+  margin: -8px -10px;
+  padding: 8px 10px;
+  border: 0;
+  border-radius: 8px;
+  background: transparent;
+  color: inherit;
+  text-align: left;
+}
+
+.customer-cell-button:hover,
+.customer-cell-button:focus-visible {
+  background: var(--brand-soft);
+}
+
+.customer-cell-button strong {
+  color: var(--brand);
+  font-weight: 800;
+}
+
+.customer-cell-button small {
+  color: var(--muted);
+  line-height: 1.45;
+  white-space: normal;
+}
+
+.customer-cell-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.icon-button {
+  width: 38px;
+  padding: 0;
+  border-color: var(--line);
+  font-size: 18px;
+}
+
+.filter-bar {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(150px, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+  padding: 14px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface);
+}
+
+label span {
+  display: block;
+  margin-bottom: 6px;
+  color: var(--muted);
+  font-size: 12px;
+  font-weight: 700;
+}
+
+input,
+select,
+textarea {
+  width: 100%;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: #ffffff;
+  color: var(--text);
+  outline: none;
+}
+
+input,
+select {
+  height: 38px;
+  padding: 0 10px;
+}
+
+input[type="checkbox"] {
+  width: auto;
+  height: auto;
+  padding: 0;
+  box-shadow: none;
+}
+
+textarea {
+  resize: vertical;
+  padding: 10px;
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+  border-color: var(--brand);
+  box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.12);
+}
+
+.page-section {
+  display: none;
+}
+
+.page-section.active {
+  display: block;
+}
+
+.kpi-grid,
+.insight-grid,
+.section-grid,
+.analysis-grid {
+  display: grid;
+  gap: 16px;
+}
+
+.kpi-grid {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  margin-bottom: 16px;
+}
+
+.metric-card,
+.panel {
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface);
+  box-shadow: var(--shadow);
+}
+
+.metric-card {
+  padding: 18px;
+}
+
+.metric-card span,
+.metric-card em {
+  display: block;
+  color: var(--muted);
+  font-style: normal;
+}
+
+.metric-card strong {
+  display: block;
+  margin: 10px 0 8px;
+  font-size: 28px;
+  line-height: 1;
+}
+
+.metric-card em {
+  font-size: 13px;
+}
+
+.insight-grid {
+  grid-template-columns: 1.1fr 1fr 1fr;
+  margin-bottom: 16px;
+}
+
+.section-grid.two,
+.analysis-grid {
+  grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.75fr);
+}
+
+.analysis-grid {
+  grid-template-columns: 1fr 0.8fr 0.8fr;
+}
+
+.panel {
+  min-width: 0;
+  padding: 18px;
+  margin-bottom: 16px;
+}
+
+.panel-chip {
+  display: inline-flex;
+  align-items: center;
+  min-height: 28px;
+  padding: 0 10px;
+  border-radius: 999px;
+  background: #fff3e3;
+  color: var(--warning);
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.funnel {
+  display: grid;
+  gap: 12px;
+  margin-top: 18px;
+}
+
+.funnel-row {
+  width: var(--w);
+  min-height: 48px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 16px;
+  border-radius: 8px;
+  color: #ffffff;
+}
+
+.tier-a {
+  background: #c2413d;
+}
+
+.tier-b {
+  background: #b7791f;
+}
+
+.tier-c {
+  background: #2363b8;
+}
+
+.bar-list,
+.rank-list,
+.risk-list,
+.checklist,
+.permission-list,
+.record-list,
+.inspection-summary {
+  display: grid;
+  gap: 12px;
+  margin-top: 18px;
+}
+
+.inspection-summary {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.inspection-summary article {
+  padding: 14px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface-soft);
+}
+
+.inspection-summary span {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.inspection-summary strong {
+  display: block;
+  margin-top: 6px;
+  font-size: 24px;
+}
+
+.bar-item {
+  display: grid;
+  grid-template-columns: 68px minmax(0, 1fr) 36px;
+  gap: 10px;
+  align-items: center;
+}
+
+.bar-item span,
+.bar-item strong {
+  font-size: 13px;
+}
+
+.bar-item div {
+  height: 10px;
+  overflow: hidden;
+  border-radius: 999px;
+  background: var(--brand-soft);
+}
+
+.bar-item i {
+  display: block;
+  height: 100%;
+  border-radius: inherit;
+  background: #2f80ed;
+}
+
+.risk-item {
+  padding: 13px;
+  border-left: 4px solid var(--info);
+  border-radius: 8px;
+  background: var(--surface-soft);
+}
+
+.risk-item b,
+.risk-item span {
+  display: block;
+}
+
+.risk-item span {
+  margin-top: 5px;
+  color: var(--muted);
+  font-size: 13px;
+  line-height: 1.5;
+}
+
+.risk-item.danger {
+  border-left-color: var(--danger);
+}
+
+.risk-item.warning {
+  border-left-color: var(--warning);
+}
+
+.risk-item.info {
+  border-left-color: var(--info);
+}
+
+.segmented {
+  display: inline-flex;
+  padding: 3px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface-soft);
+}
+
+.segmented button {
+  min-width: 54px;
+  height: 30px;
+  border: 0;
+  border-radius: 6px;
+  background: transparent;
+  color: var(--muted);
+}
+
+.segmented button.active {
+  background: #ffffff;
+  color: var(--brand);
+  box-shadow: 0 2px 8px rgba(23, 32, 51, 0.08);
+}
+
+.table-wrap {
+  margin-top: 14px;
+  overflow-x: auto;
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+  min-width: 1120px;
+}
+
+th,
+td {
+  padding: 13px 10px;
+  border-bottom: 1px solid var(--line);
+  text-align: left;
+  white-space: nowrap;
+}
+
+th {
+  color: var(--muted);
+  font-size: 12px;
+  font-weight: 700;
+}
+
+tbody tr:hover {
+  background: var(--surface-soft);
+}
+
+.badge,
+.status-badge,
+.data-tag {
+  display: inline-flex;
+  align-items: center;
+  min-height: 26px;
+  padding: 0 9px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.badge {
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.data-tag {
+  min-height: 22px;
+  margin-left: 6px;
+  padding: 0 7px;
+  background: #f1f5f9;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.status-badge.closed,
+.status-badge.normal {
+  background: #e8f7f1;
+  color: var(--success);
+}
+
+.status-badge.audit,
+.status-badge.orange {
+  background: #fff3e3;
+  color: var(--warning);
+}
+
+.status-badge.risk {
+  background: #ffecec;
+  color: var(--danger);
+}
+
+.status-badge.blue {
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.action-cell {
+  display: flex;
+  gap: 8px;
+}
+
+.drawer-backdrop,
+.modal-backdrop {
+  position: fixed;
+  inset: 0;
+  z-index: 20;
+  background: rgba(15, 23, 42, 0.32);
+}
+
+.detail-drawer {
+  position: fixed;
+  top: 0;
+  right: 0;
+  z-index: 30;
+  width: min(470px, 100vw);
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+  background: #ffffff;
+  box-shadow: -22px 0 48px rgba(23, 32, 51, 0.16);
+  transform: translateX(104%);
+  transition: transform 0.22s ease;
+}
+
+.detail-drawer.open {
+  transform: translateX(0);
+}
+
+.drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 20px;
+  border-bottom: 1px solid var(--line);
+}
+
+.drawer-body {
+  overflow-y: auto;
+  padding: 20px;
+}
+
+.detail-score {
+  padding: 18px;
+  border-radius: 8px;
+  background: var(--brand-soft);
+}
+
+.detail-score span,
+.detail-score strong {
+  display: block;
+}
+
+.detail-score span {
+  color: var(--muted);
+}
+
+.detail-score strong {
+  margin-top: 8px;
+  color: var(--brand);
+  font-size: 44px;
+  line-height: 1;
+}
+
+.detail-list {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+  margin: 16px 0;
+}
+
+.detail-list div {
+  padding: 12px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+}
+
+dt {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+dd {
+  margin: 6px 0 0;
+  font-weight: 700;
+}
+
+.timeline {
+  padding-top: 8px;
+}
+
+.detail-block {
+  margin: 16px 0;
+  padding: 14px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface-soft);
+}
+
+.detail-block h3,
+.detail-block p {
+  margin-bottom: 0;
+}
+
+.detail-block p,
+.muted-text {
+  color: var(--muted);
+  line-height: 1.6;
+}
+
+.record-card {
+  padding: 12px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: #ffffff;
+}
+
+.record-card div {
+  display: flex;
+  justify-content: space-between;
+  gap: 10px;
+  margin-bottom: 8px;
+}
+
+.record-card span,
+.record-card small {
+  color: var(--muted);
+}
+
+.record-card p {
+  margin-bottom: 8px;
+  line-height: 1.55;
+}
+
+.timeline ol {
+  display: grid;
+  gap: 12px;
+  margin: 0;
+  padding-left: 20px;
+}
+
+.timeline li {
+  padding-left: 4px;
+  line-height: 1.55;
+}
+
+.drawer-actions {
+  flex-wrap: wrap;
+  margin-top: 18px;
+  padding-top: 18px;
+  border-top: 1px solid var(--line);
+}
+
+.modal {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  z-index: 40;
+  width: min(760px, calc(100vw - 32px));
+  max-height: calc(100vh - 48px);
+  overflow-y: auto;
+  padding: 20px;
+  border-radius: 8px;
+  background: #ffffff;
+  box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24);
+  transform: translate(-50%, -50%) scale(0.98);
+  opacity: 0;
+  pointer-events: none;
+  transition: 0.18s ease;
+}
+
+.modal.open {
+  transform: translate(-50%, -50%) scale(1);
+  opacity: 1;
+  pointer-events: auto;
+}
+
+.modal-form,
+.settings-form {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+  margin-top: 18px;
+}
+
+.modal-form .full,
+.settings-form .full {
+  grid-column: 1 / -1;
+}
+
+.form-actions {
+  justify-content: flex-end;
+  gap: 10px;
+}
+
+.trend {
+  height: 260px;
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  align-items: end;
+  gap: 12px;
+  padding-top: 18px;
+}
+
+.trend span {
+  position: relative;
+  min-height: 28px;
+  border-radius: 8px 8px 0 0;
+  background: linear-gradient(180deg, #2f80ed, #a7c7ff);
+  height: var(--h);
+}
+
+.trend b {
+  position: absolute;
+  left: 50%;
+  bottom: -24px;
+  transform: translateX(-50%);
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.rank-list div,
+.permission-list div,
+.insight-note p,
+.checklist label {
+  padding: 13px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface-soft);
+}
+
+.rank-list div,
+.permission-list div {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.permission-list div {
+  display: grid;
+}
+
+.permission-list span,
+.insight-note p {
+  color: var(--muted);
+  line-height: 1.55;
+}
+
+.admin-grid,
+.admin-kpis,
+.compact-form,
+.dictionary-form {
+  display: grid;
+  gap: 12px;
+}
+
+.admin-grid {
+  grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr);
+  align-items: start;
+}
+
+.admin-kpis {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  margin-bottom: 16px;
+}
+
+.compact-form {
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  align-items: end;
+  margin-top: 16px;
+}
+
+.compact-form .form-actions {
+  align-self: end;
+}
+
+.admin-table,
+.admin-review-table {
+  min-width: 860px;
+}
+
+.admin-review-table {
+  min-width: 1180px;
+}
+
+.inline-input {
+  min-width: 120px;
+}
+
+.role-card-list {
+  display: grid;
+  gap: 12px;
+  margin-top: 16px;
+}
+
+.role-card {
+  display: grid;
+  gap: 14px;
+  padding: 14px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface-soft);
+}
+
+.role-card-head {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  align-items: flex-start;
+}
+
+.role-card-head strong,
+.role-card-head span,
+.role-card-section b {
+  display: block;
+}
+
+.role-card-head span {
+  margin-top: 5px;
+  color: var(--muted);
+  font-size: 13px;
+  line-height: 1.45;
+}
+
+.role-card-section b {
+  margin-bottom: 8px;
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.checkbox-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.checkbox-grid label {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-height: 36px;
+  padding: 8px 10px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: #ffffff;
+  color: var(--text);
+  font-size: 13px;
+}
+
+.checkbox-grid label span {
+  margin: 0;
+  color: inherit;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.dictionary-form {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  margin-top: 16px;
+}
+
+.toast {
+  position: fixed;
+  right: 24px;
+  bottom: 24px;
+  z-index: 60;
+  max-width: min(360px, calc(100vw - 48px));
+  padding: 13px 16px;
+  border-radius: 8px;
+  background: #0f172a;
+  color: #ffffff;
+  box-shadow: 0 16px 40px rgba(15, 23, 42, 0.2);
+  opacity: 0;
+  transform: translateY(10px);
+  pointer-events: none;
+  transition: 0.18s ease;
+}
+
+.toast.show {
+  opacity: 1;
+  transform: translateY(0);
+}
+
+.permission-list div.is-current {
+  border-color: var(--brand);
+  background: var(--brand-soft);
+}
+
+@media (max-width: 1180px) {
+  .kpi-grid,
+  .admin-kpis,
+  .insight-grid,
+  .section-grid.two,
+  .analysis-grid,
+  .admin-grid,
+  .dictionary-form {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 860px) {
+  .login-card {
+    grid-template-columns: 1fr;
+  }
+
+  .app-shell {
+    grid-template-columns: 1fr;
+  }
+
+  .sidebar {
+    position: static;
+    height: auto;
+  }
+
+  .nav-list {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .sidebar-card {
+    display: none;
+  }
+
+  .filter-bar {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .compact-form {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 620px) {
+  .main {
+    padding: 16px;
+  }
+
+  .topbar,
+  .panel-heading {
+    align-items: flex-start;
+    flex-direction: column;
+  }
+
+  .top-actions,
+  .segmented {
+    width: 100%;
+    flex-wrap: wrap;
+  }
+
+  .top-actions .primary-button,
+  .top-actions .ghost-button,
+  .user-panel,
+  .segmented button {
+    flex: 1;
+  }
+
+  .filter-bar,
+  .kpi-grid,
+  .modal-form,
+  .register-password-grid,
+  .settings-form,
+  .detail-list,
+  .nav-list,
+  .section-grid.two,
+  .analysis-grid,
+  .admin-grid,
+  .admin-kpis,
+  .compact-form,
+  .dictionary-form,
+  .checkbox-grid {
+    grid-template-columns: 1fr;
+  }
+
+  h1 {
+    font-size: 24px;
+  }
+}
+
+@media (max-width: 760px) {
+  .login-screen {
+    min-height: 100svh;
+    padding: 0;
+    place-items: stretch;
+  }
+
+  .login-card {
+    width: 100%;
+    min-height: 100svh;
+    grid-template-columns: 1fr;
+    align-content: start;
+    gap: 18px;
+    padding: 22px;
+    border: 0;
+    border-radius: 0;
+    box-shadow: none;
+  }
+
+  .login-copy h1 {
+    font-size: 24px;
+  }
+
+  .login-copy p:last-child {
+    line-height: 1.55;
+  }
+
+  .account-fill-button {
+    min-height: 0;
+    padding: 12px 14px;
+  }
+
+  .app-shell {
+    display: block;
+  }
+
+  .sidebar {
+    position: sticky;
+    top: 0;
+    z-index: 15;
+    height: auto;
+    gap: 10px;
+    padding: 10px 10px 8px;
+    border-bottom: 1px solid rgba(255, 255, 255, 0.12);
+  }
+
+  .brand {
+    gap: 8px;
+  }
+
+  .brand-mark {
+    width: 32px;
+    height: 32px;
+  }
+
+  .brand strong {
+    font-size: 14px;
+  }
+
+  .brand small,
+  .sidebar-card,
+  .nav-item b {
+    display: none;
+  }
+
+  .nav-list {
+    display: flex;
+    grid-template-columns: none;
+    gap: 8px;
+    overflow-x: auto;
+    padding-bottom: 2px;
+    scrollbar-width: none;
+  }
+
+  .nav-list::-webkit-scrollbar {
+    display: none;
+  }
+
+  .nav-item {
+    width: auto;
+    flex: 0 0 auto;
+    min-height: 34px;
+    padding: 0 13px;
+    border-radius: 999px;
+    background: rgba(255, 255, 255, 0.06);
+  }
+
+  .nav-item span {
+    white-space: nowrap;
+  }
+
+  .main {
+    padding: 12px 10px 80px;
+  }
+
+  .topbar {
+    display: grid;
+    gap: 10px;
+    margin-bottom: 12px;
+  }
+
+  .topbar h1 {
+    font-size: 20px;
+  }
+
+  .top-actions {
+    width: 100%;
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 8px;
+  }
+
+  .user-panel {
+    grid-column: 1 / -1;
+    grid-template-columns: 1fr auto auto;
+    min-width: 0;
+  }
+
+  .user-panel span {
+    display: none;
+  }
+
+  .user-panel strong {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .top-actions .primary-button,
+  .top-actions .ghost-button {
+    width: 100%;
+    min-height: 40px;
+    padding: 0 10px;
+  }
+
+  .filter-bar {
+    display: flex;
+    grid-template-columns: none;
+    gap: 8px;
+    overflow-x: auto;
+    margin: 0 -10px 12px;
+    padding: 10px;
+    border-right: 0;
+    border-left: 0;
+    border-radius: 0;
+    scrollbar-width: none;
+  }
+
+  .filter-bar::-webkit-scrollbar {
+    display: none;
+  }
+
+  .filter-bar label {
+    flex: 0 0 142px;
+  }
+
+  .filter-bar .search-field {
+    flex-basis: 220px;
+  }
+
+  input,
+  select {
+    height: 42px;
+  }
+
+  .kpi-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 10px;
+    margin-bottom: 12px;
+  }
+
+  .metric-card,
+  .panel {
+    box-shadow: none;
+  }
+
+  .metric-card {
+    padding: 13px;
+  }
+
+  .metric-card strong {
+    margin: 8px 0 6px;
+    font-size: 24px;
+  }
+
+  .metric-card em {
+    font-size: 12px;
+  }
+
+  .insight-grid,
+  .section-grid,
+  .section-grid.two,
+  .analysis-grid,
+  .inspection-summary,
+  .admin-grid,
+  .dictionary-form {
+    grid-template-columns: 1fr;
+    gap: 10px;
+  }
+
+  .panel {
+    padding: 14px;
+    margin-bottom: 12px;
+  }
+
+  .panel-heading {
+    align-items: flex-start;
+    gap: 10px;
+  }
+
+  .panel-heading .primary-button,
+  .panel-heading .ghost-button,
+  .panel-heading .segmented {
+    width: 100%;
+  }
+
+  .segmented {
+    display: flex;
+    overflow-x: auto;
+    gap: 4px;
+    scrollbar-width: none;
+  }
+
+  .segmented::-webkit-scrollbar {
+    display: none;
+  }
+
+  .segmented button {
+    min-width: 74px;
+    flex: 0 0 auto;
+  }
+
+  .table-wrap {
+    overflow: visible;
+    margin-top: 10px;
+  }
+
+  table,
+  thead,
+  tbody,
+  tr,
+  td {
+    display: block;
+    width: 100%;
+    min-width: 0;
+  }
+
+  table {
+    border-collapse: separate;
+  }
+
+  thead {
+    display: none;
+  }
+
+  tbody {
+    display: grid;
+    gap: 10px;
+  }
+
+  tr {
+    padding: 12px;
+    border: 1px solid var(--line);
+    border-radius: 8px;
+    background: var(--surface);
+  }
+
+  tbody tr:hover {
+    background: var(--surface);
+  }
+
+  td {
+    display: grid;
+    grid-template-columns: 82px minmax(0, 1fr);
+    gap: 10px;
+    align-items: start;
+    padding: 7px 0;
+    border-bottom: 0;
+    white-space: normal;
+  }
+
+  td::before {
+    content: attr(data-label);
+    color: var(--muted);
+    font-size: 12px;
+    font-weight: 700;
+  }
+
+  td[data-label="客户"] {
+    padding-top: 0;
+  }
+
+  td[data-label="操作"] {
+    display: block;
+    padding-top: 10px;
+    border-top: 1px solid var(--line);
+  }
+
+  td[data-label="操作"]::before {
+    display: none;
+  }
+
+  .action-cell {
+    flex-wrap: wrap;
+  }
+
+  .action-cell .small-button {
+    flex: 1 1 88px;
+    min-height: 34px;
+  }
+
+  .customer-cell-button {
+    min-width: 0;
+    margin: -4px 0;
+    padding: 6px 8px;
+  }
+
+  .funnel-row,
+  .bar-item,
+  .risk-item {
+    min-width: 0;
+  }
+
+  .detail-drawer {
+    width: 100vw;
+    box-shadow: none;
+  }
+
+  .drawer-header,
+  .drawer-body {
+    padding: 16px;
+  }
+
+  .detail-list {
+    grid-template-columns: 1fr;
+  }
+
+  .drawer-actions {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .drawer-actions button {
+    width: 100%;
+  }
+
+  .modal {
+    top: auto;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    width: 100vw;
+    max-height: 92svh;
+    padding: 16px;
+    border-radius: 16px 16px 0 0;
+    transform: translateY(104%);
+  }
+
+  .modal.open {
+    transform: translateY(0);
+  }
+
+  .modal-form,
+  .settings-form,
+  .compact-form,
+  .checkbox-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .form-actions {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .form-actions button {
+    width: 100%;
+  }
+
+  .trend {
+    height: 200px;
+  }
+
+  .toast {
+    right: 12px;
+    bottom: 12px;
+    left: 12px;
+    max-width: none;
+  }
+}

+ 25 - 0
run.sh

@@ -0,0 +1,25 @@
+#!/bin/bash
+# KeyIssue Prototype 本地开发启动脚本
+cd "$(dirname "$0")"
+
+# 数据文件路径(默认非 Windows 会找 /var/lib/keyissue-prototype/,本地需要覆盖)
+export DATA_FILE="$PWD/data/app-state.json"
+
+# 可选:设置初始密码(首次启动未持久化时生效,已有密码哈希后忽略)
+export KEYISSUE_ADMIN_PASSWORD=123456
+export KEYISSUE_EXECUTIVE_PASSWORD=123456
+export KEYISSUE_DELIVERY_PASSWORD=123456
+export KEYISSUE_MANAGER_PASSWORD=123456
+export KEYISSUE_AUDITOR_PASSWORD=123456
+export KEYISSUE_DESIGNER_PASSWORD=123456
+export KEYISSUE_DESIGN_MANAGER_PASSWORD=123456
+
+# 监听地址和端口
+export HOST=0.0.0.0
+export PORT=8787
+
+echo "数据文件: $DATA_FILE"
+echo "启动服务: http://localhost:$PORT/prototype/index.html"
+echo ""
+
+exec node server.js

+ 1609 - 0
server.js

@@ -0,0 +1,1609 @@
+const http = require("http");
+const fs = require("fs").promises;
+const path = require("path");
+const crypto = require("crypto");
+
+const rootDir = __dirname;
+const port = Number(process.env.PORT || 8787);
+const host = process.env.HOST || "0.0.0.0";
+const sessions = new Map();
+const customerTiers = ["A", "A+"];
+
+function getInitialPassword(username) {
+  const key = `KEYISSUE_${username.toUpperCase()}_PASSWORD`;
+  if (process.env[key]) return process.env[key];
+  const digest = crypto.createHash("sha256").update(`${username}:${rootDir}`).digest("hex").slice(0, 12);
+  return `keyissue-${digest}`;
+}
+
+function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) {
+  const hash = crypto.pbkdf2Sync(String(password), salt, 120000, 32, "sha256").toString("hex");
+  return `pbkdf2:sha256:120000:${salt}:${hash}`;
+}
+
+function verifyPasswordHash(password, encodedHash) {
+  const parts = String(encodedHash || "").split(":");
+  if (parts.length !== 5 || parts[0] !== "pbkdf2" || parts[1] !== "sha256") return false;
+  const iterations = Number(parts[2]);
+  const salt = parts[3];
+  const expected = parts[4];
+  if (!iterations || !salt || !expected) return false;
+  const actual = crypto.pbkdf2Sync(String(password), salt, iterations, 32, "sha256").toString("hex");
+  const expectedBuffer = Buffer.from(expected, "hex");
+  const actualBuffer = Buffer.from(actual, "hex");
+  return expectedBuffer.length === actualBuffer.length && crypto.timingSafeEqual(expectedBuffer, actualBuffer);
+}
+
+function setUserPassword(user, password) {
+  user.passwordHash = hashPassword(password);
+  delete user.password;
+}
+
+function verifyUserPassword(user, password) {
+  if (!user) return false;
+  if (user.passwordHash) return verifyPasswordHash(password, user.passwordHash);
+  return user.password === password;
+}
+
+const pageCatalog = [
+  { key: "dashboard", name: "动态驾驶舱" },
+  { key: "customers", name: "A/A+客户库" },
+  { key: "inspections", name: "每日线上巡查" },
+  { key: "issues", name: "问题反馈池" },
+  { key: "audit", name: "监察核查" },
+  { key: "analysis", name: "问题分析" },
+  { key: "rules", name: "预警规则" },
+  { key: "admin", name: "系统后台" }
+];
+
+const permissionCatalog = [
+  { key: "issue:view", name: "查看问题详情" },
+  { key: "issue:create", name: "提交问题反馈" },
+  { key: "issue:assign", name: "指派责任人" },
+  { key: "issue:handle", name: "提交处置" },
+  { key: "inspection:view", name: "查看每日巡查" },
+  { key: "inspection:create", name: "提交每日巡查" },
+  { key: "audit:review", name: "监察核查" },
+  { key: "customer:submit", name: "提交客户入池申请" },
+  { key: "customer:approve", name: "审核客户入池" },
+  { key: "rules:edit", name: "保存预警规则" },
+  { key: "admin:manage", name: "系统后台管理" }
+];
+
+let roles = {
+  admin: {
+    roleName: "系统管理员",
+    summary: "全量功能、规则配置、后台权限管理",
+    pages: ["dashboard", "customers", "inspections", "issues", "audit", "analysis", "rules", "admin"],
+    permissions: ["*", "admin:manage"]
+  },
+  executive: {
+    roleName: "管理层",
+    summary: "全局查看、督办、指派责任人",
+    pages: ["dashboard", "customers", "inspections", "issues", "audit", "analysis"],
+    permissions: ["issue:view", "issue:assign", "inspection:view", "customer:submit"]
+  },
+  delivery: {
+    roleName: "交付助理",
+    summary: "提交每日巡查、问题反馈、查看客户与问题",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  deliveryManager: {
+    roleName: "交付经理",
+    summary: "交付线全量查看、问题督办与客户入池提交",
+    pages: ["dashboard", "customers", "inspections", "issues", "audit", "analysis"],
+    permissions: ["issue:view", "issue:create", "issue:assign", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  customerOwner: {
+    roleName: "客管人员",
+    summary: "仅查看本人负责客户、提交反馈与客户入池申请",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  customerManager: {
+    roleName: "客管经理",
+    summary: "查看本部门客管人员名下客户、督办反馈与客户入池提交",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "issue:assign", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  manager: {
+    roleName: "项目经理",
+    summary: "问题处置、提交巡查、提交核查、补充根因",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  designer: {
+    roleName: "设计师",
+    summary: "查看客户与问题、提交设计相关反馈、补充处置记录",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  designManager: {
+    roleName: "设计经理",
+    summary: "设计问题统筹、责任指派、处置跟进与客户入池提交",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "issue:assign", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  auditor: {
+    roleName: "监察部",
+    summary: "查看巡查、监察核查、通过或驳回整改",
+    pages: ["dashboard", "customers", "inspections", "issues", "audit", "analysis"],
+    permissions: ["issue:view", "audit:review", "inspection:view"]
+  },
+  supplyChain: {
+    roleName: "供应链",
+    summary: "供应链相关岗位全量查看客户与问题反馈",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "inspection:view", "customer:submit"]
+  }
+};
+
+let users = [
+  { id: "U001", username: "admin", password: getInitialPassword("admin"), displayName: "肖总", role: "admin", status: "启用" },
+  { id: "U002", username: "executive", password: getInitialPassword("executive"), displayName: "管理层", role: "executive", status: "启用" },
+  { id: "U003", username: "delivery", password: getInitialPassword("delivery"), displayName: "交付助理", role: "delivery", status: "启用" },
+  { id: "U004", username: "manager", password: getInitialPassword("manager"), displayName: "项目经理", role: "manager", status: "启用" },
+  { id: "U005", username: "auditor", password: getInitialPassword("auditor"), displayName: "监察部", role: "auditor", status: "启用" },
+  { id: "U006", username: "designer", password: getInitialPassword("designer"), displayName: "设计师", role: "designer", status: "启用" },
+  { id: "U007", username: "design_manager", password: getInitialPassword("design_manager"), displayName: "设计经理", role: "designManager", status: "启用" }
+];
+
+let dictionaries = {
+  departments: ["光宸", "澜光", "蘭堂", "焕境"],
+  customerSources: ["转介绍", "公司特殊关系", "标杆项目", "风险关注"],
+  issueSources: ["线下巡检", "客户群反馈", "每日线上巡查", "客户主动反馈", "管理层转办", "监察部发现"],
+  issueTypes: ["施工质量", "设计变更", "沟通响应", "材料交付", "客户满意度"],
+  progressStatuses: ["正常", "延期"]
+};
+
+const requiredRoleDefaults = {
+  executive: {
+    roleName: "管理层",
+    summary: "全局查看、督办、指派责任人",
+    pages: ["dashboard", "customers", "inspections", "issues", "audit", "analysis"],
+    permissions: ["issue:view", "issue:assign", "inspection:view", "customer:submit"]
+  },
+  deliveryManager: {
+    roleName: "交付经理",
+    summary: "交付线全量查看、问题督办与客户入池提交",
+    pages: ["dashboard", "customers", "inspections", "issues", "audit", "analysis"],
+    permissions: ["issue:view", "issue:create", "issue:assign", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  customerOwner: {
+    roleName: "客管人员",
+    summary: "仅查看本人负责客户、提交反馈与客户入池申请",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  customerManager: {
+    roleName: "客管经理",
+    summary: "查看本部门客管人员名下客户、督办反馈与客户入池提交",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "issue:assign", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  designer: {
+    roleName: "设计师",
+    summary: "查看客户与问题、提交设计相关反馈、补充处置记录",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  designManager: {
+    roleName: "设计经理",
+    summary: "设计问题统筹、责任指派、处置跟进与客户入池提交",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "issue:assign", "issue:handle", "inspection:view", "inspection:create", "customer:submit"]
+  },
+  auditor: {
+    roleName: "监察部",
+    summary: "查看客户、巡查、监察核查、通过或驳回整改",
+    pages: ["dashboard", "customers", "inspections", "issues", "audit", "analysis"],
+    permissions: ["issue:view", "audit:review", "inspection:view"]
+  },
+  supplyChain: {
+    roleName: "供应链",
+    summary: "供应链相关岗位全量查看客户与问题反馈",
+    pages: ["dashboard", "customers", "inspections", "issues", "analysis"],
+    permissions: ["issue:view", "issue:create", "inspection:view", "customer:submit"]
+  }
+};
+
+const requiredUserDefaults = [
+  { username: "designer", displayName: "设计师", role: "designer" },
+  { username: "design_manager", displayName: "设计经理", role: "designManager" }
+];
+
+let rosterMembers = [];
+
+let customers = [
+  { id: "C001", dataTag: "测试", name: "肖女士", tier: "A+", source: "转介绍", keyCustomerReason: "业主具备持续转介绍潜力,当前木作节点需要重点口碑维护。", customerOwner: "客管负责人-罗敏", address: "阅花溪 9 栋 B 座 1702", department: "光宸", designer: "蔡菁", manager: "刘江新", stage: "木作安装", startDate: "2026-05-18", plannedFinishDate: "2026-08-20", progressStatus: "延期", satisfaction: 2, inspectionStatus: "异常", inspectionAt: "2026-07-04 09:30", inspector: "交付助理", inspectionSummary: "客户群反馈木作收口疑问,现场材料堆放偏乱。" },
+  { id: "C002", dataTag: "测试", name: "周先生", tier: "A+", source: "标杆项目", keyCustomerReason: "别墅全案标杆项目,后续样板案例展示价值高。", customerOwner: "客管负责人-周芸", address: "江山府别墅 18 号", department: "澜光", designer: "何敏", manager: "张峥", stage: "水电验收", startDate: "2026-06-02", plannedFinishDate: "2026-09-15", progressStatus: "正常", satisfaction: 3, inspectionStatus: "已巡查", inspectionAt: "2026-07-04 10:10", inspector: "设计师", inspectionSummary: "群内沟通已回复,水电验收节点正常。" },
+  { id: "C003", dataTag: "测试", name: "林女士", tier: "A", source: "特殊关系", keyCustomerReason: "公司特殊关系客户,主材确认阶段需要保持高频同步。", customerOwner: "客管负责人-陈希", address: "云麓大平层 2601", department: "蘭堂", designer: "邱然", manager: "顾森", stage: "主材确认", startDate: "2026-06-10", plannedFinishDate: "2026-10-01", progressStatus: "正常", satisfaction: 4, inspectionStatus: "已巡查", inspectionAt: "2026-07-04 10:45", inspector: "交付助理", inspectionSummary: "主材确认进度正常,客户暂无新增反馈。" },
+  { id: "C004", dataTag: "测试", name: "陈先生", tier: "A", source: "风险关注", keyCustomerReason: "油漆阶段延期风险已显现,需要纳入重点风险控制。", customerOwner: "客管负责人-何静", address: "天境花园 5 栋 902", department: "焕境", designer: "姚晨", manager: "蒋川", stage: "油漆阶段", startDate: "2026-05-25", plannedFinishDate: "2026-07-30", progressStatus: "延期", satisfaction: 3, inspectionStatus: "未巡查", inspectionAt: "-", inspector: "-", inspectionSummary: "今日尚未提交线上巡查。" },
+  { id: "C005", dataTag: "测试", name: "王女士", tier: "A+", source: "标杆项目", keyCustomerReason: "竣工交付案例价值高,软装点位变更需重点关注。", customerOwner: "客管负责人-罗敏", address: "中海天钻 11 栋 1801", department: "光宸", designer: "苏禧", manager: "李成", stage: "竣工交付", startDate: "2026-04-28", plannedFinishDate: "2026-07-20", progressStatus: "正常", satisfaction: 3, inspectionStatus: "异常", inspectionAt: "2026-07-04 11:20", inspector: "服务部", inspectionSummary: "客户提到软装点位变更记录需要再次确认。" }
+];
+
+let customerDrafts = [
+  {
+    id: "CR-240701",
+    dataTag: "测试",
+    status: "待审核",
+    submittedBy: "交付助理",
+    submittedRole: "交付助理",
+    submittedAt: "2026-07-04 14:20",
+    reviewedBy: "",
+    reviewedAt: "",
+    reviewNote: "",
+    customerId: "",
+    customer: {
+      name: "许女士",
+      tier: "A+",
+      source: "客户群反馈",
+      keyCustomerReason: "客户在群内明确表达对交付节奏的担忧,同时具备转介绍潜力,建议纳入重点客户跟进。",
+      customerOwner: "客管负责人-周芸",
+      address: "麓湖云境 7 栋 1501",
+      department: "澜光",
+      designer: "何敏",
+      manager: "张峥",
+      stage: "泥木阶段",
+      startDate: "2026-06-18",
+      plannedFinishDate: "2026-09-28",
+      progressStatus: "正常",
+      satisfaction: 3
+    }
+  }
+];
+
+let inspections = [
+  { id: "R-240701", dataTag: "测试", customerId: "C001", customerName: "肖女士", inspector: "交付助理", status: "异常", createdAt: "2026-07-04 09:30", siteQuality: "木作收口与效果图有差异", civility: "现场材料堆放偏乱", groupFeedback: "客户群追问整改到场时间", summary: "需设计师和项目经理共同到场说明整改口径。" },
+  { id: "R-240702", dataTag: "测试", customerId: "C002", customerName: "周先生", inspector: "设计师", status: "已巡查", createdAt: "2026-07-04 10:10", siteQuality: "水电验收节点正常", civility: "现场整洁", groupFeedback: "客户群暂无新增疑问", summary: "按计划推进,保持每日同步。" },
+  { id: "R-240703", dataTag: "测试", customerId: "C005", customerName: "王女士", inspector: "服务部", status: "异常", createdAt: "2026-07-04 11:20", siteQuality: "竣工收口基本完成", civility: "现场已清理", groupFeedback: "客户要求复核软装点位", summary: "建议补齐变更确认链路后回访客户。" }
+];
+
+let issues = [
+  {
+    id: "I-240701",
+    dataTag: "测试",
+    customerId: "C001",
+    type: "施工质量",
+    source: "客户主动反馈",
+    status: "红色预警",
+    owner: "刘江新",
+    score: 91,
+    summary: "客户反馈木作收口与效果图存在明显差异,希望项目经理今天到场确认整改方案。",
+    rootCause: "设计交底与现场深化尺寸未完全同步",
+    action: "项目经理 24 小时内到场复核",
+    handlerNote: "已约设计师、项目经理今日 16:00 到场",
+    postSatisfaction: "待回访",
+    submitter: "交付助理",
+    auditDue: "2026-07-02",
+    handlingRecords: [
+      { handler: "刘江新", handledAt: "2026-07-04 10:30", note: "已约设计师、项目经理到场复核木作收口,现场确认整改口径。", satisfaction: "待回访", feedback: "客户要求当天给出明确整改时间。" }
+    ],
+    timeline: ["客户反馈木作收口与效果图存在明显差异", "二次反馈触发红色预警", "建议设计师、项目经理、监察部联合到场确认整改口径"]
+  },
+  {
+    id: "I-240702",
+    dataTag: "测试",
+    customerId: "C002",
+    type: "沟通响应",
+    source: "管理层转办",
+    status: "红色预警",
+    owner: "张峥",
+    score: 86,
+    summary: "客户连续两次反馈响应慢,担心后续交付过程失控。",
+    rootCause: "设计师与项目经理对外答复口径不一致",
+    action: "高层陪访并明确沟通机制",
+    handlerNote: "已指定单一客户接口,每日 18:00 同步",
+    postSatisfaction: "一般",
+    submitter: "客户负责人",
+    auditDue: "2026-07-03",
+    handlingRecords: [
+      { handler: "张峥", handledAt: "2026-07-03 18:00", note: "指定单一客户接口,每日固定同步进度。", satisfaction: "一般", feedback: "客户接受固定同步,但要求本周持续观察。" }
+    ],
+    timeline: ["客户连续两次反馈响应慢", "担心后续交付失控", "建议设定单一接口与固定反馈节奏"]
+  },
+  {
+    id: "I-240703",
+    dataTag: "测试",
+    customerId: "C003",
+    type: "材料交付",
+    source: "线下巡检",
+    status: "橙色预警",
+    owner: "顾森",
+    score: 73,
+    summary: "进口岩板到货时间不确定,客户担心影响工期。",
+    rootCause: "供应商排产反馈滞后",
+    action: "采购补充替代方案",
+    handlerNote: "待采购提供同等级替代材料",
+    postSatisfaction: "待处置",
+    submitter: "交付助理",
+    auditDue: "2026-07-04",
+    handlingRecords: [],
+    timeline: ["进口岩板到货时间不确定", "客户担心影响工期", "建议 48 小时内提供替代材料与影响测算"]
+  },
+  {
+    id: "I-240704",
+    dataTag: "测试",
+    customerId: "C004",
+    type: "施工质量",
+    source: "监察部发现",
+    status: "待核查",
+    owner: "蒋川",
+    score: 68,
+    summary: "监察部发现墙面基层处理验收不细,要求复验整改。",
+    rootCause: "基层处理验收不细",
+    action: "等待监察部复验",
+    handlerNote: "墙面修补已完成,提交复验",
+    postSatisfaction: "满意",
+    submitter: "项目经理",
+    auditDue: "2026-07-01",
+    handlingRecords: [
+      { handler: "蒋川", handledAt: "2026-07-01 16:20", note: "完成墙面修补并提交复验。", satisfaction: "满意", feedback: "客户确认修补效果可接受。" }
+    ],
+    timeline: ["墙面修补已完成", "项目经理提交完成申请", "监察部复验通过后标记已处理并纳入班组质量复盘"]
+  },
+  {
+    id: "I-240705",
+    dataTag: "测试",
+    customerId: "C005",
+    type: "设计变更",
+    source: "客户主动反馈",
+    status: "待核查",
+    owner: "苏禧",
+    score: 79,
+    summary: "客户反馈软装点位与最终确认稿不一致,要求重新核对。",
+    rootCause: "变更记录分散在微信沟通中",
+    action: "核查变更签字与交付一致性",
+    handlerNote: "已补齐变更确认链路",
+    postSatisfaction: "待核查",
+    submitter: "设计师",
+    auditDue: "2026-07-01",
+    handlingRecords: [
+      { handler: "苏禧", handledAt: "2026-07-02 11:10", note: "补齐点位变更确认记录,并提交监察核查。", satisfaction: "待核查", feedback: "客户要求交付前再核对一次。" }
+    ],
+    timeline: ["客户反馈软装点位与最终确认稿不一致", "变更记录未固化到项目台账", "建议形成交付前核对清单"]
+  },
+  {
+    id: "I-240706",
+    dataTag: "测试",
+    customerId: "C002",
+    type: "客户满意度",
+    source: "线下巡检",
+    status: "已处理",
+    owner: "何敏",
+    score: 66,
+    summary: "满意度低于 3 分,客户认为节点说明不够清晰。",
+    rootCause: "客户期望与节点说明不一致",
+    action: "已完成解释与节点确认",
+    handlerNote: "监察部核查通过",
+    postSatisfaction: "满意",
+    submitter: "监察部",
+    auditDue: "2026-06-29",
+    handlingRecords: [
+      { handler: "何敏", handledAt: "2026-06-29 15:00", note: "补充节点说明并重新确认计划。", satisfaction: "满意", feedback: "客户确认接受新计划。" }
+    ],
+    timeline: ["满意度低于 3 分触发预警", "设计师补充节点说明", "客户确认接受新计划,监察部标记已处理"]
+  }
+];
+
+let rulesState = {
+  updatedAt: null,
+  updatedBy: null
+};
+
+const dataFile = process.env.DATA_FILE || (
+  process.platform === "win32"
+    ? path.join(rootDir, "data", "app-state.json")
+    : "/var/lib/keyissue-prototype/app-state.json"
+);
+let persistQueue = Promise.resolve();
+
+function getStateSnapshot() {
+  return {
+    version: 1,
+    savedAt: new Date().toISOString(),
+    users,
+    roles,
+    dictionaries,
+    customers,
+    customerDrafts,
+    inspections,
+    issues,
+    rulesState
+  };
+}
+
+function cloneDefault(value) {
+  return JSON.parse(JSON.stringify(value));
+}
+
+function ensureRequiredRolesAndUsers() {
+  let changed = false;
+  Object.entries(requiredRoleDefaults).forEach(([roleKey, defaults]) => {
+    if (!roles[roleKey]) {
+      roles[roleKey] = cloneDefault(defaults);
+      changed = true;
+      return;
+    }
+    ["roleName", "summary", "pages", "permissions"].forEach((field) => {
+      if (!roles[roleKey][field]) {
+        roles[roleKey][field] = cloneDefault(defaults[field]);
+        changed = true;
+        return;
+      }
+      if (Array.isArray(defaults[field]) && Array.isArray(roles[roleKey][field])) {
+        defaults[field].forEach((item) => {
+          if (!roles[roleKey][field].includes(item)) {
+            roles[roleKey][field].push(item);
+            changed = true;
+          }
+        });
+      }
+    });
+  });
+
+  requiredUserDefaults.forEach((spec) => {
+    if (users.some((user) => user.username === spec.username)) return;
+    const managedUser = {
+      id: nextUserId(),
+      username: spec.username,
+      displayName: spec.displayName,
+      role: spec.role,
+      status: "启用"
+    };
+    setUserPassword(managedUser, getInitialPassword(spec.username));
+    users.push(managedUser);
+    changed = true;
+  });
+
+  users.forEach((user) => {
+    if (user.password && !user.passwordHash) {
+      setUserPassword(user, user.password);
+      changed = true;
+    }
+    if (user.registerSource !== "roster") return;
+    const member = findRosterMemberForUser(user);
+    if (!member) return;
+    const nextRole = mapRosterMemberToRole(member);
+    const nextDepartment = getRosterScopeDepartment(member);
+    const nextPosition = member.position || "";
+    if (user.role !== nextRole) {
+      user.role = nextRole;
+      changed = true;
+    }
+    if ((user.department || "") !== nextDepartment) {
+      user.department = nextDepartment;
+      changed = true;
+    }
+    if ((user.position || "") !== nextPosition) {
+      user.position = nextPosition;
+      changed = true;
+    }
+  });
+  return changed;
+}
+
+function applyPersistedState(state) {
+  if (!state || typeof state !== "object") return;
+  if (Array.isArray(state.users)) users = state.users;
+  if (state.roles && typeof state.roles === "object") roles = state.roles;
+  if (state.dictionaries && typeof state.dictionaries === "object") dictionaries = state.dictionaries;
+  if (Array.isArray(state.customers)) customers = state.customers;
+  if (Array.isArray(state.customerDrafts)) customerDrafts = state.customerDrafts;
+  if (Array.isArray(state.inspections)) inspections = state.inspections;
+  if (Array.isArray(state.issues)) issues = state.issues;
+  if (state.rulesState && typeof state.rulesState === "object") rulesState = state.rulesState;
+}
+
+async function loadState() {
+  let loadedPersistedState = false;
+  try {
+    const raw = await fs.readFile(dataFile, "utf8");
+    applyPersistedState(JSON.parse(raw));
+    loadedPersistedState = true;
+    console.log(`Loaded persisted data from ${dataFile}`);
+  } catch (error) {
+    if (error.code !== "ENOENT") throw error;
+  }
+  const changed = ensureRequiredRolesAndUsers();
+  if (loadedPersistedState && changed) await persistState();
+}
+
+async function loadRoster() {
+  const rosterFile = path.join(rootDir, "data", "roster-members.json");
+  try {
+    const raw = await fs.readFile(rosterFile, "utf8");
+    const data = JSON.parse(raw);
+    let activeDepartment = "";
+    rosterMembers = (Array.isArray(data.members) ? data.members : []).map((member) => {
+      const department = String(member.department || "").trim();
+      if (department) activeDepartment = department;
+      return {
+        ...member,
+        scopeDepartment: inferRosterScopeDepartment(member, activeDepartment)
+      };
+    });
+    console.log(`Loaded roster whitelist: ${rosterMembers.length} members`);
+  } catch (error) {
+    if (error.code !== "ENOENT") throw error;
+    rosterMembers = [];
+    console.warn("Roster whitelist file not found; registration is disabled.");
+  }
+}
+
+async function persistState() {
+  persistQueue = persistQueue.catch(() => {}).then(async () => {
+    await fs.mkdir(path.dirname(dataFile), { recursive: true });
+    const tempFile = `${dataFile}.${process.pid}.tmp`;
+    await fs.writeFile(tempFile, JSON.stringify(getStateSnapshot(), null, 2), "utf8");
+    await fs.rename(tempFile, dataFile);
+  });
+  return persistQueue;
+}
+
+const mimeTypes = {
+  ".html": "text/html; charset=utf-8",
+  ".css": "text/css; charset=utf-8",
+  ".js": "application/javascript; charset=utf-8",
+  ".json": "application/json; charset=utf-8",
+  ".md": "text/markdown; charset=utf-8",
+  ".png": "image/png",
+  ".jpg": "image/jpeg",
+  ".jpeg": "image/jpeg",
+  ".svg": "image/svg+xml; charset=utf-8",
+  ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+};
+
+function sendJson(res, status, body) {
+  const payload = JSON.stringify(body);
+  res.writeHead(status, {
+    "Content-Type": "application/json; charset=utf-8",
+    "Content-Length": Buffer.byteLength(payload),
+    "Cache-Control": "no-store"
+  });
+  res.end(payload);
+}
+
+function sendError(res, status, code, message) {
+  sendJson(res, status, { error: code, message });
+}
+
+async function readBody(req) {
+  let raw = "";
+  for await (const chunk of req) raw += chunk;
+  if (!raw) return {};
+  try {
+    return JSON.parse(raw);
+  } catch {
+    const error = new Error("请求体不是有效的 JSON。");
+    error.status = 400;
+    throw error;
+  }
+}
+
+function publicUser(user) {
+  const role = roles[user.role];
+  return {
+    id: user.id,
+    loginName: user.username,
+    userName: user.displayName,
+    roleKey: user.role,
+    roleName: role.roleName,
+    status: user.status,
+    pages: role.pages,
+    permissions: role.permissions
+  };
+}
+
+function publicManagedUser(user) {
+  return {
+    id: user.id,
+    username: user.username,
+    displayName: user.displayName,
+    phone: user.phone || "",
+    department: user.department || "",
+    position: user.position || "",
+    registerSource: user.registerSource || "",
+    role: user.role,
+    roleName: roles[user.role]?.roleName || user.role,
+    status: user.status || "启用"
+  };
+}
+
+function publicCustomerDraft(draft) {
+  return {
+    id: draft.id,
+    dataTag: draft.dataTag,
+    status: draft.status,
+    submittedBy: draft.submittedBy,
+    submittedRole: draft.submittedRole,
+    submittedAt: draft.submittedAt,
+    reviewedBy: draft.reviewedBy,
+    reviewedAt: draft.reviewedAt,
+    reviewNote: draft.reviewNote,
+    customerId: draft.customerId,
+    customer: draft.customer
+  };
+}
+
+function createSessionToken() {
+  if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
+  return crypto.randomBytes(24).toString("hex");
+}
+
+function getBearerToken(req) {
+  const header = req.headers.authorization || "";
+  const [type, token] = header.split(" ");
+  return type === "Bearer" ? token : "";
+}
+
+function getCurrentUser(req) {
+  const token = getBearerToken(req);
+  const userId = sessions.get(token);
+  const user = users.find((item) => item.id === userId) || null;
+  return user && user.status !== "停用" ? user : null;
+}
+
+function can(user, permission) {
+  if (!user) return false;
+  const role = roles[user.role];
+  if (role.permissions.includes("*")) return true;
+  if (permission.startsWith("page:")) return role.pages.includes(permission.replace("page:", ""));
+  return role.permissions.includes(permission);
+}
+
+function normalizeTier(value) {
+  const tier = String(value || "").trim().toUpperCase();
+  return customerTiers.includes(tier) ? tier : null;
+}
+
+function normalizeProgressStatus(value) {
+  const status = String(value || "").trim();
+  return dictionaries.progressStatuses.includes(status) ? status : dictionaries.progressStatuses[0] || "正常";
+}
+
+function normalizeName(value) {
+  return String(value || "").trim().replace(/\s+/g, "");
+}
+
+function normalizePhone(value) {
+  return String(value || "").replace(/\D/g, "");
+}
+
+function normalizeDepartment(value) {
+  return normalizeName(value)
+    .replace(/筑境/g, "")
+    .replace(/设计工作室/g, "")
+    .replace(/工作室/g, "")
+    .replace(/中心/g, "")
+    .replace(/部门/g, "部");
+}
+
+function getRosterScopeDepartment(member) {
+  return String(member?.scopeDepartment || member?.department || "").trim();
+}
+
+function inferRosterScopeDepartment(member, fallbackDepartment = "") {
+  const department = String(member?.department || "").trim();
+  if (department) return department;
+  const position = String(member?.position || "").trim();
+  if (/运营副总|总经理助理|AI工程师/.test(position)) return "总经办";
+  if (/监察/.test(position)) return "监察部";
+  if (/客管|客户经理/.test(position)) return String(fallbackDepartment || "客管部").trim();
+  if (/设计经理|设计总监|设计师|深化设计|灯光设计|定制设计/.test(position)) return String(fallbackDepartment || "设计部").trim();
+  if (/交付总监|交付经理|交付助理|产业交付/.test(position)) return String(fallbackDepartment || "交付部").trim();
+  if (/供应链|采购|库管|结算|跟单|主材|辅材|定制/.test(position)) return String(fallbackDepartment || "供应链中心").trim();
+  return String(fallbackDepartment || "").trim();
+}
+
+function getCustomerOwnerName(customer) {
+  return String(customer?.customerOwner || "")
+    .replace(/^客管负责人[-—::]?/, "")
+    .replace(/^客户负责人[-—::]?/, "")
+    .trim();
+}
+
+function findRosterMemberByName(name) {
+  const target = normalizeName(name);
+  if (!target) return null;
+  return rosterMembers.find((member) => normalizeName(member.name) === target) || null;
+}
+
+function findRosterMemberForUser(user) {
+  if (!user) return null;
+  const phone = normalizePhone(user.phone || user.username);
+  if (phone) {
+    const byPhone = rosterMembers.find((member) => normalizePhone(member.phone) === phone);
+    if (byPhone) return byPhone;
+  }
+  return findRosterMemberByName(user.displayName);
+}
+
+function userScopeDepartment(user) {
+  return String(user?.department || getRosterScopeDepartment(findRosterMemberForUser(user))).trim();
+}
+
+function sameDepartmentGroup(left, right) {
+  const a = normalizeDepartment(left);
+  const b = normalizeDepartment(right);
+  if (!a || !b) return false;
+  return a === b || a.includes(b) || b.includes(a);
+}
+
+function personMatchesField(field, personName) {
+  const fieldText = normalizeName(field);
+  const name = normalizeName(personName);
+  if (!fieldText || !name) return false;
+  return fieldText === name || fieldText.includes(name) || name.includes(fieldText);
+}
+
+function mapRosterMemberToRole(member) {
+  const text = `${getRosterScopeDepartment(member)} ${member.position || ""}`;
+  if (/供应链/.test(text)) return "supplyChain";
+  if (/监察|质检/.test(text)) return "auditor";
+  if (/交付总监|交付经理/.test(text)) return "deliveryManager";
+  if (/客管经理|用户服务经理/.test(text)) return "customerManager";
+  if (/客管|客户经理|家访师|用户服务/.test(text)) return "customerOwner";
+  if (/设计总监|设计经理|定制设计主管/.test(text)) return "designManager";
+  if (/设计师|深化设计|灯光设计|定制设计/.test(text)) return "designer";
+  if (/监察|质检/.test(text)) return "auditor";
+  if (/董事长|总经理|副总|总监|管理型|AI工程师/.test(text)) return "executive";
+  if (/交付助理|产业交付|销售/.test(text)) return "delivery";
+  if (/项目|工种|售后|安装|采购|库管|结算|跟单|主材|辅材|主管|技师/.test(text)) return "manager";
+  return "delivery";
+}
+
+function findRosterMember(name, phone) {
+  const targetName = normalizeName(name);
+  const targetPhone = normalizePhone(phone);
+  if (!targetName || !targetPhone) return null;
+  return rosterMembers.find((member) => (
+    normalizeName(member.name) === targetName &&
+    normalizePhone(member.phone) === targetPhone
+  )) || null;
+}
+
+function publicRosterMember(member) {
+  return {
+    name: member.name,
+    department: getRosterScopeDepartment(member),
+    position: member.position || "",
+    role: mapRosterMemberToRole(member),
+    roleName: roles[mapRosterMemberToRole(member)]?.roleName || mapRosterMemberToRole(member)
+  };
+}
+
+function normalizeCustomerKeyPart(value) {
+  return String(value || "").trim().replace(/\s+/g, " ").toLowerCase();
+}
+
+function customerIdentityKey(customer) {
+  return [
+    customer.name,
+    customer.address,
+    customer.tier,
+    customer.manager,
+    customer.startDate,
+    customer.plannedFinishDate
+  ].map(normalizeCustomerKeyPart).join("|");
+}
+
+function findDuplicateCustomer(candidate) {
+  const key = customerIdentityKey(candidate);
+  return customers.find((customer) => customerIdentityKey(customer) === key);
+}
+
+function findDuplicateCustomerDraft(candidate) {
+  const key = customerIdentityKey(candidate);
+  return customerDrafts.find((draft) => {
+    if (!["待审核", "已通过"].includes(draft.status)) return false;
+    return draft.customer && customerIdentityKey(draft.customer) === key;
+  });
+}
+
+function nowText() {
+  const now = new Date();
+  const pad = (value) => String(value).padStart(2, "0");
+  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
+}
+
+function requireAuth(req, res) {
+  const user = getCurrentUser(req);
+  if (!user) {
+    sendError(res, 401, "UNAUTHORIZED", "请先登录后再操作。");
+    return null;
+  }
+  return user;
+}
+
+function requirePermission(req, res, permission) {
+  const user = requireAuth(req, res);
+  if (!user) return null;
+  if (!can(user, permission)) {
+    sendError(res, 403, "NO_PERMISSION", "当前登录人没有后台授权,操作已被服务器拒绝。");
+    return null;
+  }
+  return user;
+}
+
+function nextCustomerId() {
+  const max = customers.reduce((acc, customer) => Math.max(acc, Number(customer.id.slice(1)) || 0), 0);
+  return `C${String(max + 1).padStart(3, "0")}`;
+}
+
+function nextCustomerDraftId() {
+  const max = customerDrafts.reduce((acc, draft) => Math.max(acc, Number(draft.id.replace("CR-", "")) || 240700), 240700);
+  return `CR-${max + 1}`;
+}
+
+function nextUserId() {
+  const max = users.reduce((acc, user) => Math.max(acc, Number(user.id.slice(1)) || 0), 0);
+  return `U${String(max + 1).padStart(3, "0")}`;
+}
+
+function normalizeStringList(value) {
+  const list = Array.isArray(value) ? value : String(value || "").split(/[,,\n]/);
+  return [...new Set(list.map((item) => String(item || "").trim()).filter(Boolean))];
+}
+
+function normalizeRolePages(pages) {
+  const allowed = new Set(pageCatalog.map((page) => page.key));
+  return normalizeStringList(pages).filter((page) => allowed.has(page));
+}
+
+function normalizeRolePermissions(permissions) {
+  const allowed = new Set(permissionCatalog.map((permission) => permission.key));
+  const list = normalizeStringList(permissions);
+  if (list.includes("*")) return ["*"];
+  return list.filter((permission) => allowed.has(permission));
+}
+
+function nextIssueId() {
+  const max = issues.reduce((acc, issue) => Math.max(acc, Number(issue.id.replace("I-", "")) || 240700), 240700);
+  return `I-${max + 1}`;
+}
+
+function nextInspectionId() {
+  const max = inspections.reduce((acc, item) => Math.max(acc, Number(item.id.replace("R-", "")) || 240700), 240700);
+  return `R-${max + 1}`;
+}
+
+function buildCustomerCandidate(body) {
+  const tier = normalizeTier(body.tier);
+  if (!tier) {
+    const error = new Error("本系统仅保留 A / A+ 客户,请选择 A 或 A+。");
+    error.status = 400;
+    error.code = "INVALID_TIER";
+    throw error;
+  }
+  const candidate = {
+    name: String(body.name || "").trim(),
+    tier,
+    source: String(body.source || "待确认").trim(),
+    keyCustomerReason: String(body.keyCustomerReason || "").trim(),
+    customerOwner: String(body.customerOwner || "").trim(),
+    address: String(body.address || "").trim(),
+    department: String(body.department || "待确认").trim(),
+    designer: String(body.designer || "").trim(),
+    manager: String(body.manager || "").trim(),
+    stage: String(body.stage || "待确认").trim(),
+    startDate: String(body.startDate || "").trim() || "-",
+    plannedFinishDate: String(body.plannedFinishDate || "").trim() || "-",
+    progressStatus: normalizeProgressStatus(body.progressStatus),
+    satisfaction: Number(body.satisfaction || 5)
+  };
+  if (!candidate.name || !candidate.address || !candidate.designer || !candidate.manager || !candidate.customerOwner || !candidate.keyCustomerReason) {
+    const error = new Error("请补全客户姓名、地址、设计师、项目经理、客管人员负责人和重点客户入选说明。");
+    error.status = 400;
+    error.code = "INVALID_CUSTOMER";
+    throw error;
+  }
+  return candidate;
+}
+
+function promoteCustomer(candidate, reviewer) {
+  return {
+    id: nextCustomerId(),
+    ...candidate,
+    approvedBy: reviewer.displayName,
+    approvedAt: nowText(),
+    inspectionStatus: "未巡查",
+    inspectionAt: "-",
+    inspector: "-",
+    inspectionSummary: "新建客户,等待首次每日线上巡查。"
+  };
+}
+
+function scoreByStatus(status) {
+  if (status === "红色预警") return 90;
+  if (status === "橙色预警") return 78;
+  return 62;
+}
+
+function findIssue(res, issueId) {
+  const issue = issues.find((item) => item.id === issueId);
+  if (!issue) sendError(res, 404, "NOT_FOUND", "未找到对应的问题反馈。");
+  return issue;
+}
+
+function hasFullCustomerAccess(user) {
+  return ["admin", "executive", "auditor", "supplyChain", "deliveryManager", "delivery"].includes(user?.role);
+}
+
+function canAccessCustomer(user, customer) {
+  if (!user || !customer) return false;
+  if (hasFullCustomerAccess(user)) return true;
+
+  if (user.role === "designer") {
+    return personMatchesField(customer.designer, user.displayName);
+  }
+
+  if (user.role === "designManager") {
+    const department = userScopeDepartment(user);
+    const designerMember = findRosterMemberByName(customer.designer);
+    const customerDesignerDepartment = getRosterScopeDepartment(designerMember) || customer.department;
+    return sameDepartmentGroup(department, customerDesignerDepartment);
+  }
+
+  if (user.role === "manager") {
+    return personMatchesField(customer.manager, user.displayName);
+  }
+
+  if (user.role === "customerOwner") {
+    return personMatchesField(customer.customerOwner, user.displayName);
+  }
+
+  if (user.role === "customerManager") {
+    const department = userScopeDepartment(user);
+    const ownerMember = findRosterMemberByName(getCustomerOwnerName(customer));
+    return sameDepartmentGroup(department, getRosterScopeDepartment(ownerMember));
+  }
+
+  return false;
+}
+
+function visibleCustomersFor(user) {
+  return customers.filter((customer) => canAccessCustomer(user, customer));
+}
+
+function visibleCustomerIdSet(user) {
+  return new Set(visibleCustomersFor(user).map((customer) => customer.id));
+}
+
+function canAccessIssue(user, issue) {
+  const customer = customers.find((item) => item.id === issue?.customerId);
+  return canAccessCustomer(user, customer);
+}
+
+function visibleIssuesFor(user) {
+  return issues.filter((issue) => canAccessIssue(user, issue));
+}
+
+function visibleInspectionsFor(user) {
+  const customerIds = visibleCustomerIdSet(user);
+  return inspections.filter((inspection) => customerIds.has(inspection.customerId));
+}
+
+function requireCustomerAccess(user, res, customer) {
+  if (canAccessCustomer(user, customer)) return true;
+  sendError(res, 403, "CUSTOMER_SCOPE_DENIED", "当前登录人无权查看或操作该客户。");
+  return false;
+}
+
+function requireIssueAccess(user, res, issue) {
+  if (canAccessIssue(user, issue)) return true;
+  sendError(res, 403, "CUSTOMER_SCOPE_DENIED", "当前登录人无权查看或操作该客户的问题反馈。");
+  return false;
+}
+
+async function handleApi(req, res, pathname) {
+  if (req.method === "GET" && pathname === "/api/auth/options") {
+    sendJson(res, 200, {
+      accounts: users.filter((user) => user.status !== "停用").map((user) => ({
+        displayName: user.displayName,
+        roleName: roles[user.role].roleName,
+        summary: roles[user.role].summary
+      }))
+    });
+    return;
+  }
+
+  if (req.method === "POST" && pathname === "/api/auth/login") {
+    const body = await readBody(req);
+    const user = users.find((item) => item.username === body.username && item.status !== "停用" && verifyUserPassword(item, body.password));
+    if (!user) {
+      sendError(res, 401, "LOGIN_FAILED", "账号或密码错误。");
+      return;
+    }
+    const token = createSessionToken();
+    sessions.set(token, user.id);
+    sendJson(res, 200, { token, user: publicUser(user) });
+    return;
+  }
+
+  if (req.method === "POST" && pathname === "/api/auth/register") {
+    const body = await readBody(req);
+    const name = normalizeName(body.name);
+    const phone = normalizePhone(body.phone);
+    const password = String(body.password || "").trim();
+    const confirmPassword = body.confirmPassword == null ? password : String(body.confirmPassword || "").trim();
+    if (!name || !phone || !password) {
+      sendError(res, 400, "INVALID_REGISTER_INFO", "请填写姓名、手机号和登录密码。");
+      return;
+    }
+    if (password.length < 6) {
+      sendError(res, 400, "INVALID_PASSWORD", "密码至少需要 6 位。");
+      return;
+    }
+    if (password !== confirmPassword) {
+      sendError(res, 400, "PASSWORD_NOT_MATCH", "两次输入的密码不一致。");
+      return;
+    }
+    const member = findRosterMember(name, phone);
+    if (!member) {
+      sendError(res, 403, "ROSTER_NOT_MATCHED", "姓名和手机号未在名册中匹配,暂不能注册。");
+      return;
+    }
+
+    const role = mapRosterMemberToRole(member);
+    let user = users.find((item) => normalizePhone(item.phone || item.username) === phone);
+    if (user && normalizeName(user.displayName) !== normalizeName(member.name)) {
+      sendError(res, 409, "PHONE_ALREADY_REGISTERED", "该手机号已被其他账号注册,请联系系统管理员。");
+      return;
+    }
+    if (user) {
+      if (user.status === "停用") {
+        sendError(res, 403, "USER_DISABLED", "该账号已停用,请联系系统管理员。");
+        return;
+      }
+      sendError(res, 409, "USER_ALREADY_REGISTERED", "该手机号已完成注册,请直接使用手机号和已设置密码登录,或联系系统管理员重置密码。");
+      return;
+    }
+
+    user = {
+      id: nextUserId(),
+      username: phone,
+      displayName: member.name,
+      phone,
+      department: getRosterScopeDepartment(member),
+      position: member.position || "",
+      role,
+      status: "启用",
+      registerSource: "roster"
+    };
+    setUserPassword(user, password);
+    users.push(user);
+
+    await persistState();
+    const token = createSessionToken();
+    sessions.set(token, user.id);
+    sendJson(res, 201, {
+      token,
+      user: publicUser(user),
+      roster: publicRosterMember(member),
+      message: `注册成功,已自动匹配为${roles[role]?.roleName || role}。登录账号为手机号,请使用刚设置的密码登录。`
+    });
+    return;
+  }
+
+  if (req.method === "GET" && pathname === "/api/auth/me") {
+    const user = requireAuth(req, res);
+    if (!user) return;
+    sendJson(res, 200, { user: publicUser(user) });
+    return;
+  }
+
+  if (req.method === "POST" && pathname === "/api/auth/logout") {
+    const token = getBearerToken(req);
+    if (token) sessions.delete(token);
+    sendJson(res, 200, { message: "已退出登录。" });
+    return;
+  }
+
+  if (req.method === "GET" && pathname === "/api/meta") {
+    const user = requireAuth(req, res);
+    if (!user) return;
+    sendJson(res, 200, { dictionaries });
+    return;
+  }
+
+  if (req.method === "GET" && pathname === "/api/admin/config") {
+    const user = requirePermission(req, res, "admin:manage");
+    if (!user) return;
+    sendJson(res, 200, {
+      users: users.map(publicManagedUser),
+      roles,
+      pageCatalog,
+      permissionCatalog,
+      customerDrafts: customerDrafts.map(publicCustomerDraft),
+      dictionaries,
+      rules: rulesState
+    });
+    return;
+  }
+
+  if (req.method === "POST" && pathname === "/api/admin/users") {
+    const user = requirePermission(req, res, "admin:manage");
+    if (!user) return;
+    const body = await readBody(req);
+    const username = String(body.username || "").trim();
+    const password = String(body.password || "").trim();
+    const displayName = String(body.displayName || "").trim();
+    const role = String(body.role || "").trim();
+    if (!username || !password || !displayName || !roles[role]) {
+      sendError(res, 400, "INVALID_USER", "请补全账号、密码、姓名,并选择有效角色。");
+      return;
+    }
+    if (users.some((item) => item.username === username)) {
+      sendError(res, 400, "DUPLICATE_USER", "该登录账号已存在。");
+      return;
+    }
+    const managedUser = {
+      id: nextUserId(),
+      username,
+      displayName,
+      phone: normalizePhone(body.phone),
+      department: String(body.department || "").trim(),
+      position: String(body.position || "").trim(),
+      role,
+      status: body.status === "停用" ? "停用" : "启用"
+    };
+    setUserPassword(managedUser, password);
+    users.push(managedUser);
+    await persistState();
+    sendJson(res, 201, { user: publicManagedUser(managedUser), message: "后台账号已创建。" });
+    return;
+  }
+
+  const adminUserMatch = pathname.match(/^\/api\/admin\/users\/([^/]+)$/);
+  if (req.method === "PATCH" && adminUserMatch) {
+    const operator = requirePermission(req, res, "admin:manage");
+    if (!operator) return;
+    const target = users.find((item) => item.id === decodeURIComponent(adminUserMatch[1]));
+    if (!target) {
+      sendError(res, 404, "NOT_FOUND", "未找到对应账号。");
+      return;
+    }
+    const body = await readBody(req);
+    if (body.username !== undefined) {
+      const username = String(body.username || "").trim();
+      if (!username) {
+        sendError(res, 400, "INVALID_USER", "登录账号不能为空。");
+        return;
+      }
+      if (users.some((item) => item.id !== target.id && item.username === username)) {
+        sendError(res, 400, "DUPLICATE_USER", "该登录账号已存在。");
+        return;
+      }
+      target.username = username;
+    }
+    if (body.password !== undefined) {
+      const nextPassword = String(body.password || "").trim();
+      if (nextPassword) setUserPassword(target, nextPassword);
+    }
+    if (body.displayName !== undefined) target.displayName = String(body.displayName || "").trim() || target.displayName;
+    if (body.phone !== undefined) target.phone = normalizePhone(body.phone);
+    if (body.department !== undefined) target.department = String(body.department || "").trim();
+    if (body.position !== undefined) target.position = String(body.position || "").trim();
+    if (body.role !== undefined) {
+      const role = String(body.role || "").trim();
+      if (!roles[role]) {
+        sendError(res, 400, "INVALID_ROLE", "请选择有效角色。");
+        return;
+      }
+      target.role = role;
+    }
+    if (body.status !== undefined) {
+      const nextStatus = body.status === "停用" ? "停用" : "启用";
+      const activeAdmins = users.filter((item) => item.status !== "停用" && item.role === "admin");
+      if (target.role === "admin" && nextStatus === "停用" && activeAdmins.length <= 1 && activeAdmins[0]?.id === target.id) {
+        sendError(res, 400, "LAST_ADMIN", "至少保留一个启用的系统管理员。");
+        return;
+      }
+      target.status = nextStatus;
+      if (nextStatus === "停用") {
+        [...sessions.entries()].forEach(([token, userId]) => {
+          if (userId === target.id) sessions.delete(token);
+        });
+      }
+    }
+    await persistState();
+    sendJson(res, 200, { user: publicManagedUser(target), message: "账号信息已由后台更新。" });
+    return;
+  }
+
+  const adminRoleMatch = pathname.match(/^\/api\/admin\/roles\/([^/]+)$/);
+  if (req.method === "PATCH" && adminRoleMatch) {
+    const user = requirePermission(req, res, "admin:manage");
+    if (!user) return;
+    const roleKey = decodeURIComponent(adminRoleMatch[1]);
+    const role = roles[roleKey];
+    if (!role) {
+      sendError(res, 404, "NOT_FOUND", "未找到对应角色。");
+      return;
+    }
+    const body = await readBody(req);
+    if (body.roleName !== undefined) role.roleName = String(body.roleName || "").trim() || role.roleName;
+    if (body.summary !== undefined) role.summary = String(body.summary || "").trim() || role.summary;
+    if (roleKey === "admin") {
+      role.pages = pageCatalog.map((page) => page.key);
+      role.permissions = ["*", "admin:manage"];
+    } else {
+      if (body.pages !== undefined) role.pages = normalizeRolePages(body.pages);
+      if (body.permissions !== undefined) role.permissions = normalizeRolePermissions(body.permissions);
+    }
+    await persistState();
+    sendJson(res, 200, { roleKey, role, message: "角色权限已由后台更新。" });
+    return;
+  }
+
+  if (req.method === "PATCH" && pathname === "/api/admin/dictionaries") {
+    const user = requirePermission(req, res, "admin:manage");
+    if (!user) return;
+    const body = await readBody(req);
+    Object.keys(dictionaries).forEach((key) => {
+      if (body[key] !== undefined) {
+        const values = normalizeStringList(body[key]);
+        if (values.length) dictionaries[key] = values;
+      }
+    });
+    await persistState();
+    sendJson(res, 200, { dictionaries, message: "基础字典已由后台更新。" });
+    return;
+  }
+
+  const customerDraftMatch = pathname.match(/^\/api\/admin\/customer-drafts\/([^/]+)$/);
+  if (req.method === "PATCH" && customerDraftMatch) {
+    const user = requirePermission(req, res, "customer:approve");
+    if (!user) return;
+    const draft = customerDrafts.find((item) => item.id === decodeURIComponent(customerDraftMatch[1]));
+    if (!draft) {
+      sendError(res, 404, "NOT_FOUND", "未找到对应的客户入池审核单。");
+      return;
+    }
+    if (draft.status !== "待审核") {
+      sendError(res, 400, "ALREADY_REVIEWED", "该客户入池申请已经审核完成。");
+      return;
+    }
+    const body = await readBody(req);
+    const action = String(body.action || "").trim();
+    draft.reviewedBy = user.displayName;
+    draft.reviewedAt = nowText();
+    draft.reviewNote = String(body.reviewNote || "").trim();
+    if (action === "approve") {
+      const duplicateCustomer = findDuplicateCustomer(draft.customer);
+      if (duplicateCustomer) {
+        draft.status = "已通过";
+        draft.customerId = duplicateCustomer.id;
+        await persistState();
+        sendJson(res, 200, { customer: duplicateCustomer, draft: publicCustomerDraft(draft), message: "已存在相同客户档案,本次未重复入库。" });
+        return;
+      }
+      const customer = promoteCustomer(draft.customer, user);
+      customers.unshift(customer);
+      draft.status = "已通过";
+      draft.customerId = customer.id;
+      await persistState();
+      sendJson(res, 200, { customer, draft: publicCustomerDraft(draft), message: "客户入池申请已通过,客户已进入 A/A+ 客户库。" });
+      return;
+    }
+    if (action === "reject") {
+      draft.status = "已驳回";
+      await persistState();
+      sendJson(res, 200, { draft: publicCustomerDraft(draft), message: "客户入池申请已驳回,未进入客户库。" });
+      return;
+    }
+    sendError(res, 400, "INVALID_ACTION", "请选择通过或驳回该客户入池申请。");
+    return;
+  }
+
+  if (req.method === "GET" && pathname === "/api/customers") {
+    const user = requireAuth(req, res);
+    if (!user) return;
+    sendJson(res, 200, { customers: visibleCustomersFor(user) });
+    return;
+  }
+
+  if (req.method === "POST" && pathname === "/api/customers") {
+    const user = requirePermission(req, res, "customer:submit");
+    if (!user) return;
+    const body = await readBody(req);
+    let candidate;
+    try {
+      candidate = buildCustomerCandidate(body);
+    } catch (error) {
+      sendError(res, error.status || 400, error.code || "INVALID_CUSTOMER", error.message);
+      return;
+    }
+    const duplicateCustomer = findDuplicateCustomer(candidate);
+    if (duplicateCustomer) {
+      const payload = { message: "已存在相同客户档案,本次未重复入库。" };
+      if (canAccessCustomer(user, duplicateCustomer)) payload.customer = duplicateCustomer;
+      sendJson(res, 200, payload);
+      return;
+    }
+    const duplicateDraft = findDuplicateCustomerDraft(candidate);
+    if (duplicateDraft) {
+      sendJson(res, 200, { draft: publicCustomerDraft(duplicateDraft), message: "已存在相同客户入池申请,本次未重复提交。" });
+      return;
+    }
+    if (user.role === "admin") {
+      const customer = promoteCustomer(candidate, user);
+      customers.unshift(customer);
+      await persistState();
+      sendJson(res, 201, { customer, message: "管理员已直接保存客户档案,客户已进入 A/A+ 客户库。" });
+      return;
+    }
+    const draft = {
+      id: nextCustomerDraftId(),
+      status: "待审核",
+      submittedBy: user.displayName,
+      submittedRole: roles[user.role]?.roleName || user.role,
+      submittedAt: nowText(),
+      reviewedBy: "",
+      reviewedAt: "",
+      reviewNote: "",
+      customerId: "",
+      customer: candidate
+    };
+    customerDrafts.unshift(draft);
+    await persistState();
+    sendJson(res, 202, { draft: publicCustomerDraft(draft), message: "客户入池申请已提交,等待管理员审核后进入客户库。" });
+    return;
+  }
+
+  if (req.method === "GET" && pathname === "/api/inspections") {
+    const user = requirePermission(req, res, "inspection:view");
+    if (!user) return;
+    sendJson(res, 200, { inspections: visibleInspectionsFor(user) });
+    return;
+  }
+
+  if (req.method === "POST" && pathname === "/api/inspections") {
+    const user = requirePermission(req, res, "inspection:create");
+    if (!user) return;
+    const body = await readBody(req);
+    const customer = customers.find((item) => item.id === body.customerId);
+    if (!customer) {
+      sendError(res, 400, "INVALID_CUSTOMER", "请选择有效的巡查客户。");
+      return;
+    }
+    if (!requireCustomerAccess(user, res, customer)) return;
+    const status = ["已巡查", "未巡查", "异常"].includes(body.status) ? body.status : "已巡查";
+    const inspection = {
+      id: nextInspectionId(),
+      customerId: customer.id,
+      customerName: customer.name,
+      inspector: String(body.inspector || user.displayName).trim(),
+      status,
+      createdAt: nowText(),
+      siteQuality: String(body.siteQuality || "未填写").trim(),
+      civility: String(body.civility || "未填写").trim(),
+      groupFeedback: String(body.groupFeedback || "未填写").trim(),
+      summary: String(body.summary || "每日线上巡查已记录。").trim()
+    };
+    inspections.unshift(inspection);
+    customer.inspectionStatus = inspection.status;
+    customer.inspectionAt = inspection.createdAt;
+    customer.inspector = inspection.inspector;
+    customer.inspectionSummary = inspection.summary;
+    await persistState();
+    sendJson(res, 201, { inspection, message: "每日线上巡查已提交。" });
+    return;
+  }
+
+  if (req.method === "GET" && pathname === "/api/issues") {
+    const user = requirePermission(req, res, "issue:view");
+    if (!user) return;
+    sendJson(res, 200, { issues: visibleIssuesFor(user) });
+    return;
+  }
+
+  if (req.method === "POST" && pathname === "/api/issues") {
+    const user = requirePermission(req, res, "issue:create");
+    if (!user) return;
+    const body = await readBody(req);
+    const customer = customers.find((item) => item.id === body.customerId);
+    if (!customer) {
+      sendError(res, 400, "INVALID_CUSTOMER", "请选择有效的关联客户。");
+      return;
+    }
+    if (!requireCustomerAccess(user, res, customer)) return;
+    const status = body.status || "蓝色关注";
+    const issue = {
+      id: nextIssueId(),
+      customerId: customer.id,
+      type: body.type || "客户反馈",
+      source: body.source || dictionaries.issueSources[0] || "线下巡检",
+      status,
+      owner: String(body.owner || "").trim() || customer.manager,
+      score: scoreByStatus(status),
+      summary: String(body.summary || "客户反馈已记录").trim(),
+      rootCause: String(body.rootCause || "待责任人补充根因分析").trim(),
+      action: "新反馈已进入预警池,等待责任人确认处置动作",
+      handlerNote: "待提交处置记录",
+      postSatisfaction: "待处置",
+      submitter: user.displayName,
+      auditDue: body.auditDue || "",
+      handlingRecords: [],
+      timeline: [
+        String(body.summary || "客户反馈已记录").trim(),
+        String(body.rootCause || "待补充根因分析").trim(),
+        "系统已生成预警并同步至问题反馈池"
+      ]
+    };
+    issues.unshift(issue);
+    await persistState();
+    sendJson(res, 201, { issue, message: "问题反馈已由后台提交,并进入预警池。" });
+    return;
+  }
+
+  const issueActionMatch = pathname.match(/^\/api\/issues\/([^/]+)\/([^/]+)$/);
+  if (req.method === "PATCH" && issueActionMatch) {
+    const [, encodedIssueId, action] = issueActionMatch;
+    const issueId = decodeURIComponent(encodedIssueId);
+    const issue = findIssue(res, issueId);
+    if (!issue) return;
+    const body = await readBody(req);
+
+    if (action === "assign") {
+      const user = requirePermission(req, res, "issue:assign");
+      if (!user) return;
+      if (!requireIssueAccess(user, res, issue)) return;
+      issue.owner = String(body.owner || "部门负责人").trim();
+      issue.action = "已由管理层指派责任人牵头处理";
+      issue.timeline.push(`${user.displayName} 指派 ${issue.owner} 负责闭环`);
+      await persistState();
+      sendJson(res, 200, { issue, message: "后台已完成责任人指派。" });
+      return;
+    }
+
+    if (action === "quick-handle") {
+      const user = requirePermission(req, res, "issue:handle");
+      if (!user) return;
+      if (!requireIssueAccess(user, res, issue)) return;
+      issue.status = "处理中";
+      issue.action = "已进入处置流程,等待补充根因与整改记录";
+      issue.handlerNote = `${user.displayName} 已接收并开始处置`;
+      issue.handlingRecords = issue.handlingRecords || [];
+      issue.handlingRecords.push({
+        handler: user.displayName,
+        handledAt: nowText(),
+        note: "接收问题并进入处置流程。",
+        satisfaction: issue.postSatisfaction || "待处置",
+        feedback: "等待补充处理方案。"
+      });
+      issue.timeline.push(`${user.displayName} 接收问题并进入处置流程`);
+      await persistState();
+      sendJson(res, 200, { issue, message: "后台已将问题切换为处理中。" });
+      return;
+    }
+
+    if (action === "handle") {
+      const user = requirePermission(req, res, "issue:handle");
+      if (!user) return;
+      if (!requireIssueAccess(user, res, issue)) return;
+      issue.status = "待核查";
+      issue.action = "处置完成,等待监察核查";
+      issue.handlerNote = String(body.handlerNote || "项目经理已提交处置记录").trim();
+      issue.postSatisfaction = String(body.satisfaction || "待回访").trim();
+      issue.handlingRecords = issue.handlingRecords || [];
+      issue.handlingRecords.push({
+        handler: user.displayName,
+        handledAt: nowText(),
+        note: issue.handlerNote,
+        satisfaction: issue.postSatisfaction,
+        feedback: String(body.customerFeedback || "待客户回访确认").trim()
+      });
+      issue.timeline.push(`${user.displayName} 提交处置记录,处置后满意度:${issue.postSatisfaction}`);
+      await persistState();
+      sendJson(res, 200, { issue, message: "处置记录已提交,后台已转入待核查。" });
+      return;
+    }
+
+    if (action === "audit-pass") {
+      const user = requirePermission(req, res, "audit:review");
+      if (!user) return;
+      if (!requireIssueAccess(user, res, issue)) return;
+      issue.status = "已处理";
+      issue.action = "监察核查通过,问题已处理";
+      issue.handlerNote = "监察部核查通过";
+      issue.timeline.push(`${user.displayName} 核查通过,问题已处理`);
+      await persistState();
+      sendJson(res, 200, { issue, message: "监察核查已通过,问题已处理。" });
+      return;
+    }
+
+    if (action === "audit-reject") {
+      const user = requirePermission(req, res, "audit:review");
+      if (!user) return;
+      if (!requireIssueAccess(user, res, issue)) return;
+      issue.status = "红色预警";
+      issue.action = "监察驳回,需重新提交整改方案";
+      issue.handlerNote = "监察部驳回整改";
+      issue.timeline.push(`${user.displayName} 驳回整改,问题回到红色预警`);
+      await persistState();
+      sendJson(res, 200, { issue, message: "已驳回整改,问题回到红色预警。" });
+      return;
+    }
+  }
+
+  if (req.method === "POST" && pathname === "/api/rules") {
+    const user = requirePermission(req, res, "rules:edit");
+    if (!user) return;
+    rulesState = {
+      updatedAt: new Date().toISOString(),
+      updatedBy: user.displayName
+    };
+    await persistState();
+    sendJson(res, 200, { rules: rulesState, message: "预警规则已由后台保存。" });
+    return;
+  }
+
+  sendError(res, 404, "NOT_FOUND", "未找到对应的后台接口。");
+}
+
+async function serveStatic(req, res, pathname) {
+  if (pathname === "/") {
+    res.writeHead(302, { Location: "/prototype/index.html" });
+    res.end();
+    return;
+  }
+
+  let decodedPath;
+  try {
+    decodedPath = decodeURIComponent(pathname);
+  } catch {
+    res.writeHead(400);
+    res.end("Bad request");
+    return;
+  }
+
+  const filePath = path.resolve(rootDir, `.${decodedPath}`);
+  if (!filePath.startsWith(rootDir)) {
+    res.writeHead(403);
+    res.end("Forbidden");
+    return;
+  }
+
+  try {
+    const stat = await fs.stat(filePath);
+    const finalPath = stat.isDirectory() ? path.join(filePath, "index.html") : filePath;
+    const data = await fs.readFile(finalPath);
+    const type = mimeTypes[path.extname(finalPath).toLowerCase()] || "application/octet-stream";
+    res.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" });
+    res.end(data);
+  } catch {
+    res.writeHead(404);
+    res.end("Not found");
+  }
+}
+
+const server = http.createServer(async (req, res) => {
+  const url = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`);
+  try {
+    if (url.pathname.startsWith("/api/")) {
+      await handleApi(req, res, url.pathname);
+      return;
+    }
+    await serveStatic(req, res, url.pathname);
+  } catch (error) {
+    const status = error.status || 500;
+    sendError(res, status, "SERVER_ERROR", error.message || "服务器处理失败。");
+  }
+});
+
+async function start() {
+  await loadRoster();
+  await loadState();
+  server.listen(port, host, () => {
+    const shownHost = host === "0.0.0.0" ? "127.0.0.1" : host;
+    console.log(`A+在建客户动态管理系统 running at http://${shownHost}:${port}/prototype/index.html`);
+  });
+}
+
+start().catch((error) => {
+  console.error("Failed to start server", error);
+  process.exit(1);
+});

BIN
source_customer_grading.xlsx


BIN
ui-mockups/miniapp-home-ui.png


BIN
ui-mockups/miniapp-issue-warning-ui.png


BIN
ui-mockups/web-dashboard-ui.png


BIN
ui-mockups/web-issue-warning-ui.png