Files
cmautobuy/admin/static/js/app.js
T
chengmaandClaude Opus 5 5e426cacf6 feat: 顺运宝货运单同步 (#46)
顺运宝模块此前是骨架,「同步」点了提示"待接入"。5195 个蝦皮商品已经
进系统,但货运单(真实订单)一条都没有,后面的规格匹配无从谈起。

按接口契约(docs/admin/08,从 4 份 HAR 还原)实现:配置、登录(界面
手工输验证码)、会话缓存到 SQLite、按日期范围增量同步、落 syb_orders。

shopee_sku_id 绝不被同步覆盖。它是规格匹配的结果,顺运宝那边根本没有
这个值(只给 11 位商品ID,蝦皮規格ID 是 12 位)。同步写进去就是写空,
把人工攒的匹配成果洗掉且不报错。它只出现在 INSERT 列清单里,不在
DO UPDATE SET 里;repository 层和 service 端到端各有一个测试守着。

增量从「上次同步日期当天」重拉,不是第二天。created 筛选粒度是日期而
last_synced_at 精确到秒,从第二天拉会漏掉当天晚些时候创建的单且不报错。
宁可重复拉(upsert 幂等)也不能漏。中途失败不更新 last_synced_at,
否则下次跳过这段区间,漏的单永远补不回来。

日期运算用 UTC+8,不是 UTC。审查时从 HAR 确认 created 是当地时间:
抓包于 2026-07-28T03:31:45Z(= 11:31 UTC+8),同一响应里 created 是
"2026-07-28 10:37:59";若它是 UTC 则等于 18:37 UTC+8,比抓包晚 7 小时,
订单创建于未来,不成立。用 UTC 算会在本地 00:00-08:00 把"今天"算成昨天,
当天早晨的单这轮拉不到。用 time.FixedZone 写死,不用 LoadLocation——
那要读系统 tzdata,Windows 默认没有,打包成 exe 会失败。

金额一律取 detail/listByStock 的值:08 §5.1 实测同一响应里 amtOrder
在列表接口是分、escrowAmount 却不是,单位不统一,取错差 100 倍。

迁移 v5 纯追加(syb_session、syb_sync_state、syb_orders.product_spec),
v1-v4 逐字未动,CheckSchema 覆盖新表新列。

会话有效性判断把「网络故障」和「明确未登录」的分类集中在 Client.do()
一处——网络抖一下就判定登出的话,验证码会弹个不停,还会丢掉有效会话。

测试全部用 httptest 假服务端,不打真实站点。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:49:13 +08:00

200 lines
7.6 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 openModal(modal) {
modal.hidden = false;
var focusable = modal.querySelector("input, button");
if (focusable) focusable.focus();
}
function closeModal(modal) {
modal.hidden = true;
}
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) {
/* 关闭:叉、取消按钮,以及点弹窗外面的灰底 */
modal.addEventListener("click", function (e) {
if (e.target === modal || e.target.closest("[data-modal-close]")) {
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);
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("table").forEach(setupCheckAll);
setupConfirmDelete();
setupModals();
setupRowDetail();
setupCaptchaRefresh();
syncButtons();
});
/* TODO(骨架): 蝦皮数据页 -> 编辑弹窗(填 PDD 链接)。
顺运宝页的登录弹窗已经实现(setupCaptchaRefresh + 通用 setupModals);
规格匹配弹窗仍是后续工单的范围。
页面里放一个 id="detail-modal" 的壳子、行上写 data-detail-id,
服务端出一个返回片段的 /<模块>/detail,这里就不用再加代码了。 */
})();