From d79b063bf9b4754428a9b7ff252dd73e4b3c6f16 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 4 Aug 2026 23:24:37 +0800 Subject: [PATCH 1/2] feat(modal): add cancellable shared runtime Co-Authored-By: Claude Fable 5 --- assets/css/frontend/remote-modal.css | 43 +++ assets/css/frontend/vip-modal.css | 17 ++ assets/js/core/modal-cache.js | 150 ++++++++++ assets/js/core/modal-runtime.js | 416 +++++++++++++++++++++++++++ assets/js/core/modal-state.js | 34 +++ assets/js/core/remote-modal.js | 269 ++++------------- assets/js/core/request.js | 157 ++++++++++ assets/js/order-center.js | 42 +-- assets/js/pay-vip.js | 195 ++++--------- assets/js/user-badges.js | 41 +-- src/Core/ScriptsPart1b.php | 8 +- src/Core/ScriptsPart1c.php | 19 +- 12 files changed, 999 insertions(+), 392 deletions(-) create mode 100644 assets/js/core/modal-cache.js create mode 100644 assets/js/core/modal-runtime.js create mode 100644 assets/js/core/modal-state.js create mode 100644 assets/js/core/request.js diff --git a/assets/css/frontend/remote-modal.css b/assets/css/frontend/remote-modal.css index 044a33e5..6783d12c 100644 --- a/assets/css/frontend/remote-modal.css +++ b/assets/css/frontend/remote-modal.css @@ -83,6 +83,49 @@ body.aksrt-remote-modal-open { font-size: 14px; } +.aksrt-remote-modal__skeleton { + display: grid; + gap: 12px; + padding: 8px 2px; +} + +.aksrt-remote-modal__skeleton-line, +.aksrt-remote-modal__skeleton-block { + display: block; + border-radius: 8px; + background: linear-gradient(90deg, rgba(148, 163, 184, 0.16), rgba(148, 163, 184, 0.3), rgba(148, 163, 184, 0.16)); + background-size: 200% 100%; + animation: aksrt-modal-skeleton 1.2s ease-in-out infinite; +} + +.aksrt-remote-modal__skeleton-line { + width: 72%; + height: 12px; +} + +.aksrt-remote-modal__skeleton-line.is-wide { + width: 92%; + height: 18px; +} + +.aksrt-remote-modal__skeleton-line.is-short { + width: 44%; +} + +.aksrt-remote-modal__skeleton-block { + width: 100%; + min-height: 84px; +} + +.aksrt-remote-modal__retry { + cursor: pointer; +} + +@keyframes aksrt-modal-skeleton { + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } +} + .aksrt-remote-modal .touch.border-title, .aksrt-remote-modal .border-title { font-size: 16px; diff --git a/assets/css/frontend/vip-modal.css b/assets/css/frontend/vip-modal.css index 8349818e..bd68c1ce 100644 --- a/assets/css/frontend/vip-modal.css +++ b/assets/css/frontend/vip-modal.css @@ -30,6 +30,23 @@ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.22); } +/* VIP now uses the shared modal runtime host. */ +.aksrt-remote-modal.aksrt-vip-modal-shell { + z-index: 1200; +} + +.aksrt-remote-modal.aksrt-vip-modal-shell .aksrt-remote-modal__dialog { + width: min(560px, 100%); + max-height: min(90vh, 760px); + background: var(--main-bg-color, #fff); + border-radius: 12px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.22); +} + +.aksrt-remote-modal.aksrt-vip-modal-shell .aksrt-remote-modal__content { + padding: 0; +} + .payvip-modal { position: relative; padding: 18px 16px 16px; diff --git a/assets/js/core/modal-cache.js b/assets/js/core/modal-cache.js new file mode 100644 index 00000000..c7fe8eaf --- /dev/null +++ b/assets/js/core/modal-cache.js @@ -0,0 +1,150 @@ +/** + * User-scoped in-memory modal cache with short TTLs and request deduplication. + * Private modal payloads are never persisted to browser storage. + * + * @package AKSRT + */ +(function (window, document) { + "use strict"; + + var DEFAULT_TTL = 15000; + var entries = Object.create(null); + var flights = Object.create(null); + + function scope() { + var config = window.aksrtData || window.aksrt || {}; + var userId = parseInt(config.userId || 0, 10) || 0; + return (config.isLoggedIn && userId > 0 ? "user:" + userId : "guest") + ":v1"; + } + + function token(key) { + return scope() + "::" + String(key || ""); + } + + function read(key) { + var id = token(key); + var entry = entries[id]; + if (!entry) return { hit: false, value: undefined }; + if (entry.expiresAt <= Date.now()) { + delete entries[id]; + return { hit: false, value: undefined }; + } + return { hit: true, value: entry.value }; + } + + function tagsOf(value) { + return Array.isArray(value) ? value.slice() : (value ? [value] : []); + } + + function set(key, value, options) { + options = options || {}; + var ttl = Number(options.ttl || DEFAULT_TTL); + entries[token(key)] = { + value: value, + expiresAt: Date.now() + Math.max(0, ttl), + tags: tagsOf(options.tags), + }; + return value; + } + + function matchesTags(entry, wanted) { + if (!wanted.length) return true; + return wanted.some(function (tag) { return entry.tags.indexOf(tag) >= 0; }); + } + + function invalidate(tags) { + var wanted = Array.isArray(tags) ? tags : (tags ? [tags] : []); + var removed = 0; + Object.keys(entries).forEach(function (id) { + if (!wanted.length || matchesTags(entries[id], wanted)) { + delete entries[id]; + removed += 1; + } + }); + Object.keys(flights).forEach(function (id) { + var flight = flights[id]; + if (!wanted.length || matchesTags(flight, wanted)) { + flight.cancelled = true; + delete flights[id]; + if (typeof flight.cancel === "function") flight.cancel(); + } + }); + return removed; + } + + function clear() { + entries = Object.create(null); + Object.keys(flights).forEach(function (id) { + var flight = flights[id]; + flight.cancelled = true; + if (typeof flight.cancel === "function") flight.cancel(); + }); + flights = Object.create(null); + } + + function acquire(key, loader, options) { + options = options || {}; + var id = token(key); + var cached = options.skipCache ? { hit: false } : read(key); + + if (cached.hit) { + return { + cached: true, + promise: Promise.resolve(cached.value), + release: function () {}, + }; + } + + var flight = flights[id]; + if (!flight) { + flight = { + refs: 0, + settled: false, + cancelled: false, + tags: tagsOf(options.tags), + cancel: options.cancel, + }; + flight.promise = Promise.resolve().then(loader).then(function (value) { + flight.settled = true; + if (!flight.cancelled && options.store !== false) set(key, value, options); + if (flights[id] === flight) delete flights[id]; + return value; + }, function (error) { + flight.settled = true; + if (flights[id] === flight) delete flights[id]; + throw error; + }); + flights[id] = flight; + } + + flight.refs += 1; + var released = false; + return { + cached: false, + promise: flight.promise, + release: function () { + if (released) return; + released = true; + flight.refs = Math.max(0, flight.refs - 1); + if (flight.refs === 0 && !flight.settled) { + flight.cancelled = true; + if (flights[id] === flight) delete flights[id]; + if (typeof flight.cancel === "function") flight.cancel(); + } + }, + }; + } + + ["aksrt:auth:logout", "aksrt:account-switch", "aksrt:logout", "aksrt:user:logout", "aksrt:auth:changed", "wp_logout"].forEach(function (eventName) { + document.addEventListener(eventName, clear); + }); + + window.AksrtModalCache = { + get: function (key) { return read(key); }, + set: set, + acquire: acquire, + invalidate: invalidate, + clear: clear, + scope: scope, + }; +})(window, document); diff --git a/assets/js/core/modal-runtime.js b/assets/js/core/modal-runtime.js new file mode 100644 index 00000000..d4d417e3 --- /dev/null +++ b/assets/js/core/modal-runtime.js @@ -0,0 +1,416 @@ +/** + * Canonical frontend modal runtime. + * + * Keeps the existing remote-modal shell while adding cancellable loaders, + * short in-memory caching, request deduplication, retryable errors, focus + * restoration, and modal-scoped rehydration. + * + * @package AKSRT + */ +(function (window, document) { + "use strict"; + + var ROOT_ID = "aksrt-remote-modal-root"; + var active = null; + 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 = "aksrt-remote-modal-host"; + document.body.appendChild(root); + return root; + } + + function escapeHtml(value) { + return String(value == null ? "" : value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); + } + + function lockScroll(lock) { + document.documentElement.classList.toggle("aksrt-remote-modal-open", !!lock); + document.body.classList.toggle("aksrt-remote-modal-open", !!lock); + } + + function skeleton(options) { + return (options && options.skeleton) || + '
' + + '' + + '' + + '' + + '' + + '
'; + } + + function telemetryStart(kind) { + if (kind === false) return ""; + var telemetry = window.AksrtModalTelemetry; + if (!telemetry || typeof telemetry.start !== "function") return ""; + return telemetry.start(kind || "unknown"); + } + + function telemetryMark(session, event) { + var telemetry = window.AksrtModalTelemetry; + if (!session || !session.telemetryId || !telemetry || typeof telemetry.mark !== "function") return; + telemetry.mark(session.telemetryId, event); + } + + 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-aksrt-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-aksrt-remote-modal-close], [data-aksrt-modal-close], [data-dismiss='modal'], .aksrt-remote-modal__close, .aksrt-vip-modal-close, .payvip-modal .close"; + } + + function bindChrome(session) { + var shell = session.shell; + shell.addEventListener("click", function (event) { + var closeButton = event.target.closest(closeSelector()); + if (closeButton) { + event.preventDefault(); + close("control"); + return; + } + if (event.target === shell || event.target.classList.contains("aksrt-remote-modal__overlay")) { + close("overlay"); + return; + } + var retry = event.target.closest("[data-aksrt-remote-modal-retry], [data-aksrt-modal-retry]"); + if (retry) { + event.preventDefault(); + refresh(); + } + }); + + shell.addEventListener("keydown", function (event) { + 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); + + shell.className = + "aksrt-remote-modal" + + (bottom ? " aksrt-remote-modal--bottom" : "") + + (options.className ? " " + options.className : ""); + shell.setAttribute("role", "dialog"); + shell.setAttribute("aria-modal", "true"); + shell.setAttribute("tabindex", "-1"); + shell.innerHTML = + '
' + + '
' + + '
' + initialHtml + "
"; + + var session = { + id: String(++sequence), + shell: shell, + content: shell.querySelector(".aksrt-remote-modal__content"), + options: options, + state: window.AksrtModalState && typeof window.AksrtModalState.create === "function" + ? window.AksrtModalState.create(options.kind || "generic") + : { status: "idle", error: null }, + focusReturn: document.activeElement, + controller: null, + flight: null, + closed: false, + telemetryId: telemetryStart(options.telemetry === false ? false : options.kind), + }; + + root.appendChild(shell); + bindChrome(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("aksrt:remote-modal:open", { + detail: { shell: shell, options: options, session: session }, + })); + document.dispatchEvent(new CustomEvent("aksrt:modal:open", { + detail: { shell: shell, options: options, session: session }, + })); + + if (options.html !== undefined) { + rehydrate(session); + telemetryMark(session, "first-content"); + telemetryMark(session, "ready"); + if (typeof options.onContent === "function") options.onContent(options.html, session); + } + + return session; + } + + function rehydrate(session) { + if (!session || !session.content) return; + if (window.AksrtFrontend && typeof window.AksrtFrontend.rehydrate === "function") { + window.AksrtFrontend.rehydrate(session.content, "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 close(reason, preserveRequest) { + if (!active) return; + var session = active; + active = null; + session.closed = true; + if (!preserveRequest) abortSessionRequest(session); + session.shell.classList.remove("is-open"); + if (!preserveRequest) lockScroll(false); + document.dispatchEvent(new CustomEvent("aksrt:remote-modal:close", { + detail: { shell: session.shell, reason: reason || "close", session: session }, + })); + document.dispatchEvent(new CustomEvent("aksrt:modal:close", { + detail: { shell: session.shell, reason: reason || "close", session: session }, + })); + + var node = session.shell; + window.setTimeout(function () { + if (node && node.parentNode) node.parentNode.removeChild(node); + var target = session.focusReturn; + if (!preserveRequest && !active && target && target !== node && document.contains(target) && typeof target.focus === "function") { + try { target.focus(); } catch (error) {} + } + }, 220); + } + + function responseHtml(result) { + if (!result) return ""; + var json = result.json; + if (json) { + if (typeof json.html === "string") return json.html; + if (json.data && typeof json.data.html === "string") return json.data.html; + if (json.data && typeof json.data.show_modal === "string") return json.data.show_modal; + if (typeof json.data === "string") return json.data; + } + return 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 (!session || session.closed || active !== session) return; + var unauthorized = error && error.code === "unauthorized"; + setState(session, unauthorized ? "unauthorized" : "error", error); + telemetryMark(session, unauthorized ? "unauthorized" : "error"); + session.content.innerHTML = + '
提示
' + + '
' + escapeHtml(errorMessage(error)) + + '
'; + rehydrate(session); + if (typeof session.options.onError === "function") session.options.onError(error, session); + } + + function renderResult(session, result) { + if (!session || session.closed || active !== session) return; + var html = responseHtml(result); + if (!html) { + setState(session, "empty"); + telemetryMark(session, "first-content"); + telemetryMark(session, "ready"); + session.content.innerHTML = '
暂无内容
'; + return; + } + session.content.innerHTML = html; + setState(session, "ready"); + telemetryMark(session, "first-content"); + rehydrate(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 = window.AksrtRequest && typeof window.AksrtRequest.request === "function" + ? function () { return window.AksrtRequest.request(session.options.url, requestOptions); } + : function () { + return window.fetch(session.options.url, requestOptions).then(function (response) { + return response.text().then(function (text) { return { ok: response.ok, status: response.status, text: text, json: null }; }); + }); + }; + + var method = String(session.options.method || "GET").toUpperCase(); + var cacheable = method === "GET" && session.options.cache !== false; + var cacheKey = session.options.cacheKey || + method + ":" + + (window.AksrtRequest && typeof window.AksrtRequest.url === "function" + ? window.AksrtRequest.url(session.options.url, session.options.query) + : session.options.url); + var cache = window.AksrtModalCache; + 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) {} + } + }, + }; + + session.flight = cacheable && cache && typeof cache.acquire === "function" + ? cache.acquire(cacheKey, request, acquireOptions) + : { promise: request(), release: function () {} }; + + return session.flight.promise.then(function (result) { + if (!session.closed && active === session) renderResult(session, result); + return session.shell; + }).catch(function (error) { + var aborted = error && (error.name === "AbortError" || error.code === "ABORT_ERR"); + if (!aborted && !session.closed && active === session) renderError(session, error); + return session.shell; + }).then(function (shell) { + if (session.flight && typeof session.flight.release === "function") session.flight.release(); + session.flight = null; + return shell; + }); + } + + function open(options) { + options = options || {}; + close("replace"); + 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.AksrtRequest && typeof window.AksrtRequest.url === "function" + ? window.AksrtRequest.url(options.url, options.query) + : String(options.url); + 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); + close("replace", !!sameRequest); + 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) { + if (window.AksrtModalCache && typeof window.AksrtModalCache.invalidate === "function") { + return window.AksrtModalCache.invalidate(tags); + } + return 0; + } + + window.AksrtModalRuntime = { + 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("aksrt:modal-runtime:ready")); +})(window, document); diff --git a/assets/js/core/modal-state.js b/assets/js/core/modal-state.js new file mode 100644 index 00000000..b324919a --- /dev/null +++ b/assets/js/core/modal-state.js @@ -0,0 +1,34 @@ +/** + * Shared modal state vocabulary. + * + * @package AKSRT + */ +(function (window) { + "use strict"; + + var statuses = { + idle: "idle", + loading: "loading", + ready: "ready", + empty: "empty", + submitting: "submitting", + success: "success", + error: "error", + unauthorized: "unauthorized", + stale: "stale", + }; + + function create(kind) { + return { + kind: String(kind || "generic"), + status: statuses.idle, + updatedAt: 0, + error: null, + }; + } + + window.AksrtModalState = { + statuses: statuses, + create: create, + }; +})(window); diff --git a/assets/js/core/remote-modal.js b/assets/js/core/remote-modal.js index e07802ce..e115c9b5 100644 --- a/assets/js/core/remote-modal.js +++ b/assets/js/core/remote-modal.js @@ -1,232 +1,77 @@ /** - * AKSRT Remote Modal — zibll RefreshModal parity. + * AKSRT Remote Modal compatibility bridge. * - * Open a center/bottom modal with static HTML or remote fetch. - * Contract: - * - data-toggle="AksrtRemoteModal" | data-aksrt-remote-modal - * - data-remote / href → fetch HTML - * - data-class → dialog class (modal-mini full-sm …) - * - data-height → min-height hint - * - mobile-bottom="true" → bottom sheet on narrow viewports + * The lifecycle implementation lives in AksrtModalRuntime. This file keeps + * the legacy trigger contract and the AksrtRemoteModal public alias stable. * * @package AKSRT */ (function (window, document) { "use strict"; - var ROOT_ID = "aksrt-remote-modal-root"; - var active = null; - - function isMobile() { - return window.matchMedia && window.matchMedia("(max-width: 768px)").matches; + function runtime() { + return window.AksrtModalRuntime || null; } - function ensureRoot() { - var root = document.getElementById(ROOT_ID); - if (root) return root; - root = document.createElement("div"); - root.id = ROOT_ID; - root.className = "aksrt-remote-modal-host"; - document.body.appendChild(root); - return root; - } + function fromTrigger(element) { + var modalRuntime = runtime(); + if (!modalRuntime || !element) return Promise.reject(new Error("modal runtime unavailable")); - function restHeaders() { - var h = { Accept: "text/html, application/json" }; - if (window.aksrtData && aksrtData.restNonce) { - h["X-WP-Nonce"] = aksrtData.restNonce; - } - return h; - } + var url = element.getAttribute("data-remote") || + element.getAttribute("data-aksrt-remote") || + element.getAttribute("href") || ""; + if (!url || url === "#") return Promise.resolve(null); - function lockScroll(lock) { - document.documentElement.classList.toggle("aksrt-remote-modal-open", !!lock); - document.body.classList.toggle("aksrt-remote-modal-open", !!lock); - } + var ttl = parseInt(element.getAttribute("data-modal-cache-ttl") || "15000", 10); + var tags = (element.getAttribute("data-modal-cache-tags") || "").split(",").map(function (tag) { + return tag.trim(); + }).filter(Boolean); - function close() { - if (!active) return; - active.classList.remove("is-open"); - lockScroll(false); - var node = active; - active = null; - window.setTimeout(function () { - if (node && node.parentNode) node.parentNode.removeChild(node); - }, 220); - document.dispatchEvent(new CustomEvent("aksrt:remote-modal:close")); - } - - function bindChrome(shell) { - shell.addEventListener("click", function (e) { - if (e.target.closest("[data-aksrt-remote-modal-close], [data-dismiss='modal'], .aksrt-remote-modal__close")) { - e.preventDefault(); - close(); - return; - } - if (e.target === shell || e.target.classList.contains("aksrt-remote-modal__overlay")) { - close(); - } - }); - shell.addEventListener("keydown", function (e) { - if (e.key === "Escape") close(); - }); - } - - function open(options) { - options = options || {}; - close(); - - var root = ensureRoot(); - var shell = document.createElement("div"); - var bottom = options.bottom === true || (options.mobileBottom !== false && isMobile()); - shell.className = - "aksrt-remote-modal" + - (bottom ? " aksrt-remote-modal--bottom" : "") + - (options.className ? " " + options.className : ""); - shell.setAttribute("role", "dialog"); - shell.setAttribute("aria-modal", "true"); - - var heightStyle = options.height ? "min-height:" + parseInt(options.height, 10) + "px;" : ""; - shell.innerHTML = - '
' + - '
' + - '
' + - (options.html || "") + - "
"; - - root.appendChild(shell); - bindChrome(shell); - active = shell; - lockScroll(true); - - // Force reflow then open for CSS transition. - void shell.offsetWidth; - shell.classList.add("is-open"); - - var focusable = shell.querySelector( - "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])" - ); - if (focusable) { - try { - focusable.focus(); - } catch (err) {} - } - - document.dispatchEvent( - new CustomEvent("aksrt:remote-modal:open", { detail: { shell: shell, options: options } }) - ); - return shell; - } - - function openRemote(options) { - options = options || {}; - var url = options.url || ""; - if (!url) return Promise.reject(new Error("missing url")); - - var loading = open({ - html: - '
' + - ((window.aksrtData && aksrtData.ajaxLoading) || "加载中…") + - "
", - className: options.className || "modal-mini full-sm", - bottom: options.bottom, - mobileBottom: options.mobileBottom, - height: options.height, - }); - - return fetch(url, { - method: options.method || "GET", - credentials: "same-origin", - headers: Object.assign(restHeaders(), options.headers || {}), - body: options.body, - }) - .then(function (res) { - var type = (res.headers.get("content-type") || "").toLowerCase(); - if (type.indexOf("application/json") !== -1) { - return res.json().then(function (json) { - if (!res.ok || (json && json.success === false)) { - var msg = - (json && (json.message || (json.data && json.data.message))) || - "加载失败"; - throw new Error(msg); - } - var html = - (json && (json.html || (json.data && (json.data.html || json.data)))) || ""; - if (typeof html !== "string") html = ""; - return html; - }); - } - return res.text().then(function (text) { - if (!res.ok) throw new Error("加载失败"); - return text; - }); - }) - .then(function (html) { - if (loading && loading.parentNode) loading.parentNode.removeChild(loading); - if (active === loading) active = null; - return open({ - html: html, - className: options.className || "modal-mini full-sm", - bottom: options.bottom, - mobileBottom: options.mobileBottom, - height: options.height, - }); - }) - .catch(function (err) { - if (loading && loading.parentNode) loading.parentNode.removeChild(loading); - if (active === loading) active = null; - open({ - html: - '
提示
' + - '
' + - (err && err.message ? String(err.message) : "加载失败") + - '
', - className: "modal-mini", - mobileBottom: true, - }); - throw err; - }); - } - - function fromTrigger(el) { - if (!el) return null; - var url = - el.getAttribute("data-remote") || - el.getAttribute("data-aksrt-remote") || - el.getAttribute("href") || - ""; - if (!url || url === "#") return null; - var height = el.getAttribute("data-height") || el.getAttribute("data-aksrt-height"); - return openRemote({ + return modalRuntime.openRemote({ url: url, - className: el.getAttribute("data-class") || el.getAttribute("data-aksrt-class") || "modal-mini full-sm", - height: height ? parseInt(height, 10) : 0, - mobileBottom: el.getAttribute("mobile-bottom") !== "false", - bottom: el.getAttribute("data-bottom") === "true", + method: element.getAttribute("data-method") || "GET", + className: element.getAttribute("data-class") || element.getAttribute("data-aksrt-class") || "modal-mini full-sm", + height: parseInt(element.getAttribute("data-height") || element.getAttribute("data-aksrt-height") || "0", 10) || 0, + mobileBottom: element.getAttribute("mobile-bottom") !== "false", + bottom: element.getAttribute("data-bottom") === "true", + kind: element.getAttribute("data-modal-kind") || "generic", + cacheKey: element.getAttribute("data-modal-cache-key") || "", + cacheTtl: ttl > 0 ? ttl : 15000, + cacheTags: tags, }); } - document.addEventListener("click", function (e) { - var trigger = e.target.closest( - '[data-toggle="AksrtRemoteModal"], [data-toggle="RefreshModal"], [data-aksrt-remote-modal], a.aksrt-remote-modal-link' - ); - if (!trigger) return; - if (trigger.getAttribute("data-aksrt-remote-modal") === "off") return; - e.preventDefault(); - fromTrigger(trigger); - }); + function bind() { + if (window.__aksrtRemoteModalBound) return; + if (!runtime()) return; + window.__aksrtRemoteModalBound = true; - document.addEventListener("keydown", function (e) { - if (e.key === "Escape" && active) close(); - }); + document.addEventListener("click", function (event) { + var trigger = event.target.closest( + '[data-toggle="AksrtRemoteModal"], [data-toggle="RefreshModal"], [data-aksrt-remote-modal], a.aksrt-remote-modal-link' + ); + if (!trigger || trigger.getAttribute("data-aksrt-remote-modal") === "off") return; + event.preventDefault(); + fromTrigger(trigger); + }); + } - window.AksrtRemoteModal = { - open: open, - openRemote: openRemote, - close: close, - fromTrigger: fromTrigger, - getActive: function () { - return active; - }, - }; + function expose() { + var modalRuntime = runtime(); + if (!modalRuntime) return; + window.AksrtRemoteModal = { + open: modalRuntime.open, + openRemote: modalRuntime.openRemote, + close: modalRuntime.close, + refresh: modalRuntime.refresh, + fromTrigger: fromTrigger, + getActive: modalRuntime.getActive, + }; + bind(); + } + + expose(); + if (!window.AksrtModalRuntime) { + document.addEventListener("aksrt:modal-runtime:ready", expose, { once: true }); + } })(window, document); diff --git a/assets/js/core/request.js b/assets/js/core/request.js new file mode 100644 index 00000000..b82c768f --- /dev/null +++ b/assets/js/core/request.js @@ -0,0 +1,157 @@ +/** + * AKSRT frontend request adapter. + * + * Owns the injected REST root, nonce headers, response envelopes, and + * abort-aware error normalization used by modal loaders. + * + * @package AKSRT + */ +(function (window) { + "use strict"; + + function data() { + return window.aksrtData || window.aksrt || {}; + } + + function restBase() { + var config = data(); + var raw = config.restUrl || config.apiUrl || config.rest_url || ""; + var siteUrl = config.siteUrl || config.homeUrl || (window.location.origin + "/"); + + if (window.location.protocol === "https:" && siteUrl.substring(0, 5) === "http:") { + siteUrl = "https:" + siteUrl.substring(5); + } + + if (raw) return String(raw).replace(/\/$/, ""); + return String(siteUrl).replace(/\/$/, "") + "/index.php?rest_route=/aksrt/v1"; + } + + function appendQuery(url, query) { + if (!query) return url; + var parts = []; + + if (typeof query === "string") { + parts = query.replace(/^\?/, "").split("&").filter(Boolean); + } else if (typeof query === "object") { + Object.keys(query).forEach(function (key) { + if (query[key] === undefined || query[key] === null) return; + parts.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(query[key]))); + }); + } + + if (!parts.length) return url; + return url + (url.indexOf("?") >= 0 ? "&" : "?") + parts.join("&"); + } + + function url(path, query) { + path = String(path || ""); + var inlineQuery = ""; + var queryIndex = path.indexOf("?"); + + if (queryIndex >= 0) { + inlineQuery = path.slice(queryIndex + 1); + path = path.slice(0, queryIndex); + } + + if (/^https?:\/\//i.test(path)) { + return appendQuery(path, query || inlineQuery); + } + + if (path.charAt(0) !== "/") path = "/" + path; + var base = restBase(); + var result = base.indexOf("rest_route=") >= 0 ? base + path : base + path; + return appendQuery(result, query || inlineQuery); + } + + function headers(options) { + var config = data(); + var source = (options && options.headers) || {}; + var result = {}; + var nonce = config.restNonce || config.nonce || ""; + + Object.keys(source).forEach(function (key) { + result[key] = source[key]; + }); + + if (!result.Accept) result.Accept = "application/json, text/html;q=0.9, */*;q=0.8"; + if (nonce && !result["X-WP-Nonce"]) result["X-WP-Nonce"] = nonce; + + if (options && options.body !== undefined && options.body !== null && + !(window.FormData && options.body instanceof window.FormData) && + !result["Content-Type"]) { + result["Content-Type"] = "application/json"; + } + + return result; + } + + function responseError(result) { + var json = result.json; + var message = json && (json.message || (json.data && json.data.message)); + var error = new Error(message || (result.status ? "请求失败(HTTP " + result.status + ")" : "请求失败")); + error.status = result.status; + error.code = result.status === 401 ? "unauthorized" : "http_error"; + error.response = result; + error.json = json; + return error; + } + + function parseResponse(response) { + return response.text().then(function (text) { + var type = (response.headers.get("content-type") || "").toLowerCase(); + var json = null; + var trimmed = String(text || "").trim(); + + if (type.indexOf("application/json") >= 0 || trimmed.charAt(0) === "{" || trimmed.charAt(0) === "[") { + try { + json = trimmed ? JSON.parse(trimmed) : {}; + } catch (error) { + json = null; + } + } + + return { + ok: response.ok, + status: response.status, + headers: response.headers, + text: text, + json: json, + data: json && json.data !== undefined ? json.data : json, + }; + }); + } + + function request(path, options) { + options = Object.assign({}, options || {}); + var requestUrl = url(path, options.query); + delete options.query; + + var fetchOptions = Object.assign({}, options, { + credentials: options.credentials || "same-origin", + headers: headers(options), + }); + + return window.fetch(requestUrl, fetchOptions).then(function (response) { + return parseResponse(response); + }).then(function (result) { + if (!result.ok || (result.json && result.json.success === false)) { + throw responseError(result); + } + return result; + }); + } + + window.AksrtRequest = { + url: url, + request: request, + get: function (path, options) { + return request(path, Object.assign({}, options || {}, { method: "GET" })); + }, + post: function (path, body, options) { + return request(path, Object.assign({}, options || {}, { + method: "POST", + body: body, + })); + }, + }; +})(window); diff --git a/assets/js/order-center.js b/assets/js/order-center.js index 2549413f..941b14c9 100644 --- a/assets/js/order-center.js +++ b/assets/js/order-center.js @@ -15,31 +15,31 @@ var detailFooter = page.querySelector("[data-aksrt-order-detail-footer]"); var detailOrderNumber = ""; - function toast(message) { - if (window.AksrtToast && typeof window.AksrtToast.show === "function") { - window.AksrtToast.show(message); + function toast(message, type) { + if (typeof window.AksrtToast === "function") { + window.AksrtToast(message, type || "info"); + return; + } + if (typeof window.aksrtNotify === "function") { + window.aksrtNotify(message, type || "info"); return; } window.alert(message); } - function restRoot() { - return String(aksrtData.siteUrl || "").replace(/\/$/, "") + "/wp-json/aksrt/v1"; - } - function request(path, body, method) { - var options = { - method: method || "POST", - credentials: "same-origin", - headers: { "X-WP-Nonce": aksrtData.restNonce || "" } - }; - if (body !== undefined) { - options.headers["Content-Type"] = "application/json"; - options.body = JSON.stringify(body || {}); + if (!window.AksrtRequest || typeof window.AksrtRequest.request !== "function") { + return Promise.resolve({ success: false, message: "请求适配器未加载" }); } - return fetch(restRoot() + path, options).then(function (response) { - return response.json().catch(function () { - return { success: false, message: "请求失败(HTTP " + response.status + ")" }; + var options = { method: method || "POST" }; + if (body !== undefined) options.body = JSON.stringify(body || {}); + return window.AksrtRequest.request(path, options).then(function (response) { + return response.json || {}; + }).catch(function (error) { + var response = error && error.response ? error.response : {}; + return error && error.json ? error.json : (response.json || { + success: false, + message: error && error.message ? error.message : "请求失败", }); }); } @@ -219,6 +219,9 @@ button.disabled = true; request(orderPath(number, "/pay"), method ? { payment_method: method } : {}).then(function (result) { if (!result.success) throw new Error(result.message || "支付失败"); + if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.invalidate === "function") { + window.AksrtModalRuntime.invalidate(["orders", "vip"]); + } var data = result.data || {}; if (data.redirect && data.payment_url) { window.location.assign(data.payment_url); @@ -243,6 +246,9 @@ button.disabled = true; request(orderPath(number, "/cancel")).then(function (result) { if (!result.success) throw new Error(result.message || "关闭订单失败"); + if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.invalidate === "function") { + window.AksrtModalRuntime.invalidate(["orders", "vip"]); + } toast("订单已关闭"); refreshView(); }).catch(function (error) { diff --git a/assets/js/pay-vip.js b/assets/js/pay-vip.js index 9a32b42c..fbbf5283 100644 --- a/assets/js/pay-vip.js +++ b/assets/js/pay-vip.js @@ -5,126 +5,51 @@ (function () { "use strict"; - function restBase() { - var aksrt = window.aksrtData || window.aksrt || {}; - var siteUrl = aksrt.siteUrl || aksrt.homeUrl || (window.location.origin + "/"); - if (window.location.protocol === "https:" && siteUrl.substring(0, 5) === "http:") { - siteUrl = "https:" + siteUrl.substring(5); - } - var raw = aksrt.apiUrl || aksrt.restUrl || aksrt.rest_url || ""; - if (raw) { - return String(raw).replace(/\/$/, ""); - } - return siteUrl.replace(/\/$/, "") + "/index.php?rest_route=/aksrt/v1"; - } - - /** - * Join REST root + path [+ query]. - * Pretty: /wp-json/aksrt/v1/pay/vip/modal?vip_level=1 - * Plain: index.php?rest_route=/aksrt/v1/pay/vip/modal&vip_level=1 - * Never put a second "?" after rest_route= (that 404s). - */ - function restUrl(path, query) { - path = String(path || ""); - if (path.charAt(0) !== "/") path = "/" + path; - var base = restBase(); - var qstr = ""; - if (query && typeof query === "object") { - var parts = []; - Object.keys(query).forEach(function (k) { - if (query[k] === undefined || query[k] === null) return; - parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(query[k]))); - }); - qstr = parts.join("&"); - } else if (typeof query === "string" && query) { - qstr = query.replace(/^\?/, ""); - } - - if (base.indexOf("rest_route=") !== -1) { - var url = base + path; - if (qstr) url += "&" + qstr; - return url; - } - - var pretty = base + path; - if (qstr) pretty += (pretty.indexOf("?") >= 0 ? "&" : "?") + qstr; - return pretty; - } - - function nonceHeaders() { - var aksrt = window.aksrtData || window.aksrt || {}; - var n = aksrt.restNonce || aksrt.nonce || ""; - return n ? { "X-WP-Nonce": n } : {}; - } - function api(path, options) { var opts = Object.assign({}, options || {}); - var query = opts.query; - if (Object.prototype.hasOwnProperty.call(opts, "query")) { - delete opts.query; - } - var onlyPath = String(path || ""); - var inlineQ = null; - var qi = onlyPath.indexOf("?"); - if (qi >= 0) { - inlineQ = onlyPath.slice(qi + 1); - onlyPath = onlyPath.slice(0, qi); - } - var url = restUrl(onlyPath, query || inlineQ); - - var merged = Object.assign( - { - credentials: "same-origin", - headers: Object.assign({ "Content-Type": "application/json" }, nonceHeaders()), - }, - opts - ); - return fetch(url, merged).then(function (r) { - return r - .json() - .catch(function () { - return { message: "Invalid JSON (HTTP " + r.status + ")" }; - }) - .then(function (j) { - return { ok: r.ok, status: r.status, json: j }; + var request = window.AksrtRequest && typeof window.AksrtRequest.request === "function" + ? window.AksrtRequest.request(path, opts) + : fetch(path, Object.assign({ credentials: "same-origin" }, opts)).then(function (response) { + return response.json().then(function (json) { + return { ok: response.ok, status: response.status, json: json }; }); + }); + + return request.then(function (response) { + return { + ok: response.ok, + status: response.status, + json: response.json || {}, + }; + }).catch(function (error) { + var response = error && error.response ? error.response : {}; + return { + ok: false, + status: error && error.status ? error.status : (response.status || 0), + json: error && error.json ? error.json : (response.json || { message: error && error.message }), + }; }); } - function ensureModalShell() { - var el = document.getElementById("modal_pay_uservip"); - if (el) return el; - el = document.createElement("div"); - el.id = "modal_pay_uservip"; - el.className = "aksrt-vip-modal-overlay"; - el.setAttribute("aria-hidden", "true"); - el.innerHTML = - ''; - document.body.appendChild(el); - el.addEventListener("click", function (e) { - if (e.target === el || e.target.closest(".aksrt-vip-modal-close")) { - closeModal(); - } - }); - document.addEventListener("keydown", function (e) { - if (e.key === "Escape") closeModal(); - }); - return el; - } + + var activeVipShell = null; function openModal() { - var el = ensureModalShell(); - el.classList.add("is-open"); - el.setAttribute("aria-hidden", "false"); - document.body.classList.add("aksrt-vip-modal-open"); + if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== "function") return null; + activeVipShell = window.AksrtModalRuntime.open({ + className: "aksrt-vip-modal-shell", + mobileBottom: false, + skeleton: '
加载中…
', + telemetry: false, + kind: "vip", + }); + return activeVipShell; } function closeModal() { - var el = document.getElementById("modal_pay_uservip"); - if (!el) return; - el.classList.remove("is-open"); - el.setAttribute("aria-hidden", "true"); - document.body.classList.remove("aksrt-vip-modal-open"); + if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.close === "function") { + window.AksrtModalRuntime.close("vip"); + } + activeVipShell = null; } function bindModalUi(root) { @@ -226,11 +151,17 @@ } var data = r.json && r.json.data ? r.json.data : r.json; if (data && data.redirect && (data.payment_url || data.qr_content)) { + if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.invalidate === "function") { + window.AksrtModalRuntime.invalidate(["vip", "orders"]); + } if (data.payment_url) window.open(data.payment_url, "_blank"); setTip(pane, data.message || "请完成支付,支付成功后将自动刷新。"); if (data.order_number) pollOrder(data.order_number); return; } + if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.invalidate === "function") { + window.AksrtModalRuntime.invalidate(["vip", "orders"]); + } setTip(pane, (data && data.message) || "开通成功,正在刷新…"); setTimeout(function () { window.location.reload(); @@ -257,10 +188,13 @@ function telemetryMark(event) { if (telemetry && telemetryId && typeof telemetry.mark === "function") telemetry.mark(telemetryId, event); } - var shell = ensureModalShell(); + var shell = openModal(); + if (!shell) { + loading = false; + telemetryMark("error"); + return; + } var body = shell.querySelector("[data-aksrt-vip-modal-body]"); - if (body) body.innerHTML = '
加载中…
'; - openModal(); telemetryMark("shell"); api("/pay/vip/modal", { query: { vip_level: level } }) @@ -286,6 +220,9 @@ if (body) { body.innerHTML = r.json.html; telemetryMark("first-content"); + if (window.AksrtFrontend && typeof window.AksrtFrontend.rehydrate === "function") { + window.AksrtFrontend.rehydrate(body, "modal"); + } bindModalUi(body); telemetryMark("ready"); } @@ -318,31 +255,19 @@ document.addEventListener("click", onDocClick, true); } - // Prefer module registry, but always bind once as hard fallback - // (float rail must work even if AksrtFrontend.rehydrate is a no-op). - if (window.__aksrtPayVipBound) { - // already initialized - } else if (window.AksrtFrontend && typeof window.AksrtFrontend.register === "function") { - window.AksrtFrontend.register("aksrt-pay-vip", function () { - if (window.__aksrtPayVipBound) return; - window.__aksrtPayVipBound = true; - init(); - }); - // Also bind immediately — float button is in footer, available now. - if (!window.__aksrtPayVipBound) { - window.__aksrtPayVipBound = true; - init(); - } - } else if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", function () { - if (window.__aksrtPayVipBound) return; - window.__aksrtPayVipBound = true; - init(); - }); - } else { + // This handler is delegated from document, so registry rehydration is not + // needed. Bind exactly once and keep dynamically injected triggers working. + function bindOnce() { + if (window.__aksrtPayVipBound) return; window.__aksrtPayVipBound = true; init(); } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bindOnce, { once: true }); + } else { + bindOnce(); + } + window.aksrtVipPay = vipPay; })(); diff --git a/assets/js/user-badges.js b/assets/js/user-badges.js index 7feac6fd..9e3fd19e 100644 --- a/assets/js/user-badges.js +++ b/assets/js/user-badges.js @@ -8,36 +8,31 @@ (function () { 'use strict'; - var aksrt = window.aksrtData || {}; - var siteUrl = aksrt.siteUrl || ''; - if (window.location.protocol === 'https:' && siteUrl.substring(0, 5) === 'http:') { - siteUrl = 'https:' + siteUrl.substring(5); - } - var REST = (siteUrl || '').replace(/\/$/, '') + '/wp-json/aksrt/v1'; - var NONCE = aksrt.restNonce || aksrt.nonce || ''; - function api(path, options) { - var opts = Object.assign({ - credentials: 'same-origin', - headers: Object.assign({ 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE }, (options && options.headers) || {}), - }, options || {}); - return fetch(REST + path, opts).then(function (r) { - return r.json().catch(function () { return {}; }).then(function (j) { - return { ok: r.ok, status: r.status, json: j }; - }); + if (!window.AksrtRequest || typeof window.AksrtRequest.request !== 'function') { + return Promise.resolve({ ok: false, status: 0, json: { message: '请求适配器未加载' } }); + } + return window.AksrtRequest.request(path, options || {}).then(function (response) { + return { ok: response.ok, status: response.status, json: response.json || {} }; + }).catch(function (error) { + var response = error && error.response ? error.response : {}; + return { + ok: false, + status: error && error.status ? error.status : (response.status || 0), + json: error && error.json ? error.json : (response.json || { message: error && error.message }), + }; }); } function toast(msg, type) { - if (window.AksrtToast && typeof window.AksrtToast.show === 'function') { - window.AksrtToast.show(msg, type || 'info'); + if (typeof window.AksrtToast === 'function') { + window.AksrtToast(msg, type || 'info'); return; } - if (window.aksrtNotify) { + if (typeof window.aksrtNotify === 'function') { window.aksrtNotify(msg, type || 'info'); return; } - // fallback try { console.log('[AKSRT badges]', msg); } catch (_) {} } @@ -265,6 +260,9 @@ return; } toast((r.json && r.json.message) || '已佩戴徽章', 'success'); + if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.invalidate === 'function') { + window.AksrtModalRuntime.invalidate(['badges', 'identity']); + } state.data = (r.json && r.json.data) || state.data; renderHead(); renderGrid(); @@ -354,6 +352,9 @@ return; } toast((r.json && r.json.message) || '已佩戴徽章', 'success'); + if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.invalidate === 'function') { + window.AksrtModalRuntime.invalidate(['badges', 'identity']); + } // Re-open the refreshed single-badge modal so state stays in sync. var refreshed = null; try { diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php index b8518de9..1b9bd2ff 100644 --- a/src/Core/ScriptsPart1b.php +++ b/src/Core/ScriptsPart1b.php @@ -227,7 +227,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js'; wp_enqueue_script( 'aksrt-pay-vip', $base_uri . '/assets/js/pay-vip.js', - ['aksrt-script'], + ['aksrt-script', 'aksrt-core-request', 'aksrt-core-modal-runtime'], (string) filemtime($vip_js), true ); @@ -671,7 +671,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js'; wp_enqueue_script( 'aksrt-order-center', $base_uri . '/assets/js/order-center.js', - ['aksrt-script'], + ['aksrt-script', 'aksrt-core-request', 'aksrt-core-modal-runtime'], file_exists(get_template_directory() . '/assets/js/order-center.js') ? (string) filemtime(get_template_directory() . '/assets/js/order-center.js') : $version, @@ -894,7 +894,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js'; wp_enqueue_script( 'aksrt-user-badges', $base_uri . '/assets/js/user-badges.js', - ['aksrt-script'], + ['aksrt-script', 'aksrt-core-request', 'aksrt-core-modal-runtime', 'aksrt-core-remote-modal'], (string) filemtime($badges_js_path), true ); @@ -917,7 +917,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js'; wp_enqueue_script( 'aksrt-user-badges', $base_uri . '/assets/js/user-badges.js', - ['aksrt-script', 'aksrt-core-remote-modal'], + ['aksrt-script', 'aksrt-core-request', 'aksrt-core-modal-runtime', 'aksrt-core-remote-modal'], (string) filemtime($badges_js_path_global), true ); diff --git a/src/Core/ScriptsPart1c.php b/src/Core/ScriptsPart1c.php index 0a5d3796..a715b0b7 100644 --- a/src/Core/ScriptsPart1c.php +++ b/src/Core/ScriptsPart1c.php @@ -126,10 +126,14 @@ trait ScriptsPart1c { 'aksrt-core-comment-fixed' => 'comment-fixed-input.js', 'aksrt-core-comment-move' => 'comment-move-form.js', // Shared zibll-parity list/modal engines (before pagination consumers). - 'aksrt-core-ajax-list' => 'ajax-list.js', + 'aksrt-core-ajax-list' => 'ajax-list.js', 'aksrt-core-modal-telemetry' => 'modal-telemetry.js', - 'aksrt-core-remote-modal' => 'remote-modal.js', - 'aksrt-core-pagination' => 'pagination.js', + 'aksrt-core-request' => 'request.js', + 'aksrt-core-modal-cache' => 'modal-cache.js', + 'aksrt-core-modal-state' => 'modal-state.js', + 'aksrt-core-modal-runtime' => 'modal-runtime.js', + 'aksrt-core-remote-modal' => 'remote-modal.js', + 'aksrt-core-pagination' => 'pagination.js', 'aksrt-core-archive' => 'archive.js', 'aksrt-core-footer-tabbar' => 'footer-tabbar.js', 'aksrt-core-misc' => 'misc.js', @@ -142,6 +146,15 @@ trait ScriptsPart1c { $deps = ['aksrt-rehydrate', 'aksrt-runtime']; if ($handle === 'aksrt-core-slider') { $deps = array_merge($deps, $needs_swiper ? ['aksrt-swiper'] : []); + } elseif ($handle === 'aksrt-core-modal-runtime') { + $deps = array_merge($deps, [ + 'aksrt-core-modal-telemetry', + 'aksrt-core-request', + 'aksrt-core-modal-cache', + 'aksrt-core-modal-state', + ]); + } elseif ($handle === 'aksrt-core-remote-modal') { + $deps[] = 'aksrt-core-modal-runtime'; } wp_enqueue_script( $handle, From bdfe3a461dd33439116a083ad992dd802b93c888 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 4 Aug 2026 23:28:13 +0800 Subject: [PATCH 2/2] fix(modal): route refresh modal through runtime Co-Authored-By: Claude Fable 5 --- assets/css/frontend/remote-modal.css | 14 +++ assets/js/aksrt-runtime.js | 126 +++++++-------------------- assets/js/core/modal-runtime.js | 6 ++ 3 files changed, 53 insertions(+), 93 deletions(-) diff --git a/assets/css/frontend/remote-modal.css b/assets/css/frontend/remote-modal.css index 6783d12c..92a2fdd2 100644 --- a/assets/css/frontend/remote-modal.css +++ b/assets/css/frontend/remote-modal.css @@ -71,6 +71,20 @@ body.aksrt-remote-modal-open { transform: translateY(0); } +.aksrt-remote-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px 0; +} + +.aksrt-remote-modal__title { + margin: 0; + font-size: 17px; + line-height: 1.35; +} + .aksrt-remote-modal__content { padding: 18px 18px 16px; } diff --git a/assets/js/aksrt-runtime.js b/assets/js/aksrt-runtime.js index 0360c8b7..349575ba 100644 --- a/assets/js/aksrt-runtime.js +++ b/assets/js/aksrt-runtime.js @@ -317,120 +317,60 @@ // §10.10 — refreshModal: remote content modal // ══════════════════════════════════════════════════════════════ - var _refreshModalEl = null; - - function getRefreshModalEl() { - if (_refreshModalEl && _refreshModalEl.parentNode) return _refreshModalEl; - _refreshModalEl = document.createElement('div'); - _refreshModalEl.className = 'aksrt-refresh-modal'; - _refreshModalEl.innerHTML = - '
' + - '
' + - '
' + - '

' + - '' + - '
' + - '
' + - '
'; - document.body.appendChild(_refreshModalEl); - - _refreshModalEl.querySelector('.aksrt-refresh-modal__close').addEventListener('click', closeRefreshModal); - _refreshModalEl.querySelector('.aksrt-refresh-modal__overlay').addEventListener('click', function (e) { - if (e.target === e.currentTarget) closeRefreshModal(); - }); - - return _refreshModalEl; - } - function closeRefreshModal() { - if (!_refreshModalEl) return; - _refreshModalEl.classList.remove('is-open', 'from-bottom'); - document.body.classList.remove('modal-open'); - setTimeout(function () { - var body = _refreshModalEl.querySelector('.aksrt-refresh-modal__body'); - if (body) body.innerHTML = ''; - }, 300); + if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.close === 'function') { + window.AksrtModalRuntime.close('refresh-modal'); + } } aksrt.refreshModal = function (opts) { opts = opts || {}; - var modal = getRefreshModalEl(); - var body = modal.querySelector('.aksrt-refresh-modal__body'); - var title = modal.querySelector('.aksrt-refresh-modal__title'); + var runtime = window.AksrtModalRuntime; + if (!runtime || typeof runtime.open !== 'function' || typeof runtime.openRemote !== 'function') { + return Promise.reject(new Error('modal runtime unavailable')); + } - if (opts.title) title.textContent = opts.title; - else title.textContent = ''; + var base = { + title: opts.title || '', + className: opts.class || '', + bottom: opts.mobile_from_bottom === true, + mobileBottom: opts.mobile_from_bottom === true, + cache: opts.cache !== false, + cacheTtl: opts.cacheTtl || 15000, + cacheTags: opts.cacheTags || opts.tags || [], + kind: opts.kind || 'generic', + }; - var isMobile = window.innerWidth < 768; - modal.classList.toggle('from-bottom', !!(opts.mobile_from_bottom && isMobile)); - - if (opts.class) modal.querySelector('.aksrt-refresh-modal__dialog').className = 'aksrt-refresh-modal__dialog ' + opts.class; - - body.innerHTML = ''; - aksrt.loading(body, true); - modal.classList.add('is-open'); - document.body.classList.add('modal-open'); - - if (opts.content) { - aksrt.loading(body, false); - body.innerHTML = opts.content; - if (window.AksrtFrontend) AksrtFrontend.rehydrate(body, 'modal'); - return Promise.resolve(modal); + if (opts.content !== undefined) { + return Promise.resolve(runtime.open(Object.assign({}, base, { html: opts.content }))) + .catch(function () { return null; }); } if (opts.remote) { - return fetch(opts.remote, { credentials: 'same-origin' }) - .then(function (r) { return r.text(); }) - .then(function (html) { - aksrt.loading(body, false); - body.innerHTML = html; - if (window.AksrtFrontend) AksrtFrontend.rehydrate(body, 'modal'); - return modal; - }) - .catch(function (err) { - aksrt.loading(body, false); - body.innerHTML = '

' + escapeHtml(err.message) + '

'; - return modal; - }); + return runtime.openRemote(Object.assign({}, base, { + url: opts.remote, + method: opts.method || 'GET', + })); } if (opts.action) { - var ajaxUrl = (window.aksrtData && aksrtData.ajaxUrl) || '/wp-admin/admin-ajax.php'; + var ajaxUrl = (window.aksrtData && window.aksrtData.ajaxUrl) || '/wp-admin/admin-ajax.php'; var params = new URLSearchParams(); params.set('action', opts.action); - if (window.aksrtData && aksrtData.nonce) params.set('nonce', aksrtData.nonce); + if (window.aksrtData && window.aksrtData.nonce) params.set('nonce', window.aksrtData.nonce); if (opts.data) { - Object.keys(opts.data).forEach(function (k) { params.set(k, opts.data[k]); }); + Object.keys(opts.data).forEach(function (key) { params.set(key, opts.data[key]); }); } - - return fetch(ajaxUrl, { + return runtime.openRemote(Object.assign({}, base, { + url: ajaxUrl, method: 'POST', + cache: false, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, - credentials: 'same-origin', - body: params.toString() - }) - .then(function (r) { return r.json(); }) - .then(function (json) { - aksrt.loading(body, false); - if (json && json.data && json.data.html) { - body.innerHTML = json.data.html; - } else if (json && json.data && json.data.show_modal) { - body.innerHTML = json.data.show_modal; - } else { - body.innerHTML = '

' + escapeHtml(json.data && json.data.message || '') + '

'; - } - if (window.AksrtFrontend) AksrtFrontend.rehydrate(body, 'modal'); - return modal; - }) - .catch(function (err) { - aksrt.loading(body, false); - body.innerHTML = '

' + escapeHtml(err.message) + '

'; - return modal; - }); + body: params.toString(), + })); } - aksrt.loading(body, false); - return Promise.resolve(modal); + return Promise.resolve(runtime.open(base)); }; aksrt.refreshModal.close = closeRefreshModal; diff --git a/assets/js/core/modal-runtime.js b/assets/js/core/modal-runtime.js index d4d417e3..c5150ccd 100644 --- a/assets/js/core/modal-runtime.js +++ b/assets/js/core/modal-runtime.js @@ -136,6 +136,10 @@ 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 titleId = "aksrt-modal-title-" + String(sequence + 1); + var titleHtml = options.title + ? '

' + escapeHtml(options.title) + '

' + : ""; shell.className = "aksrt-remote-modal" + @@ -143,10 +147,12 @@ (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 = '
' + '
' + + titleHtml + '
' + initialHtml + "
"; var session = {