Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.
Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)
Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
(dropped 3 stale entries, added the 17 missing ones)
Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
when it is empty
Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.
Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
536 lines
20 KiB
JavaScript
536 lines
20 KiB
JavaScript
/**
|
||
* Canonical frontend modal runtime.
|
||
*
|
||
* Owns the modal shell while adding cancellable loaders,
|
||
* short in-memory caching, request deduplication, retryable errors, focus
|
||
* restoration, and legacy `loaded.bs.modal` content lifecycle (auto_fun rescan).
|
||
*
|
||
* @package Jio
|
||
*/
|
||
(function (window, document) {
|
||
"use strict";
|
||
|
||
var ROOT_ID = "jio-modal-runtime-root";
|
||
var active = null;
|
||
var sessions = [];
|
||
var sequence = 0;
|
||
var loaders = Object.create(null);
|
||
|
||
function isMobile() {
|
||
return window.matchMedia && window.matchMedia("(max-width: 768px)").matches;
|
||
}
|
||
|
||
function ensureRoot() {
|
||
var root = document.getElementById(ROOT_ID);
|
||
if (root) return root;
|
||
root = document.createElement("div");
|
||
root.id = ROOT_ID;
|
||
root.className = "jio-modal-runtime-host";
|
||
document.body.appendChild(root);
|
||
return root;
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value == null ? "" : value)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/\"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
// 参考主题 Bootstrap Modal setScrollbar/resetScrollbar 等价物:
|
||
// overflow:hidden 会去掉页面滚动条,版心与 fixed 顶栏随之变宽而横向跳动,
|
||
// 因此锁定前测量滚动条宽度,给 body 与 fixed 元素补同宽 padding-right,解锁时还原。
|
||
var SCROLLBAR_FIXED = ".nav-fixed .header, .float-right";
|
||
var scrollLock = null;
|
||
|
||
function padRight(element, gap) {
|
||
var inline = element.style.paddingRight;
|
||
var computed = parseFloat(window.getComputedStyle(element).paddingRight) || 0;
|
||
element.style.paddingRight = computed + gap + "px";
|
||
return { element: element, inline: inline };
|
||
}
|
||
|
||
function lockScroll(lock) {
|
||
var html = document.documentElement;
|
||
var body = document.body;
|
||
if (lock) {
|
||
if (scrollLock) return;
|
||
var gap = window.innerWidth - html.clientWidth;
|
||
scrollLock = [];
|
||
if (gap > 0) {
|
||
scrollLock.push(padRight(body, gap));
|
||
document.querySelectorAll(SCROLLBAR_FIXED).forEach(function (element) {
|
||
scrollLock.push(padRight(element, gap));
|
||
});
|
||
}
|
||
html.classList.add("jio-modal-runtime-open");
|
||
body.classList.add("jio-modal-runtime-open");
|
||
return;
|
||
}
|
||
if (!scrollLock) return;
|
||
html.classList.remove("jio-modal-runtime-open");
|
||
body.classList.remove("jio-modal-runtime-open");
|
||
scrollLock.forEach(function (entry) {
|
||
entry.element.style.paddingRight = entry.inline;
|
||
});
|
||
scrollLock = null;
|
||
}
|
||
|
||
function skeleton(options) {
|
||
if (options && options.loading === "spinner") {
|
||
return '<div class="jio-modal-runtime__loading flex jc"><div class="em2x opacity5"><svg class="loading-circle" viewBox="0 0 50 50"><circle cx="25" cy="25" r="20"></circle></svg></div></div>';
|
||
}
|
||
return (options && options.skeleton) ||
|
||
'<div class="jio-modal-runtime__skeleton" aria-live="polite" aria-busy="true">' +
|
||
'<span class="jio-modal-runtime__skeleton-line is-wide"></span>' +
|
||
'<span class="jio-modal-runtime__skeleton-line"></span>' +
|
||
'<span class="jio-modal-runtime__skeleton-line is-short"></span>' +
|
||
'<span class="jio-modal-runtime__skeleton-block"></span>' +
|
||
'</div>';
|
||
}
|
||
|
||
function telemetryStart(kind) {
|
||
if (kind === false) return "";
|
||
return window.JioModalTelemetry.start(kind || "generic");
|
||
}
|
||
|
||
function telemetryMark(session, event) {
|
||
if (!session || !session.telemetryId) return;
|
||
window.JioModalTelemetry.mark(session.telemetryId, event);
|
||
}
|
||
|
||
function isLive(session) {
|
||
return !!session && !session.closed && sessions.indexOf(session) !== -1;
|
||
}
|
||
|
||
function setState(session, status, error) {
|
||
session.state.status = status;
|
||
session.state.error = error || null;
|
||
session.state.updatedAt = Date.now();
|
||
if (session.shell) session.shell.setAttribute("data-jio-modal-state", status);
|
||
if (typeof session.options.onState === "function") {
|
||
session.options.onState(status, session);
|
||
}
|
||
}
|
||
|
||
function focusable(shell) {
|
||
return shell.querySelectorAll("button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])");
|
||
}
|
||
|
||
function closeSelector() {
|
||
return "[data-jio-modal-close], .jio-modal-runtime__close";
|
||
}
|
||
|
||
function bindChrome(session) {
|
||
var shell = session.shell;
|
||
shell.addEventListener("click", function (event) {
|
||
if (active !== session) return;
|
||
var closeButton = event.target.closest(closeSelector());
|
||
if (closeButton) {
|
||
event.preventDefault();
|
||
close("control");
|
||
return;
|
||
}
|
||
if (event.target === shell || event.target.classList.contains("jio-modal-runtime__overlay")) {
|
||
close("overlay");
|
||
return;
|
||
}
|
||
var retry = event.target.closest("[data-jio-modal-retry]");
|
||
if (retry) {
|
||
event.preventDefault();
|
||
refresh();
|
||
}
|
||
});
|
||
|
||
shell.addEventListener("keydown", function (event) {
|
||
if (active !== session) return;
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
close("escape");
|
||
return;
|
||
}
|
||
if (event.key !== "Tab") return;
|
||
var nodes = focusable(shell);
|
||
if (!nodes.length) {
|
||
event.preventDefault();
|
||
shell.focus();
|
||
return;
|
||
}
|
||
var first = nodes[0];
|
||
var last = nodes[nodes.length - 1];
|
||
if (event.shiftKey && document.activeElement === first) {
|
||
event.preventDefault();
|
||
last.focus();
|
||
} else if (!event.shiftKey && document.activeElement === last) {
|
||
event.preventDefault();
|
||
first.focus();
|
||
}
|
||
});
|
||
}
|
||
|
||
function createSession(options) {
|
||
options = options || {};
|
||
var root = ensureRoot();
|
||
var shell = document.createElement("div");
|
||
var bottom = options.bottom === true || (options.mobileBottom !== false && isMobile());
|
||
var heightStyle = options.height ? "min-height:" + parseInt(options.height, 10) + "px;" : "";
|
||
var initialHtml = options.html !== undefined ? options.html : skeleton(options);
|
||
var swipeHandle = bottom && options.swipeClose ? '<div class="touch-close" aria-hidden="true"></div>' : "";
|
||
var titleId = "jio-modal-title-" + String(sequence + 1);
|
||
var titleHtml = options.title
|
||
? '<header class="jio-modal-runtime__header"><h3 class="jio-modal-runtime__title" id="' + titleId + '">' + escapeHtml(options.title) + '</h3><button type="button" class="jio-modal-runtime__close" data-jio-modal-close aria-label="关闭">×</button></header>'
|
||
: "";
|
||
|
||
shell.className =
|
||
"jio-modal-runtime" +
|
||
(active ? " jio-modal-runtime--child" : "") +
|
||
(bottom ? " jio-modal-runtime--bottom" : "") +
|
||
(options.className ? " " + options.className : "");
|
||
shell.setAttribute("role", "dialog");
|
||
shell.setAttribute("aria-modal", "true");
|
||
if (options.title) shell.setAttribute("aria-labelledby", titleId);
|
||
shell.setAttribute("tabindex", "-1");
|
||
shell.innerHTML =
|
||
'<div class="jio-modal-runtime__overlay" data-jio-modal-close></div>' +
|
||
'<div class="jio-modal-runtime__dialog" style="' + heightStyle + '">' +
|
||
titleHtml +
|
||
'<div class="jio-modal-runtime__content">' + initialHtml + "</div>" + swipeHandle + "</div>";
|
||
|
||
var session = {
|
||
id: String(++sequence),
|
||
shell: shell,
|
||
content: shell.querySelector(".jio-modal-runtime__content"),
|
||
options: options,
|
||
state: window.JioModalState.create(options.kind || "generic"),
|
||
focusReturn: options.focusReturn !== undefined ? options.focusReturn : document.activeElement,
|
||
controller: null,
|
||
flight: null,
|
||
closed: false,
|
||
telemetryId: telemetryStart(options.telemetry === false ? false : options.kind),
|
||
};
|
||
|
||
if (active) {
|
||
active.shell.classList.add("jio-modal-runtime--parent");
|
||
active.shell.setAttribute("aria-modal", "false");
|
||
active.shell.setAttribute("aria-hidden", "true");
|
||
active.shell.setAttribute("inert", "");
|
||
}
|
||
root.appendChild(shell);
|
||
bindChrome(session);
|
||
if (bottom && options.swipeClose && window.jQuery && window.jQuery.fn && typeof window.jQuery.fn.minitouch === "function") {
|
||
window.jQuery(shell).minitouch({
|
||
direction: "bottom",
|
||
selector: ".jio-modal-runtime__dialog",
|
||
start_selector: ".modal-colorful-header,.touch-close,.touch",
|
||
depreciation: 50,
|
||
stop: function () { return active !== session; },
|
||
onEnd: function () {
|
||
if (active === session) close("swipe");
|
||
},
|
||
});
|
||
}
|
||
sessions.push(session);
|
||
active = session;
|
||
lockScroll(true);
|
||
void shell.offsetWidth;
|
||
shell.classList.add("is-open");
|
||
setState(session, options.html !== undefined ? "ready" : "loading");
|
||
telemetryMark(session, "shell");
|
||
|
||
var nodes = focusable(shell);
|
||
try {
|
||
(nodes.length ? nodes[0] : shell).focus();
|
||
} catch (error) {}
|
||
|
||
document.dispatchEvent(new CustomEvent("jio:modal:open", {
|
||
detail: { shell: shell, options: options, session: session },
|
||
}));
|
||
|
||
if (options.html !== undefined) {
|
||
notifyContentLoaded(session);
|
||
telemetryMark(session, "first-content");
|
||
telemetryMark(session, "ready");
|
||
if (typeof options.onContent === "function") options.onContent(options.html, session);
|
||
}
|
||
|
||
return session;
|
||
}
|
||
|
||
// legacy 契约:modal 内容注入后触发 loaded.bs.modal,
|
||
// 由全局委托(见文件底部)驱动 auto_fun 重扫,不做模块内扫描。
|
||
function notifyContentLoaded(session) {
|
||
if (!session || !session.shell || !window.jQuery) return;
|
||
window.jQuery(session.shell).trigger("loaded.bs.modal");
|
||
}
|
||
|
||
function abortSessionRequest(session) {
|
||
if (!session) return;
|
||
if (session.flight && typeof session.flight.release === "function") session.flight.release();
|
||
session.flight = null;
|
||
if (session.controller) {
|
||
try { session.controller.abort(); } catch (error) {}
|
||
}
|
||
session.controller = null;
|
||
}
|
||
|
||
function removeActive(reason, preserveRequest, resumeParent) {
|
||
if (!active) return;
|
||
var session = active;
|
||
if (sessions[sessions.length - 1] !== session) return;
|
||
sessions.pop();
|
||
active = sessions[sessions.length - 1] || null;
|
||
session.closed = true;
|
||
if (!preserveRequest) abortSessionRequest(session);
|
||
session.shell.classList.remove("is-open");
|
||
if (active && resumeParent) {
|
||
active.shell.classList.remove("jio-modal-runtime--parent");
|
||
active.shell.setAttribute("aria-modal", "true");
|
||
active.shell.removeAttribute("aria-hidden");
|
||
active.shell.removeAttribute("inert");
|
||
}
|
||
if (!active && !preserveRequest) lockScroll(false);
|
||
document.dispatchEvent(new CustomEvent("jio:modal:close", {
|
||
detail: { shell: session.shell, reason: reason || "close", session: session },
|
||
}));
|
||
|
||
var node = session.shell;
|
||
var exitDelay = 220;
|
||
var dialog = node.querySelector(".jio-modal-runtime__dialog");
|
||
if (dialog) {
|
||
var durations = window.getComputedStyle(dialog).transitionDuration || "";
|
||
durations.split(",").forEach(function (duration) {
|
||
var milliseconds = parseFloat(duration) * (duration.trim().slice(-2) === "ms" ? 1 : 1000);
|
||
if (milliseconds > exitDelay) exitDelay = milliseconds;
|
||
});
|
||
}
|
||
window.setTimeout(function () {
|
||
if (node && node.parentNode) node.parentNode.removeChild(node);
|
||
var target = session.focusReturn;
|
||
if (!preserveRequest && resumeParent && active) {
|
||
var canRestoreTarget = target && target !== node && active.shell.contains(target) && typeof target.focus === "function";
|
||
var fallbackNodes = canRestoreTarget ? [] : focusable(active.shell);
|
||
var fallback = fallbackNodes.length ? fallbackNodes[0] : active.shell;
|
||
try { (canRestoreTarget ? target : fallback).focus(); } catch (error) {}
|
||
} else if (!preserveRequest && resumeParent && !active && target && target !== node && document.contains(target) && typeof target.focus === "function") {
|
||
try { target.focus(); } catch (error) {}
|
||
}
|
||
}, exitDelay);
|
||
}
|
||
|
||
function close(reason, preserveRequest) {
|
||
removeActive(reason, preserveRequest, true);
|
||
}
|
||
|
||
function responseData(result) {
|
||
if (!result) return null;
|
||
if (result.data !== undefined && result.data !== null) return result.data;
|
||
if (result.json && result.json.data !== undefined) return result.json.data;
|
||
return result.json;
|
||
}
|
||
|
||
function responseHtml(result) {
|
||
var data = responseData(result);
|
||
if (data && typeof data.html === "string") return data.html;
|
||
if (result && result.json && typeof result.json.html === "string") return result.json.html;
|
||
return result && typeof result.text === "string" ? result.text : "";
|
||
}
|
||
|
||
function errorMessage(error) {
|
||
if (error && error.code === "unauthorized") return "请先登录";
|
||
return error && error.message ? String(error.message) : "加载失败,请重试";
|
||
}
|
||
|
||
function renderError(session, error) {
|
||
if (!isLive(session)) return;
|
||
var unauthorized = error && error.code === "unauthorized";
|
||
setState(session, unauthorized ? "unauthorized" : "error", error);
|
||
telemetryMark(session, unauthorized ? "unauthorized" : "error");
|
||
session.content.innerHTML =
|
||
'<div class="jio-modal-runtime__error touch border-title"><b>提示</b></div>' +
|
||
'<div class="jio-modal-runtime__error-body">' + escapeHtml(errorMessage(error)) +
|
||
'</div><div class="but-average modal-buts">' +
|
||
'<button type="button" class="but" data-jio-modal-retry>重试</button>' +
|
||
'<button type="button" class="but" data-jio-modal-close>关闭</button></div>';
|
||
notifyContentLoaded(session);
|
||
if (typeof session.options.onError === "function") session.options.onError(error, session);
|
||
}
|
||
|
||
function renderResult(session, result) {
|
||
if (!isLive(session)) return;
|
||
|
||
if (session.options.responseType === "json" || typeof session.options.renderData === "function") {
|
||
var data = responseData(result);
|
||
if (data === null || data === undefined) {
|
||
setState(session, "empty");
|
||
telemetryMark(session, "first-content");
|
||
telemetryMark(session, "ready");
|
||
session.content.innerHTML = '<div class="jio-modal-runtime__error-body">暂无内容</div>';
|
||
return;
|
||
}
|
||
if (typeof session.options.renderData === "function") {
|
||
session.options.renderData(data, session, result);
|
||
}
|
||
setState(session, "ready");
|
||
telemetryMark(session, "first-content");
|
||
notifyContentLoaded(session);
|
||
telemetryMark(session, "ready");
|
||
if (typeof session.options.onData === "function") session.options.onData(data, session, result);
|
||
return;
|
||
}
|
||
|
||
var html = responseHtml(result);
|
||
if (!html) {
|
||
setState(session, "empty");
|
||
telemetryMark(session, "first-content");
|
||
telemetryMark(session, "ready");
|
||
session.content.innerHTML = '<div class="jio-modal-runtime__error-body">暂无内容</div>';
|
||
return;
|
||
}
|
||
session.content.innerHTML = html;
|
||
setState(session, "ready");
|
||
telemetryMark(session, "first-content");
|
||
notifyContentLoaded(session);
|
||
telemetryMark(session, "ready");
|
||
if (typeof session.options.onContent === "function") session.options.onContent(html, session);
|
||
}
|
||
|
||
function load(session, loadOptions) {
|
||
loadOptions = loadOptions || {};
|
||
if (!session || session.closed || !session.options.url) return Promise.resolve(session && session.shell);
|
||
abortSessionRequest(session);
|
||
setState(session, "loading");
|
||
session.content.innerHTML = skeleton(session.options);
|
||
session.controller = window.AbortController ? new window.AbortController() : null;
|
||
|
||
var requestOptions = {
|
||
method: session.options.method || "GET",
|
||
headers: session.options.headers || {},
|
||
body: session.options.body,
|
||
query: session.options.query,
|
||
signal: session.controller ? session.controller.signal : undefined,
|
||
};
|
||
if (requestOptions.body === undefined) delete requestOptions.body;
|
||
if (!requestOptions.signal) delete requestOptions.signal;
|
||
|
||
var request = function () {
|
||
return window.JioRequest.request(session.options.url, requestOptions);
|
||
};
|
||
|
||
var method = String(session.options.method || "GET").toUpperCase();
|
||
var cacheable = method === "GET" && session.options.cache !== false;
|
||
var cacheKey = session.options.cacheKey ||
|
||
method + ":" + window.JioRequest.url(session.options.url, session.options.query);
|
||
var cache = window.JioModalCache;
|
||
var acquireOptions = {
|
||
ttl: Number(session.options.cacheTtl || 15000),
|
||
tags: session.options.cacheTags || session.options.tags || [],
|
||
skipCache: !!loadOptions.force || !cacheable,
|
||
store: cacheable,
|
||
cancel: function () {
|
||
if (session.controller) {
|
||
try { session.controller.abort(); } catch (error) {}
|
||
}
|
||
},
|
||
};
|
||
|
||
var flight = cacheable
|
||
? cache.acquire(cacheKey, request, acquireOptions)
|
||
: { promise: request(), release: function () {} };
|
||
session.flight = flight;
|
||
|
||
return flight.promise.then(function (result) {
|
||
if (isLive(session)) renderResult(session, result);
|
||
return session.shell;
|
||
}).catch(function (error) {
|
||
var aborted = error && (error.name === "AbortError" || error.code === "ABORT_ERR");
|
||
if (!aborted && isLive(session)) renderError(session, error);
|
||
return session.shell;
|
||
}).then(function (shell) {
|
||
// A refresh/replacement may have installed a newer flight on this session.
|
||
// Only the owner of this promise may release and clear the slot.
|
||
if (session.flight === flight) {
|
||
if (flight && typeof flight.release === "function") flight.release();
|
||
session.flight = null;
|
||
}
|
||
return shell;
|
||
});
|
||
}
|
||
|
||
function open(options) {
|
||
options = options || {};
|
||
if (!options.newModal || !active) {
|
||
if (active && !options.newModal) options.focusReturn = active.focusReturn;
|
||
removeActive("replace", false, false);
|
||
}
|
||
var session = createSession(options);
|
||
return session.shell;
|
||
}
|
||
|
||
function sessionKey(options) {
|
||
if (!options || !options.url) return "";
|
||
var method = String(options.method || "GET").toUpperCase();
|
||
var requestUrl = window.JioRequest.url(options.url, options.query);
|
||
return method + ":" + requestUrl;
|
||
}
|
||
|
||
function openRemote(options) {
|
||
options = Object.assign({}, options || {});
|
||
if (!options.url) return Promise.reject(new Error("missing url"));
|
||
var sameRequest = active && active.flight && sessionKey(active.options) === sessionKey(options);
|
||
if (!options.newModal || !active) {
|
||
if (active && !options.newModal) options.focusReturn = active.focusReturn;
|
||
removeActive("replace", !!sameRequest, false);
|
||
}
|
||
var session = createSession(options);
|
||
return load(session).then(function () { return session.shell; });
|
||
}
|
||
|
||
function refresh(options) {
|
||
if (!active || !active.options.url) return Promise.resolve(null);
|
||
if (options && typeof options === "object") active.options = Object.assign({}, active.options, options);
|
||
return load(active, { force: true });
|
||
}
|
||
|
||
function registerLoader(kind, loader) {
|
||
if (!kind || typeof loader !== "function") return;
|
||
loaders[String(kind)] = loader;
|
||
}
|
||
|
||
function openLoader(kind, context) {
|
||
var loader = loaders[String(kind)];
|
||
if (!loader) return Promise.reject(new Error("missing modal loader: " + kind));
|
||
return openRemote(loader(context || {}));
|
||
}
|
||
|
||
function invalidate(tags) {
|
||
return window.JioModalCache.invalidate(tags);
|
||
}
|
||
|
||
window.JioModalRuntime = {
|
||
open: open,
|
||
openRemote: openRemote,
|
||
close: close,
|
||
refresh: refresh,
|
||
registerLoader: registerLoader,
|
||
openLoader: openLoader,
|
||
invalidate: invalidate,
|
||
getActive: function () { return active ? active.shell : null; },
|
||
getState: function () { return active ? active.state : null; },
|
||
};
|
||
|
||
document.dispatchEvent(new CustomEvent("jio:modal-runtime:ready"));
|
||
|
||
// legacy 契约(只绑定一次):modal 展示/内容注入后调用全局 auto_fun 重扫。
|
||
if (window.jQuery) {
|
||
window.jQuery(document).on(
|
||
"loaded.bs.modal show.bs.modal",
|
||
".modal, .jio-modal-runtime",
|
||
function () {
|
||
if (typeof window.auto_fun === "function") window.auto_fun();
|
||
}
|
||
);
|
||
}
|
||
})(window, document);
|