fix(vip): 积分兑换弹窗改服务端渲染并与 Zibll 1:1 对齐

/user/vip/ 的积分兑换会员弹窗原先是前端自行拼装的,结构与层级都与参考主题
Zibll 不一致:Zibll 走 zibpay_get_vip_points_exchange_modal() 服务端渲染静态
壳,前端只按需开合。改为同一模型,前端不再拼 HTML。

- VipServiceModal 新增 get_points_exchange_modal_html():门控(未登录/未开启
  返回空串)、vip-card 套餐选择、积分不足提示与提交表单;wp_footer 挂载输出
- pay-vip.js 移除 renderPointsModal / pointsPackage / exchangeFromModal /
  vipPointsPay,改为自包含开合(.in + 内联 display:flex + 独立遮罩 +
  body.modal-open + ESC),保留 aksrt-vip-points-toast 的 load/success/danger 契约
- 移除 /user/vip/ 页面底部独立积分兑换入口及其样式(uc-vip-points-link)
- credits.js 清理依赖全站不存在选择器的死代码 initVipPointsExchange()
- vip-modal.css 删除 71 行自实现样式,改为 .modal[data-aksrt-vip-points-modal]
  显隐契约;层级 100050 < 遮罩 100055 < 弹窗 100060 < notyf 10000000
- 新增 vip-points-exchange-modal 契约测试(7 项),toast-contract 同步

静止态必须显式 display:none,否则 .flex{display:flex} 会覆盖 .modal{display:none}
形成隐形遮罩拦截整页点击。

未写数据迁移。

