feat(ux): shared ajax-list/remote-modal + order review modal 1:1

Add zibll-parity AjaxList/RemoteModal engines and replace order-center
prompt() review flow with multi-dim star modal, multi-image upload,
auto-praise countdown, and REST review-modal endpoint.
This commit is contained in:
kate522 committed 2026-07-18 16:26:28 +08:00
1 parent 081da99528
commit e2587ca3a0
12 files changed
+1499 -48

No files matched your search

+361
View File
@@ -0,0 +1,361 @@
/**
* AKSRT Ajax List Engine — zibll .ajax-next / ajax-replace / route parity.
*
* DOM contract:
* .ajaxpager list container
* .ajax-item replace/append unit
* .ajax-pag .next-page.ajax-next paginate control
* ajax-replace="true" replace mode
* route / route-title history + document.title
* [win-ajax-replace] partial header sync from response HTML
*
* Transport:
* - Prefer data-aksrt-ajax-url JSON { html, header_html, paginate_html, title }
* - Else fetch href as HTML fragment and parse .ajaxpager / .ajax-item
*
* Events: post_ajax.before / post_ajax.ed on container (bubbles).
*
* @package AKSRT
*/
(function (window, document) {
"use strict";
var loadingMap = new WeakMap();
function restHeaders() {
var h = { Accept: "application/json, text/html", "X-AKSRT-Ajax": "list" };
if (window.aksrtData && aksrtData.restNonce) {
h["X-WP-Nonce"] = aksrtData.restNonce;
}
return h;
}
function closestPager(el) {
if (!el) return null;
return (
el.closest(".ajaxpager") ||
el.closest("[data-aksrt-ajaxpager]") ||
el.closest(".aksrt-ajaxpager")
);
}
function dispatch(container, name, detail) {
if (!container) return;
try {
container.dispatchEvent(
new CustomEvent(name, { bubbles: true, detail: detail || {} })
);
} catch (err) {}
}
function showLoader(container) {
if (!container) return;
var loader = container.querySelector(".post_ajax_loader, .post-ajax-loader, [data-aksrt-ajax-loader]");
if (loader) {
loader.classList.add("is-loading");
loader.hidden = false;
loader.style.display = "";
return;
}
container.classList.add("is-ajax-loading");
}
function hideLoader(container) {
if (!container) return;
var loader = container.querySelector(".post_ajax_loader, .post-ajax-loader, [data-aksrt-ajax-loader]");
if (loader) {
loader.classList.remove("is-loading");
if (loader.hasAttribute("data-aksrt-ajax-loader-hide")) {
loader.hidden = true;
loader.style.display = "none";
}
}
container.classList.remove("is-ajax-loading");
}
function parseHtml(html) {
var wrap = document.createElement("div");
wrap.innerHTML = html || "";
return wrap;
}
function extractFromHtml(html, container) {
var doc = parseHtml(html);
var pager =
doc.querySelector(".ajaxpager") ||
doc.querySelector("[data-aksrt-ajaxpager]") ||
doc.querySelector(".aksrt-ajaxpager");
var items = [];
var scope = pager || doc;
scope.querySelectorAll(".ajax-item").forEach(function (n) {
items.push(n.outerHTML);
});
if (!items.length && pager) {
items.push(pager.innerHTML);
}
var pag = scope.querySelector(".ajax-pag");
var headerBits = {};
doc.querySelectorAll("[win-ajax-replace]").forEach(function (n) {
var key = n.getAttribute("win-ajax-replace");
if (key) headerBits[key] = n.outerHTML;
});
var titleEl = doc.querySelector("title");
return {
itemsHtml: items.join(""),
paginateHtml: pag ? pag.outerHTML : "",
headerBits: headerBits,
title: titleEl ? titleEl.textContent : "",
fullHtml: pager ? pager.innerHTML : html,
};
}
function syncWinReplace(bits) {
if (!bits) return;
Object.keys(bits).forEach(function (key) {
var live = document.querySelector('[win-ajax-replace="' + key + '"]');
if (!live) return;
var tmp = document.createElement("div");
tmp.innerHTML = bits[key];
var next = tmp.firstElementChild;
if (next) live.replaceWith(next);
});
}
function applyRoute(link, title) {
if (!link) return;
var route = link.getAttribute("route") || link.getAttribute("data-route");
if (!(route === "1" || route === "true" || link.hasAttribute("route"))) return;
var href = link.getAttribute("href") || link.getAttribute("ajax-href") || "";
if (!href) return;
try {
window.history.replaceState({}, "", href);
} catch (err) {}
var rt =
title ||
link.getAttribute("route-title") ||
link.getAttribute("data-route-title") ||
"";
if (rt) {
try {
document.title = rt;
} catch (e2) {}
}
}
function replacePaginate(container, paginateHtml) {
if (!container || !paginateHtml) return;
var old = container.querySelector(".ajax-pag");
if (!old) {
container.insertAdjacentHTML("beforeend", paginateHtml);
return;
}
var tmp = document.createElement("div");
tmp.innerHTML = paginateHtml;
var next = tmp.firstElementChild;
if (next) old.replaceWith(next);
}
function inject(container, payload, options) {
options = options || {};
var replace = options.replace !== false;
if (!container) return;
var itemsHtml = payload.itemsHtml || payload.html || "";
if (replace) {
container.querySelectorAll(".ajax-item").forEach(function (n) {
n.remove();
});
// If container itself holds only items, clear non-loader children.
if (container.getAttribute("data-aksrt-ajax-clear") === "1") {
Array.prototype.slice.call(container.children).forEach(function (child) {
if (
child.classList.contains("post_ajax_loader") ||
child.classList.contains("post-ajax-loader") ||
child.hasAttribute("data-aksrt-ajax-loader") ||
child.classList.contains("ajax-pag")
) {
return;
}
child.remove();
});
}
if (itemsHtml) {
var pag = container.querySelector(".ajax-pag");
if (pag) {
pag.insertAdjacentHTML("beforebegin", itemsHtml);
} else {
container.insertAdjacentHTML("beforeend", itemsHtml);
}
}
} else if (itemsHtml) {
var pag2 = container.querySelector(".ajax-pag");
if (pag2) {
pag2.insertAdjacentHTML("beforebegin", itemsHtml);
} else {
container.insertAdjacentHTML("beforeend", itemsHtml);
}
}
if (payload.paginateHtml) {
replacePaginate(container, payload.paginateHtml);
}
if (payload.headerBits) {
syncWinReplace(payload.headerBits);
}
if (payload.headerHtml) {
var headerHost = document.querySelector(
options.headerSelector || "[data-aksrt-ajax-header]"
);
if (headerHost) headerHost.innerHTML = payload.headerHtml;
}
}
function normalizeJson(json) {
var data = json && json.data ? json.data : json;
if (!data) return null;
var html = data.html || data.list_html || "";
var itemsHtml = data.items_html || "";
if (!itemsHtml && html) {
// If html is only item nodes, use as-is; else extract.
if (html.indexOf("ajax-item") !== -1) {
var parsed = extractFromHtml("<div class='ajaxpager'>" + html + "</div>");
itemsHtml = parsed.itemsHtml || html;
} else {
itemsHtml = html;
}
}
return {
itemsHtml: itemsHtml,
html: html,
paginateHtml: data.paginate_html || data.paginateHtml || "",
headerHtml: data.header_html || data.headerHtml || "",
headerBits: data.header_bits || null,
title: data.title || "",
emptyHtml: data.empty_html || "",
hasMore: typeof data.has_more !== "undefined" ? !!data.has_more : null,
};
}
function loadFromLink(link, override) {
override = override || {};
if (!link) return Promise.resolve();
var container =
override.container ||
(link.getAttribute("ajaxpager-target")
? document.querySelector(link.getAttribute("ajaxpager-target"))
: null) ||
closestPager(link);
if (!container) {
// Fall back: parent stream list.
container = link.closest(".aksrt-product-grid, .aksrt-forum-topics__list, [data-aksrt-ajax-list]");
}
if (!container) return Promise.resolve();
if (loadingMap.get(container)) return Promise.resolve();
loadingMap.set(container, true);
var replaceAttr = link.getAttribute("ajax-replace");
var replace =
typeof override.replace === "boolean"
? override.replace
: replaceAttr === "true" || replaceAttr === "1" || replaceAttr === "replace";
var href =
override.url ||
link.getAttribute("data-aksrt-ajax-url") ||
link.getAttribute("ajax-href") ||
link.getAttribute("href") ||
"";
dispatch(container, "post_ajax.before", { link: link, replace: replace, url: href });
showLoader(container);
if (replace) {
// Soft clear items while loading (keep skeleton if present).
container.querySelectorAll(".ajax-item").forEach(function (n) {
n.classList.add("is-fading");
});
}
return fetch(href, {
method: override.method || "GET",
credentials: "same-origin",
headers: restHeaders(),
})
.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)) {
throw new Error(
(json && (json.message || (json.data && json.data.message))) || "加载失败"
);
}
return normalizeJson(json);
});
}
return res.text().then(function (text) {
if (!res.ok) throw new Error("加载失败");
return extractFromHtml(text, container);
});
})
.then(function (payload) {
if (!payload) throw new Error("空响应");
if (replace && !payload.itemsHtml && payload.emptyHtml) {
inject(container, { itemsHtml: payload.emptyHtml, paginateHtml: payload.paginateHtml }, { replace: true });
} else {
inject(container, payload, { replace: replace });
}
applyRoute(link, payload.title);
hideLoader(container);
loadingMap.set(container, false);
dispatch(container, "post_ajax.ed", {
link: link,
replace: replace,
url: href,
payload: payload,
});
document.dispatchEvent(
new CustomEvent("aksrt:posts:rendered", {
bubbles: true,
detail: { container: container, replace: replace },
})
);
return payload;
})
.catch(function (err) {
hideLoader(container);
loadingMap.set(container, false);
container.querySelectorAll(".ajax-item.is-fading").forEach(function (n) {
n.classList.remove("is-fading");
});
if (typeof window.aksrtToast === "function") {
window.aksrtToast((err && err.message) || "加载失败");
}
dispatch(container, "post_ajax.error", { link: link, error: err });
throw err;
});
}
document.addEventListener("click", function (e) {
var link = e.target.closest("a.ajax-next, .ajax-next a, button.ajax-next, [data-aksrt-ajax-next]");
if (!link) return;
// Ignore if inside a component that opts out.
if (link.closest("[data-aksrt-ajax-list='off']")) return;
// Native pagination core may own .aksrt-pagination-ajax buttons.
if (link.closest(".aksrt-pagination-ajax") && !link.classList.contains("ajax-next")) return;
var href = link.getAttribute("href") || link.getAttribute("ajax-href") || link.getAttribute("data-aksrt-ajax-url");
if (!href && !link.getAttribute("data-aksrt-ajax-url")) return;
e.preventDefault();
loadFromLink(link.tagName === "A" || link.classList.contains("ajax-next") ? link : link.closest(".ajax-next") || link);
});
window.AksrtAjaxList = {
load: loadFromLink,
inject: inject,
extractFromHtml: extractFromHtml,
closestPager: closestPager,
};
})(window, document);
+232
View File
@@ -0,0 +1,232 @@
/**
* AKSRT Remote Modal — zibll RefreshModal parity.
*
* 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
*
* @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 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 restHeaders() {
var h = { Accept: "text/html, application/json" };
if (window.aksrtData && aksrtData.restNonce) {
h["X-WP-Nonce"] = aksrtData.restNonce;
}
return h;
}
function lockScroll(lock) {
document.documentElement.classList.toggle("aksrt-remote-modal-open", !!lock);
document.body.classList.toggle("aksrt-remote-modal-open", !!lock);
}
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 =
'<div class="aksrt-remote-modal__overlay" data-aksrt-remote-modal-close></div>' +
'<div class="aksrt-remote-modal__dialog" style="' + heightStyle + '">' +
'<div class="aksrt-remote-modal__content">' +
(options.html || "") +
"</div></div>";
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:
'<div class="aksrt-remote-modal__loading"><i class="fas fa-spinner fa-spin"></i> ' +
((window.aksrtData && aksrtData.ajaxLoading) || "加载中…") +
"</div>",
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:
'<div class="aksrt-remote-modal__error touch border-title"><b>提示</b></div>' +
'<div class="aksrt-remote-modal__error-body">' +
(err && err.message ? String(err.message) : "加载失败") +
'</div><div class="but-average modal-buts"><button type="button" class="but" data-aksrt-remote-modal-close>关闭</button></div>',
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({
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",
});
}
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);
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && active) close();
});
window.AksrtRemoteModal = {
open: open,
openRemote: openRemote,
close: close,
fromTrigger: fromTrigger,
getActive: function () {
return active;
},
};
})(window, document);