|
@@ -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);
|
|
|
|
|
+});
|