feat(vip): skeleton loading state and faster modal/site startup

The VIP purchase/renew modal opened with a single "loading..." line, which
reads as a hang while /pay/vip/modal round-trips. Every frontend pageview
also paid an option write inside wp_head, and the header user-card CSS was
enqueued even for guests who never see the card.

pay-vip.js now builds a full-layout skeleton (user box, level tabs, the
two-column package panel) from vipSkeleton(), reusing the global
aksrt-modal-runtime__skeleton line/block classes; the VIP-specific shapes
live in the conditionally loaded vip-modal.css, and the 640px two-column
threshold matches .pay-vip-form-row. The modal cache TTL goes 15s -> 60s
(purchase, card-pass redeem and points exchange already invalidate the
vip tag), and hover/focus on a .pay-vip trigger now prefetches the payload
through the same cacheKey and flight openRemote uses, so a warm click
opens instantly.

The bottom note and the whole help_text pipeline behind it are removed; the
admin-editable pay_user_vip_desc still renders at the top of the modal, and
the recharge/gateway help_text keys are untouched. get_catalog() reads
points and wallet balance through one new LedgerService::points_and_balance()
call. The StatsTracker counter write moves to shutdown so wp_head only does
the visitor check and setcookie, and header-userbox.css is enqueued for
logged-in users only.

Verification: node --test across the suite (628 tests, including the 7 new
contract assertions in tests/widget-system/vip-modal-skeleton.test.cjs) and
php -l on every changed PHP file. No visual acceptance performed, per request.
This commit is contained in:
kate522 committed 2026-10-18 13:44:01 +08:00
1 parent 319ec2f47f
commit cb76b95b01
9 files changed
+376 -13

No files matched your search