验证:node --test tests/widget-system/*.test.cjs 308 项全部通过;php -l 全部
改动文件通过;隔离渲染桩确认积分充足 / 积分不足 / 功能关闭 / 未登录四种情形
的 HTML 产物正确。
This commit is contained in:
2026-09-18 20:05:05 +08:00
parent d2447f8aa6
commit 95ff0761b7
11 changed files with 401 additions and 318 deletions
+3 -1
View File
@@ -281,7 +281,9 @@ test('VIP purchase and points exchange use direct fixed-id notyf flows', () => {
assert.match(vip, /onTimeout:[\s\S]*?notyf\([^;]+?"danger"[^;]+?"aksrt-vip-payment-toast"/);
assert.match(vip, /已取消会员购买", "warning", 3, activePurchaseToastId/);
assert.match(vip, /会员套餐加载失败,请重试[\s\S]*?"danger"/);
assert.match(vip, /会员兑换信息加载失败,请重试[\s\S]*?"danger"/);
// 积分兑换弹窗改为服务端静态壳(inc/helpers/aksrt-vip.php 输出,pay-vip.js 只负责
// 开合与提交),兑换失败沿用固定 id 的 danger toast。
assert.match(vip, /兑换失败,请重试。[\s\S]*?"danger"/);
assert.doesNotMatch(vip, /setTip|aksrt-vip-modal-tip/);
assert.doesNotMatch(css, /\.aksrt-vip-modal-tip/);
@@ -0,0 +1,112 @@
/**
* 积分兑换会员弹窗契约(参考主题 zibpay_get_vip_points_exchange_modal 1:1 对齐)。
*
* 三条硬约束:
* 1) 服务端静态输出参考主题 refresh_modal 同构壳(`modal flex jc fade` >
* `modal-dialog modal-mini full-sm` > `modal-content` > `modal-body`),
* 入口只保留在会员购买弹窗内——/user/vip/ 页底部的独立入口已移除。
* 2) 静止态必须被 vip-modal.css 显式隐藏(.flex 覆盖回归护栏),打开态为
* flex 而非 block,且层级高于 runtime 宿主(100050),以复现参考主题在
* 会员弹窗之上再叠一层兑换弹窗的行为。
* 3) 旧的客户端自实现弹窗(renderPointsModal / aksrt-vip-points-option /
* catalog.vip_url)必须已彻底移除。
*/
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');
const modal = read('src', 'Content', 'VipServiceModal.php');
const catalogSource = read('src', 'Content', 'VipServicePurchase.php');
const footer = read('inc', 'helpers', 'aksrt-vip.php');
const vipJs = read('assets', 'js', 'pay-vip.js');
const creditsJs = read('assets', 'js', 'credits.js');
const vipCss = read('assets', 'css', 'frontend', 'vip-modal.css');
const vipTab = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
const creditsCss = read('assets', 'css', 'routes', 'user-center', 'credits.css');
test('弹窗为参考主题 refresh_modal 同构静态壳并带 data 钩子', () => {
assert.match(modal, /public function get_points_exchange_modal_html/);
assert.match(modal, /class="modal flex jc fade"[^']*data-aksrt-vip-points-modal/);
assert.match(modal, /class="modal-dialog modal-mini full-sm"/);
assert.match(modal, /class="modal-content"><div class="modal-body">/);
assert.match(modal, /data-dismiss="modal"/);
assert.match(modal, /#icon-close/);
assert.match(modal, /data-aksrt-vip-points-form/);
});
test('弹窗内容保持参考主题结构:标题/我的积分/卡片选择/立即兑换', () => {
// 头部:.mb10.touch + modal-title.flex.ac + vip_1 图标。
assert.match(modal, /'<div class="mb10 touch">'/);
assert.match(modal, /class="modal-title flex ac"/);
assert.match(modal, /#icon-vip_1/);
// 我的积分盒(点回用户中心积分页)。
assert.match(modal, /muted-box padding-h10 mb20/);
assert.match(modal, /我的积分/);
assert.match(modal, /#icon-points/);
assert.match(modal, /aksrt_get_user_center_tab_url\('credits'\)/);
// 卡片列表 + 参考主题 data-for/data-value 选中模型。
assert.match(modal, /请选择需兑换的会员/);
assert.match(modal, /flex hh exchange-card-box/);
assert.match(modal, /vip-card vip-theme/);
assert.match(modal, /data-for="product_id" data-value=/);
assert.match(modal, /flex jsb ac hh em16 mt10/);
// 表单:hidden 契约 + 立即兑换按钮 + 积分不足提示。
assert.match(modal, /name="order_type" value="4"/);
assert.match(modal, /name="action" value="points_initiate_pay"/);
assert.match(modal, /mt6 but jb-yellow btn-block radius padding-lg/);
assert.match(modal, /data-aksrt-vip-points-submit/);
assert.match(modal, /badg c-red btn-block/);
});
test('弹窗壳随 wp_footer 全局输出且不进入 catalog 载荷', () => {
assert.match(footer, /function aksrt_render_vip_points_exchange_modal/);
assert.match(footer, /add_action\('wp_footer', 'aksrt_render_vip_points_exchange_modal'\)/);
assert.match(footer, /VipService::get_instance\(\)->get_points_exchange_modal_html/);
// 兑换入口不再跳用户中心:catalog 里的 vip_url 已清除。
assert.doesNotMatch(catalogSource, /'vip_url'/);
assert.doesNotMatch(modal, /'vip_url'/);
});
test('弹窗壳静止态必须被 vip-modal.css 显式隐藏(.flex 覆盖回归护栏)', () => {
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s*\{\s*display:\s*none;/);
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\.in\s*\{\s*display:\s*flex;/);
assert.doesNotMatch(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\.in\s*\{\s*display:\s*block;/);
// 层级必须高于 runtime 宿主(.aksrt-modal-runtime 的 z-index: 100050)。
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s*\{[^}]*z-index:\s*100060;/s);
assert.match(vipCss, /\.modal-backdrop\[data-aksrt-vip-points-backdrop\]\s*\{\s*z-index:\s*100055;/);
});
test('开关自包含:不依赖 jQuery/Bootstrap modal 运行时', () => {
assert.match(vipJs, /function openPointsModal\(trigger\)/);
assert.match(vipJs, /function closePointsModal\(\)/);
assert.doesNotMatch(vipJs, /\.modal\(\s*['"]show['"]/);
assert.doesNotMatch(vipJs, /hidden\.bs\.modal/);
assert.match(vipJs, /pointsBackdrop\.className = "modal-backdrop fade in"/);
assert.match(vipJs, /modal\.style\.display = "flex"/);
assert.doesNotMatch(vipJs, /modal\.style\.display = "block"/);
assert.match(vipJs, /void modal\.offsetWidth/);
});
test('会员弹窗内的入口为参考主题同款按钮并开本地弹窗', () => {
// 参考主题 zibpay_get_pay_uservip_modal 的 class 与图标顺序。
assert.match(vipJs, /node\("a", "btn-block but mb10 padding-lg c-green mini-radius"\)/);
assert.match(vipJs, /#icon-points-color/);
assert.match(vipJs, /fa fa-angle-right em12 ml10/);
assert.match(vipJs, /data-aksrt-vip-points-open/);
assert.match(vipJs, /openPointsModal\(pointsLink\)/);
});
test('旧的自实现兑换弹窗与独立入口已彻底移除', () => {
assert.doesNotMatch(vipJs, /renderPointsModal|exchangeFromModal|vipPointsPay/);
assert.doesNotMatch(vipJs, /aksrt-vip-points-option|aksrt-vip-points-modal__/);
assert.doesNotMatch(vipCss, /\.aksrt-vip-points-option|\.aksrt-vip-points-submit/);
// /user/vip/ 页底部不再有独立积分兑换入口。
assert.doesNotMatch(vipTab, /uc-vip-points-link/);
assert.doesNotMatch(creditsCss, /\.uc-vip-points-link/);
// 用户中心侧的旧兑换控制器([data-aksrt-vip-points],全站无对应 DOM)。
assert.doesNotMatch(creditsJs, /initVipPointsExchange|data-aksrt-vip-points/);
});