/* Admin 的全部 JavaScript。 * * 原生 JS,没有框架、没有构建步骤——见 admin/AGENTS.md 前端约束。 * 只做轻量界面交互:全选、操作前确认、按钮禁用和弹窗。 * 搜索、删除、导入本身都是普通表单 POST,服务端渲染,不需要 JS。 * * 不要往这里加业务逻辑。业务逻辑在服务端。 */ (function () { "use strict"; /* ── 模块导航状态 ──────────────────────────── 列表筛选和页码本来就在 URL 中。这里按账号、按浏览器标签页记住每个 主模块最后一次稳定 URL,让采购员切走再回来时不用重复筛选。 只接受已知主列表路径和查询参数;勾选、弹窗、临时提示及写操作状态 都不进入 sessionStorage。链接原始 href 保持模块根地址,因此脚本失效时 仍可正常导航。 */ var MODULE_QUERY_KEYS = { "/shopee": ["q", "search_field", "status", "deleted", "unlinked", "page"], "/pdd": ["q", "status", "page"], "/syb": ["order_no", "shop", "stage", "page", "date_from", "date_to"], "/shops": [], "/tasks": ["type", "status", "creator", "q", "page"], "/clients": ["name", "page"], "/inner-codes": ["date", "status", "q", "page"], "/users": ["q", "status", "page"] }; var MODULE_STATE_PREFIX = "cmautobuy:module-state:"; function moduleStateStorageKey(userID, moduleRoot) { return MODULE_STATE_PREFIX + userID + ":" + moduleRoot; } function stableModuleURL(moduleRoot, rawURL) { if (!Object.prototype.hasOwnProperty.call(MODULE_QUERY_KEYS, moduleRoot)) return moduleRoot; if (!rawURL || rawURL.length > 2048) return moduleRoot; var parsed; try { parsed = new URL(rawURL, window.location.origin); } catch (error) { return moduleRoot; } if (parsed.origin !== window.location.origin || parsed.pathname !== moduleRoot) return moduleRoot; var allowed = MODULE_QUERY_KEYS[moduleRoot]; var stable = new URLSearchParams(); parsed.searchParams.forEach(function (value, key) { if (allowed.indexOf(key) !== -1 && value !== "") stable.set(key, value); }); var query = stable.toString(); return moduleRoot + (query ? "?" + query : ""); } function setupModuleNavigationState() { var nav = document.querySelector("[data-module-navigation]"); if (!nav) return; var userID = nav.getAttribute("data-ui-state-user") || ""; if (!userID) return; var currentRoot = Object.prototype.hasOwnProperty.call(MODULE_QUERY_KEYS, window.location.pathname) ? window.location.pathname : ""; try { if (currentRoot) { sessionStorage.setItem( moduleStateStorageKey(userID, currentRoot), stableModuleURL(currentRoot, window.location.href) ); } nav.querySelectorAll("[data-module-root]").forEach(function (link) { var moduleRoot = link.getAttribute("data-module-root") || ""; var saved = sessionStorage.getItem(moduleStateStorageKey(userID, moduleRoot)); link.href = stableModuleURL(moduleRoot, saved); }); } catch (error) { /* 隐私模式或浏览器策略可能禁用存储。默认 href 仍是安全回退。 */ } var logoutForm = nav.querySelector(".nav-logout"); if (logoutForm) { logoutForm.addEventListener("submit", function () { try { Object.keys(MODULE_QUERY_KEYS).forEach(function (moduleRoot) { sessionStorage.removeItem(moduleStateStorageKey(userID, moduleRoot)); }); } catch (error) { /* 登出不能因为清理界面状态失败而被阻止。 */ } }); } } /* ── 全选 / 反选 ────────────────────────────── 表头的勾选框控制本表格所有行的勾选框。 */ function setupCheckAll(root) { var master = root.querySelector("[data-check-all]"); if (!master) return; var table = master.closest("table"); if (!table) return; function rowBoxes() { return table.querySelectorAll("tbody input[type=checkbox]:not(:disabled)"); } master.addEventListener("change", function () { rowBoxes().forEach(function (box) { box.checked = master.checked; }); syncButtons(); }); table.addEventListener("change", function (e) { if (e.target.type !== "checkbox" || e.target === master) return; var boxes = Array.prototype.slice.call(rowBoxes()); master.checked = boxes.length > 0 && boxes.every(function (b) { return b.checked; }); syncButtons(); }); } /* ── 选中数量 ────────────────────────────── */ function supportsSelectAction(box, action) { if (!action) return true; var actions = (box.getAttribute("data-select-action") || "").split(/\s+/); return actions.indexOf(action) !== -1; } function checkedBoxes(action) { return Array.prototype.filter.call( document.querySelectorAll("tbody input[type=checkbox]:checked:not(:disabled)"), function (box) { return supportsSelectAction(box, action); } ); } function checkedCount(action) { return checkedBoxes(action).length; } /* ── 需要勾选才能点的按钮 ────────────────── 标了 data-need-checked 的按钮,没勾选任何行时禁用。 */ function syncButtons() { document.querySelectorAll("[data-need-checked]").forEach(function (btn) { var action = btn.getAttribute("data-need-checked") || ""; btn.disabled = checkedCount(action) === 0; }); } /* ── 删除前二次确认 ──────────────────────── 标了 data-confirm-delete 的元素,提交前必须确认, 并明确告诉用户会删掉几条、能不能恢复。 标在表单上就拦 submit,标在按钮上就拦 click—— 一个表单配多个提交按钮时(PDD 页就是),只有删除那个要确认。 属性值可以写自定义文案,里面的 {n} 会替换成勾选条数; 不写就用默认那句。 */ var DEFAULT_CONFIRM = "将删除 {n} 条记录,不可恢复。确定继续?"; function setupConfirmDelete() { document.querySelectorAll("[data-confirm-delete]").forEach(function (el) { var isForm = el.tagName === "FORM"; el.addEventListener(isForm ? "submit" : "click", function (e) { var n = checkedCount(); if (n === 0) { e.preventDefault(); return; } var text = el.getAttribute("data-confirm-delete") || DEFAULT_CONFIRM; if (!window.confirm(text.replace("{n}", n) + "\n\n确定继续?")) { e.preventDefault(); } }); }); } /* ── 普通敏感操作的二次确认 ─────────────── 账号启停和密码重置不是删除,但同样会撤销登录。服务端仍负责权限和事务, 这里仅降低误操作概率。 */ function setupConfirmSubmit() { document.querySelectorAll("form[data-confirm-submit]").forEach(function (form) { form.addEventListener("submit", function (e) { if (!window.confirm(form.getAttribute("data-confirm-submit"))) { e.preventDefault(); } }); }); } /* 用户列表共用一个重置密码弹窗。按钮只把用户编号和显示名放进表单, 不在前端处理权限、密码或 Session。 */ function setupUserResetModal() { var modal = document.getElementById("reset-password-modal"); if (!modal) return; var idInput = modal.querySelector("[data-reset-user-id-input]"); var nameLabel = modal.querySelector("[data-reset-username-label]"); document.querySelectorAll("[data-reset-user-id]").forEach(function (button) { button.addEventListener("click", function () { idInput.value = button.getAttribute("data-reset-user-id") || ""; nameLabel.textContent = button.getAttribute("data-reset-username") || ""; }); }); } /* 客户端列表共用绑定/转交弹窗。这里只回填行数据,权限和写入由服务端处理。 */ function setupClientAssignmentModal() { var modal = document.getElementById("assign-client-modal"); if (!modal) return; var idInput = modal.querySelector("[data-assign-client-id-input]"); var nameLabel = modal.querySelector("[data-assign-client-label]"); var select = modal.querySelector("select[name=user_id]"); var title = modal.querySelector("#assign-client-title"); var note = modal.querySelector("[data-assignment-note]"); document.querySelectorAll("[data-assign-client-id]").forEach(function (button) { button.addEventListener("click", function () { var currentName = button.getAttribute("data-assign-username") || ""; idInput.value = button.getAttribute("data-assign-client-id") || ""; nameLabel.textContent = button.getAttribute("data-assign-client-name") || ""; if (select) select.value = button.getAttribute("data-assign-user-id") || ""; title.textContent = currentName ? "转交客户端" : "绑定客户端"; note.textContent = currentName ? "当前负责人是“" + currentName + "”。保存后新负责人立即生效;既有任务不变。" : "绑定只改变负责人和网页可见范围,不影响 Client 接口或既有任务。"; }); }); } /* 采购确认弹窗只负责把服务端已经渲染的本页行按勾选结果显示出来。 规格有效性、价格和客户端权限仍由服务端重新校验。 */ function setupPurchaseModal() { var openButtons = document.querySelectorAll("[data-purchase-open]"); var form = document.querySelector("[data-purchase-form]"); if (!openButtons.length || !form) return; var submitButton = form.querySelector("[data-purchase-submit]"); function updateTotalPrice(row) { var input = row.querySelector("[data-unit-price-input]"); var output = row.querySelector("[data-total-price-output]"); var quantity = Number(row.getAttribute("data-purchase-quantity")); if (!input || !output || !Number.isInteger(quantity) || quantity <= 0) return; var raw = input.value.trim(); var match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(raw); if (!match) { output.textContent = "—"; return; } var fraction = (match[2] || "").padEnd(2, "0"); var unitCent = Number(match[1]) * 100 + Number(fraction || "0"); var totalCent = unitCent * quantity; if (!Number.isSafeInteger(totalCent) || totalCent <= 0) { output.textContent = "—"; return; } output.textContent = "¥" + Math.floor(totalCent / 100) + "." + String(totalCent % 100).padStart(2, "0"); } form.querySelectorAll("[data-purchase-row]").forEach(function (row) { var input = row.querySelector("[data-unit-price-input]"); if (input) input.addEventListener("input", function () { updateTotalPrice(row); }); }); function showPurchaseSelection(selected) { var shown = 0; form.querySelectorAll("[data-purchase-row]").forEach(function (row) { var enabled = !!selected[row.getAttribute("data-purchase-row")]; row.hidden = !enabled; row.querySelectorAll("input").forEach(function (input) { input.disabled = !enabled; }); if (enabled) updateTotalPrice(row); if (enabled) shown += 1; }); var empty = form.querySelector("[data-purchase-empty]"); if (empty) empty.hidden = shown > 0; return shown; } openButtons.forEach(function (openButton) { openButton.addEventListener("click", function () { var selected = {}; var singleID = openButton.getAttribute("data-purchase-id"); if (singleID) { selected[singleID] = true; } else { document.querySelectorAll("tbody input[name=ids]:checked:not(:disabled)").forEach(function (box) { if (supportsSelectAction(box, "purchase")) selected[box.value] = true; }); } showPurchaseSelection(selected); }); }); /* 任务详情的“重新创建”只把稳定 syb_id 带回当前确认流程。 找不到或当前已不可采购时服务端不会输出这个 ID,前端绝不自行放宽。 */ var autoPurchaseID = form.getAttribute("data-auto-open-purchase-id") || ""; var autoSelected = {}; if (autoPurchaseID) autoSelected[autoPurchaseID] = true; if (autoPurchaseID && showPurchaseSelection(autoSelected) > 0) { var purchaseModal = form.closest(".modal-backdrop"); if (purchaseModal) { openModal(purchaseModal); var clientSelect = form.querySelector("select[name=client_id]"); if (clientSelect) clientSelect.focus({ preventScroll: true }); } } form.addEventListener("submit", function (event) { if (event.defaultPrevented || !submitButton) return; submitButton.disabled = true; submitButton.setAttribute("aria-busy", "true"); submitButton.textContent = "创建中…"; }); } /* PDD 批量采集弹窗只同步已选数量并提供提交反馈。 客户端权限和任务初始状态始终由服务端决定。 */ function setupCollectModals() { document.querySelectorAll("[data-collect-open]").forEach(function (openButton) { var formID = openButton.getAttribute("data-collect-form") || "pdd-form"; var form = document.getElementById(formID); var modal = document.getElementById(openButton.getAttribute("data-modal-open")); if (!form || !modal) return; var count = modal.querySelector("[data-collect-count]"); var submitButton = modal.querySelector("[data-collect-submit]"); var action = openButton.getAttribute("data-collect-action") || ""; if (!count || !submitButton) return; openButton.addEventListener("click", function () { var fixedCount = openButton.getAttribute("data-collect-count-value"); count.textContent = fixedCount === null ? checkedCount(action) : fixedCount; }); form.addEventListener("submit", function (event) { if (event.submitter !== submitButton) return; submitButton.disabled = true; submitButton.setAttribute("aria-busy", "true"); submitButton.textContent = "创建中…"; }); }); } /* Excel 解析可能需要几秒。浏览器校验通过并开始上传后立即锁住提交按钮, 防止采购员因为没有反馈而重复提交同一批文件。 */ function setupUploadFeedback() { document.querySelectorAll("[data-upload-form]").forEach(function (form) { var button = form.querySelector("[data-upload-submit]"); if (!button) return; form.addEventListener("submit", function () { button.disabled = true; button.setAttribute("aria-busy", "true"); button.textContent = "导入中…"; }); }); } /* 档口入库码页只有轻量表单联动:一个可见业务日期同步给三个普通表单, 并按“匹配/回写”动作分别提交当前页符合条件的选中 ID。 匹配和回写规则仍全部在服务端。 */ function setupInnerCodePage() { var root = document.querySelector("[data-inner-code-page]"); if (!root) return; var dateInput = root.querySelector("[data-inner-code-date]"); var fileInput = root.querySelector("[data-file-input]"); var fileName = root.querySelector("[data-file-name]"); var fileOpen = root.querySelector("[data-inner-code-file-open]"); var applyButton = root.querySelector("[data-inner-code-apply-open]"); function syncDate() { document.querySelectorAll("[data-inner-code-date-field]").forEach(function (field) { field.value = dateInput ? dateInput.value : field.value; }); } function updateSelectionSummary() { var matchSelected = checkedBoxes("inner-code-match"); var applySelected = checkedBoxes("inner-code-apply"); var replacements = applySelected.filter(function (box) { return box.getAttribute("data-inner-code-old-value") === "1"; }).length; document.querySelectorAll("[data-inner-code-match-count]") .forEach(function (node) { node.textContent = matchSelected.length; }); document.querySelectorAll("[data-inner-code-selected-count], [data-inner-code-confirm-count]") .forEach(function (node) { node.textContent = applySelected.length; }); document.querySelectorAll("[data-inner-code-replace-count]") .forEach(function (node) { node.textContent = replacements; }); } function appendSelectedIDs(form, action) { form.querySelectorAll("[data-inner-code-generated-id]").forEach(function (field) { field.remove(); }); checkedBoxes(action).forEach(function (box) { var field = document.createElement("input"); field.type = "hidden"; field.name = "ids"; field.value = box.getAttribute("data-inner-code-id") || box.value; field.setAttribute("data-inner-code-generated-id", ""); form.appendChild(field); }); } if (dateInput) dateInput.addEventListener("change", syncDate); if (fileOpen && fileInput) fileOpen.addEventListener("click", function () { fileInput.click(); }); if (fileInput && fileName) { fileInput.addEventListener("change", function () { fileName.textContent = fileInput.files.length ? fileInput.files[0].name : "未选择文件"; fileName.title = fileName.textContent; }); } root.addEventListener("change", updateSelectionSummary); var table = document.querySelector(".inner-code-table"); if (table) table.addEventListener("change", updateSelectionSummary); if (applyButton) applyButton.addEventListener("click", updateSelectionSummary); var matchForm = document.getElementById("inner-code-match-form"); if (matchForm) matchForm.addEventListener("submit", function () { appendSelectedIDs(matchForm, "inner-code-match"); var button = document.querySelector('[form="inner-code-match-form"]'); if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); button.textContent = "匹配中…"; } }); var applyForm = document.getElementById("inner-code-apply-form"); if (applyForm) applyForm.addEventListener("submit", function () { appendSelectedIDs(applyForm, "inner-code-apply"); var button = document.querySelector('#inner-code-apply-modal button[type="submit"]'); if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); button.textContent = "回写中…"; } }); syncDate(); updateSelectionSummary(); } /* ── 弹窗 ────────────────────────────────── 弹窗**内容由服务端渲染**,这里只负责显示、隐藏和把内容取回来。 不要在这里拼业务数据——价格格式、规格顺序都是业务规则, 散到前端就没人维护得住了。 */ function openModal(modal) { modal._returnFocus = document.activeElement; modal.hidden = false; var focusable = modal.querySelector("[autofocus]") || modal.querySelector("input, button"); if (focusable) focusable.focus(); } function closeModal(modal) { modal.hidden = true; if (modal._returnFocus && modal._returnFocus.focus) { modal._returnFocus.focus(); } } function setupModals() { /* 打开:按钮上写 data-modal-open="弹窗的 id" */ document.querySelectorAll("[data-modal-open]").forEach(function (btn) { btn.addEventListener("click", function () { var modal = document.getElementById(btn.getAttribute("data-modal-open")); if (modal) openModal(modal); }); }); document.querySelectorAll(".modal-backdrop").forEach(function (modal) { /* 关闭:叉、取消按钮,以及真正点弹窗外面的灰底。 不能只看 click 的 target:从输入框按下拖选到灰底松开时,浏览器会把 合成的 click 目标设成灰底。只有手势也从灰底开始,才算有意关闭。 */ var pointerStartedOnBackdrop = false; modal.addEventListener("pointerdown", function (e) { pointerStartedOnBackdrop = e.target === modal; }); modal.addEventListener("pointercancel", function () { pointerStartedOnBackdrop = false; }); modal.addEventListener("click", function (e) { var clickedCloseButton = e.target.closest("[data-modal-close]"); var clickedBackdrop = e.target === modal && pointerStartedOnBackdrop; pointerStartedOnBackdrop = false; if (clickedBackdrop || clickedCloseButton) { closeModal(modal); } }); }); /* Esc 关掉最上面那个打开着的弹窗 */ document.addEventListener("keydown", function (e) { if (e.key !== "Escape") return; var open = document.querySelectorAll(".modal-backdrop:not([hidden])"); if (open.length > 0) closeModal(open[open.length - 1]); }); /* 服务端确认需要立即提醒的错误,页面加载后使用同一套弹窗焦点和关闭规则。 */ document.querySelectorAll("[data-auto-open-modal]").forEach(function (modal) { openModal(modal); }); } /* ── 双击行打开详情弹窗 ──────────────────── 行上写 data-detail-id,双击时去 /<模块>/detail?id=… 取回 已经渲染好的 HTML 片段,塞进壳子里显示。 */ function setupRowDetail() { var modal = document.getElementById("detail-modal"); if (!modal) return; var slot = modal.querySelector("[data-detail-slot]"); var base = modal.getAttribute("data-detail-url") || ""; function loadDetail(id) { if (!modal.hidden && modal.getAttribute("data-loaded-detail-id") === id) return; modal.setAttribute("data-loaded-detail-id", id); slot.innerHTML = "
打不开详情:" + err.message + "。刷新页面后重试。