Files
cmautobuy/admin/static/js/app.js
T

302 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Admin 的全部 JavaScript。
*
* 原生 JS,没有框架、没有构建步骤——见 admin/AGENTS.md 前端约束。
* 只做轻量界面交互:全选、操作前确认、按钮禁用和弹窗。
* 搜索、删除、导入本身都是普通表单 POST,服务端渲染,不需要 JS。
*
* 不要往这里加业务逻辑。业务逻辑在服务端。
*/
(function () {
"use strict";
/* ── 全选 / 反选 ──────────────────────────────
表头的勾选框控制本表格所有行的勾选框。 */
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]");
}
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 checkedCount() {
return document.querySelectorAll(
"tbody input[type=checkbox]:checked"
).length;
}
/* ── 需要勾选才能点的按钮 ──────────────────
标了 data-need-checked 的按钮,没勾选任何行时禁用。 */
function syncButtons() {
var n = checkedCount();
document.querySelectorAll("[data-need-checked]").forEach(function (btn) {
btn.disabled = n === 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 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]);
});
}
/* ── 双击行打开详情弹窗 ────────────────────
行上写 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") || "";
document.querySelectorAll("tr[data-detail-id]").forEach(function (row) {
row.addEventListener("dblclick", function (e) {
/* 双击到勾选框或链接上时不要弹窗,那是另一个动作 */
if (e.target.closest("input, a, button")) return;
slot.innerHTML = "<div class='modal-body'>正在加载…</div>";
openModal(modal);
fetch(base + "?id=" + encodeURIComponent(row.getAttribute("data-detail-id")), {
headers: { "X-Requested-With": "fetch" }
})
.then(function (resp) {
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.text();
})
.then(function (html) {
slot.innerHTML = html;
})
.catch(function (err) {
/* 出错也要说清楚,不能让弹窗一直停在"正在加载…" */
slot.innerHTML =
"<div class='modal-body'><p class='missing'>打不开详情:" +
err.message +
"。刷新页面后重试。</p></div>" +
"<div class='modal-foot'><button type='button' data-modal-close>关闭</button></div>";
});
});
});
}
/* ── 顺运宝登录弹窗:验证码"换一张" ─────────────
图片本身、以及旁边的"换一张"按钮,点了都重新请求验证码接口。
`[必须]` 每次请求带一个新的时间戳查询参数,绕开浏览器缓存——
否则点了"换一张"看到的还是同一张图(工单 #46)。 */
function setupCaptchaRefresh() {
var img = document.getElementById("login-captcha-img");
if (!img) return;
var base = img.getAttribute("data-captcha-refresh") || img.src;
function refresh() {
img.src = base + "?_=" + Date.now();
}
img.addEventListener("click", refresh);
var btn = document.getElementById("login-captcha-refresh");
if (btn) btn.addEventListener("click", refresh);
}
/* 顺运宝同步是后台长任务。表单通过浏览器校验并提交后立即锁住按钮,
让操作员在页面跳转前就看到反馈;服务端互斥仍是最终保护。 */
function setupSybSyncFeedback() {
var form = document.querySelector("[data-syb-sync-form]");
if (!form) return;
var button = form.querySelector("[data-syb-sync-submit]");
if (!button) return;
form.addEventListener("submit", function () {
button.disabled = true;
button.setAttribute("aria-busy", "true");
button.textContent = "同步中…";
});
}
/* 同步记录继续由服务端渲染。刷新按钮带着 history=1 和当前页码重新加载,
返回后弹窗会自动打开;点击后先显示加载态,避免连续刷新。 */
function setupSybHistoryRefresh() {
var button = document.querySelector("[data-history-refresh-url]");
if (!button) return;
button.addEventListener("click", function () {
if (button.disabled) return;
button.disabled = true;
button.setAttribute("aria-busy", "true");
button.textContent = "刷新中…";
window.location.assign(button.getAttribute("data-history-refresh-url"));
});
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("table").forEach(setupCheckAll);
setupConfirmDelete();
setupConfirmSubmit();
setupUserResetModal();
setupClientAssignmentModal();
setupModals();
setupRowDetail();
setupCaptchaRefresh();
setupSybSyncFeedback();
setupSybHistoryRefresh();
syncButtons();
});
/* TODO(骨架): 蝦皮数据页 -> 编辑弹窗(填 PDD 链接)。
顺运宝页的登录弹窗已经实现(setupCaptchaRefresh + 通用 setupModals);
规格匹配弹窗仍是后续工单的范围。
页面里放一个 id="detail-modal" 的壳子、行上写 data-detail-id,
服务端出一个返回片段的 /<模块>/detail,这里就不用再加代码了。 */
})();