perf(modal): unify cancellable frontend modal flows
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25ce1d8f43
commit
5375ad6429
36 files changed
+2623
-1806
No files matched your search
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Canonical VIP purchase and order polling coordinator.
|
||||
*
|
||||
* Purchase requests are deduplicated by package/payment method. External
|
||||
* orders use bounded, visibility-aware polling with AbortController support;
|
||||
* terminal states invalidate the user-scoped modal cache exactly once.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
var ORDER_PATH = "/pay/content/order/";
|
||||
var DEFAULT_MAX_ATTEMPTS = 12;
|
||||
var DEFAULT_MAX_ELAPSED = 120000;
|
||||
var MAX_DELAY = 15000;
|
||||
var createFlights = Object.create(null);
|
||||
var polls = Object.create(null);
|
||||
var SUCCESS_STATUSES = {
|
||||
paid: true,
|
||||
delivered: true,
|
||||
completed: true,
|
||||
pending_review: true,
|
||||
};
|
||||
var FAILURE_STATUSES = {
|
||||
cancelled: true,
|
||||
expired: true,
|
||||
refunded: true,
|
||||
failed: true,
|
||||
};
|
||||
|
||||
function userScope() {
|
||||
var config = window.aksrtData || window.aksrt || {};
|
||||
var userId = parseInt(config.userId || config.user_id || 0, 10) || 0;
|
||||
if (userId > 0) return "user:" + userId;
|
||||
return config.isLoggedIn ? "auth" : "guest";
|
||||
}
|
||||
|
||||
function clearFlights() {
|
||||
createFlights = Object.create(null);
|
||||
Object.keys(polls).forEach(function (orderNumber) {
|
||||
var active = polls[orderNumber];
|
||||
if (active && active.handle && typeof active.handle.abort === "function") active.handle.abort();
|
||||
});
|
||||
polls = Object.create(null);
|
||||
}
|
||||
|
||||
["aksrt:auth:logout", "aksrt:account-switch", "aksrt:logout", "aksrt:user:logout", "aksrt:auth:changed", "wp_logout"].forEach(function (eventName) {
|
||||
document.addEventListener(eventName, clearFlights);
|
||||
});
|
||||
|
||||
function responseData(result) {
|
||||
if (!result) return {};
|
||||
if (result.data !== undefined && result.data !== null) return result.data || {};
|
||||
if (result.json && result.json.data !== undefined && result.json.data !== null) return result.json.data || {};
|
||||
return result.json || {};
|
||||
}
|
||||
|
||||
function normalizeError(error) {
|
||||
var response = error && error.response ? error.response : {};
|
||||
var json = error && error.json ? error.json : (response.json || {});
|
||||
return {
|
||||
ok: false,
|
||||
status: Number((error && error.status) || response.status || 0),
|
||||
json: json || { message: error && error.message ? error.message : "请求失败" },
|
||||
};
|
||||
}
|
||||
|
||||
function request(path, options) {
|
||||
return window.AksrtRequest.request(path, options || {}).then(function (result) {
|
||||
return {
|
||||
ok: true,
|
||||
status: Number(result.status || 200),
|
||||
json: result.json || {},
|
||||
data: responseData(result),
|
||||
};
|
||||
}).catch(normalizeError);
|
||||
}
|
||||
|
||||
function invalidate(tags) {
|
||||
window.AksrtModalRuntime.invalidate(tags || ["vip", "orders"]);
|
||||
}
|
||||
|
||||
function callback(options, name) {
|
||||
var args = Array.prototype.slice.call(arguments, 2);
|
||||
if (!options || typeof options[name] !== "function") return;
|
||||
try {
|
||||
options[name].apply(null, args);
|
||||
} catch (error) {
|
||||
if (window.console && typeof window.console.error === "function") {
|
||||
window.console.error("[AKSRT VIP] callback failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function purchase(options) {
|
||||
options = options || {};
|
||||
var packageId = parseInt(options.packageId || options.package_id || 0, 10);
|
||||
var method = String(options.paymentMethod || options.payment_method || "balance");
|
||||
if (!packageId) {
|
||||
return Promise.resolve({ ok: false, status: 400, json: { message: "套餐无效" } });
|
||||
}
|
||||
|
||||
var key = userScope() + ":" + String(packageId) + ":" + method;
|
||||
if (createFlights[key]) return createFlights[key];
|
||||
|
||||
var promise = request("/pay/vip", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
package_id: packageId,
|
||||
payment_method: method,
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
createFlights[key] = promise;
|
||||
promise.then(function () {
|
||||
if (createFlights[key] === promise) delete createFlights[key];
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function exchange(options) {
|
||||
options = options || {};
|
||||
var packageId = parseInt(options.packageId || options.package_id || 0, 10);
|
||||
if (!packageId) {
|
||||
return Promise.resolve({ ok: false, status: 400, json: { message: "兑换套餐无效" } });
|
||||
}
|
||||
return request("/pay/vip/points-exchange", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ package_id: packageId }),
|
||||
signal: options.signal,
|
||||
}).then(function (response) {
|
||||
if (response.ok) invalidate(options.tags || ["vip", "orders"]);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
function isTerminal(status, data) {
|
||||
if (data && data.paid) return true;
|
||||
return !!(SUCCESS_STATUSES[status] || FAILURE_STATUSES[status]);
|
||||
}
|
||||
|
||||
function backoff(attempt) {
|
||||
return Math.min(MAX_DELAY, 1000 * Math.pow(2, Math.max(0, attempt - 1)));
|
||||
}
|
||||
|
||||
function poll(orderNumber, options) {
|
||||
options = options || {};
|
||||
orderNumber = String(orderNumber || "");
|
||||
if (!orderNumber) {
|
||||
return {
|
||||
promise: Promise.resolve({ terminal: true, success: false, status: "invalid" }),
|
||||
abort: function () {},
|
||||
};
|
||||
}
|
||||
if (polls[orderNumber]) return polls[orderNumber].handle;
|
||||
|
||||
var maxAttempts = Math.max(1, Number(options.maxAttempts || DEFAULT_MAX_ATTEMPTS));
|
||||
var maxElapsed = Math.max(1000, Number(options.maxElapsed || DEFAULT_MAX_ELAPSED));
|
||||
var state = {
|
||||
attempt: 0,
|
||||
startedAt: Date.now(),
|
||||
timer: null,
|
||||
controller: null,
|
||||
requestTimer: null,
|
||||
waitingForVisible: false,
|
||||
done: false,
|
||||
aborted: false,
|
||||
resolve: null,
|
||||
onVisibility: null,
|
||||
};
|
||||
|
||||
state.promise = new Promise(function (resolve) {
|
||||
state.resolve = resolve;
|
||||
});
|
||||
|
||||
function finish(result) {
|
||||
if (state.done) return;
|
||||
state.done = true;
|
||||
if (state.timer) {
|
||||
window.clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
if (state.requestTimer) {
|
||||
window.clearTimeout(state.requestTimer);
|
||||
state.requestTimer = null;
|
||||
}
|
||||
if (state.controller) {
|
||||
try { state.controller.abort(); } catch (error) {}
|
||||
state.controller = null;
|
||||
}
|
||||
if (state.onVisibility) document.removeEventListener("visibilitychange", state.onVisibility);
|
||||
if (polls[orderNumber] && polls[orderNumber].handle === state.handle) delete polls[orderNumber];
|
||||
state.resolve(result);
|
||||
}
|
||||
|
||||
function terminal(data, response, status) {
|
||||
var success = !!(data && data.paid) || !!SUCCESS_STATUSES[status];
|
||||
invalidate(options.tags || ["vip", "orders"]);
|
||||
callback(options, "onTerminal", {
|
||||
terminal: true,
|
||||
success: success,
|
||||
status: status,
|
||||
data: data || {},
|
||||
response: response || null,
|
||||
});
|
||||
finish({
|
||||
terminal: true,
|
||||
success: success,
|
||||
status: status,
|
||||
data: data || {},
|
||||
response: response || null,
|
||||
});
|
||||
}
|
||||
|
||||
function schedule(delay) {
|
||||
if (state.done || state.aborted) return;
|
||||
if (document.hidden) {
|
||||
state.waitingForVisible = true;
|
||||
return;
|
||||
}
|
||||
state.waitingForVisible = false;
|
||||
state.timer = window.setTimeout(run, Math.max(0, delay || 0));
|
||||
}
|
||||
|
||||
function run() {
|
||||
state.timer = null;
|
||||
if (state.done || state.aborted) return;
|
||||
if (document.hidden) {
|
||||
state.waitingForVisible = true;
|
||||
return;
|
||||
}
|
||||
if (state.attempt >= maxAttempts || Date.now() - state.startedAt >= maxElapsed) {
|
||||
callback(options, "onTimeout", { terminal: false, timedOut: true, orderNumber: orderNumber });
|
||||
finish({ terminal: false, timedOut: true, status: "timeout" });
|
||||
return;
|
||||
}
|
||||
|
||||
state.attempt += 1;
|
||||
state.controller = window.AbortController ? new window.AbortController() : null;
|
||||
var requestOptions = {};
|
||||
if (state.controller) requestOptions.signal = state.controller.signal;
|
||||
var remaining = Math.max(1000, maxElapsed - (Date.now() - state.startedAt));
|
||||
state.requestTimer = window.setTimeout(function () {
|
||||
if (state.controller) {
|
||||
try { state.controller.abort(); } catch (error) {}
|
||||
}
|
||||
}, Math.min(20000, remaining));
|
||||
request(ORDER_PATH + encodeURIComponent(orderNumber), requestOptions).then(function (response) {
|
||||
if (state.requestTimer) {
|
||||
window.clearTimeout(state.requestTimer);
|
||||
state.requestTimer = null;
|
||||
}
|
||||
state.controller = null;
|
||||
if (state.done || state.aborted) return;
|
||||
var data = response.data || response.json || {};
|
||||
var status = String(data.status || (data.paid ? "paid" : "")).toLowerCase();
|
||||
callback(options, "onStatus", { status: status, data: data, response: response });
|
||||
if (response.ok && isTerminal(status, data)) {
|
||||
terminal(data, response, status || "paid");
|
||||
return;
|
||||
}
|
||||
if (!response.ok && [401, 403, 404].indexOf(response.status) >= 0) {
|
||||
terminal(data, response, "error");
|
||||
return;
|
||||
}
|
||||
if (state.attempt >= maxAttempts || Date.now() - state.startedAt >= maxElapsed) {
|
||||
callback(options, "onTimeout", { terminal: false, timedOut: true, orderNumber: orderNumber });
|
||||
finish({ terminal: false, timedOut: true, status: "timeout", response: response });
|
||||
return;
|
||||
}
|
||||
schedule(backoff(state.attempt));
|
||||
});
|
||||
}
|
||||
|
||||
state.onVisibility = function () {
|
||||
if (document.hidden || state.done || state.aborted) return;
|
||||
if (state.waitingForVisible) {
|
||||
schedule(0);
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", state.onVisibility);
|
||||
|
||||
state.handle = {
|
||||
promise: state.promise,
|
||||
abort: function () {
|
||||
if (state.done) return;
|
||||
state.aborted = true;
|
||||
finish({ terminal: false, aborted: true, status: "aborted" });
|
||||
},
|
||||
};
|
||||
polls[orderNumber] = state;
|
||||
schedule(0);
|
||||
return state.handle;
|
||||
}
|
||||
|
||||
function start(options) {
|
||||
options = Object.assign({}, options || {});
|
||||
var activePoll = null;
|
||||
var stopped = false;
|
||||
var controller = window.AbortController ? new window.AbortController() : null;
|
||||
if (controller) options.signal = controller.signal;
|
||||
var promise = purchase(options).then(function (response) {
|
||||
if (stopped) return { ok: false, aborted: true, status: "aborted" };
|
||||
var data = response.data || response.json || {};
|
||||
if (!response.ok) {
|
||||
callback(options, "onError", response);
|
||||
return response;
|
||||
}
|
||||
if (data.redirect && (data.payment_url || data.qr_content)) {
|
||||
callback(options, "onPending", data);
|
||||
if (!data.order_number) {
|
||||
var missing = { ok: false, status: 500, json: { message: "支付订单号缺失" } };
|
||||
callback(options, "onError", missing);
|
||||
return missing;
|
||||
}
|
||||
activePoll = poll(data.order_number, options);
|
||||
activePoll.promise.then(function (result) {
|
||||
if (result.success) callback(options, "onSuccess", result);
|
||||
else if (result.terminal) callback(options, "onFailure", result);
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
pending: true,
|
||||
data: data,
|
||||
poll: activePoll,
|
||||
};
|
||||
}
|
||||
invalidate(options.tags || ["vip", "orders"]);
|
||||
callback(options, "onSuccess", { terminal: true, success: true, data: data, response: response });
|
||||
return { ok: true, pending: false, data: data, response: response };
|
||||
});
|
||||
|
||||
return {
|
||||
promise: promise,
|
||||
abort: function () {
|
||||
stopped = true;
|
||||
if (controller) {
|
||||
try { controller.abort(); } catch (error) {}
|
||||
}
|
||||
if (activePoll && typeof activePoll.abort === "function") activePoll.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.AksrtVipPurchaseCoordinator = {
|
||||
purchase: purchase,
|
||||
exchange: exchange,
|
||||
poll: poll,
|
||||
start: start,
|
||||
abort: function (orderNumber) {
|
||||
var active = polls[String(orderNumber || "")];
|
||||
if (active && active.handle) active.handle.abort();
|
||||
},
|
||||
};
|
||||
})(window, document);
|
||||
Reference in new issue
Block a user