diff --git a/assets/css/frontend/vip-modal.css b/assets/css/frontend/vip-modal.css index ac24c40b..db9f04d4 100644 --- a/assets/css/frontend/vip-modal.css +++ b/assets/css/frontend/vip-modal.css @@ -49,8 +49,8 @@ opacity:0 形成隐形遮罩,拦截整页点击; - 打开态由 pay-vip.js 自包含开关的内联 display:flex 驱动(block 会破坏 .flex.jc 双轴居中),与余额充值/购买积分弹窗同构; - - 入口在本页 runtime 会员弹窗内,壳需高于 runtime 宿主(100050),以复现 - 参考主题在会员弹窗之上再叠一层兑换弹窗的行为。 */ + - 入口在会员购买弹窗与 /user/vip/ 页面横条两处,壳需高于 runtime 宿主 + (100050),以复现参考主题在会员弹窗之上再叠一层兑换弹窗的行为。 */ .modal[data-aksrt-vip-points-modal] { display: none; z-index: 100060; diff --git a/assets/css/routes/user-center/credits.css b/assets/css/routes/user-center/credits.css index da9d08c9..47f15e64 100644 --- a/assets/css/routes/user-center/credits.css +++ b/assets/css/routes/user-center/credits.css @@ -1325,6 +1325,28 @@ body.uc-modal-open { margin: 0 !important; line-height: 1; } + +/* 积分兑换会员入口(参考主题用户中心 VIP 页 1:1:两张档位购买卡下方的全宽横条, + 左侧 points-color 图标 + 「积分兑换会员」,右侧「立即兑换」badg + 右箭头; + 结构与配色由 zibll 工具类(flex/ac/jsb/flex1/em12/c-yellow/badg)承担, + 此处只提供与 .uc-vip-level-card 同一变量体系的表面与间距)。 */ +.uc-vip-points-entry { + margin-top: 12px; + padding: 12px 16px; + border-radius: var(--radius, 10px); + background: var(--main-bg-color, var(--surface-1, #fff)); + box-shadow: var(--shadow-sm); + color: inherit; + text-decoration: none; +} +.uc-vip-points-entry:hover, +.uc-vip-points-entry:focus { + text-decoration: none; +} +.uc-vip-points-entry .icon { + flex: 0 0 auto; +} + .uc-vip-assets-entry { gap: 12px; } @@ -1361,6 +1383,9 @@ html:not(.dark):not(.theme-dark) .uc-vip-level-card.level-2 .v-name { .uc-vip-level-card { flex: 1 1 auto; } + .uc-vip-points-entry { + padding: 12px 14px; + } .uc-vip-tab .user-content-myvip { padding: 14px 16px; gap: 12px; diff --git a/assets/js/pay-vip.js b/assets/js/pay-vip.js index 03a04288..968897c6 100644 --- a/assets/js/pay-vip.js +++ b/assets/js/pay-vip.js @@ -695,12 +695,6 @@ }); return; } - var pointsLink = event.target.closest("[data-aksrt-vip-points-open]"); - if (pointsLink) { - event.preventDefault(); - openPointsModal(pointsLink); - return; - } var submit = event.target.closest("[data-vip-submit]"); if (submit) { event.preventDefault(); @@ -763,11 +757,24 @@ } }); + // 积分兑换入口(data-aksrt-vip-points-open)文档级委托:一处绑定同时覆盖 + // /user/vip/ 页面静态输出的入口横条与会员购买弹窗内运行时注入的同款按钮 + // (事件冒泡至 document,弹窗内不再单独挂监听)。 + function bindPointsEntry() { + document.addEventListener("click", function (event) { + var pointsLink = event.target.closest ? event.target.closest("[data-aksrt-vip-points-open]") : null; + if (!pointsLink) return; + event.preventDefault(); + openPointsModal(pointsLink); + }); + } + function bindOnce() { if (window.__aksrtPayVipBound) return; window.__aksrtPayVipBound = true; document.addEventListener("click", onDocClick, true); bindPointsModal(); + bindPointsEntry(); } jQuery(document).off("auto_fun.aksrt-pay-vip").on("auto_fun.aksrt-pay-vip", bindOnce); diff --git a/inc/helpers/aksrt-vip.php b/inc/helpers/aksrt-vip.php index b8b0c45e..1d466246 100644 --- a/inc/helpers/aksrt-vip.php +++ b/inc/helpers/aksrt-vip.php @@ -427,6 +427,55 @@ if (!function_exists('aksrt_get_vip_purchase_url')) { } } +/** + * 用户中心 /user/vip/ 页面的积分兑换会员入口(参考主题 zibpay_user_page_tab_content_vip 1:1)。 + * + * 参考主题在「我的会员」摘要与两张档位购买卡之后输出全宽入口横条: + * .c-yellow.zib-widget.flex.ac.jsb(zib-widget → 本主题 aksrt-panel 表面) + * ├─ .flex1.em12:points-color 图标 +「积分兑换会员」 + * └─ .badg.radius.c-yellow:「立即兑换」+ fa-chevron-circle-right + * 门控对齐参考主题 zibpay_get_vip_points_exchange_link:已是会员不展示 + * ($vip_level ? '' : link),且要求积分兑换会员开启、至少一个会员等级 + * 开启并存在可兑换套餐(catalog.points_exchange 已内含 credits 门控)。 + * + * 入口点击经 data-aksrt-vip-points-open 由 pay-vip.js 打开 wp_footer 输出的 + * 静态兑换弹窗(aksrt_render_vip_points_exchange_modal),与会员购买弹窗内 + * 的入口共用同一弹窗与开合开关。 + * + * @param int $user_id 当前用户 ID(<=0 返回空串)。 + * @param int $vip_level 用户当前会员等级(>0 时返回空串;缺省 -1 表示自行解析)。 + * @return string 空串表示不满足展示条件。 + */ +if (!function_exists('aksrt_get_vip_points_exchange_entry_html')) : + function aksrt_get_vip_points_exchange_entry_html(int $user_id, int $vip_level = -1): string { + if ($user_id <= 0 || !class_exists('\AKSRT\Content\VipService')) { + return ''; + } + if ($vip_level < 0) { + $vip_level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; + } + // 参考主题契约:已经是会员就不再展示积分兑换入口。 + if ($vip_level > 0) { + return ''; + } + + $catalog = \AKSRT\Content\VipService::get_instance()->get_catalog($user_id); + $points_exchange = is_array($catalog['points_exchange'] ?? null) ? $catalog['points_exchange'] : []; + $packages = is_array($points_exchange['packages'] ?? null) ? $points_exchange['packages'] : []; + if (empty($points_exchange['enabled']) || $packages === []) { + return ''; + } + + return '' + . '' + . '' + . esc_html__('积分兑换会员', 'aksrt') + . '' + . '' . esc_html__('立即兑换', 'aksrt') + . ''; + } +endif; + /** * Format VIP equity HTML for front-end display. * @@ -507,10 +556,13 @@ if (!function_exists('aksrt_format_vip_equity_html')) { /** * 全局积分兑换会员弹窗(参考主题 refresh_modal 同构静态壳)。 * - * 入口位于会员购买弹窗(.pay-vip → assets/js/pay-vip.js)内,而 .pay-vip - * 触发点全站可见(页头、浮动按钮、用户中心、付费卡),因此弹窗壳在满足 - * 条件时随 wp_footer 全局输出一次;显隐由 pay-vip.js 自包含开关驱动, - * 静止态由 vip-modal.css 的 .modal[data-aksrt-vip-points-modal] 显式隐藏。 + * 入口有两处:会员购买弹窗(.pay-vip → assets/js/pay-vip.js)内的「积分兑换 + * 会员」按钮,以及 /user/vip/ 档位购买卡下方的入口横条 + * (aksrt_get_vip_points_exchange_entry_html);二者均带 + * data-aksrt-vip-points-open,由 pay-vip.js 文档级委托打开本弹窗。.pay-vip + * 触发点全站可见,因此弹窗壳在满足条件时随 wp_footer 全局输出一次;显隐由 + * pay-vip.js 自包含开关驱动,静止态由 vip-modal.css 的 + * .modal[data-aksrt-vip-points-modal] 显式隐藏。 */ if (!function_exists('aksrt_render_vip_points_exchange_modal')) : function aksrt_render_vip_points_exchange_modal(): void { diff --git a/inc/helpers/user-center-renderers/vip-tab.php b/inc/helpers/user-center-renderers/vip-tab.php index 0ed3b42b..90b86559 100644 --- a/inc/helpers/user-center-renderers/vip-tab.php +++ b/inc/helpers/user-center-renderers/vip-tab.php @@ -164,6 +164,12 @@ foreach ([1, 2] as $lv) { + +
diff --git a/src/Content/VipServiceModal.php b/src/Content/VipServiceModal.php index 64f3b7c7..0b90e94e 100644 --- a/src/Content/VipServiceModal.php +++ b/src/Content/VipServiceModal.php @@ -164,9 +164,11 @@ trait VipServiceModal { * 与余额充值/购买积分弹窗同构(静止态由 vip-modal.css 显式隐藏,打开态由 * pay-vip.js 的自包含开关以内联 display:flex 驱动)。 * - * 入口位于会员购买弹窗内(pay-vip.js 的「积分兑换会员」按钮),而 .pay-vip - * 触发点全站可见,因此由 aksrt_render_vip_points_exchange_modal() 在 - * wp_footer 全局输出一次。 + * 入口有两处(均带 data-aksrt-vip-points-open,pay-vip.js 文档级委托开合): + * 会员购买弹窗内的「积分兑换会员」按钮,以及 /user/vip/ 档位购买卡下方的 + * 入口横条(aksrt_get_vip_points_exchange_entry_html)。.pay-vip 触发点 + * 全站可见,因此由 aksrt_render_vip_points_exchange_modal() 在 wp_footer + * 全局输出一次。 * * @return string 空串表示积分兑换不可用(不输出弹窗壳)。 */ diff --git a/tests/widget-system/vip-points-exchange-modal.test.cjs b/tests/widget-system/vip-points-exchange-modal.test.cjs index 0266372f..e79d6754 100644 --- a/tests/widget-system/vip-points-exchange-modal.test.cjs +++ b/tests/widget-system/vip-points-exchange-modal.test.cjs @@ -1,15 +1,22 @@ /** - * 积分兑换会员弹窗契约(参考主题 zibpay_get_vip_points_exchange_modal 1:1 对齐)。 + * 积分兑换会员弹窗与双入口契约(参考主题 1:1 对齐)。 * - * 三条硬约束: + * 硬约束: * 1) 服务端静态输出参考主题 refresh_modal 同构壳(`modal flex jc fade` > - * `modal-dialog modal-mini full-sm` > `modal-content` > `modal-body`), - * 入口只保留在会员购买弹窗内——/user/vip/ 页底部的独立入口已移除。 + * `modal-dialog modal-mini full-sm` > `modal-content` > `modal-body`)。 + * 入口有两处,分别对应参考主题的两个变体: + * - 会员购买弹窗内(zibpay_get_pay_uservip_modal 的 + * `btn-block but mb10 padding-lg c-green mini-radius` 按钮); + * - /user/vip/ 档位购买卡下方(zibpay_user_page_tab_content_vip 的 + * `c-yellow zib-widget flex ac jsb` 全宽横条,仅非会员展示)。 + * 两处入口均带 data-aksrt-vip-points-open,由 pay-vip.js 文档级委托打开 + * 同一静态弹窗。 * 2) 静止态必须被 vip-modal.css 显式隐藏(.flex 覆盖回归护栏),打开态为 * flex 而非 block,且层级高于 runtime 宿主(100050),以复现参考主题在 * 会员弹窗之上再叠一层兑换弹窗的行为。 * 3) 旧的客户端自实现弹窗(renderPointsModal / aksrt-vip-points-option / - * catalog.vip_url)必须已彻底移除。 + * catalog.vip_url)与旧版 /user/vip/ 自定义入口(uc-vip-points-link) + * 必须已彻底移除。 */ const test = require('node:test'); const assert = require('node:assert'); @@ -102,11 +109,54 @@ test('会员弹窗内的入口为参考主题同款按钮并开本地弹窗', () assert.match(vipJs, /openPointsModal\(pointsLink\)/); }); -test('旧的自实现兑换弹窗与独立入口已彻底移除', () => { +test('/user/vip/ 档位购买卡下方的入口为参考主题用户中心同款横条', () => { + // 参考主题 zibpay_user_page_tab_content_vip:my_box → vip_card → points link, + // 横条为 `c-yellow zib-widget flex ac jsb`(zib-widget → 本主题aksrt-panel表面)。 + assert.match(footer, /function aksrt_get_vip_points_exchange_entry_html/); + assert.match(vipTab, /aksrt_get_vip_points_exchange_entry_html\(\$user_id, \$level\)/); + // 结构 1:1:左 points-color 图标 + 标题(flex1 em12 c-yellow), + // 右「立即兑换」badg + 右箭头。 + assert.match(footer, /uc-vip-points-entry flex ac jsb/); + assert.match(footer, /flex1 em12 c-yellow/); + assert.match(footer, /#icon-points-color/); + assert.match(footer, /badg radius c-yellow/); + assert.match(footer, /fa fas fa-chevron-circle-right ml6/); + assert.match(footer, /data-aksrt-vip-points-open/); +}); + +test('/user/vip/ 入口门控与弹窗壳同源:非会员 + 兑换开启 + 有可兑换套餐', () => { + // 参考主题 zibpay_get_vip_points_exchange_link 门控:已是会员不展示。 + assert.match(footer, /if \(\$vip_level > 0\) \{\s*return '';/); + // 可用性直接取服务端 catalog(内含 credits 门控与等级启用过滤), + // 与 wp_footer 弹窗壳的渲染条件一致,避免出现无弹窗可开的死入口。 + assert.match(footer, /get_catalog\(\$user_id\)/); + assert.match(footer, /empty\(\$points_exchange\['enabled'\]\) \|\| \$packages === \[\]/); + // 模板传入当前会员等级(>0 时不输出)。 + assert.match(footer, /int \$vip_level = -1/); +}); + +test('页面入口样式落在 credits.css 且与档位卡同一表面体系', () => { + assert.match(creditsCss, /\.uc-vip-points-entry \{/); + assert.match(creditsCss, /\.uc-vip-points-entry\s*\{[^}]*var\(--main-bg-color, var\(--surface-1, #fff\)\)/s); + assert.match(creditsCss, /\.uc-vip-points-entry\s*\{[^}]*box-shadow:\s*var\(--shadow-sm\)/s); +}); + +test('入口开合由文档级委托驱动,弹窗内不再单独挂监听', () => { + // /user/vip/ 静态横条与弹窗内按钮共用一处 document 委托(冒泡)。 + assert.match(vipJs, /function bindPointsEntry\(\)/); + assert.match(vipJs, /bindPointsEntry\(\);/); + assert.match(vipJs, /document\.addEventListener\("click", function \(event\) \{[\s\S]*?data-aksrt-vip-points-open/); + // bindModalUi 内的重复分支已移除:openPointsModal(pointsLink) 只保留一处调用。 + const openCalls = vipJs.match(/openPointsModal\(pointsLink\)/g) || []; + assert.strictEqual(openCalls.length, 1); +}); + +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/ 页底部不再有独立积分兑换入口。 + // 旧版 /user/vip/ 自定义入口(uc-vip-points-link)不得回归;现行入口为 + // 参考主题 1:1 的 uc-vip-points-entry 横条。 assert.doesNotMatch(vipTab, /uc-vip-points-link/); assert.doesNotMatch(creditsCss, /\.uc-vip-points-link/); // 用户中心侧的旧兑换控制器([data-aksrt-vip-points],全站无对应 DOM)。