fix(single): align article like/favorite interaction UX with reference
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>
This commit is contained in:
1 parent
3c07ef7a8d
commit
93e83e1835
7 files changed
+470
-92
No files matched your search
@@ -6256,6 +6256,13 @@ body:has(.modal.in) .float-btn,
|
||||
animation: lovestar 0.6s 0s 1 ease normal none;
|
||||
}
|
||||
|
||||
/* 参考主题点赞/收藏的请求中状态:计数换成行内加载点,按钮仍保持普通指针。 */
|
||||
.action[disabled],
|
||||
.action[disabled]:hover {
|
||||
cursor: pointer !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@-webkit-keyframes lovestar {
|
||||
0% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
body{--mian-max-width: 1200px;--posts-card-scale: 70%;--posts-list-scale: 70%;--theme-color: #f04494;--focus-color: var(--theme-color);--focus-shadow-color: rgba(253, 83, 161, .4);--key-color: #333;--main-color: #4e5358;--main-shadow: rgba(116, 116, 116, .08);--muted-color: #777;--muted-2-color: #999;--muted-3-color: #b1b1b1;--muted-4-color: #d2d2d2;--body-bg-color: #f5f6f7;--main-bg-color: #fff;--muted-bg-color: #eee;--muted-bg-2-color: #f6f7f8;--main-border-color: rgba(50, 50, 50, .06);--muted-border-color: rgba(0, 0, 0, .03);--main-radius: 8px;--mini-radius: calc(var(--main-radius) / 1.4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}.dark-theme{--key-color: #f8fafc;--main-color: #e5eef7;--main-shadow: rgba(24, 24, 24, .1);--muted-color: #b4b6bb;--muted-2-color: #888a8f;--muted-3-color: #636469;--muted-4-color: #43454a;--body-bg-color: #292a2d;--main-bg-color: #323335;--muted-bg-color: #2d2e31;--muted-bg-2-color: #37383a;--main-border-color: rgba(114, 114, 114, .1);--muted-border-color: rgba(184, 184, 184, .02);--blur-bg: rgba(50, 51, 53, .8);--float-btn-bg: rgba(70, 70, 70, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}.dark::view-transition-old(root){z-index:1}.dark::view-transition-new(root){z-index:2147483646}::view-transition-old(root){z-index:2147483646}::view-transition-new(root){z-index:1}@supports not ((-webkit-backdrop-filter: blur(20px)) or (backdrop-filter: blur(20px))){body{--blur-bg: #ffffff}body.dark-theme{--blur-bg: #37383a}}body:has(.modal.in),body.modal-open{--blur-bg: #ffffff}body.dark-theme:has(.modal.in),body.dark-theme.modal-open{--blur-bg: #37383a}body.fps-accelerat{--blur-bg: #ffffff}body.fps-accelerat.dark-theme{--blur-bg: #37383a}body.fps-accelerat *{-webkit-backdrop-filter:unset!important;backdrop-filter:unset!important}a[href],.relates-thumb li a>.post-info,.more-posts a>span{transition:color .2s,background .3s,opacity .3s,box-shadow .3s,transform .3s}.main-shadow{transition:box-shadow .3s}.but:focus,.but:hover,.progress,.navbar-nav>li:before{transition:.3s}.navbar-top .sub-menu,.theme-popover,.hover-show .hover-show-con{opacity:0;transform:translateY(6px);transition:.4s}.navbar-top li:hover>.sub-menu,.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{opacity:1;transform:translateY(0)}.hover-show{position:relative;cursor:pointer;z-index:10}.hover-show .hover-show-con{position:absolute;visibility:hidden}.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{visibility:unset}.match-ok,.navbar-top .sub-menu li>a,.ic-close{transition:.3s}.ic-close:hover{transform:rotate(270deg);color:inherit}.social_loginbar a,.item-thumbnail img,.dosc-nav .fa{transition:.2s ease-out}.hover-zoom:hover,.hover-zoom-img:hover img,.social_loginbar .xh-social-item:hover,.social_loginbar .social-login-item:hover{transform:scale(1.05)}.hover-zoom-sm:hover,.hover-zoom-img-sm:hover img,.posts-mini:hover img,.posts-item:hover .item-thumbnail img,.posts-item.mult-thumb .thumb-items>span>img:hover{transform:scale(1.02)}.main-min-height{min-height:calc(100vh - 280px)}.main-shadow{box-shadow:0 0 10px var(--main-shadow)}.key-color{color:var(--key-color)}.focus-color{--this-color: var(--focus-color);color:var(--focus-color)}.muted-color{color:var(--muted-color)}.muted-2-color{color:var(--muted-2-color)}.muted-3-color{color:var(--muted-3-color)}.main-bg{background:var(--main-bg-color)}.placeholder{background:linear-gradient(118deg,#7979791a 25%,#79797933 37%,#7979791a 63%);opacity:1;border-radius:4px;-webkit-animation:placeholder 1.5s ease infinite!important;animation:placeholder 1.5s ease infinite!important;background-size:400% 100%!important}.placeholder.img{height:119px}.placeholder.t1{height:24px;width:80%}.placeholder.k1{height:20px;width:60%}.placeholder.k2{height:20px}.placeholder.k3{height:15px}.placeholder.s1{width:70px;height:15px;display:inline-block}@-webkit-keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@-webkit-keyframes fade{0%{opacity:0}to{opacity:1}}@keyframes fade{0%{opacity:0}to{opacity:1}}.ani.shake{-webkit-animation:shake .3s ease-in!important;animation:shake .3s ease-in!important}@-webkit-keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}@keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}.obs-animate{opacity:0;transition:opacity .6sLine truncated
|
||||
body{--mian-max-width: 1200px;--posts-card-scale: 70%;--posts-list-scale: 70%;--theme-color: #f04494;--focus-color: var(--theme-color);--focus-shadow-color: rgba(253, 83, 161, .4);--key-color: #333;--main-color: #4e5358;--main-shadow: rgba(116, 116, 116, .08);--muted-color: #777;--muted-2-color: #999;--muted-3-color: #b1b1b1;--muted-4-color: #d2d2d2;--body-bg-color: #f5f6f7;--main-bg-color: #fff;--muted-bg-color: #eee;--muted-bg-2-color: #f6f7f8;--main-border-color: rgba(50, 50, 50, .06);--muted-border-color: rgba(0, 0, 0, .03);--main-radius: 8px;--mini-radius: calc(var(--main-radius) / 1.4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}.dark-theme{--key-color: #f8fafc;--main-color: #e5eef7;--main-shadow: rgba(24, 24, 24, .1);--muted-color: #b4b6bb;--muted-2-color: #888a8f;--muted-3-color: #636469;--muted-4-color: #43454a;--body-bg-color: #292a2d;--main-bg-color: #323335;--muted-bg-color: #2d2e31;--muted-bg-2-color: #37383a;--main-border-color: rgba(114, 114, 114, .1);--muted-border-color: rgba(184, 184, 184, .02);--blur-bg: rgba(50, 51, 53, .8);--float-btn-bg: rgba(70, 70, 70, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}.dark::view-transition-old(root){z-index:1}.dark::view-transition-new(root){z-index:2147483646}::view-transition-old(root){z-index:2147483646}::view-transition-new(root){z-index:1}@supports not ((-webkit-backdrop-filter: blur(20px)) or (backdrop-filter: blur(20px))){body{--blur-bg: #ffffff}body.dark-theme{--blur-bg: #37383a}}body:has(.modal.in),body.modal-open{--blur-bg: #ffffff}body.dark-theme:has(.modal.in),body.dark-theme.modal-open{--blur-bg: #37383a}body.fps-accelerat{--blur-bg: #ffffff}body.fps-accelerat.dark-theme{--blur-bg: #37383a}body.fps-accelerat *{-webkit-backdrop-filter:unset!important;backdrop-filter:unset!important}a[href],.relates-thumb li a>.post-info,.more-posts a>span{transition:color .2s,background .3s,opacity .3s,box-shadow .3s,transform .3s}.main-shadow{transition:box-shadow .3s}.but:focus,.but:hover,.progress,.navbar-nav>li:before{transition:.3s}.navbar-top .sub-menu,.theme-popover,.hover-show .hover-show-con{opacity:0;transform:translateY(6px);transition:.4s}.navbar-top li:hover>.sub-menu,.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{opacity:1;transform:translateY(0)}.hover-show{position:relative;cursor:pointer;z-index:10}.hover-show .hover-show-con{position:absolute;visibility:hidden}.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{visibility:unset}.match-ok,.navbar-top .sub-menu li>a,.ic-close{transition:.3s}.ic-close:hover{transform:rotate(270deg);color:inherit}.social_loginbar a,.item-thumbnail img,.dosc-nav .fa{transition:.2s ease-out}.hover-zoom:hover,.hover-zoom-img:hover img,.social_loginbar .xh-social-item:hover,.social_loginbar .social-login-item:hover{transform:scale(1.05)}.hover-zoom-sm:hover,.hover-zoom-img-sm:hover img,.posts-mini:hover img,.posts-item:hover .item-thumbnail img,.posts-item.mult-thumb .thumb-items>span>img:hover{transform:scale(1.02)}.main-min-height{min-height:calc(100vh - 280px)}.main-shadow{box-shadow:0 0 10px var(--main-shadow)}.key-color{color:var(--key-color)}.focus-color{--this-color: var(--focus-color);color:var(--focus-color)}.muted-color{color:var(--muted-color)}.muted-2-color{color:var(--muted-2-color)}.muted-3-color{color:var(--muted-3-color)}.main-bg{background:var(--main-bg-color)}.placeholder{background:linear-gradient(118deg,#7979791a 25%,#79797933 37%,#7979791a 63%);opacity:1;border-radius:4px;-webkit-animation:placeholder 1.5s ease infinite!important;animation:placeholder 1.5s ease infinite!important;background-size:400% 100%!important}.placeholder.img{height:119px}.placeholder.t1{height:24px;width:80%}.placeholder.k1{height:20px;width:60%}.placeholder.k2{height:20px}.placeholder.k3{height:15px}.placeholder.s1{width:70px;height:15px;display:inline-block}@-webkit-keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@-webkit-keyframes fade{0%{opacity:0}to{opacity:1}}@keyframes fade{0%{opacity:0}to{opacity:1}}.ani.shake{-webkit-animation:shake .3s ease-in!important;animation:shake .3s ease-in!important}@-webkit-keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}@keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}.obs-animate{opacity:0;transition:opacity .6sLine truncated
|
||||
+103
-76
@@ -163,16 +163,55 @@
|
||||
});
|
||||
}).then(function (payload) {
|
||||
if (!payload || !payload.success) {
|
||||
var message = payload && payload.data && payload.data.message ? payload.data.message : "请求失败";
|
||||
throw new Error(message);
|
||||
var data = (payload && payload.data) || {};
|
||||
var error = new Error(data.message || "请求失败");
|
||||
// 参考主题:游客操作受限时直接拉起登录弹窗,而不是只弹一条错误提示。
|
||||
error.needsLogin = !!data.login;
|
||||
throw error;
|
||||
}
|
||||
return payload.data || {};
|
||||
});
|
||||
}
|
||||
|
||||
function setButtonBusy(button, busy) {
|
||||
button.disabled = !!busy;
|
||||
button.classList.toggle("is-loading", !!busy);
|
||||
/** 游客被拦下时,复用站点的登录弹窗触发器。 */
|
||||
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) {
|
||||
@@ -219,87 +258,75 @@
|
||||
var likeBtns = collectActionButtons(scope, ".action-like[data-post-id]");
|
||||
var favBtns = collectActionButtons(scope, ".action-favorite[data-post-id]");
|
||||
|
||||
function paintLike(btn, isActive, animate) {
|
||||
btn.dataset.initialActive = isActive ? "1" : "0";
|
||||
// actived 为参考主题的选中态类名;action-animation 只在点亮瞬间附加,
|
||||
// 触发 main.css 中的 lovestar 关键帧。
|
||||
btn.classList.toggle("actived", isActive);
|
||||
btn.classList.toggle("action-animation", !!(isActive && animate));
|
||||
}
|
||||
|
||||
function paintFavorite(btn, isActive, animate) {
|
||||
btn.dataset.initialActive = isActive ? "1" : "0";
|
||||
btn.classList.toggle("actived", isActive);
|
||||
btn.classList.toggle("action-animation", !!(isActive && animate));
|
||||
}
|
||||
|
||||
function applyLikeState(isActive, count, animate) {
|
||||
for (var i = 0; i < likeBtns.length; i++) paintLike(likeBtns[i], isActive, animate);
|
||||
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("likes", count);
|
||||
setBaseStatCount(statName, count);
|
||||
} else {
|
||||
updateStatCounts("likes", getBaseStatCount("likes"));
|
||||
updateStatCounts(statName, getBaseStatCount(statName));
|
||||
}
|
||||
}
|
||||
|
||||
function applyFavoriteState(isActive, count, animate) {
|
||||
for (var i = 0; i < favBtns.length; i++) paintFavorite(favBtns[i], isActive, animate);
|
||||
if ("number" === typeof count && !isNaN(count)) {
|
||||
setBaseStatCount("favorites", count);
|
||||
} else {
|
||||
updateStatCounts("favorites", getBaseStatCount("favorites"));
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 参考主题的点击流程:立即还原计数 → 提交 → 按服务端结果重绘。
|
||||
* 服务端返回未变化(例如访客重复记录)时不播放动画,只提示。
|
||||
*/
|
||||
function bindToggle(buttons, statName, actionName, messages) {
|
||||
if (!buttons.length) return;
|
||||
if (buttons[0].dataset.jioInteractionBound) return;
|
||||
|
||||
if (likeBtns.length) {
|
||||
var seedLike = likeBtns[0];
|
||||
if (!seedLike.dataset.jioInteractionBound) {
|
||||
applyLikeState(seedLike.dataset.initialActive === "1", getBaseStatCount("likes"));
|
||||
likeBtns.forEach(function (likeBtn) {
|
||||
if (likeBtn.dataset.jioInteractionBound) return;
|
||||
likeBtn.dataset.jioInteractionBound = "1";
|
||||
likeBtn.addEventListener("click", function () {
|
||||
var postId = likeBtn.dataset.postId ? String(likeBtn.dataset.postId) : "";
|
||||
if (!postId || likeBtn.disabled) return;
|
||||
var nextState = likeBtn.dataset.initialActive !== "1";
|
||||
setButtonBusy(likeBtn, true);
|
||||
requestInteraction("jio_toggle_like", postId, nextState).then(function (data) {
|
||||
applyLikeState(!!data.active, parseInt(data.count, 10) || 0, true);
|
||||
notyf(data.active ? "感谢你的点赞" : "已取消点赞", "success");
|
||||
}).catch(function (error) {
|
||||
notyf(error && error.message ? error.message : "点赞失败", "danger");
|
||||
}).finally(function () {
|
||||
setButtonBusy(likeBtn, false);
|
||||
});
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (favBtns.length) {
|
||||
var seedFav = favBtns[0];
|
||||
if (!seedFav.dataset.jioInteractionBound) {
|
||||
applyFavoriteState(seedFav.dataset.initialActive === "1", getBaseStatCount("favorites"));
|
||||
favBtns.forEach(function (favBtn) {
|
||||
if (favBtn.dataset.jioInteractionBound) return;
|
||||
favBtn.dataset.jioInteractionBound = "1";
|
||||
favBtn.addEventListener("click", function () {
|
||||
var postId = favBtn.dataset.postId ? String(favBtn.dataset.postId) : "";
|
||||
if (!postId || favBtn.disabled) return;
|
||||
var nextState = favBtn.dataset.initialActive !== "1";
|
||||
setButtonBusy(favBtn, true);
|
||||
requestInteraction("jio_toggle_favorite", postId, nextState).then(function (data) {
|
||||
applyFavoriteState(!!data.active, parseInt(data.count, 10) || 0, true);
|
||||
notyf(data.active ? "收藏成功" : "已取消收藏", "success");
|
||||
}).catch(function (error) {
|
||||
notyf(error && error.message ? error.message : "收藏失败", "danger");
|
||||
}).finally(function () {
|
||||
setButtonBusy(favBtn, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
bindToggle(likeBtns, "likes", "jio_toggle_like", {
|
||||
added: "已赞!感谢您的支持",
|
||||
removed: "点赞已取消",
|
||||
duplicate: "已赞过此文章了!",
|
||||
failed: "点赞失败"
|
||||
});
|
||||
|
||||
bindToggle(favBtns, "favorites", "jio_toggle_favorite", {
|
||||
added: "已收藏此文章",
|
||||
removed: "已取消收藏",
|
||||
duplicate: "已经收藏过此文章了!",
|
||||
failed: "收藏失败"
|
||||
});
|
||||
}
|
||||
|
||||
function init(root) {
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
(function($,window2,document2){"use strict";function closeNotyf($element){$element.addClass("notyn-out"),window2.setTimeout(function(){$element.remove()},1e3)}function notyf2(message,type,time,id){var $body=jQuery(document2.body);jQuery(".notyn").length||$body.append('<div class="notyn"></div>'),type=type||"success",type==="error"&&(type="danger"),time=time||5e3,time=time<100?time*1e3:time;var idAttribute=id?' id="'+id+'"':"",$notification=jQuery('<div class="noty1"'+idAttribute+'><div class="notyf '+type+'">'+message+"</div></div>"),shouldClose=!id;id&&jQuery("#"+id).length?(jQuery("#"+id).find(".notyf").removeClass().addClass("notyf "+type).html(message),$notification=jQuery("#"+id),shouldClose=!0):jQuery(".notyn").append($notification),shouldClose&&window2.setTimeout(function(){closeNotyf($notification)},time)}jQuery(document2.body).on("click",".noty1",function(){closeNotyf(jQuery(this))}),window2.notyf=notyf2})(jQuery,window,document),(function(window2){"use strict";var allowedKinds={identity:!0,badges:!0,orders:!0,vip:!0},allowedEvents={click:!0,shell:!0,"first-content":!0,ready:!0,"action-start":!0,"action-end":!0,unauthorized:!0,error:!0},sessions=Object.create(null),sequence=0,maxSessions=100;function normalize(value,fallback){var normalized=String(value||fallback||"").toLowerCase().replace(/[^a-z0-9-]/g,"");return normalized||fallback}function now(){return window2.performance&&typeof window2.performance.now=="function"?window2.performance.now():0}function markName(session,event){return"jio-modal-"+session.kind+"-"+session.id+"-"+event}function emit(session,event){if(!(!window2.performance||typeof window2.performance.mark!="function")){var name=markName(session,event);if(window2.performance.mark(name),session.marks[event]=name,typeof window2.performance.measure=="function"&&session.marks.click&&event!=="click")try{window2.performance.measure("jio-modal-"+session.kind+"-"+session.id+"-click-to-"+event,session.marks.click,name)}catch{}}}function start(kind){var safeKind=normalize(kind,"unknown");allowedKinds[safeKind]||(safeKind="unknown"),sequence+=1;var id=String(sequence),session={id,kind:safeKind,startedAt:now(),marks:Object.create(null)};sessions[id]=session,emit(session,"click");for(var ids=Object.keys(sessions);ids.length>maxSessions;)delete sessions[ids.shift()];return id}function mark(id,event){var session=sessions[String(id||"")],safeEvent=normalize(event,"error");!session||!allowedEvents[safeEvent]||emit(session,safeEvent)}function finish(id){mark(id,"ready")}function fail(id){mark(id,"error")}window2.JioModalTelemetry={start,mark,finish,error:fail}})(window),(function(window2){"use strict";function data(){return window2.jioData||window2.jio||{}}function createIdempotencyKey(){if(window2.crypto&&typeof window2.crypto.randomUUID=="function")return window2.crypto.randomUUID();if(!window2.crypto||typeof window2.crypto.getRandomValues!="function")throw new Error("当前浏览器无法生成安全的操作标识");var bytes=new Uint8Array(16);window2.crypto.getRandomValues(bytes),bytes[6]=bytes[6]&15|64,bytes[8]=bytes[8]&63|128;var hex=Array.prototype.map.call(bytes,function(value){return value.toString(16).padStart(2,"0")}).join("");return hex.slice(0,8)+"-"+hex.slice(8,12)+"-"+hex.slice(12,16)+"-"+hex.slice(16,20)+"-"+hex.slice(20)}function restBase(){var config=data(),raw=config.restUrl||config.apiUrl||config.rest_url||"",siteUrl=config.siteUrl||config.homeUrl||window2.location.origin+"/";return window2.location.protocol==="https:"&&siteUrl.substring(0,5)==="http:"&&(siteUrl="https:"+siteUrl.substring(5)),raw?String(raw).replace(/\/$/,""):String(siteUrl).replace(/\/$/,"")+"/index.php?rest_route=/jio/v1"}function appendQuery(url2,query){if(!query)return url2;var parts=[];return typeof query=="string"?parts=query.replace(/^\?/,"").split("&").filter(Boolean):typeof query=="object"&&Object.keys(query).forEach(function(key){query[key]===void 0||query[key]===null||parts.push(encodeURIComponent(key)+"="+encodeURIComponent(String(query[key])))}),parts.length?url2+(url2.indexOf("?")>=0?"&":"?")+parts.join("&"):url2}function url(path,query){path=String(path||"");var inlineQuery="",queryIndex=path.indexOf("?");if(queryIndex>=0&&(inlineQuery=path.slice(queryIndex+1),path=path.slice(0,queryIndex)),/^https?:\/\//i.test(path))return appendQuery(path,query||inlineQuery);path.charAt(0)!=="/"&&(path="/"+path);var base=restBase(),result=(base.indexOf("rest_route=")>=0,base+path);return appendQuery(result,query||inlineQuery)}function headers(options){var config=data(),source=options&&options.headers||{},result={},nonce=config.restNonce||config.nonce||"";return Object.keys(source).forEach(function(key){result[key]=source[key]}),result.Accept||(result.Accept="application/json, text/html;q=0.9, */*;q=0.8"),nonce&&!result["X-WP-Nonce"]&&(result["X-WP-Nonce"]=nonce),options&&options.body!==void 0&&options.body!==null&&!(window2.FormData&&options.body instanceof window2.FormData)&&!result["Content-Type"]&&(result["CoLine truncated
|
||||
(function($,window2,document2){"use strict";function closeNotyf($element){$element.addClass("notyn-out"),window2.setTimeout(function(){$element.remove()},1e3)}function notyf2(message,type,time,id){var $body=jQuery(document2.body);jQuery(".notyn").length||$body.append('<div class="notyn"></div>'),type=type||"success",type==="error"&&(type="danger"),time=time||5e3,time=time<100?time*1e3:time;var idAttribute=id?' id="'+id+'"':"",$notification=jQuery('<div class="noty1"'+idAttribute+'><div class="notyf '+type+'">'+message+"</div></div>"),shouldClose=!id;id&&jQuery("#"+id).length?(jQuery("#"+id).find(".notyf").removeClass().addClass("notyf "+type).html(message),$notification=jQuery("#"+id),shouldClose=!0):jQuery(".notyn").append($notification),shouldClose&&window2.setTimeout(function(){closeNotyf($notification)},time)}jQuery(document2.body).on("click",".noty1",function(){closeNotyf(jQuery(this))}),window2.notyf=notyf2})(jQuery,window,document),(function(window2){"use strict";var allowedKinds={identity:!0,badges:!0,orders:!0,vip:!0},allowedEvents={click:!0,shell:!0,"first-content":!0,ready:!0,"action-start":!0,"action-end":!0,unauthorized:!0,error:!0},sessions=Object.create(null),sequence=0,maxSessions=100;function normalize(value,fallback){var normalized=String(value||fallback||"").toLowerCase().replace(/[^a-z0-9-]/g,"");return normalized||fallback}function now(){return window2.performance&&typeof window2.performance.now=="function"?window2.performance.now():0}function markName(session,event){return"jio-modal-"+session.kind+"-"+session.id+"-"+event}function emit(session,event){if(!(!window2.performance||typeof window2.performance.mark!="function")){var name=markName(session,event);if(window2.performance.mark(name),session.marks[event]=name,typeof window2.performance.measure=="function"&&session.marks.click&&event!=="click")try{window2.performance.measure("jio-modal-"+session.kind+"-"+session.id+"-click-to-"+event,session.marks.click,name)}catch{}}}function start(kind){var safeKind=normalize(kind,"unknown");allowedKinds[safeKind]||(safeKind="unknown"),sequence+=1;var id=String(sequence),session={id,kind:safeKind,startedAt:now(),marks:Object.create(null)};sessions[id]=session,emit(session,"click");for(var ids=Object.keys(sessions);ids.length>maxSessions;)delete sessions[ids.shift()];return id}function mark(id,event){var session=sessions[String(id||"")],safeEvent=normalize(event,"error");!session||!allowedEvents[safeEvent]||emit(session,safeEvent)}function finish(id){mark(id,"ready")}function fail(id){mark(id,"error")}window2.JioModalTelemetry={start,mark,finish,error:fail}})(window),(function(window2){"use strict";function data(){return window2.jioData||window2.jio||{}}function createIdempotencyKey(){if(window2.crypto&&typeof window2.crypto.randomUUID=="function")return window2.crypto.randomUUID();if(!window2.crypto||typeof window2.crypto.getRandomValues!="function")throw new Error("当前浏览器无法生成安全的操作标识");var bytes=new Uint8Array(16);window2.crypto.getRandomValues(bytes),bytes[6]=bytes[6]&15|64,bytes[8]=bytes[8]&63|128;var hex=Array.prototype.map.call(bytes,function(value){return value.toString(16).padStart(2,"0")}).join("");return hex.slice(0,8)+"-"+hex.slice(8,12)+"-"+hex.slice(12,16)+"-"+hex.slice(16,20)+"-"+hex.slice(20)}function restBase(){var config=data(),raw=config.restUrl||config.apiUrl||config.rest_url||"",siteUrl=config.siteUrl||config.homeUrl||window2.location.origin+"/";return window2.location.protocol==="https:"&&siteUrl.substring(0,5)==="http:"&&(siteUrl="https:"+siteUrl.substring(5)),raw?String(raw).replace(/\/$/,""):String(siteUrl).replace(/\/$/,"")+"/index.php?rest_route=/jio/v1"}function appendQuery(url2,query){if(!query)return url2;var parts=[];return typeof query=="string"?parts=query.replace(/^\?/,"").split("&").filter(Boolean):typeof query=="object"&&Object.keys(query).forEach(function(key){query[key]===void 0||query[key]===null||parts.push(encodeURIComponent(key)+"="+encodeURIComponent(String(query[key])))}),parts.length?url2+(url2.indexOf("?")>=0?"&":"?")+parts.join("&"):url2}function url(path,query){path=String(path||"");var inlineQuery="",queryIndex=path.indexOf("?");if(queryIndex>=0&&(inlineQuery=path.slice(queryIndex+1),path=path.slice(0,queryIndex)),/^https?:\/\//i.test(path))return appendQuery(path,query||inlineQuery);path.charAt(0)!=="/"&&(path="/"+path);var base=restBase(),result=(base.indexOf("rest_route=")>=0,base+path);return appendQuery(result,query||inlineQuery)}function headers(options){var config=data(),source=options&&options.headers||{},result={},nonce=config.restNonce||config.nonce||"";return Object.keys(source).forEach(function(key){result[key]=source[key]}),result.Accept||(result.Accept="application/json, text/html;q=0.9, */*;q=0.8"),nonce&&!result["X-WP-Nonce"]&&(result["X-WP-Nonce"]=nonce),options&&options.body!==void 0&&options.body!==null&&!(window2.FormData&&options.body instanceof window2.FormData)&&!result["Content-Type"]&&(result["CoLine truncated
|
||||
@@ -487,16 +487,13 @@ if (!function_exists('jio_is_post_liked')) :
|
||||
}
|
||||
endif;
|
||||
if (!function_exists('jio_is_post_favorited')) :
|
||||
/** 收藏必须登录(与参考主题一致),游客恒为未收藏。 */
|
||||
function jio_is_post_favorited($post_id = null) {
|
||||
$post_id = $post_id ? (int) $post_id : get_the_ID();
|
||||
if ($post_id <= 0) {
|
||||
if ($post_id <= 0 || ! is_user_logged_in()) {
|
||||
return false;
|
||||
}
|
||||
if (is_user_logged_in()) {
|
||||
return in_array($post_id, jio_get_user_interaction_posts('_jio_favorite_posts'), true);
|
||||
}
|
||||
$cookie = jio_get_interaction_cookie_name('favorite', $post_id);
|
||||
return ! empty($_COOKIE[$cookie]) && '1' === $_COOKIE[$cookie];
|
||||
return in_array($post_id, jio_get_user_interaction_posts('_jio_favorite_posts'), true);
|
||||
}
|
||||
endif;
|
||||
if (!function_exists('jio_update_post_counter')) :
|
||||
@@ -568,6 +565,10 @@ endif;
|
||||
if (!function_exists('jio_toggle_favorite_ajax')) :
|
||||
function jio_toggle_favorite_ajax() {
|
||||
check_ajax_referer('jio_public_nonce', 'nonce');
|
||||
// 参考主题:收藏必须先登录,游客请求直接返回登录提示。
|
||||
if (! is_user_logged_in()) {
|
||||
wp_send_json_error(['message' => __('请先登录', 'jio'), 'login' => true], 401);
|
||||
}
|
||||
$post_id = isset($_POST['postId']) ? absint(wp_unslash($_POST['postId'])) : 0;
|
||||
$post = $post_id ? get_post($post_id) : null;
|
||||
if (! $post || 'post' !== $post->post_type || 'publish' !== $post->post_status) {
|
||||
@@ -576,15 +577,9 @@ if (!function_exists('jio_toggle_favorite_ajax')) :
|
||||
$current = jio_is_post_favorited($post_id);
|
||||
$target = isset($_POST['active']) ? '1' === sanitize_text_field(wp_unslash($_POST['active'])) : ! $current;
|
||||
if ($target !== $current) {
|
||||
if (is_user_logged_in()) {
|
||||
jio_set_user_interaction_state('_jio_favorite_posts', $post_id, $target);
|
||||
} else {
|
||||
jio_set_interaction_cookie(jio_get_interaction_cookie_name('favorite', $post_id), $target);
|
||||
}
|
||||
jio_set_user_interaction_state('_jio_favorite_posts', $post_id, $target);
|
||||
$count = jio_update_post_counter($post_id, '_jio_favorite_count', $target ? 1 : -1);
|
||||
if (is_user_logged_in()) {
|
||||
do_action('jio_post_favorited', $post_id, get_current_user_id(), (bool) $target);
|
||||
}
|
||||
do_action('jio_post_favorited', $post_id, get_current_user_id(), (bool) $target);
|
||||
} else {
|
||||
$count = jio_get_post_favorites($post_id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Minimal DOM shim + fake fetch, so article.js can be exercised end-to-end:
|
||||
* pending spinner, animation replay, duplicate no-op and error rollback.
|
||||
*/
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
|
||||
function makeClassList(el) {
|
||||
return {
|
||||
_set: new Set(),
|
||||
add(c) { this._set.add(c); el.className = [...this._set].join(' '); },
|
||||
remove(c) { this._set.delete(c); el.className = [...this._set].join(' '); },
|
||||
toggle(c, force) {
|
||||
const on = force === undefined ? !this._set.has(c) : !!force;
|
||||
if (on) this.add(c); else this.remove(c);
|
||||
return on;
|
||||
},
|
||||
contains(c) { return this._set.has(c); }
|
||||
};
|
||||
}
|
||||
|
||||
function el(tag, attrs = {}, inner = '') {
|
||||
const node = {
|
||||
tagName: String(tag).toUpperCase(),
|
||||
nodeType: 1,
|
||||
children: [],
|
||||
dataset: {},
|
||||
style: {},
|
||||
disabled: false,
|
||||
innerHTML: inner,
|
||||
textContent: inner,
|
||||
className: attrs.class || '',
|
||||
id: attrs.id || '',
|
||||
offsetWidth: 100,
|
||||
_attrs: { ...attrs },
|
||||
_listeners: {},
|
||||
matches(sel) { return selectorMatches(this, sel); },
|
||||
querySelector(sel) { return this.querySelectorAll(sel)[0] || null; },
|
||||
querySelectorAll(sel) {
|
||||
const out = [];
|
||||
const walk = (n) => {
|
||||
for (const child of n.children) {
|
||||
if (selectorMatches(child, sel)) out.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(this);
|
||||
return out;
|
||||
},
|
||||
appendChild(child) { this.children.push(child); child.parentNode = this; return child; },
|
||||
setAttribute(k, v) { this._attrs[k] = String(v); if (k === 'class') this.className = String(v); },
|
||||
getAttribute(k) { return this._attrs[k] === undefined ? null : this._attrs[k]; },
|
||||
removeAttribute(k) { delete this._attrs[k]; },
|
||||
addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); },
|
||||
click() {
|
||||
if (this.disabled) return;
|
||||
(this._listeners.click || []).forEach((fn) => fn({ target: this, preventDefault() {} }));
|
||||
}
|
||||
};
|
||||
node.classList = makeClassList(node);
|
||||
node.classList._set = new Set((attrs.class || '').split(/\s+/).filter(Boolean));
|
||||
// 把 data-* 属性映射到 dataset,与浏览器行为一致。
|
||||
for (const [name, value] of Object.entries(attrs)) {
|
||||
if (!name.startsWith('data-')) continue;
|
||||
const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
node.dataset[key] = String(value);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function selectorMatches(node, sel) {
|
||||
// supports: tag, .class, [attr], [attr="v"], and simple compounds
|
||||
return sel.split(/\s+/).every((part) => {
|
||||
const tagMatch = part.match(/^[a-zA-Z][\w-]*/);
|
||||
if (tagMatch && node.tagName !== tagMatch[0].toUpperCase()) return false;
|
||||
const classes = [...part.matchAll(/\.([\w-]+)/g)].map((m) => m[1]);
|
||||
if (classes.some((c) => !node.classList.contains(c))) return false;
|
||||
const attrs = [...part.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)];
|
||||
for (const [, name, value] of attrs) {
|
||||
if (name.startsWith('data-')) {
|
||||
const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
if (node.dataset[key] === undefined) return false;
|
||||
if (value !== undefined && String(node.dataset[key]) !== value) return false;
|
||||
} else if (value !== undefined) {
|
||||
if (String(node._attrs[name]) !== value) return false;
|
||||
} else if (node._attrs[name] === undefined) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function buildArticle() {
|
||||
const body = el('body');
|
||||
const doc = {
|
||||
nodeType: 9,
|
||||
readyState: 'complete',
|
||||
documentElement: el('html'),
|
||||
head: el('head'),
|
||||
body,
|
||||
_listeners: {},
|
||||
addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); },
|
||||
querySelector(sel) { return body.querySelector(sel); },
|
||||
querySelectorAll(sel) { return body.querySelectorAll(sel); },
|
||||
getElementById(id) {
|
||||
for (const node of [body, ...body.querySelectorAll('[id]')]) {
|
||||
if (String(node.id) === String(id)) return node;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
createElement(tag) { return el(tag); }
|
||||
};
|
||||
body.parentNode = doc;
|
||||
|
||||
const likeBtn = el('button', { class: 'action action-like', id: 'action-like' });
|
||||
likeBtn.dataset.postId = '42';
|
||||
likeBtn.dataset.initialActive = '0';
|
||||
likeBtn.appendChild(el('svg', { class: 'icon' }));
|
||||
const likeCount = el('count', { 'data-stat-count': 'likes', 'data-base-count': '7' }, '7');
|
||||
likeBtn.appendChild(likeCount);
|
||||
|
||||
const favBtn = el('button', { class: 'action action-favorite', id: 'action-favorite' });
|
||||
favBtn.dataset.postId = '42';
|
||||
favBtn.dataset.initialActive = '0';
|
||||
favBtn.appendChild(el('svg', { class: 'icon' }));
|
||||
const favCount = el('count', { 'data-stat-count': 'favorites', 'data-base-count': '3' }, '3');
|
||||
favBtn.appendChild(favCount);
|
||||
|
||||
body.appendChild(likeBtn);
|
||||
body.appendChild(favBtn);
|
||||
return { doc, body, likeBtn, likeCount, favBtn, favCount };
|
||||
}
|
||||
|
||||
function runScenario(name, { responses }, options = {}) {
|
||||
const { doc, body, likeBtn, favBtn } = buildArticle();
|
||||
const toasts = [];
|
||||
const queue = responses.slice();
|
||||
|
||||
const context = {
|
||||
console,
|
||||
URLSearchParams,
|
||||
Promise,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
document: doc,
|
||||
notyf: (msg, type) => toasts.push({ msg, type }),
|
||||
jioData: { ajaxUrl: 'https://example.test/admin-ajax.php', nonce: 'n' },
|
||||
fetch: () => {
|
||||
const next = queue.shift() || { success: true, data: { active: true, count: 8 } };
|
||||
return Promise.resolve({ json: () => Promise.resolve(next) });
|
||||
},
|
||||
window: {},
|
||||
jQuery: undefined
|
||||
};
|
||||
context.window = context;
|
||||
context.window.document = doc;
|
||||
context.window.notyf = context.notyf;
|
||||
|
||||
const state = { doc, body, likeBtn, favBtn, toasts, queue };
|
||||
// 允许在脚本执行(绑定)之前插入额外节点,模拟真实的正文 + 底部栏并存。
|
||||
if (typeof options.decorate === 'function') options.decorate(state);
|
||||
|
||||
const source = fs.readFileSync(path.join(root, 'assets/js/core/article.js'), 'utf8');
|
||||
vm.runInNewContext(source, context, { filename: 'article.js' });
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
module.exports = { el, buildArticle, runScenario, root, makeClassList, selectorMatches };
|
||||
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
/**
|
||||
* 文章页点赞/收藏的点击 UX 对齐参考主题:
|
||||
* 计数行内加载点 → 服务端回包 → 选中态 + lovestar 动画 + 提示。
|
||||
*/
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { runScenario } = require('./article-actions-harness.cjs');
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('点赞成功:请求中显示行内加载点,成功后更新计数并播放动画', async () => {
|
||||
const state = runScenario('like', {
|
||||
responses: [{ success: true, data: { active: true, count: 8 } }]
|
||||
});
|
||||
const { likeBtn, toasts } = state;
|
||||
|
||||
likeBtn.click();
|
||||
// 请求尚未返回时:按钮禁用 + 计数换成加载点。
|
||||
assert.equal(likeBtn.disabled, true, '请求中应禁用按钮,避免重复提交');
|
||||
const pending = likeBtn.querySelectorAll('count')[0];
|
||||
assert.match(pending.innerHTML, /loading zts/, '请求中计数应替换为行内加载点');
|
||||
|
||||
await flush();
|
||||
|
||||
assert.equal(likeBtn.disabled, false, '请求结束后按钮恢复');
|
||||
assert.ok(likeBtn.classList.contains('actived'), '成功后应带参考主题的 actived 类');
|
||||
assert.ok(likeBtn.classList.contains('action-animation'), '成功后应触发 lovestar 动画');
|
||||
assert.equal(pending.textContent, '8', '计数应更新为服务端返回值');
|
||||
assert.equal(pending.dataset.baseCount, '8');
|
||||
assert.deepEqual(toasts, [{ msg: '已赞!感谢您的支持', type: 'success' }]);
|
||||
});
|
||||
|
||||
test('重复点赞(服务端状态未变化):不播放动画,给出 warning 提示', async () => {
|
||||
const state = runScenario('like-duplicate', {
|
||||
// 首次成功点亮,第二次服务端返回相同状态与计数。
|
||||
responses: [
|
||||
{ success: true, data: { active: true, count: 8 } },
|
||||
{ success: true, data: { active: true, count: 8 } }
|
||||
]
|
||||
});
|
||||
const { likeBtn, toasts } = state;
|
||||
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
assert.ok(likeBtn.classList.contains('action-animation'));
|
||||
|
||||
// 清掉第一次的动画类,验证第二次点击不会再被重新加上。
|
||||
likeBtn.classList.remove('action-animation');
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.equal(likeBtn.classList.contains('action-animation'), false, '状态未变化时不应重放动画');
|
||||
assert.ok(likeBtn.classList.contains('actived'), '仍保持已赞状态');
|
||||
assert.deepEqual(toasts[1], { msg: '已赞过此文章了!', type: 'warning' });
|
||||
});
|
||||
|
||||
test('取消点赞:移除 actived 与动画类,计数回退', async () => {
|
||||
const state = runScenario('unlike', {
|
||||
responses: [
|
||||
{ success: true, data: { active: true, count: 8 } },
|
||||
{ success: true, data: { active: false, count: 7 } }
|
||||
]
|
||||
});
|
||||
const { likeBtn, toasts } = state;
|
||||
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.equal(likeBtn.classList.contains('actived'), false);
|
||||
assert.equal(likeBtn.classList.contains('action-animation'), false);
|
||||
assert.equal(likeBtn.querySelectorAll('count')[0].textContent, '7');
|
||||
assert.deepEqual(toasts[1], { msg: '点赞已取消', type: 'warning' });
|
||||
});
|
||||
|
||||
test('收藏与点赞共用同一套交互,收藏文案沿用参考主题', async () => {
|
||||
const state = runScenario('favorite', {
|
||||
responses: [{ success: true, data: { active: true, count: 4 } }]
|
||||
});
|
||||
const { favBtn, toasts } = state;
|
||||
|
||||
favBtn.click();
|
||||
assert.equal(favBtn.disabled, true);
|
||||
assert.match(favBtn.querySelectorAll('count')[0].innerHTML, /loading zts/);
|
||||
|
||||
await flush();
|
||||
|
||||
assert.ok(favBtn.classList.contains('actived'));
|
||||
assert.ok(favBtn.classList.contains('action-animation'));
|
||||
assert.equal(favBtn.querySelectorAll('count')[0].textContent, '4');
|
||||
assert.deepEqual(toasts, [{ msg: '已收藏此文章', type: 'success' }]);
|
||||
});
|
||||
|
||||
test('请求失败:还原计数内容并恢复按钮,提示 danger', async () => {
|
||||
const state = runScenario('failure', {
|
||||
responses: [{ success: false, data: { message: '请先登录' } }]
|
||||
});
|
||||
const { likeBtn, toasts } = state;
|
||||
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.equal(likeBtn.disabled, false);
|
||||
assert.equal(likeBtn.classList.contains('actived'), false);
|
||||
assert.equal(likeBtn.querySelectorAll('count')[0].textContent, '7', '失败后应还原原计数');
|
||||
assert.deepEqual(toasts, [{ msg: '请先登录', type: 'danger' }]);
|
||||
});
|
||||
|
||||
test('服务端计数被并发修正:即使状态未变也更新计数并播放动画', async () => {
|
||||
const state = runScenario('concurrent', {
|
||||
responses: [
|
||||
{ success: true, data: { active: true, count: 8 } },
|
||||
// 别人也点了赞:状态仍是已赞,但计数变成 12。
|
||||
{ success: true, data: { active: true, count: 12 } }
|
||||
]
|
||||
});
|
||||
const { likeBtn, toasts } = state;
|
||||
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
likeBtn.classList.remove('action-animation');
|
||||
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.equal(likeBtn.querySelectorAll('count')[0].textContent, '12', '应同步服务端的真实计数');
|
||||
assert.ok(likeBtn.classList.contains('action-animation'), '计数变化时仍应播放动画');
|
||||
assert.deepEqual(toasts[1], { msg: '已赞!感谢您的支持', type: 'success' });
|
||||
});
|
||||
|
||||
test('游客收藏被拦:还原计数并拉起站点登录弹窗', async () => {
|
||||
const { el } = require('./article-actions-harness.cjs');
|
||||
let loginClicks = 0;
|
||||
const state = runScenario('guest-favorite', {
|
||||
responses: [{ success: false, data: { message: '请先登录', login: true } }]
|
||||
}, {
|
||||
decorate(state) {
|
||||
const loginTrigger = el('a', { class: 'signin-loader' });
|
||||
loginTrigger.addEventListener('click', () => { loginClicks += 1; });
|
||||
state.body.appendChild(loginTrigger);
|
||||
}
|
||||
});
|
||||
|
||||
state.favBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.equal(state.favBtn.disabled, false);
|
||||
assert.equal(state.favBtn.classList.contains('actived'), false);
|
||||
assert.equal(state.favBtn.querySelectorAll('count')[0].textContent, '3', '失败后应还原原计数');
|
||||
assert.deepEqual(state.toasts, [{ msg: '请先登录', type: 'danger' }]);
|
||||
assert.equal(loginClicks, 1, '游客收藏应打开登录弹窗,与参考主题一致');
|
||||
});
|
||||
|
||||
test('页面内多个同款按钮(正文 + 底部栏)保持状态同步', async () => {
|
||||
const { el } = require('./article-actions-harness.cjs');
|
||||
const state = runScenario('mirror', {
|
||||
responses: [{ success: true, data: { active: true, count: 9 } }]
|
||||
}, {
|
||||
// 绑定前就存在的镜像按钮(正文 + 底部栏),与真实页面一致。
|
||||
decorate(state) {
|
||||
const mirror = el('button', { class: 'tabbar-item action-like' });
|
||||
mirror.dataset.postId = '42';
|
||||
mirror.dataset.initialActive = '0';
|
||||
mirror.appendChild(el('count', { 'data-stat-count': 'likes', 'data-base-count': '7' }, '7'));
|
||||
state.body.appendChild(mirror);
|
||||
state.mirror = mirror;
|
||||
}
|
||||
});
|
||||
|
||||
state.likeBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.ok(state.mirror.classList.contains('actived'), '镜像按钮应同步选中态');
|
||||
assert.equal(state.mirror.querySelectorAll('count')[0].textContent, '9');
|
||||
});
|
||||
Reference in new issue
Block a user