feat(admin): authorize batch purchase starts
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
const form = document.querySelector("[data-start-purchases]");
|
||||
if (!form) return;
|
||||
const all = form.querySelector("[data-select-all]");
|
||||
const summary = form.querySelector("[data-selection-summary]");
|
||||
const button = form.querySelector("[data-start-button]");
|
||||
const feedback = form.querySelector("[data-start-feedback]");
|
||||
const boxes = () => [...form.querySelectorAll("input[data-task-id]")];
|
||||
let selectionFrozen = false;
|
||||
const parseCents = (value) => {
|
||||
const match = /^(0|[1-9]\d*)\.(\d{2})$/.exec(value);
|
||||
return match ? BigInt(match[1] + match[2]) : null;
|
||||
};
|
||||
const refresh = () => {
|
||||
const available = boxes();
|
||||
const selected = available.filter((box) => box.checked);
|
||||
let cents = 0n;
|
||||
let pricesValid = true;
|
||||
selected.forEach((box) => {
|
||||
const price = parseCents(box.dataset.price);
|
||||
if (price === null) pricesValid = false;
|
||||
else cents += price;
|
||||
});
|
||||
summary.textContent = `已选 ${selected.length} 条,最高总额 ¥${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`;
|
||||
button.disabled = !selected.length || !pricesValid;
|
||||
if (!pricesValid) feedback.textContent = "所选任务金额无法安全汇总,请刷新后重选。";
|
||||
if (all) {
|
||||
all.checked = selected.length > 0 && selected.length === available.length;
|
||||
all.indeterminate = selected.length > 0 && selected.length < available.length;
|
||||
all.disabled = selectionFrozen || available.length === 0;
|
||||
}
|
||||
};
|
||||
const freezeSelection = (frozen) => {
|
||||
selectionFrozen = frozen;
|
||||
boxes().forEach((box) => { box.disabled = frozen; });
|
||||
refresh();
|
||||
};
|
||||
boxes().forEach((box) => box.addEventListener("change", refresh));
|
||||
if (all) all.addEventListener("change", () => { boxes().forEach((box) => { box.checked = all.checked; }); refresh(); });
|
||||
let frozenPayload = null;
|
||||
let inFlight = false;
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const selected = boxes().filter((box) => box.checked);
|
||||
if (!selected.length || inFlight) return;
|
||||
const tasks = selected.map((box) => ({task_id: box.dataset.taskId, expected_task_version: Number(box.dataset.taskVersion)}));
|
||||
if (tasks.some((item) => !Number.isSafeInteger(item.expected_task_version) || item.expected_task_version < 1)) { feedback.textContent = "任务版本无效,请刷新后重选。"; return; }
|
||||
frozenPayload = frozenPayload || JSON.stringify({start_key: form.dataset.startKey, tasks});
|
||||
inFlight = true; freezeSelection(true); button.disabled = true; button.textContent = "正在授权…";
|
||||
try { const response = await fetch("/tasks/start-purchases", {method:"POST", headers:{"Content-Type":"application/json", "X-CSRF-Token":form.dataset.csrf}, body:frozenPayload});
|
||||
if (response.ok) { window.location.reload(); return; }
|
||||
if (response.status === 409) { feedback.textContent = "任务已变化,请刷新后重选。"; frozenPayload = null; freezeSelection(false); boxes().forEach((box) => { box.checked = false; }); refresh(); return; }
|
||||
if (response.status === 400 || response.status === 401 || response.status === 403) { feedback.textContent = "请求未被接受,请刷新页面后重试。"; frozenPayload = null; freezeSelection(false); return; }
|
||||
feedback.textContent = "结果暂时不明确,只能使用同一按钮原样重放。";
|
||||
} catch (_) { feedback.textContent = "网络结果不明确,请使用同一按钮原样重试。"; }
|
||||
finally { inFlight = false; button.textContent = "开始采购(只创建待付款订单)"; if (frozenPayload) button.disabled = false; }
|
||||
});
|
||||
refresh();
|
||||
})();
|
||||
@@ -0,0 +1,138 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
|
||||
|
||||
test("successful authorization sends numeric version and reloads", async () => {
|
||||
const requests = [];
|
||||
const harness = createHarness(async (_url, options) => {
|
||||
requests.push(options);
|
||||
return {ok: true, status: 200};
|
||||
});
|
||||
|
||||
await harness.submit();
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].headers["Content-Type"], "application/json");
|
||||
assert.equal(requests[0].headers["X-CSRF-Token"], "csrf-token");
|
||||
const payload = JSON.parse(requests[0].body);
|
||||
assert.equal(payload.start_key, "start-key");
|
||||
assert.equal(typeof payload.tasks[0].expected_task_version, "number");
|
||||
assert.equal(payload.tasks[0].expected_task_version, 7);
|
||||
assert.equal(harness.reloads(), 1);
|
||||
});
|
||||
|
||||
test("409 clears stale selection and requires a fresh choice", async () => {
|
||||
const harness = createHarness(async () => ({ok: false, status: 409}));
|
||||
|
||||
await harness.submit();
|
||||
|
||||
assert.equal(harness.box.checked, false);
|
||||
assert.equal(harness.box.disabled, false);
|
||||
assert.equal(harness.button.disabled, true);
|
||||
assert.match(harness.feedback.textContent, /任务已变化/);
|
||||
});
|
||||
|
||||
for (const status of [400, 401, 403]) {
|
||||
test(`${status} releases the frozen payload for a page refresh`, async () => {
|
||||
const harness = createHarness(async () => ({ok: false, status}));
|
||||
|
||||
await harness.submit();
|
||||
|
||||
assert.equal(harness.box.checked, true);
|
||||
assert.equal(harness.box.disabled, false);
|
||||
assert.equal(harness.button.disabled, false);
|
||||
assert.match(harness.feedback.textContent, /刷新页面后重试/);
|
||||
});
|
||||
}
|
||||
|
||||
test("5xx retries the byte-identical frozen payload", async () => {
|
||||
const bodies = [];
|
||||
const harness = createHarness(async (_url, options) => {
|
||||
bodies.push(options.body);
|
||||
return {ok: false, status: 503};
|
||||
});
|
||||
|
||||
await harness.submit();
|
||||
assert.equal(harness.box.disabled, true);
|
||||
assert.equal(harness.button.disabled, false);
|
||||
assert.match(harness.feedback.textContent, /原样重放/);
|
||||
await harness.submit();
|
||||
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.equal(bodies[1], bodies[0]);
|
||||
});
|
||||
|
||||
test("network ambiguity retries the same payload and can finish", async () => {
|
||||
const bodies = [];
|
||||
let call = 0;
|
||||
const harness = createHarness(async (_url, options) => {
|
||||
bodies.push(options.body);
|
||||
call++;
|
||||
if (call === 1) throw new Error("network result unknown");
|
||||
return {ok: true, status: 200};
|
||||
});
|
||||
|
||||
await harness.submit();
|
||||
assert.equal(harness.box.disabled, true);
|
||||
assert.match(harness.feedback.textContent, /原样重试/);
|
||||
await harness.submit();
|
||||
|
||||
assert.deepEqual(bodies, [bodies[0], bodies[0]]);
|
||||
assert.equal(harness.reloads(), 1);
|
||||
});
|
||||
|
||||
function createHarness(fetchImplementation) {
|
||||
class FakeElement {
|
||||
constructor() {
|
||||
this.dataset = {};
|
||||
this.checked = false;
|
||||
this.disabled = false;
|
||||
this.indeterminate = false;
|
||||
this.textContent = "";
|
||||
this.listeners = {};
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
this.listeners[type] = listener;
|
||||
}
|
||||
}
|
||||
|
||||
const box = new FakeElement();
|
||||
box.checked = true;
|
||||
box.dataset = {taskId: "task-id", taskVersion: "7", price: "12.80"};
|
||||
const selectAll = new FakeElement();
|
||||
const summary = new FakeElement();
|
||||
const button = new FakeElement();
|
||||
const feedback = new FakeElement();
|
||||
const form = new FakeElement();
|
||||
form.dataset = {startKey: "start-key", csrf: "csrf-token"};
|
||||
form.querySelector = (selector) => ({
|
||||
"[data-select-all]": selectAll,
|
||||
"[data-selection-summary]": summary,
|
||||
"[data-start-button]": button,
|
||||
"[data-start-feedback]": feedback,
|
||||
})[selector] || null;
|
||||
form.querySelectorAll = (selector) => selector === "input[data-task-id]" ? [box] : [];
|
||||
|
||||
let reloadCount = 0;
|
||||
const context = {
|
||||
document: {querySelector: (selector) => selector === "[data-start-purchases]" ? form : null},
|
||||
fetch: fetchImplementation,
|
||||
window: {location: {reload: () => { reloadCount++; }}},
|
||||
};
|
||||
vm.runInNewContext(source, context, {filename: "tasks.js"});
|
||||
|
||||
return {
|
||||
box,
|
||||
button,
|
||||
feedback,
|
||||
reloads: () => reloadCount,
|
||||
submit: () => form.listeners.submit({preventDefault() {}}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user