app.js.before-feedback-button-20260709153000 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209
  1. let currentUser = null;
  2. let authToken = window.localStorage.getItem("keyissueToken") || "";
  3. let customers = [];
  4. let issues = [];
  5. let inspections = [];
  6. let adminConfig = null;
  7. let activePage = "dashboard";
  8. let activeStatus = "all";
  9. let selectedIssueId = null;
  10. let selectedCustomerId = null;
  11. let dictionaries = {
  12. departments: ["光宸", "澜光", "蘭堂", "焕境"],
  13. customerSources: ["转介绍", "公司特殊关系", "标杆项目", "风险关注"],
  14. issueSources: ["线下巡检", "客户群反馈", "每日线上巡查", "客户主动反馈", "管理层转办", "监察部发现"],
  15. issueTypes: ["施工质量", "设计变更", "沟通响应", "材料交付", "客户满意度"],
  16. progressStatuses: ["正常", "延期"]
  17. };
  18. const pageTitles = {
  19. dashboard: "A+在建客户动态管理驾驶舱",
  20. customers: "A/A+客户库",
  21. inspections: "每日线上巡查",
  22. issues: "问题反馈池",
  23. audit: "监察核查",
  24. analysis: "问题分析",
  25. rules: "预警规则",
  26. admin: "系统后台"
  27. };
  28. const pageTitle = document.querySelector("#pageTitle");
  29. const searchInput = document.querySelector("#searchInput");
  30. const tierFilter = document.querySelector("#tierFilter");
  31. const departmentFilter = document.querySelector("#departmentFilter");
  32. const statusButtons = document.querySelectorAll("[data-status]");
  33. const drawer = document.querySelector("#detailDrawer");
  34. const customerDrawer = document.querySelector("#customerDrawer");
  35. const drawerBackdrop = document.querySelector("#drawerBackdrop");
  36. const modalBackdrop = document.querySelector("#modalBackdrop");
  37. const toast = document.querySelector("#toast");
  38. const loginScreen = document.querySelector("#loginScreen");
  39. const appShell = document.querySelector("#appShell");
  40. const currentUserName = document.querySelector("#currentUserName");
  41. const currentRoleName = document.querySelector("#currentRoleName");
  42. const logoutBtn = document.querySelector("#logoutBtn");
  43. const loginForm = document.querySelector("#loginForm");
  44. const loginUsername = document.querySelector("#loginUsername");
  45. const loginPassword = document.querySelector("#loginPassword");
  46. const loginMessage = document.querySelector("#loginMessage");
  47. const registerForm = document.querySelector("#registerForm");
  48. const registerName = document.querySelector("#registerName");
  49. const registerPhone = document.querySelector("#registerPhone");
  50. const registerPassword = document.querySelector("#registerPassword");
  51. const registerPasswordConfirm = document.querySelector("#registerPasswordConfirm");
  52. const registerMessage = document.querySelector("#registerMessage");
  53. const issueCustomerSearch = document.querySelector("#issueCustomerSearch");
  54. function escapeHtml(value) {
  55. return String(value ?? "").replace(/[&<>"']/g, (char) => ({
  56. "&": "&amp;",
  57. "<": "&lt;",
  58. ">": "&gt;",
  59. '"': "&quot;",
  60. "'": "&#039;"
  61. })[char]);
  62. }
  63. function showToast(message) {
  64. toast.textContent = message;
  65. toast.classList.add("show");
  66. window.clearTimeout(showToast.timer);
  67. showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
  68. }
  69. async function apiRequest(path, options = {}) {
  70. const headers = {
  71. Accept: "application/json",
  72. ...(options.headers || {})
  73. };
  74. const request = { ...options, headers };
  75. if (authToken) headers.Authorization = `Bearer ${authToken}`;
  76. if (options.body && typeof options.body !== "string") {
  77. headers["Content-Type"] = "application/json";
  78. request.body = JSON.stringify(options.body);
  79. }
  80. const response = await fetch(path, request);
  81. const contentType = response.headers.get("content-type") || "";
  82. const payload = contentType.includes("application/json") ? await response.json() : await response.text();
  83. if (!response.ok) {
  84. const error = new Error(payload.message || `请求失败:${response.status}`);
  85. error.status = response.status;
  86. error.payload = payload;
  87. throw error;
  88. }
  89. return payload;
  90. }
  91. function hasPermission(permission) {
  92. if (!currentUser) return false;
  93. if (currentUser.permissions.includes("*")) return true;
  94. if (permission.startsWith("page:")) {
  95. return currentUser.pages.includes(permission.replace("page:", ""));
  96. }
  97. return currentUser.permissions.includes(permission);
  98. }
  99. function requirePermission(permission, actionName = "执行该操作") {
  100. if (hasPermission(permission)) return true;
  101. showToast(`当前登录人无权${actionName}`);
  102. return false;
  103. }
  104. function applyPermissions() {
  105. document.querySelectorAll("[data-page]").forEach((button) => {
  106. const allowed = hasPermission(`page:${button.dataset.page}`);
  107. button.disabled = !allowed;
  108. button.title = allowed ? "" : "后台未授权访问该页面";
  109. });
  110. document.querySelectorAll("[data-requires]").forEach((element) => {
  111. const allowed = hasPermission(element.dataset.requires);
  112. element.disabled = !allowed;
  113. element.classList.toggle("is-disabled", !allowed);
  114. element.title = allowed ? "" : "后台未授权执行该操作";
  115. });
  116. document.querySelectorAll(".permission-list div").forEach((item) => {
  117. const label = item.querySelector("strong")?.textContent || "";
  118. item.classList.toggle("is-current", currentUser && label === currentUser.roleName);
  119. });
  120. }
  121. function handleApiError(error) {
  122. if (error.status === 401) {
  123. clearSession();
  124. showToast("登录已失效,请重新登录。");
  125. return;
  126. }
  127. showToast(error.message || "操作失败,请稍后再试。");
  128. }
  129. function beginFormSubmit(form) {
  130. if (form.dataset.submitting === "true") return false;
  131. form.dataset.submitting = "true";
  132. form.querySelectorAll('button[type="submit"]').forEach((button) => {
  133. button.dataset.wasDisabled = button.disabled ? "true" : "false";
  134. button.disabled = true;
  135. });
  136. return true;
  137. }
  138. function endFormSubmit(form) {
  139. delete form.dataset.submitting;
  140. form.querySelectorAll('button[type="submit"]').forEach((button) => {
  141. button.disabled = button.dataset.wasDisabled === "true";
  142. delete button.dataset.wasDisabled;
  143. });
  144. }
  145. function getCustomer(id) {
  146. return customers.find((customer) => customer.id === id) || {
  147. id,
  148. name: "未匹配客户",
  149. tier: "-",
  150. source: "-",
  151. keyCustomerReason: "-",
  152. customerOwner: "-",
  153. address: "-",
  154. department: "-",
  155. designer: "-",
  156. manager: "-",
  157. stage: "-",
  158. startDate: "-",
  159. plannedFinishDate: "-",
  160. progressStatus: "正常",
  161. satisfaction: "-",
  162. inspectionStatus: "-",
  163. inspectionAt: "-",
  164. inspector: "-",
  165. inspectionSummary: "-"
  166. };
  167. }
  168. function statusClass(status) {
  169. if (status === "红色预警") return "risk";
  170. if (status === "橙色预警") return "orange";
  171. if (status === "蓝色关注") return "blue";
  172. if (status === "待核查") return "audit";
  173. if (status === "已处理") return "closed";
  174. return "normal";
  175. }
  176. function inspectionClass(status) {
  177. if (status === "异常") return "risk";
  178. if (status === "未巡查" || status === "-" || !status) return "orange";
  179. return "closed";
  180. }
  181. function inspectionFlag(status) {
  182. return status === "未巡查" || status === "-" || !status ? "未巡查" : "已巡查";
  183. }
  184. function inspectionFlagClass(status) {
  185. return status === "未巡查" || status === "-" || !status ? "orange" : "closed";
  186. }
  187. function progressClass(status) {
  188. return status === "延期" ? "risk" : "closed";
  189. }
  190. function cell(label, content) {
  191. return `<td data-label="${escapeHtml(label)}">${content}</td>`;
  192. }
  193. function dataTagBadge(item) {
  194. return item?.dataTag ? `<span class="data-tag">${escapeHtml(item.dataTag)}</span>` : "";
  195. }
  196. function dictionaryValues(key) {
  197. const values = dictionaries[key];
  198. return Array.isArray(values) && values.length ? values : [];
  199. }
  200. function optionList(values, selected = "") {
  201. return values.map((value) => {
  202. const text = String(value);
  203. const isSelected = selected === text ? " selected" : "";
  204. return `<option value="${escapeHtml(text)}"${isSelected}>${escapeHtml(text)}</option>`;
  205. }).join("");
  206. }
  207. function setSelectOptions(select, values, selectedValue) {
  208. if (!select) return;
  209. const currentValue = selectedValue ?? select.value;
  210. const safeValues = values.length ? values : [currentValue].filter(Boolean);
  211. select.innerHTML = optionList(safeValues, currentValue);
  212. if (safeValues.includes(currentValue)) select.value = currentValue;
  213. }
  214. function renderDictionaryOptions() {
  215. const departments = dictionaryValues("departments");
  216. const customerSources = dictionaryValues("customerSources");
  217. const issueSources = dictionaryValues("issueSources");
  218. const issueTypes = dictionaryValues("issueTypes");
  219. const progressStatuses = dictionaryValues("progressStatuses");
  220. const activeDepartment = departmentFilter.value || "all";
  221. departmentFilter.innerHTML = `<option value="all">全部部门</option>${optionList(departments, activeDepartment)}`;
  222. departmentFilter.value = departments.includes(activeDepartment) ? activeDepartment : "all";
  223. setSelectOptions(document.querySelector('#customerForm select[name="source"]'), customerSources);
  224. setSelectOptions(document.querySelector('#customerForm select[name="department"]'), departments);
  225. setSelectOptions(document.querySelector('#customerForm select[name="progressStatus"]'), progressStatuses);
  226. setSelectOptions(document.querySelector('#issueForm select[name="source"]'), issueSources);
  227. setSelectOptions(document.querySelector('#issueForm select[name="type"]'), issueTypes);
  228. }
  229. function customerCell(customer) {
  230. return `
  231. <button class="customer-cell-button" type="button" data-view-customer="${escapeHtml(customer.id)}">
  232. <strong>${escapeHtml(customer.name)}${dataTagBadge(customer)}</strong>
  233. <small>${escapeHtml(customer.address)}</small>
  234. <span class="customer-cell-meta">
  235. <span class="badge">${escapeHtml(customer.tier)}</span>
  236. <span class="status-badge ${progressClass(customer.progressStatus)}">${escapeHtml(customer.progressStatus || "正常")}</span>
  237. <span class="status-badge ${inspectionFlagClass(customer.inspectionStatus)}">${escapeHtml(inspectionFlag(customer.inspectionStatus))}</span>
  238. </span>
  239. </button>
  240. `;
  241. }
  242. function filteredIssues() {
  243. const keyword = searchInput.value.trim().toLowerCase();
  244. const tier = tierFilter.value;
  245. const department = departmentFilter.value;
  246. return issues.filter((issue) => {
  247. const customer = getCustomer(issue.customerId);
  248. const targetText = [
  249. customer.name,
  250. customer.address,
  251. customer.stage,
  252. customer.startDate,
  253. customer.plannedFinishDate,
  254. customer.progressStatus,
  255. issue.type,
  256. issue.source,
  257. issue.rootCause,
  258. issue.owner,
  259. issue.action
  260. ].join(" ").toLowerCase();
  261. const matchesKeyword = !keyword || targetText.includes(keyword);
  262. const matchesTier = tier === "all" || customer.tier === tier;
  263. const matchesDepartment = department === "all" || customer.department === department;
  264. const matchesStatus = activeStatus === "all" || issue.status === activeStatus;
  265. return matchesKeyword && matchesTier && matchesDepartment && matchesStatus;
  266. });
  267. }
  268. function countBy(list, getter) {
  269. return list.reduce((acc, item) => {
  270. const key = getter(item);
  271. acc[key] = (acc[key] || 0) + 1;
  272. return acc;
  273. }, {});
  274. }
  275. function renderKpis() {
  276. const visible = filteredIssues();
  277. const red = visible.filter((issue) => issue.status === "红色预警").length;
  278. const audit = visible.filter((issue) => issue.status === "待核查").length;
  279. const inspectionDone = customers.filter((customer) => customer.inspectionStatus === "已巡查" || customer.inspectionStatus === "异常").length;
  280. const abnormalInspection = customers.filter((customer) => customer.inspectionStatus === "异常").length;
  281. document.querySelector("#kpiInspectionDone").textContent = inspectionDone;
  282. document.querySelector("#kpiAbnormalInspections").textContent = abnormalInspection;
  283. document.querySelector("#kpiRedIssues").textContent = red;
  284. document.querySelector("#kpiAuditIssues").textContent = audit;
  285. document.querySelector("#funnelRed").textContent = `${issues.filter((issue) => issue.status === "红色预警").length} 件`;
  286. document.querySelector("#funnelOrange").textContent = `${issues.filter((issue) => issue.status === "橙色预警").length} 件`;
  287. document.querySelector("#funnelBlue").textContent = `${issues.filter((issue) => issue.status === "蓝色关注").length} 件`;
  288. document.querySelector("#sideAlertCount").textContent = `${issues.filter((issue) => issue.status === "红色预警").length} 件`;
  289. }
  290. function renderBars() {
  291. const typeCounts = countBy(issues, (issue) => issue.type);
  292. const typeMax = Math.max(1, ...Object.values(typeCounts));
  293. document.querySelector("#issueTypeBars").innerHTML = Object.entries(typeCounts).map(([type, count]) => `
  294. <div class="bar-item">
  295. <span>${escapeHtml(type)}</span>
  296. <div><i style="width: ${Math.max(22, Math.round((count / typeMax) * 100))}%;"></i></div>
  297. <strong>${count}</strong>
  298. </div>
  299. `).join("");
  300. const departmentCounts = countBy(issues, (issue) => getCustomer(issue.customerId).department);
  301. document.querySelector("#departmentRanks").innerHTML = Object.entries(departmentCounts)
  302. .sort((a, b) => b[1] - a[1])
  303. .map(([department, count], index) => `<div><strong>${index + 1}. ${escapeHtml(department)}</strong><span>${count} 件</span></div>`)
  304. .join("");
  305. }
  306. function issueRow(issue, compact = false) {
  307. const customer = getCustomer(issue.customerId);
  308. const cells = compact ? `
  309. ${cell("客户", customerCell(customer))}
  310. ${cell("等级", `<span class="badge">${escapeHtml(customer.tier)}</span>`)}
  311. ${cell("部门", escapeHtml(customer.department))}
  312. ${cell("问题来源", escapeHtml(issue.source || "-"))}
  313. ${cell("问题类型", escapeHtml(issue.type))}
  314. ${cell("预警状态", `<span class="status-badge ${statusClass(issue.status)}">${escapeHtml(issue.status)}</span>`)}
  315. ${cell("责任人", escapeHtml(issue.owner))}
  316. ${cell("当前处置", escapeHtml(issue.action))}
  317. ` : `
  318. ${cell("反馈编号", `${escapeHtml(issue.id)}${dataTagBadge(issue)}`)}
  319. ${cell("客户", customerCell(customer))}
  320. ${cell("问题来源", escapeHtml(issue.source || "-"))}
  321. ${cell("问题类型", escapeHtml(issue.type))}
  322. ${cell("问题描述", escapeHtml(issue.summary || "未填写"))}
  323. ${cell("预警状态", `<span class="status-badge ${statusClass(issue.status)}">${escapeHtml(issue.status)}</span>`)}
  324. ${cell("责任人", escapeHtml(issue.owner))}
  325. ${cell("根因", escapeHtml(issue.rootCause))}
  326. ${cell("当前处置", escapeHtml(issue.action))}
  327. ${cell("处置后满意度", `<span class="status-badge ${statusClass(issue.postSatisfaction)}">${escapeHtml(issue.postSatisfaction || "待处置")}</span>`)}
  328. `;
  329. 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>`;
  330. }
  331. function renderIssueTables() {
  332. const visible = filteredIssues();
  333. const highPriority = [...visible].sort((a, b) => b.score - a.score).slice(0, 5);
  334. document.querySelector("#dashboardRows").innerHTML = highPriority.map((issue) => issueRow(issue, true)).join("") ||
  335. `<tr><td colspan="9">暂无符合条件的问题。</td></tr>`;
  336. document.querySelector("#issueRows").innerHTML = visible.map((issue) => issueRow(issue)).join("") ||
  337. `<tr><td colspan="11">暂无符合条件的问题反馈。</td></tr>`;
  338. const auditIssues = issues.filter((issue) => issue.status === "待核查");
  339. document.querySelector("#auditCount").textContent = `${auditIssues.length} 件`;
  340. document.querySelector("#auditRows").innerHTML = auditIssues.map((issue) => {
  341. const customer = getCustomer(issue.customerId);
  342. return `
  343. <tr>
  344. ${cell("客户", customerCell(customer))}
  345. ${cell("问题", escapeHtml(issue.type))}
  346. ${cell("处置说明", escapeHtml(issue.handlerNote))}
  347. ${cell("提交人", escapeHtml(issue.submitter))}
  348. ${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>`)}
  349. </tr>
  350. `;
  351. }).join("") || `<tr><td colspan="5">当前没有待核查问题。</td></tr>`;
  352. }
  353. function renderCustomers() {
  354. document.querySelector("#customerRows").innerHTML = customers.map((customer) => {
  355. const count = issues.filter((issue) => issue.customerId === customer.id).length;
  356. return `
  357. <tr>
  358. ${cell("客户", customerCell(customer))}
  359. ${cell("等级", `<span class="badge">${escapeHtml(customer.tier)}</span>`)}
  360. ${cell("新居地址", escapeHtml(customer.address))}
  361. ${cell("客管负责人", escapeHtml(customer.customerOwner || "-"))}
  362. ${cell("入选说明", escapeHtml(customer.keyCustomerReason || "-"))}
  363. ${cell("部门", escapeHtml(customer.department))}
  364. ${cell("设计师", escapeHtml(customer.designer))}
  365. ${cell("项目经理", escapeHtml(customer.manager))}
  366. ${cell("施工阶段", escapeHtml(customer.stage))}
  367. ${cell("开工日期", escapeHtml(customer.startDate || "-"))}
  368. ${cell("计划完工日期", escapeHtml(customer.plannedFinishDate || "-"))}
  369. ${cell("当前进度", `<span class="status-badge ${progressClass(customer.progressStatus)}">${escapeHtml(customer.progressStatus || "正常")}</span>`)}
  370. ${cell("巡查与否", `<span class="status-badge ${inspectionFlagClass(customer.inspectionStatus)}">${escapeHtml(inspectionFlag(customer.inspectionStatus))}</span>`)}
  371. ${cell("巡查状态", `<span class="status-badge ${inspectionClass(customer.inspectionStatus)}">${escapeHtml(customer.inspectionStatus || "未巡查")}</span>`)}
  372. ${cell("最近巡查", `${escapeHtml(customer.inspectionAt || "-")}<br><small>${escapeHtml(customer.inspectionSummary || "")}</small>`)}
  373. ${cell("问题数", count)}
  374. ${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>`)}
  375. </tr>
  376. `;
  377. }).join("");
  378. renderCustomerSelects();
  379. }
  380. function renderCustomerSelects(filter = "") {
  381. const keyword = filter.trim().toLowerCase();
  382. const visibleCustomers = customers.filter((customer) => {
  383. const text = `${customer.name} ${customer.address} ${customer.manager} ${customer.designer} ${customer.stage} ${customer.startDate} ${customer.plannedFinishDate} ${customer.progressStatus}`.toLowerCase();
  384. return !keyword || text.includes(keyword);
  385. });
  386. const options = visibleCustomers.map((customer) => `<option value="${escapeHtml(customer.id)}">${escapeHtml(customer.name)} · ${escapeHtml(customer.address)}</option>`).join("");
  387. const issueSelect = document.querySelector("#issueCustomerSelect");
  388. const inspectionSelect = document.querySelector("#inspectionCustomerSelect");
  389. if (issueSelect) issueSelect.innerHTML = options;
  390. if (inspectionSelect) inspectionSelect.innerHTML = customers.map((customer) => `<option value="${escapeHtml(customer.id)}">${escapeHtml(customer.name)} · ${escapeHtml(customer.address)}</option>`).join("");
  391. }
  392. function renderInspections() {
  393. const done = customers.filter((customer) => customer.inspectionStatus === "已巡查" || customer.inspectionStatus === "异常").length;
  394. const abnormal = customers.filter((customer) => customer.inspectionStatus === "异常").length;
  395. const pending = customers.filter((customer) => customer.inspectionStatus === "未巡查").length;
  396. document.querySelector("#inspectionSummaryCards").innerHTML = `
  397. <article><span>今日已巡查</span><strong>${done}</strong></article>
  398. <article><span>异常巡查</span><strong>${abnormal}</strong></article>
  399. <article><span>未巡查</span><strong>${pending}</strong></article>
  400. `;
  401. document.querySelector("#inspectionRows").innerHTML = inspections.map((item) => `
  402. <tr>
  403. ${cell("巡查编号", `${escapeHtml(item.id)}${dataTagBadge(item)}`)}
  404. ${cell("客户", customerCell(getCustomer(item.customerId)))}
  405. ${cell("巡查与否", `<span class="status-badge ${inspectionFlagClass(item.status)}">${escapeHtml(inspectionFlag(item.status))}</span>`)}
  406. ${cell("巡查状态", `<span class="status-badge ${inspectionClass(item.status)}">${escapeHtml(item.status)}</span>`)}
  407. ${cell("巡查人", escapeHtml(item.inspector))}
  408. ${cell("巡查时间", escapeHtml(item.createdAt))}
  409. ${cell("施工质量", escapeHtml(item.siteQuality))}
  410. ${cell("文明施工", escapeHtml(item.civility))}
  411. ${cell("客户群反馈", escapeHtml(item.groupFeedback))}
  412. ${cell("巡查结论", escapeHtml(item.summary))}
  413. </tr>
  414. `).join("") || `<tr><td colspan="10">暂无每日线上巡查记录。</td></tr>`;
  415. }
  416. function adminRoleOptions(selectedRole) {
  417. if (!adminConfig) return "";
  418. return Object.entries(adminConfig.roles).map(([roleKey, role]) => {
  419. const selected = roleKey === selectedRole ? " selected" : "";
  420. return `<option value="${escapeHtml(roleKey)}"${selected}>${escapeHtml(role.roleName)}</option>`;
  421. }).join("");
  422. }
  423. function renderAdminRoleCards() {
  424. return Object.entries(adminConfig.roles).map(([roleKey, role]) => {
  425. const isAdmin = roleKey === "admin";
  426. const allPermissions = role.permissions.includes("*");
  427. const pageControls = adminConfig.pageCatalog.map((page) => `
  428. <label>
  429. <input type="checkbox" data-role-page="${escapeHtml(roleKey)}" value="${escapeHtml(page.key)}" ${role.pages.includes(page.key) || isAdmin ? "checked" : ""} ${isAdmin ? "disabled" : ""}>
  430. <span>${escapeHtml(page.name)}</span>
  431. </label>
  432. `).join("");
  433. const permissionControls = adminConfig.permissionCatalog.map((permission) => `
  434. <label>
  435. <input type="checkbox" data-role-permission="${escapeHtml(roleKey)}" value="${escapeHtml(permission.key)}" ${allPermissions || role.permissions.includes(permission.key) ? "checked" : ""} ${isAdmin ? "disabled" : ""}>
  436. <span>${escapeHtml(permission.name)}</span>
  437. </label>
  438. `).join("");
  439. return `
  440. <article class="role-card">
  441. <div class="role-card-head">
  442. <div>
  443. <strong>${escapeHtml(role.roleName)}</strong>
  444. <span>${escapeHtml(role.summary)}</span>
  445. </div>
  446. <button class="small-button" type="button" data-admin-save-role="${escapeHtml(roleKey)}" ${isAdmin ? "disabled" : ""}>保存角色权限</button>
  447. </div>
  448. <div class="role-card-section">
  449. <b>可访问页面</b>
  450. <div class="checkbox-grid">${pageControls}</div>
  451. </div>
  452. <div class="role-card-section">
  453. <b>可执行操作</b>
  454. <div class="checkbox-grid">${permissionControls}</div>
  455. </div>
  456. </article>
  457. `;
  458. }).join("");
  459. }
  460. function renderCustomerReviewRows() {
  461. const drafts = adminConfig.customerDrafts || [];
  462. return drafts.map((draft) => {
  463. const customer = draft.customer || {};
  464. const canReview = draft.status === "待审核";
  465. return `
  466. <tr>
  467. ${cell("审核单", `${escapeHtml(draft.id)}${dataTagBadge(draft)}`)}
  468. ${cell("客户", `<strong>${escapeHtml(customer.name)}</strong><br><small>${escapeHtml(customer.address || "-")}</small>`)}
  469. ${cell("等级", `<span class="badge">${escapeHtml(customer.tier || "-")}</span>`)}
  470. ${cell("提交人", `${escapeHtml(draft.submittedBy || "-")}<br><small>${escapeHtml(draft.submittedRole || "-")}</small>`)}
  471. ${cell("客管负责人", escapeHtml(customer.customerOwner || "-"))}
  472. ${cell("入选说明", escapeHtml(customer.keyCustomerReason || "-"))}
  473. ${cell("提交时间", escapeHtml(draft.submittedAt || "-"))}
  474. ${cell("状态", `<span class="status-badge ${draft.status === "待审核" ? "orange" : draft.status === "已通过" ? "closed" : "risk"}">${escapeHtml(draft.status)}</span>`)}
  475. ${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 || "-"))}
  476. </tr>
  477. `;
  478. }).join("") || `<tr><td colspan="9">暂无客户入池审核单。</td></tr>`;
  479. }
  480. function renderAdmin() {
  481. if (!adminConfig) {
  482. document.querySelector("#adminUserRows").innerHTML = `<tr><td colspan="9">当前登录人没有系统后台管理权限。</td></tr>`;
  483. document.querySelector("#adminRoleCards").innerHTML = `<p class="muted-text">后台配置仅系统管理员可维护。</p>`;
  484. return;
  485. }
  486. const activeUsers = adminConfig.users.filter((user) => user.status !== "停用").length;
  487. const pendingDrafts = (adminConfig.customerDrafts || []).filter((draft) => draft.status === "待审核").length;
  488. document.querySelector("#adminUserCount").textContent = adminConfig.users.length;
  489. document.querySelector("#adminActiveUserCount").textContent = activeUsers;
  490. document.querySelector("#adminRoleCount").textContent = Object.keys(adminConfig.roles).length;
  491. document.querySelector("#adminPermissionCount").textContent = adminConfig.permissionCatalog.length;
  492. document.querySelector("#adminPendingCustomerCount").textContent = pendingDrafts;
  493. document.querySelector("#adminCustomerReviewCount").textContent = `${pendingDrafts} 单待审核`;
  494. document.querySelector("#adminUserRoleSelect").innerHTML = adminRoleOptions("delivery");
  495. document.querySelector("#customerReviewRows").innerHTML = renderCustomerReviewRows();
  496. document.querySelector("#adminUserRows").innerHTML = adminConfig.users.map((user) => `
  497. <tr>
  498. ${cell("账号", `<strong>${escapeHtml(user.username)}</strong>`)}
  499. ${cell("姓名", `<input class="inline-input" data-admin-user-name="${escapeHtml(user.id)}" value="${escapeHtml(user.displayName)}">`)}
  500. ${cell("手机号", `<input class="inline-input" data-admin-user-phone="${escapeHtml(user.id)}" value="${escapeHtml(user.phone || "")}" placeholder="可选">`)}
  501. ${cell("部门", `<input class="inline-input" data-admin-user-department="${escapeHtml(user.id)}" value="${escapeHtml(user.department || "")}" placeholder="用于范围">`)}
  502. ${cell("岗位", `<input class="inline-input" data-admin-user-position="${escapeHtml(user.id)}" value="${escapeHtml(user.position || "")}" placeholder="用于范围">`)}
  503. ${cell("角色", `<select data-admin-user-role="${escapeHtml(user.id)}">${adminRoleOptions(user.role)}</select>`)}
  504. ${cell("状态", `<span class="status-badge ${user.status === "停用" ? "orange" : "closed"}">${escapeHtml(user.status)}</span>`)}
  505. ${cell("重置密码", `<input class="inline-input" type="password" data-admin-user-password="${escapeHtml(user.id)}" value="" placeholder="留空不修改">`)}
  506. ${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>`)}
  507. </tr>
  508. `).join("");
  509. document.querySelector("#adminRoleCards").innerHTML = renderAdminRoleCards();
  510. const dictionaryForm = document.querySelector("#adminDictionaryForm");
  511. Object.entries(adminConfig.dictionaries || {}).forEach(([key, values]) => {
  512. if (dictionaryForm.elements[key]) dictionaryForm.elements[key].value = values.join("\n");
  513. });
  514. }
  515. function renderAll() {
  516. renderKpis();
  517. renderBars();
  518. renderIssueTables();
  519. renderCustomers();
  520. renderInspections();
  521. if (activePage === "admin") renderAdmin();
  522. applyPermissions();
  523. }
  524. async function loadData() {
  525. const issueRequest = hasPermission("issue:view") ? apiRequest("/api/issues") : Promise.resolve({ issues: [] });
  526. const inspectionRequest = hasPermission("inspection:view") ? apiRequest("/api/inspections") : Promise.resolve({ inspections: [] });
  527. const adminRequest = hasPermission("admin:manage") ? apiRequest("/api/admin/config") : Promise.resolve(null);
  528. const [customerPayload, issuePayload, inspectionPayload, metaPayload, adminPayload] = await Promise.all([
  529. apiRequest("/api/customers"),
  530. issueRequest,
  531. inspectionRequest,
  532. apiRequest("/api/meta"),
  533. adminRequest
  534. ]);
  535. customers = customerPayload.customers;
  536. issues = issuePayload.issues;
  537. inspections = inspectionPayload.inspections;
  538. dictionaries = { ...dictionaries, ...(metaPayload.dictionaries || {}) };
  539. adminConfig = adminPayload;
  540. renderDictionaryOptions();
  541. }
  542. function switchPage(page) {
  543. if (!hasPermission(`page:${page}`)) {
  544. showToast("后台未授权访问该页面。");
  545. return;
  546. }
  547. activePage = page;
  548. pageTitle.textContent = pageTitles[page] || pageTitles.dashboard;
  549. document.querySelectorAll(".page-section").forEach((section) => {
  550. section.classList.toggle("active", section.dataset.section === page);
  551. });
  552. document.querySelectorAll("[data-page]").forEach((button) => {
  553. button.classList.toggle("active", button.dataset.page === page);
  554. });
  555. renderAll();
  556. }
  557. function openDrawer(issueId) {
  558. const issue = issues.find((item) => item.id === issueId);
  559. if (!issue) return;
  560. const customer = getCustomer(issue.customerId);
  561. selectedIssueId = issueId;
  562. selectedCustomerId = null;
  563. customerDrawer.classList.remove("open");
  564. customerDrawer.setAttribute("aria-hidden", "true");
  565. document.querySelector("#drawerName").textContent = `${customer.name} · ${issue.type}`;
  566. document.querySelector("#drawerScore").textContent = issue.score;
  567. document.querySelector("#drawerStatus").textContent = issue.status;
  568. document.querySelector("#drawerDepartment").textContent = customer.department;
  569. document.querySelector("#drawerType").textContent = issue.type;
  570. document.querySelector("#drawerOwner").textContent = issue.owner;
  571. document.querySelector("#drawerPostSatisfaction").textContent = issue.postSatisfaction || "待处置";
  572. document.querySelector("#drawerLatestHandle").textContent = issue.handlerNote || "待提交处置记录";
  573. document.querySelector("#drawerSummary").textContent = issue.summary || "未填写问题描述。";
  574. const records = issue.handlingRecords || [];
  575. document.querySelector("#drawerHandlingRecords").innerHTML = records.length ? records.map((record) => `
  576. <article class="record-card">
  577. <div><strong>${escapeHtml(record.handler)}</strong><span>${escapeHtml(record.handledAt)}</span></div>
  578. <p>${escapeHtml(record.note)}</p>
  579. <small>处置后满意度:${escapeHtml(record.satisfaction || "待回访")};客户反馈:${escapeHtml(record.feedback || "待补充")}</small>
  580. </article>
  581. `).join("") : `<p class="muted-text">暂无处置记录,责任人提交后会按时间形成卡片流水。</p>`;
  582. document.querySelector("#drawerTimeline").innerHTML = issue.timeline.map((item) => `<li>${escapeHtml(item)}</li>`).join("");
  583. drawerBackdrop.hidden = false;
  584. drawer.classList.add("open");
  585. drawer.setAttribute("aria-hidden", "false");
  586. applyPermissions();
  587. }
  588. function openCustomerDrawer(customerId) {
  589. const customer = getCustomer(customerId);
  590. selectedCustomerId = customer.id;
  591. selectedIssueId = null;
  592. drawer.classList.remove("open");
  593. drawer.setAttribute("aria-hidden", "true");
  594. const relatedIssues = issues.filter((issue) => issue.customerId === customer.id);
  595. document.querySelector("#customerDrawerName").textContent = customer.name;
  596. document.querySelector("#customerDrawerInspectionFlag").textContent = inspectionFlag(customer.inspectionStatus);
  597. document.querySelector("#customerDrawerTier").textContent = customer.tier;
  598. document.querySelector("#customerDrawerSource").textContent = customer.source || "-";
  599. document.querySelector("#customerDrawerCustomerOwner").textContent = customer.customerOwner || "-";
  600. document.querySelector("#customerDrawerDepartment").textContent = customer.department;
  601. document.querySelector("#customerDrawerDesigner").textContent = customer.designer;
  602. document.querySelector("#customerDrawerManager").textContent = customer.manager;
  603. document.querySelector("#customerDrawerStage").textContent = customer.stage;
  604. document.querySelector("#customerDrawerStartDate").textContent = customer.startDate || "-";
  605. document.querySelector("#customerDrawerPlannedFinishDate").textContent = customer.plannedFinishDate || "-";
  606. document.querySelector("#customerDrawerProgressStatus").innerHTML = `<span class="status-badge ${progressClass(customer.progressStatus)}">${escapeHtml(customer.progressStatus || "正常")}</span>`;
  607. document.querySelector("#customerDrawerSatisfaction").textContent = `${customer.satisfaction || "-"} 分`;
  608. document.querySelector("#customerDrawerInspectionStatus").innerHTML = `<span class="status-badge ${inspectionClass(customer.inspectionStatus)}">${escapeHtml(customer.inspectionStatus || "未巡查")}</span>`;
  609. document.querySelector("#customerDrawerInspector").textContent = customer.inspector || "-";
  610. document.querySelector("#customerDrawerInspectionAt").textContent = customer.inspectionAt || "-";
  611. document.querySelector("#customerDrawerAddress").textContent = customer.address || "-";
  612. document.querySelector("#customerDrawerKeyReason").textContent = customer.keyCustomerReason || "暂无重点客户入选说明。";
  613. document.querySelector("#customerDrawerInspectionSummary").textContent = customer.inspectionSummary || "暂无巡查结论。";
  614. document.querySelector("#customerDrawerIssues").innerHTML = relatedIssues.length ? relatedIssues.map((issue) => `
  615. <article class="record-card">
  616. <div><strong>${escapeHtml(issue.type)}</strong><span class="status-badge ${statusClass(issue.status)}">${escapeHtml(issue.status)}</span></div>
  617. <p>${escapeHtml(issue.summary || "未填写问题描述。")}</p>
  618. <small>责任人:${escapeHtml(issue.owner)};处置后满意度:${escapeHtml(issue.postSatisfaction || "待处置")}</small>
  619. </article>
  620. `).join("") : `<p class="muted-text">暂无关联问题反馈。</p>`;
  621. drawerBackdrop.hidden = false;
  622. customerDrawer.classList.add("open");
  623. customerDrawer.setAttribute("aria-hidden", "false");
  624. applyPermissions();
  625. }
  626. function closeDrawer() {
  627. drawer.classList.remove("open");
  628. drawer.setAttribute("aria-hidden", "true");
  629. customerDrawer.classList.remove("open");
  630. customerDrawer.setAttribute("aria-hidden", "true");
  631. drawerBackdrop.hidden = true;
  632. selectedIssueId = null;
  633. selectedCustomerId = null;
  634. }
  635. function openModal(id, customerId) {
  636. const modal = document.querySelector(`#${id}`);
  637. if (id === "issueModal" && customerId) {
  638. if (issueCustomerSearch) issueCustomerSearch.value = "";
  639. renderCustomerSelects();
  640. document.querySelector("#issueCustomerSelect").value = customerId;
  641. }
  642. if (id === "issueModal" && !customerId) {
  643. if (issueCustomerSearch) issueCustomerSearch.value = "";
  644. renderCustomerSelects();
  645. }
  646. if (id === "inspectionModal" && customerId) {
  647. document.querySelector("#inspectionCustomerSelect").value = customerId;
  648. }
  649. modalBackdrop.hidden = false;
  650. modal.classList.add("open");
  651. modal.setAttribute("aria-hidden", "false");
  652. }
  653. function closeModal() {
  654. document.querySelectorAll(".modal.open").forEach((modal) => {
  655. modal.classList.remove("open");
  656. modal.setAttribute("aria-hidden", "true");
  657. });
  658. modalBackdrop.hidden = true;
  659. }
  660. async function refreshAfterMutation(issueId, message) {
  661. await loadData();
  662. renderAll();
  663. if (issueId && drawer.classList.contains("open")) openDrawer(issueId);
  664. if (message) showToast(message);
  665. }
  666. async function refreshAdminConfig(message) {
  667. adminConfig = await apiRequest("/api/admin/config");
  668. dictionaries = { ...dictionaries, ...(adminConfig.dictionaries || {}) };
  669. renderDictionaryOptions();
  670. renderAdmin();
  671. applyPermissions();
  672. if (message) showToast(message);
  673. }
  674. async function mutateIssue(issueId, action, body = {}) {
  675. try {
  676. const payload = await apiRequest(`/api/issues/${encodeURIComponent(issueId)}/${action}`, {
  677. method: "PATCH",
  678. body
  679. });
  680. await refreshAfterMutation(issueId, payload.message);
  681. } catch (error) {
  682. handleApiError(error);
  683. }
  684. }
  685. async function enterApp(user, token, silent = false) {
  686. currentUser = user;
  687. authToken = token;
  688. window.localStorage.setItem("keyissueToken", token);
  689. loginScreen.hidden = true;
  690. appShell.hidden = false;
  691. currentUserName.textContent = currentUser.userName;
  692. currentRoleName.textContent = currentUser.roleName;
  693. activePage = currentUser.pages[0] || "dashboard";
  694. await loadData();
  695. switchPage(activePage);
  696. if (!silent) showToast(`已通过后台登录:${currentUser.roleName}`);
  697. }
  698. function clearSession() {
  699. currentUser = null;
  700. authToken = "";
  701. adminConfig = null;
  702. customers = [];
  703. issues = [];
  704. inspections = [];
  705. window.localStorage.removeItem("keyissueToken");
  706. appShell.hidden = true;
  707. loginScreen.hidden = false;
  708. currentUserName.textContent = "未登录";
  709. currentRoleName.textContent = "-";
  710. loginMessage.textContent = "";
  711. if (registerMessage) registerMessage.textContent = "";
  712. closeDrawer();
  713. closeModal();
  714. }
  715. async function restoreSession() {
  716. if (!authToken) return;
  717. try {
  718. const payload = await apiRequest("/api/auth/me");
  719. await enterApp(payload.user, authToken, true);
  720. } catch {
  721. clearSession();
  722. }
  723. }
  724. document.querySelectorAll("[data-page]").forEach((button) => {
  725. button.addEventListener("click", () => switchPage(button.dataset.page));
  726. });
  727. document.querySelectorAll("[data-page-jump]").forEach((button) => {
  728. button.addEventListener("click", () => switchPage(button.dataset.pageJump));
  729. });
  730. [searchInput, tierFilter, departmentFilter].forEach((control) => {
  731. control.addEventListener("input", renderAll);
  732. control.addEventListener("change", renderAll);
  733. });
  734. statusButtons.forEach((button) => {
  735. button.addEventListener("click", () => {
  736. activeStatus = button.dataset.status;
  737. statusButtons.forEach((item) => item.classList.toggle("active", item === button));
  738. renderAll();
  739. });
  740. });
  741. document.addEventListener("click", async (event) => {
  742. const saveUserButton = event.target.closest("[data-admin-save-user]");
  743. if (saveUserButton) {
  744. if (!requirePermission("admin:manage", "保存后台账号")) return;
  745. const userId = saveUserButton.dataset.adminSaveUser;
  746. try {
  747. const payload = await apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, {
  748. method: "PATCH",
  749. body: {
  750. displayName: document.querySelector(`[data-admin-user-name="${userId}"]`)?.value,
  751. phone: document.querySelector(`[data-admin-user-phone="${userId}"]`)?.value,
  752. department: document.querySelector(`[data-admin-user-department="${userId}"]`)?.value,
  753. position: document.querySelector(`[data-admin-user-position="${userId}"]`)?.value,
  754. password: document.querySelector(`[data-admin-user-password="${userId}"]`)?.value,
  755. role: document.querySelector(`[data-admin-user-role="${userId}"]`)?.value
  756. }
  757. });
  758. await refreshAdminConfig(payload.message);
  759. } catch (error) {
  760. handleApiError(error);
  761. }
  762. return;
  763. }
  764. const toggleUserButton = event.target.closest("[data-admin-toggle-user]");
  765. if (toggleUserButton) {
  766. if (!requirePermission("admin:manage", "停启用后台账号")) return;
  767. const userId = toggleUserButton.dataset.adminToggleUser;
  768. const user = adminConfig?.users.find((item) => item.id === userId);
  769. if (!user) return;
  770. try {
  771. const payload = await apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, {
  772. method: "PATCH",
  773. body: { status: user.status === "停用" ? "启用" : "停用" }
  774. });
  775. await refreshAdminConfig(payload.message);
  776. } catch (error) {
  777. handleApiError(error);
  778. }
  779. return;
  780. }
  781. const saveRoleButton = event.target.closest("[data-admin-save-role]");
  782. if (saveRoleButton) {
  783. if (!requirePermission("admin:manage", "保存角色权限")) return;
  784. const roleKey = saveRoleButton.dataset.adminSaveRole;
  785. let pages = [...document.querySelectorAll(`[data-role-page="${roleKey}"]`)].filter((input) => input.checked).map((input) => input.value);
  786. const permissions = [...document.querySelectorAll(`[data-role-permission="${roleKey}"]`)].filter((input) => input.checked).map((input) => input.value);
  787. if (!permissions.includes("admin:manage")) pages = pages.filter((page) => page !== "admin");
  788. try {
  789. const payload = await apiRequest(`/api/admin/roles/${encodeURIComponent(roleKey)}`, {
  790. method: "PATCH",
  791. body: { pages, permissions }
  792. });
  793. await refreshAdminConfig(payload.message);
  794. } catch (error) {
  795. handleApiError(error);
  796. }
  797. return;
  798. }
  799. const approveCustomerButton = event.target.closest("[data-admin-customer-approve]");
  800. if (approveCustomerButton) {
  801. if (!requirePermission("customer:approve", "审核客户入池")) return;
  802. try {
  803. const payload = await apiRequest(`/api/admin/customer-drafts/${encodeURIComponent(approveCustomerButton.dataset.adminCustomerApprove)}`, {
  804. method: "PATCH",
  805. body: { action: "approve" }
  806. });
  807. await loadData();
  808. renderAll();
  809. showToast(payload.message);
  810. } catch (error) {
  811. handleApiError(error);
  812. }
  813. return;
  814. }
  815. const rejectCustomerButton = event.target.closest("[data-admin-customer-reject]");
  816. if (rejectCustomerButton) {
  817. if (!requirePermission("customer:approve", "驳回客户入池")) return;
  818. try {
  819. const payload = await apiRequest(`/api/admin/customer-drafts/${encodeURIComponent(rejectCustomerButton.dataset.adminCustomerReject)}`, {
  820. method: "PATCH",
  821. body: { action: "reject", reviewNote: "管理员驳回,需补充入选说明或责任信息。" }
  822. });
  823. await loadData();
  824. renderAll();
  825. showToast(payload.message);
  826. } catch (error) {
  827. handleApiError(error);
  828. }
  829. return;
  830. }
  831. const openButton = event.target.closest("[data-open-modal]");
  832. if (openButton) {
  833. const modalName = openButton.dataset.openModal;
  834. const permissionMap = {
  835. customerModal: ["customer:submit", "提交客户入池申请"],
  836. issueModal: ["issue:create", "新增问题反馈"],
  837. inspectionModal: ["inspection:create", "提交每日巡查"],
  838. handleModal: ["issue:handle", "提交处置"]
  839. };
  840. const [required, actionName] = permissionMap[modalName] || ["issue:view", "打开表单"];
  841. if (!requirePermission(required, actionName)) return;
  842. openModal(modalName, openButton.dataset.customer || openButton.dataset.inspectionCustomer);
  843. return;
  844. }
  845. const customerButton = event.target.closest("[data-view-customer]");
  846. if (customerButton) {
  847. if (!requirePermission("issue:view", "查看客户详情")) return;
  848. openCustomerDrawer(customerButton.dataset.viewCustomer);
  849. return;
  850. }
  851. const viewButton = event.target.closest("[data-view-issue]");
  852. if (viewButton) {
  853. if (!requirePermission("issue:view", "查看问题详情")) return;
  854. openDrawer(viewButton.dataset.viewIssue);
  855. return;
  856. }
  857. const handleButton = event.target.closest("[data-quick-handle]");
  858. if (handleButton) {
  859. if (!requirePermission("issue:handle", "处置问题")) return;
  860. selectedIssueId = handleButton.dataset.quickHandle;
  861. openModal("handleModal");
  862. return;
  863. }
  864. const passButton = event.target.closest("[data-audit-pass]");
  865. if (passButton) {
  866. if (!requirePermission("audit:review", "核查通过")) return;
  867. mutateIssue(passButton.dataset.auditPass, "audit-pass");
  868. return;
  869. }
  870. const rejectButton = event.target.closest("[data-audit-reject]");
  871. if (rejectButton) {
  872. if (!requirePermission("audit:review", "驳回整改")) return;
  873. mutateIssue(rejectButton.dataset.auditReject, "audit-reject");
  874. }
  875. });
  876. document.querySelectorAll("[data-close-modal]").forEach((button) => button.addEventListener("click", closeModal));
  877. modalBackdrop.addEventListener("click", closeModal);
  878. document.querySelector("[data-close-drawer]").addEventListener("click", closeDrawer);
  879. document.querySelector("[data-close-customer-drawer]").addEventListener("click", closeDrawer);
  880. drawerBackdrop.addEventListener("click", closeDrawer);
  881. document.querySelectorAll("[data-customer-drawer-action]").forEach((button) => {
  882. button.addEventListener("click", () => {
  883. if (!selectedCustomerId) return;
  884. if (button.dataset.customerDrawerAction === "inspection") {
  885. if (!requirePermission("inspection:create", "提交每日巡查")) return;
  886. openModal("inspectionModal", selectedCustomerId);
  887. }
  888. if (button.dataset.customerDrawerAction === "feedback") {
  889. if (!requirePermission("issue:create", "提交问题反馈")) return;
  890. openModal("issueModal", selectedCustomerId);
  891. }
  892. });
  893. });
  894. document.querySelectorAll("[data-drawer-action]").forEach((button) => {
  895. button.addEventListener("click", () => {
  896. if (!selectedIssueId) return;
  897. const action = button.dataset.drawerAction;
  898. if (action === "assign") {
  899. if (!requirePermission("issue:assign", "指派责任人")) return;
  900. mutateIssue(selectedIssueId, "assign", { owner: "部门负责人" });
  901. }
  902. if (action === "handle") {
  903. if (!requirePermission("issue:handle", "提交处置")) return;
  904. openModal("handleModal");
  905. }
  906. if (action === "approve") {
  907. if (!requirePermission("audit:review", "核查通过")) return;
  908. mutateIssue(selectedIssueId, "audit-pass");
  909. }
  910. if (action === "reject") {
  911. if (!requirePermission("audit:review", "驳回整改")) return;
  912. mutateIssue(selectedIssueId, "audit-reject");
  913. }
  914. });
  915. });
  916. document.querySelector("#issueForm").addEventListener("submit", async (event) => {
  917. event.preventDefault();
  918. if (!requirePermission("issue:create", "提交问题反馈")) return;
  919. const form = event.currentTarget;
  920. if (!beginFormSubmit(form)) return;
  921. try {
  922. const data = Object.fromEntries(new FormData(form).entries());
  923. const payload = await apiRequest("/api/issues", { method: "POST", body: data });
  924. form.reset();
  925. closeModal();
  926. await refreshAfterMutation(payload.issue.id, payload.message);
  927. switchPage("issues");
  928. } catch (error) {
  929. handleApiError(error);
  930. } finally {
  931. endFormSubmit(form);
  932. }
  933. });
  934. document.querySelector("#inspectionForm").addEventListener("submit", async (event) => {
  935. event.preventDefault();
  936. if (!requirePermission("inspection:create", "提交每日线上巡查")) return;
  937. const form = event.currentTarget;
  938. if (!beginFormSubmit(form)) return;
  939. try {
  940. const data = Object.fromEntries(new FormData(form).entries());
  941. const payload = await apiRequest("/api/inspections", { method: "POST", body: data });
  942. form.reset();
  943. closeModal();
  944. await loadData();
  945. switchPage("inspections");
  946. showToast(payload.message);
  947. } catch (error) {
  948. handleApiError(error);
  949. } finally {
  950. endFormSubmit(form);
  951. }
  952. });
  953. document.querySelector("#handleForm").addEventListener("submit", async (event) => {
  954. event.preventDefault();
  955. if (!selectedIssueId) return;
  956. if (!requirePermission("issue:handle", "提交处置")) return;
  957. const form = event.currentTarget;
  958. if (!beginFormSubmit(form)) return;
  959. try {
  960. const data = Object.fromEntries(new FormData(form).entries());
  961. const payload = await apiRequest(`/api/issues/${encodeURIComponent(selectedIssueId)}/handle`, {
  962. method: "PATCH",
  963. body: data
  964. });
  965. form.reset();
  966. closeModal();
  967. await refreshAfterMutation(selectedIssueId, payload.message);
  968. } catch (error) {
  969. handleApiError(error);
  970. } finally {
  971. endFormSubmit(form);
  972. }
  973. });
  974. document.querySelector("#customerForm").addEventListener("submit", async (event) => {
  975. event.preventDefault();
  976. if (!requirePermission("customer:submit", "提交客户入池申请")) return;
  977. const form = event.currentTarget;
  978. if (!beginFormSubmit(form)) return;
  979. try {
  980. const data = Object.fromEntries(new FormData(form).entries());
  981. const payload = await apiRequest("/api/customers", { method: "POST", body: data });
  982. form.reset();
  983. closeModal();
  984. await loadData();
  985. if (payload.customer) switchPage("customers");
  986. if (payload.draft && hasPermission("page:admin")) switchPage("admin");
  987. showToast(payload.message);
  988. } catch (error) {
  989. handleApiError(error);
  990. } finally {
  991. endFormSubmit(form);
  992. }
  993. });
  994. document.querySelector("#adminUserForm")?.addEventListener("submit", async (event) => {
  995. event.preventDefault();
  996. if (!requirePermission("admin:manage", "新增后台账号")) return;
  997. const form = event.currentTarget;
  998. if (!beginFormSubmit(form)) return;
  999. try {
  1000. const data = Object.fromEntries(new FormData(form).entries());
  1001. const payload = await apiRequest("/api/admin/users", { method: "POST", body: data });
  1002. form.reset();
  1003. await refreshAdminConfig(payload.message);
  1004. } catch (error) {
  1005. handleApiError(error);
  1006. } finally {
  1007. endFormSubmit(form);
  1008. }
  1009. });
  1010. document.querySelector("#adminDictionaryForm")?.addEventListener("submit", async (event) => {
  1011. event.preventDefault();
  1012. if (!requirePermission("admin:manage", "保存基础字典")) return;
  1013. const form = event.currentTarget;
  1014. if (!beginFormSubmit(form)) return;
  1015. try {
  1016. const data = Object.fromEntries(new FormData(form).entries());
  1017. const payload = await apiRequest("/api/admin/dictionaries", { method: "PATCH", body: data });
  1018. dictionaries = { ...dictionaries, ...(payload.dictionaries || {}) };
  1019. await refreshAdminConfig(payload.message);
  1020. } catch (error) {
  1021. handleApiError(error);
  1022. } finally {
  1023. endFormSubmit(form);
  1024. }
  1025. });
  1026. issueCustomerSearch?.addEventListener("input", () => {
  1027. renderCustomerSelects(issueCustomerSearch.value);
  1028. });
  1029. document.querySelector("#saveRulesBtn").addEventListener("click", async () => {
  1030. if (!requirePermission("rules:edit", "保存预警规则")) return;
  1031. try {
  1032. const payload = await apiRequest("/api/rules", { method: "POST", body: { source: "official" } });
  1033. showToast(payload.message);
  1034. } catch (error) {
  1035. handleApiError(error);
  1036. }
  1037. });
  1038. loginForm.addEventListener("submit", async (event) => {
  1039. event.preventDefault();
  1040. loginMessage.textContent = "正在请求后台登录...";
  1041. try {
  1042. const payload = await apiRequest("/api/auth/login", {
  1043. method: "POST",
  1044. body: {
  1045. username: loginUsername.value.trim(),
  1046. password: loginPassword.value
  1047. }
  1048. });
  1049. loginMessage.textContent = "";
  1050. await enterApp(payload.user, payload.token);
  1051. } catch (error) {
  1052. loginMessage.textContent = error.message;
  1053. }
  1054. });
  1055. registerForm?.addEventListener("submit", async (event) => {
  1056. event.preventDefault();
  1057. const form = event.currentTarget;
  1058. const password = registerPassword.value.trim();
  1059. const confirmPassword = registerPasswordConfirm.value.trim();
  1060. if (password.length < 6) {
  1061. registerMessage.textContent = "密码至少需要 6 位。";
  1062. return;
  1063. }
  1064. if (password !== confirmPassword) {
  1065. registerMessage.textContent = "两次输入的密码不一致。";
  1066. return;
  1067. }
  1068. if (!beginFormSubmit(form)) return;
  1069. registerMessage.textContent = "正在核对花名册并创建账号...";
  1070. try {
  1071. const payload = await apiRequest("/api/auth/register", {
  1072. method: "POST",
  1073. body: {
  1074. name: registerName.value.trim(),
  1075. phone: registerPhone.value.trim(),
  1076. password,
  1077. confirmPassword
  1078. }
  1079. });
  1080. registerMessage.textContent = "";
  1081. form.reset();
  1082. await enterApp(payload.user, payload.token);
  1083. showToast(payload.message);
  1084. } catch (error) {
  1085. registerMessage.textContent = error.message;
  1086. } finally {
  1087. endFormSubmit(form);
  1088. }
  1089. });
  1090. logoutBtn.addEventListener("click", async () => {
  1091. try {
  1092. await apiRequest("/api/auth/logout", { method: "POST" });
  1093. } catch {
  1094. // 即使后台会话已经失效,也要清理本地登录态。
  1095. }
  1096. clearSession();
  1097. });
  1098. document.addEventListener("keydown", (event) => {
  1099. if (event.key === "Escape") {
  1100. closeDrawer();
  1101. closeModal();
  1102. }
  1103. });
  1104. restoreSession();