+83
View File
@@ -101,3 +101,86 @@
opacity: 0.7;
cursor: wait;
}
/* 会员弹窗骨架屏(pay-vip.js vipSkeleton()):仿真实弹窗布局——
用户框 + 等级卡行 + 双列套餐面板。线条/色块与 shimmer 动画复用全局
.aksrt-modal-runtime__skeleton-line/-block 与 @keyframes
aksrt-modal-skeleton(build/public.min.css 已全局入队);
这里只定义 VIP 专属形状(头像圆、tab 卡、双列 grid、按钮条),
与 .pay-vip-form-row 的 640px 双列阈值保持一致。 */
.aksrt-vip-modal-skeleton {
display: flex;
flex-direction: column;
gap: 14px;
padding: 15px;
}
.aksrt-vip-modal-skeleton__user {
display: flex;
align-items: center;
gap: 10px;
}
.aksrt-vip-modal-skeleton__avatar {
width: 42px;
height: 42px;
border-radius: 50%;
flex: none;
}
.aksrt-vip-modal-skeleton__lines {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 0;
}
.aksrt-vip-modal-skeleton__tabs {
display: flex;
gap: 10px;
}
.aksrt-vip-modal-skeleton__tab {
flex: 1;
height: 56px;
}
.aksrt-vip-modal-skeleton__pane {
display: grid;
gap: 12px;
}
.aksrt-vip-modal-skeleton__info {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
}
.aksrt-vip-modal-skeleton__products {
display: flex;
flex-direction: column;
gap: 10px;
}
.aksrt-vip-modal-skeleton__button {
height: 38px;
}
/* 专属形状共用骨架底色与 shimmer(值与 modal-runtime.css 的线条/块一致)。 */
.aksrt-vip-modal-skeleton__avatar,
.aksrt-vip-modal-skeleton__tab,
.aksrt-vip-modal-skeleton__button {
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;
}
@media (min-width: 640px) {
.aksrt-vip-modal-skeleton__pane {
grid-template-columns: 1fr 1.2fr;
}
}
+90 -4
View File
@@ -519,8 +519,6 @@
append(modal, tabs);
append(modal, content);
if (catalog.help_text) append(modal, node("div", "muted-2-color em09 mb6", catalog.help_text));
// 卡密渠道初始显隐(选中 card_pass 时隐藏套餐、显示卡密输入盒)。
modal.querySelectorAll(".aksrt-vip-pane").forEach(function (pane) {
applyVipMethodVisibility(pane);
@@ -698,6 +696,46 @@
});
}
// 骨架屏:仿弹窗真实布局(用户框 + 等级卡 + 双列套餐面板)。
// 线条/色块复用全局骨架类(build/public.min.css),VIP 专属形状见 vip-modal.css。
function vipSkeleton() {
var skeleton = node("div", "aksrt-vip-modal-skeleton");
skeleton.setAttribute("aria-live", "polite");
skeleton.setAttribute("aria-busy", "true");
// 用户框:头像圆 + 两行文字。
var user = node("div", "aksrt-vip-modal-skeleton__user");
append(user, node("span", "aksrt-vip-modal-skeleton__avatar"));
var userLines = node("span", "aksrt-vip-modal-skeleton__lines");
append(userLines, node("span", "aksrt-modal-runtime__skeleton-line is-wide"));
append(userLines, node("span", "aksrt-modal-runtime__skeleton-line is-short"));
append(user, userLines);
append(skeleton, user);
// 等级卡行(vip-cardminis):两张 tab 占位。
var tabs = node("div", "aksrt-vip-modal-skeleton__tabs");
append(tabs, node("span", "aksrt-vip-modal-skeleton__tab"));
append(tabs, node("span", "aksrt-vip-modal-skeleton__tab"));
append(skeleton, tabs);
// 双列面板:左权益信息 + 右套餐区(套餐卡、支付方式行、提交按钮条)。
var pane = node("div", "aksrt-vip-modal-skeleton__pane");
var info = node("div", "aksrt-vip-modal-skeleton__info");
append(info, node("span", "aksrt-modal-runtime__skeleton-line is-wide"));
append(info, node("span", "aksrt-modal-runtime__skeleton-line"));
append(info, node("span", "aksrt-modal-runtime__skeleton-line is-short"));
append(pane, info);
var products = node("div", "aksrt-vip-modal-skeleton__products");
append(products, node("span", "aksrt-modal-runtime__skeleton-block"));
append(products, node("span", "aksrt-modal-runtime__skeleton-block"));
append(products, node("span", "aksrt-modal-runtime__skeleton-line is-short"));
append(products, node("span", "aksrt-vip-modal-skeleton__button"));
append(pane, products);
append(skeleton, pane);
return skeleton.outerHTML;
}
function vipPay(trigger) {
var level = trigger
? parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1
@@ -706,11 +744,11 @@
url: "/pay/vip/modal",
query: { vip_level: level },
className: "aksrt-vip-modal-shell",
skeleton: '<div class="aksrt-vip-modal-panel"><div class="aksrt-vip-modal-content" data-aksrt-vip-modal-body><div class="text-center padding-20 muted-2-color">加载中…</div></div></div>',
skeleton: vipSkeleton(),
telemetry: "vip",
kind: "vip",
responseType: "json",
cacheTtl: 15000,
cacheTtl: 60000,
cacheTags: ["vip"],
renderData: function (data, session) {
renderModalView(session.content, data);
@@ -728,6 +766,53 @@
});
}
var prefetchedLevels = Object.create(null);
// 与 openRemote 同一 cacheKey(method + AksrtRequest.url),命中同一份缓存/flight。
function vipModalCacheKey(level) {
return "GET:" + window.AksrtRequest.url("/pay/vip/modal", { vip_level: level });
}
// 意图预热:悬停/聚焦开通入口时按等级预热弹窗数据(不打开弹窗,失败静默)。
// 点击打开时若预热请求在途,cache.acquire 的 flight 去重会自动合并。
function prefetchVipModal(trigger) {
if (!trigger || !window.AksrtModalCache || !window.AksrtRequest) return;
var level = parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1;
if (level < 1 || level > 2) level = 1;
if (prefetchedLevels[level]) return;
prefetchedLevels[level] = true;
var cache = window.AksrtModalCache;
if (cache.get && cache.get(vipModalCacheKey(level)).hit) return;
try {
cache.acquire(vipModalCacheKey(level), function () {
return window.AksrtRequest.request("/pay/vip/modal", {
method: "GET",
query: { vip_level: level },
});
}, {
ttl: 60000,
tags: ["vip"],
});
} catch (error) {
// 预热失败不影响点击后的正常打开路径。
}
}
function vipTriggerFrom(event) {
var target = event.target;
if (!target || !target.closest) return null;
return target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]");
}
function bindPrefetch() {
// pointerenter 不冒泡但走捕获阶段;focusin 冒泡,两者互补覆盖鼠标与键盘。
["pointerenter", "focusin"].forEach(function (type) {
document.addEventListener(type, function (event) {
prefetchVipModal(vipTriggerFrom(event));
}, true);
});
}
function onDocClick(event) {
var trigger = event.target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]");
if (!trigger) return;
@@ -768,6 +853,7 @@
if (window.__aksrtPayVipBound) return;
window.__aksrtPayVipBound = true;
document.addEventListener("click", onDocClick, true);
bindPrefetch();
bindPointsModal();
bindPointsEntry();
}