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

@@ -0,0 +1,134 @@
/**
* 会员弹窗骨架屏 / 打开提速 / help_text 清理 / 启动优化契约。
*
* 硬约束:
* 1) 会员弹窗加载占位为完整布局仿形骨架(vipSkeleton()),复用全局
* aksrt-modal-runtime__skeleton-line/-block 与 shimmer 动画,
* VIP 专属形状样式落在 assets/css/frontend/vip-modal.css;
* 2) 弹窗数据缓存 TTL 60s,悬停/聚焦入口按等级意图预热,预热与
* openRemote 共用 cacheKey 与 flight;
* 3) 弹窗底部提示句与整条 help_text 管线已移除,顶部说明 desc 保留;
* 4) 积分/钱包余额合并为一次 LedgerService::points_and_balance();
* 5) 访问统计写库移到 shutdown,wp_head 只做访客判定与 setcookie;
* 6) header-userbox 样式仅登录用户入队。
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..', '..');
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
// 取某个方法体(到下一个同类方法声明为止),避免断言误伤其它功能的同名键。
function sliceMethod(source, signature, nextSignature) {
const start = source.indexOf(signature);
assert.notStrictEqual(start, -1, `missing ${signature}`);
const end = source.indexOf(nextSignature, start + signature.length);
return source.slice(start, end === -1 ? undefined : end);
}
const vipJs = read('assets', 'js', 'pay-vip.js');
const vipCss = read('assets', 'css', 'frontend', 'vip-modal.css');
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
const modal = read('src', 'Content', 'VipServiceModal.php');
const catalogSource = read('src', 'Content', 'VipServicePurchase.php');
const ledger = read('src', 'Ledger', 'LedgerService.php');
const stats = read('src', 'Core', 'StatsTracker.php');
const scripts = read('src', 'Core', 'ScriptsPart1b.php');
test('会员弹窗使用完整布局仿形骨架屏', () => {
assert.match(vipJs, /function vipSkeleton\(\) \{/);
assert.match(vipJs, /node\("div", "aksrt-vip-modal-skeleton"\)/);
assert.match(vipJs, /skeleton\.setAttribute\("aria-busy", "true"\)/);
// 结构:用户框(头像+两行)、等级卡行、双列面板(权益+套餐区)。
assert.match(vipJs, /aksrt-vip-modal-skeleton__user/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__avatar/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__lines/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__tabs/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__tab/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__pane/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__info/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__products/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__button/);
// 线条/色块复用全局骨架类。
assert.match(vipJs, /aksrt-modal-runtime__skeleton-line is-wide/);
assert.match(vipJs, /aksrt-modal-runtime__skeleton-line is-short/);
assert.match(vipJs, /aksrt-modal-runtime__skeleton-block/);
// openRemote 传入自定义骨架,不再使用纯文本占位。
assert.match(vipJs, /skeleton: vipSkeleton\(\),/);
assert.doesNotMatch(vipJs, /加载中/);
});
test('骨架样式落在 vip-modal.css 并对齐 640px 双列阈值', () => {
assert.match(vipCss, /\.aksrt-vip-modal-skeleton \{/);
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__avatar \{[\s\S]*?border-radius: 50%/);
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__tab \{[\s\S]*?flex: 1/);
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__button \{[\s\S]*?height: 38px/);
assert.match(vipCss, /animation: aksrt-modal-skeleton/);
assert.match(vipCss, /@media \(min-width: 640px\)[\s\S]*?\.aksrt-vip-modal-skeleton__pane \{[\s\S]*?grid-template-columns: 1fr 1\.2fr/);
});
test('弹窗打开提速:缓存 TTL 提升与意图预热', () => {
assert.match(vipJs, /cacheTtl: 60000,/);
// 预热与 openRemote 同一 cacheKey(method + AksrtRequest.url)。
assert.match(vipJs, /function vipModalCacheKey\(level\) \{/);
assert.match(vipJs, /"GET:" \+ window\.AksrtRequest\.url\("\/pay\/vip\/modal", \{ vip_level: level \}\)/);
assert.match(vipJs, /cache\.acquire\(vipModalCacheKey\(level\)/);
assert.match(vipJs, /ttl: 60000,/);
assert.match(vipJs, /tags: \["vip"\]/);
// 悬停/聚焦触发,绑定进 bindOnce。
assert.match(vipJs, /\["pointerenter", "focusin"\]/);
assert.match(vipJs, /function bindPrefetch\(\) \{/);
assert.match(vipJs, /document\.addEventListener\("click", onDocClick, true\);\s*bindPrefetch\(\);/);
// 每等级每页只预热一次。
assert.match(vipJs, /prefetchedLevels\[level\] = true;/);
});
test('弹窗底部提示与 help_text 管线已彻底移除', () => {
assert.doesNotMatch(vipJs, /help_text/);
// 仅收敛 VIP 设置函数:充值/网关设置的 help_text 与本文无关,保持原样。
const vipSettings = sliceMethod(
paymentRuntime,
'public static function get_payment_vip_settings',
'public static function get_payment_revenue_settings'
);
assert.doesNotMatch(vipSettings, /help_text/);
assert.doesNotMatch(vipSettings, /开通后立即生效/);
assert.doesNotMatch(modal, /help_text/);
const viewModel = sliceMethod(modal, 'public function get_modal_view_model', 'public function get_points_exchange_modal_html');
assert.doesNotMatch(viewModel, /help_text/);
assert.doesNotMatch(catalogSource, /help_text/);
// 顶部说明 desc 仍保留(后台 pay_user_vip_desc 继续生效)。
assert.match(vipJs, /catalog\.desc/);
assert.match(paymentRuntime, /'desc'\s+=> \$desc,/);
});
test('积分与钱包余额合并为一次 LedgerService 读取', () => {
assert.match(ledger, /public function points_and_balance\(int \$user_id\): array/);
// 仅收敛 get_catalog:购买/履约路径的余额读取与本文无关,保持原样。
const getCatalog = sliceMethod(catalogSource, 'public function get_catalog', 'private function find_catalog_package');
assert.match(getCatalog, /points_and_balance\(\$user_id\)/);
assert.match(getCatalog, /'user_points'\s+=> \$balances\['points'\]/);
assert.match(getCatalog, /'wallet_balance'\s+=> \$balances\['balance'\]/);
assert.doesNotMatch(getCatalog, /LedgerService::get_instance\(\)->balance\(/);
assert.doesNotMatch(getCatalog, /balance_decimal/);
});
test('访问统计写库移出渲染关键路径', () => {
assert.match(stats, /add_action\('wp_head', \[self::get_instance\(\), 'track_visit'\], 1\)/);
assert.match(stats, /add_action\('shutdown'/);
assert.match(stats, /public function record_stats\(string \$option_key, bool \$is_unique\): void/);
// wp_head 阶段只做判定与 setcookie,不再读写 option。
const trackVisit = stats.slice(stats.indexOf('public function track_visit'), stats.indexOf('public function record_stats'));
assert.doesNotMatch(trackVisit, /update_option|get_option/);
assert.match(trackVisit, /setcookie\(\$visitor_cookie/);
// 落库与月初清理收敛到 record_stats。
const recordStats = stats.slice(stats.indexOf('public function record_stats'));
assert.match(recordStats, /update_option\(\$option_key, \$stats, false\)/);
assert.match(recordStats, /cleanup_old_stats\(\)/);
});
test('header-userbox 样式仅登录用户入队', () => {
assert.match(scripts, /if \(is_user_logged_in\(\)\) \{\s*\$enqueue_module\('header-userbox'\);/);
});