The like and favorite buttons gave no in-flight feedback: the label just sat there until the request returned, the lovestar animation never replayed on a repeat like, duplicate clicks reported "success", and guest favorites silently wrote a cookie instead of asking the user to sign in. - pending state: replace the count with the reference inline spinner (`<i class="loading zts">`) and disable the button until the response lands - animation: strip `action-animation` before re-adding it (forced reflow), so every successful like replays the reference lovestar keyframes - duplicate: when the server reports the same state and count, skip the animation and surface the reference warning copy (已赞过此文章了!) - failures: restore the original count and toast `danger`; guest-only actions now open the site login modal instead of dead-ending on an error - favorite requires login server-side, matching the reference; the guest favourite cookie branch and its reader are removed as dead code - unify like/favorite through one bindToggle path and keep every mirror button (article row + footer tabbar) in sync Tests: new article-actions-ux suite (8 cases) drives assets/js/core/article.js against a DOM/fetch shim — pending spinner, replay, duplicate no-op, concurrency, rollback, guest login, mirror sync. Widget suite 828/829; the one failure (frontend-lifecycle theme-init stub) predates this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
349 lines
12 KiB
JavaScript
349 lines
12 KiB
JavaScript
/**
|
|
* Jio Article Tools — password toggles, article actions, interaction bar
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
function initPasswordToggles(root) {
|
|
(root || document).querySelectorAll(".toggle-pwd").forEach(function (btn) {
|
|
if (btn.dataset.pwdToggleBound) return;
|
|
btn.dataset.pwdToggleBound = "1";
|
|
btn.addEventListener("click", function () {
|
|
var id = btn.getAttribute("data-target");
|
|
var input = id ? document.getElementById(id) : null;
|
|
if (!input) return;
|
|
var show = input.getAttribute("type") === "password";
|
|
input.setAttribute("type", show ? "text" : "password");
|
|
btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码");
|
|
});
|
|
});
|
|
}
|
|
|
|
function initArticleTools() {
|
|
var buttons = document.querySelectorAll("[data-article-tool]");
|
|
if (!buttons.length) return;
|
|
|
|
buttons.forEach(function (button) {
|
|
if (button.dataset.jioArticleToolBound) return;
|
|
button.dataset.jioArticleToolBound = "1";
|
|
button.addEventListener("click", function () {
|
|
var tool = button.getAttribute("data-article-tool");
|
|
if (tool === "print") {
|
|
window.print();
|
|
return;
|
|
}
|
|
|
|
if (tool === "pdf") {
|
|
window.print();
|
|
notyf("请在打印对话框中选择“另存为 PDF”", "info");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
var postMetaDocumentEventsBound = false;
|
|
|
|
function setPostMetaMenuState(menuRoot, open) {
|
|
var toggle = menuRoot.querySelector(".post-drop-meta__toggle");
|
|
var menu = menuRoot.querySelector(".post-drop-meta__menu");
|
|
if (!toggle || !menu) return;
|
|
|
|
menuRoot.classList.toggle("is-open", open);
|
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
menu.setAttribute("aria-hidden", open ? "false" : "true");
|
|
}
|
|
|
|
function closePostMetaMenus(except, restoreFocus) {
|
|
document.querySelectorAll(".post-drop-meta.is-open").forEach(function (menuRoot) {
|
|
if (menuRoot === except) return;
|
|
var toggle = menuRoot.querySelector(".post-drop-meta__toggle");
|
|
setPostMetaMenuState(menuRoot, false);
|
|
if (restoreFocus && toggle) toggle.focus();
|
|
});
|
|
}
|
|
|
|
function initPostMetaMenus(root) {
|
|
var scope = root || document;
|
|
var menuRoots = [];
|
|
|
|
if (scope.nodeType === 1 && scope.matches(".post-drop-meta")) {
|
|
menuRoots.push(scope);
|
|
}
|
|
scope.querySelectorAll(".post-drop-meta").forEach(function (menuRoot) {
|
|
menuRoots.push(menuRoot);
|
|
});
|
|
|
|
menuRoots.forEach(function (menuRoot) {
|
|
var toggle = menuRoot.querySelector(".post-drop-meta__toggle");
|
|
var menu = menuRoot.querySelector(".post-drop-meta__menu");
|
|
if (!toggle || !menu || toggle.dataset.postMetaMenuBound) return;
|
|
|
|
toggle.dataset.postMetaMenuBound = "1";
|
|
setPostMetaMenuState(menuRoot, false);
|
|
|
|
toggle.addEventListener("click", function () {
|
|
var open = !menuRoot.classList.contains("is-open");
|
|
closePostMetaMenus(menuRoot, false);
|
|
setPostMetaMenuState(menuRoot, open);
|
|
});
|
|
|
|
menu.addEventListener("click", function (event) {
|
|
if (event.target.closest(".post-drop-meta__item")) {
|
|
setPostMetaMenuState(menuRoot, false);
|
|
}
|
|
});
|
|
});
|
|
|
|
if (menuRoots.length && !postMetaDocumentEventsBound) {
|
|
postMetaDocumentEventsBound = true;
|
|
document.addEventListener("click", function (event) {
|
|
if (!event.target.closest(".post-drop-meta")) {
|
|
closePostMetaMenus(null, false);
|
|
}
|
|
});
|
|
document.addEventListener("keydown", function (event) {
|
|
if (event.key === "Escape") {
|
|
closePostMetaMenus(null, true);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function formatStatCount(count) {
|
|
// 与 PHP jio_format_number() 同一套缩写:≥10000 → 1.2W+,≥1000 → 1.5K。
|
|
var safeCount = Math.max(0, count);
|
|
if (safeCount >= 10000) {
|
|
return Math.round((safeCount / 10000) * 10) / 10 + "W+";
|
|
}
|
|
if (safeCount >= 1000) {
|
|
return Math.round((safeCount / 1000) * 10) / 10 + "K";
|
|
}
|
|
return String(safeCount);
|
|
}
|
|
|
|
function getBaseStatCount(statName) {
|
|
var statEl = document.querySelector('[data-stat-count="' + statName + '"][data-base-count]');
|
|
return statEl ? (parseInt(statEl.dataset.baseCount, 10) || 0) : 0;
|
|
}
|
|
|
|
function updateStatCounts(statName, count) {
|
|
var label = formatStatCount(count);
|
|
document.querySelectorAll('[data-stat-count="' + statName + '"]').forEach(function (statEl) {
|
|
statEl.textContent = label;
|
|
});
|
|
}
|
|
|
|
function setBaseStatCount(statName, count) {
|
|
var safeCount = Math.max(0, count);
|
|
document.querySelectorAll('[data-stat-count="' + statName + '"][data-base-count]').forEach(function (statEl) {
|
|
statEl.dataset.baseCount = String(safeCount);
|
|
});
|
|
updateStatCounts(statName, safeCount);
|
|
}
|
|
|
|
function requestInteraction(actionName, postId, active) {
|
|
if (!window.jioData || !jioData.ajaxUrl || !jioData.nonce) {
|
|
return Promise.reject(new Error("互动服务未配置"));
|
|
}
|
|
|
|
var body = new URLSearchParams();
|
|
body.set("action", actionName);
|
|
body.set("nonce", jioData.nonce);
|
|
body.set("postId", String(postId));
|
|
body.set("active", active ? "1" : "0");
|
|
|
|
return fetch(jioData.ajaxUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
|
credentials: "same-origin",
|
|
body: body.toString()
|
|
}).then(function (response) {
|
|
return response.json().catch(function () {
|
|
throw new Error("服务器返回无效响应");
|
|
});
|
|
}).then(function (payload) {
|
|
if (!payload || !payload.success) {
|
|
var data = (payload && payload.data) || {};
|
|
var error = new Error(data.message || "请求失败");
|
|
// 参考主题:游客操作受限时直接拉起登录弹窗,而不是只弹一条错误提示。
|
|
error.needsLogin = !!data.login;
|
|
throw error;
|
|
}
|
|
return payload.data || {};
|
|
});
|
|
}
|
|
|
|
/** 游客被拦下时,复用站点的登录弹窗触发器。 */
|
|
function openLoginModal() {
|
|
var trigger = document.querySelector(".signin-loader") ||
|
|
document.querySelector("[data-jio-auth-required]") ||
|
|
document.querySelector("#nav-login-btn");
|
|
if (trigger) {
|
|
trigger.click();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 参考主题的待发送态:计数被行内加载点替换,按钮不可重复提交。
|
|
* @returns {Function} 还原计数内容的函数
|
|
*/
|
|
function showPending(button) {
|
|
var counts = button.querySelectorAll("count");
|
|
var previous = [];
|
|
for (var i = 0; i < counts.length; i++) {
|
|
previous.push(counts[i].innerHTML);
|
|
counts[i].innerHTML = '<i class="loading zts"></i>';
|
|
}
|
|
button.disabled = true;
|
|
return function restore() {
|
|
for (var j = 0; j < counts.length; j++) {
|
|
if (previous[j] === undefined) continue;
|
|
counts[j].innerHTML = previous[j];
|
|
}
|
|
};
|
|
}
|
|
|
|
/** 参考主题只在点亮时播放 lovestar;先摘类再于下一帧加回,重复点赞也会重放。 */
|
|
function paintActive(button, isActive, animate) {
|
|
button.dataset.initialActive = isActive ? "1" : "0";
|
|
button.classList.toggle("actived", isActive);
|
|
button.classList.remove("action-animation");
|
|
if (!isActive || !animate) return;
|
|
// 读取 offsetWidth 强制重排,使动画类移除→添加之间重新触发关键帧。
|
|
void button.offsetWidth;
|
|
button.classList.add("action-animation");
|
|
}
|
|
|
|
function findInScope(scope, id) {
|
|
scope = scope || document;
|
|
if (!scope) return null;
|
|
if (scope.nodeType === 9) {
|
|
return scope.getElementById(id);
|
|
}
|
|
if (scope.nodeType === 1) {
|
|
if (scope.id === id) return scope;
|
|
return typeof scope.querySelector === "function"
|
|
? scope.querySelector("#" + id)
|
|
: null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function collectActionButtons(scope, selector) {
|
|
scope = scope || document;
|
|
var list = [];
|
|
if (!scope) return list;
|
|
if (scope.nodeType === 1 && scope.matches && scope.matches(selector)) {
|
|
list.push(scope);
|
|
}
|
|
if (typeof scope.querySelectorAll === "function") {
|
|
var found = scope.querySelectorAll(selector);
|
|
for (var i = 0; i < found.length; i++) list.push(found[i]);
|
|
} else if (scope.nodeType === 9) {
|
|
var all = document.querySelectorAll(selector);
|
|
for (var j = 0; j < all.length; j++) list.push(all[j]);
|
|
}
|
|
// also keep legacy id hooks
|
|
var legacyId = selector.indexOf("action-like") >= 0 ? "action-like" : (selector.indexOf("action-favorite") >= 0 ? "action-favorite" : "");
|
|
if (legacyId) {
|
|
var legacy = findInScope(scope, legacyId);
|
|
if (legacy && list.indexOf(legacy) === -1) list.push(legacy);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
function initInteractionBar(root) {
|
|
var scope = root || document;
|
|
// Bind all like/favorite controls (in-content + footer tabbar extend).
|
|
var likeBtns = collectActionButtons(scope, ".action-like[data-post-id]");
|
|
var favBtns = collectActionButtons(scope, ".action-favorite[data-post-id]");
|
|
|
|
function applyState(buttons, statName, isActive, count, animate) {
|
|
for (var i = 0; i < buttons.length; i++) paintActive(buttons[i], isActive, animate);
|
|
if ("number" === typeof count && !isNaN(count)) {
|
|
setBaseStatCount(statName, count);
|
|
} else {
|
|
updateStatCounts(statName, getBaseStatCount(statName));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 参考主题的点击流程:立即还原计数 → 提交 → 按服务端结果重绘。
|
|
* 服务端返回未变化(例如访客重复记录)时不播放动画,只提示。
|
|
*/
|
|
function bindToggle(buttons, statName, actionName, messages) {
|
|
if (!buttons.length) return;
|
|
if (buttons[0].dataset.jioInteractionBound) return;
|
|
|
|
var seed = buttons[0];
|
|
applyState(buttons, statName, seed.dataset.initialActive === "1", getBaseStatCount(statName), false);
|
|
|
|
buttons.forEach(function (button) {
|
|
if (button.dataset.jioInteractionBound) return;
|
|
button.dataset.jioInteractionBound = "1";
|
|
button.addEventListener("click", function () {
|
|
var postId = button.dataset.postId ? String(button.dataset.postId) : "";
|
|
if (!postId || button.disabled) return;
|
|
|
|
// 点击前的快照:用来判断服务端是否真的改变了状态。
|
|
var wasActive = button.dataset.initialActive === "1";
|
|
var previousCount = getBaseStatCount(statName);
|
|
var restoreCount = showPending(button);
|
|
|
|
requestInteraction(actionName, postId, !wasActive).then(function (data) {
|
|
var active = !!data.active;
|
|
var count = parseInt(data.count, 10);
|
|
if (isNaN(count)) count = previousCount;
|
|
restoreCount();
|
|
|
|
var unchanged = active === wasActive && count === previousCount;
|
|
applyState(buttons, statName, active, count, !unchanged);
|
|
if (unchanged) {
|
|
notyf(messages.duplicate, "warning");
|
|
} else {
|
|
notyf(active ? messages.added : messages.removed, active ? "success" : "warning");
|
|
}
|
|
}).catch(function (error) {
|
|
restoreCount();
|
|
notyf(error && error.message ? error.message : messages.failed, "danger");
|
|
if (error && error.needsLogin) openLoginModal();
|
|
}).finally(function () {
|
|
button.disabled = false;
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
bindToggle(likeBtns, "likes", "jio_toggle_like", {
|
|
added: "已赞!感谢您的支持",
|
|
removed: "点赞已取消",
|
|
duplicate: "已赞过此文章了!",
|
|
failed: "点赞失败"
|
|
});
|
|
|
|
bindToggle(favBtns, "favorites", "jio_toggle_favorite", {
|
|
added: "已收藏此文章",
|
|
removed: "已取消收藏",
|
|
duplicate: "已经收藏过此文章了!",
|
|
failed: "收藏失败"
|
|
});
|
|
}
|
|
|
|
function init(root) {
|
|
initArticleTools();
|
|
initPostMetaMenus(root);
|
|
initPasswordToggles(root);
|
|
initInteractionBar(root);
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", function () { init(); });
|
|
} else {
|
|
init();
|
|
}
|
|
// AJAX 注入新内容后由 auto_fun 重扫(各 init 均带 data 标记,重复调用幂等)。
|
|
if (window.jQuery) {
|
|
window.jQuery(document).off("auto_fun.jio-article").on("auto_fun.jio-article", function () { init(); });
|
|
}
|
|
})();
|