app.js 52 KB

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