Files
kate522 03871f0342 fix(pay): 渠道名称禁折行,消除三字渠道名的零余量换行
实测(bootstrap+main 真实加载组合):芯片文本行可用宽度 46px 对
三字渠道名「支付宝」(雅黑 14px×3=42px)近乎零余量,站点字体渲染
的细微差异(字距/亚像素舍入)即触发 CJK 逐字换行,参考主题实测为
恒单行。

- .payment-method-radio > div 增加 white-space:nowrap,锁定与参考
  主题同等的单行表现(渠道名集合均为 2-3 字短名,无溢出风险)
- main.min.css 重建,契约测试锁定(323 项全部通过)
2026-09-18 18:38:07 +08:00

227 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 卡密兑换渠道契约(三个购买弹窗 · 1:1 对齐参考主题)。
*
* 背景:余额卡片顶部的独立「卡密兑换」通用弹窗已删除,兑换入口收敛到
* 三个购买弹窗的支付方式中(对齐参考主题 zibpay_get_payment_methods +
* zibpay_is_allow_card_pass_pay 过滤器):
* 1) 余额充值弹窗(credits-wallet.php,服务端渲染)
* 2) 购买积分弹窗(credits-balance.php,服务端渲染,Zibll points-pay modal 结构)
* 3) 会员购买/续费弹窗(VipServiceModal JSON 视图模型 + pay-vip.js 渲染)
*
* 后台语义:余额充值 → 卡密兑换 → 三个独立开关
* 启用卡密兑换余额功能 / 启用卡密兑换积分功能 / 启用卡密兑换会员功能
* 任一开关关闭后,对应前台弹窗不出现卡密兑换渠道收款接口。
*
* REST 契约:POST /pay/card-pass/redeem,type 必填(balance/vip/credits),
* 不再有「空类型按卡密自动识别」;核销订单金额恒为 0。
*/
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 wallet = read('inc', 'helpers', 'user-center-renderers', 'credits-wallet.php');
const creditsRenderer = read('inc', 'helpers', 'user-center-renderers', 'credits-balance.php');
const creditsJs = read('assets', 'js', 'credits.js');
const payVipJs = read('assets', 'js', 'pay-vip.js');
const chargeCard = read('src', 'Payment', 'ChargeCard.php');
const userOrders = read('src', 'Payment', 'UserOrders.php');
const vipPackages = read('src', 'Content', 'VipServicePackages.php');
const aksrtPay = read('inc', 'helpers', 'aksrt-pay.php');
const mainCss = read('assets', 'css', 'main.css');
const vipPurchase = read('src', 'Content', 'VipServicePurchase.php');
const vipModal = read('src', 'Content', 'VipServiceModal.php');
const schemaPhp = read('src', 'Admin', 'Schema', 'SchemaPayment.php');
const schemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'recharge.js');
const vipSchemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'vip.js');
test('独立入口与通用弹窗已删除', () => { assert.equal(
fs.existsSync(path.join(root, 'inc', 'helpers', 'user-center-renderers', 'credits-vip.php')),
false,
'credits-vip.php 死文件应已删除'
);
// 通用卡密弹窗与 initCardPass 不回潮。
assert.doesNotMatch(wallet, /data-aksrt-card-pass-modal|data-aksrt-card-redeem/);
assert.doesNotMatch(creditsJs, /function initCardPass\(|initCardPass\(\)/);
assert.doesNotMatch(creditsRenderer, /data-aksrt-card-pass-modal|data-aksrt-card-redeem/);
// 卡密 logo 就位(三个弹窗共用)。
assert.equal(fs.existsSync(path.join(root, 'assets', 'img', 'pay-card-pass-logo.svg')), true);
});
test('余额充值弹窗:卡密渠道为支付方式之一,卡号/卡密输入按单密码模式渲染', () => {
assert.match(wallet, /'card_pass'\s+=>\s+'pay-card-pass-logo\.svg'/);
// 支付方式列表注入 card_pass(由 get_charge_view / get_public_options['balance'] 驱动)。
assert.match(wallet, /card_pass_redeem\['desc'\]/);
assert.match(wallet, /card_pass_redeem\['only_password'\]/);
// 卡密输入盒契约与参考主题一致。
assert.match(wallet, /data-controller="payment_method" data-condition="==" data-value="card_pass"/);
assert.match(wallet, /name="card_pass\[card\]"/);
assert.match(wallet, /name="card_pass\[password\]"/);
// 仅卡密可用时隐藏金额选择(参考主题语义)。
assert.match(wallet, /\$charge_card_only/);
assert.match(wallet, /\$charge_card_in_methods/);
});
test('购买积分弹窗:Zibll points-pay modal 结构 + 卡密渠道', () => {
// 商品/自定义选择 + 支付方式 + hidden + 立即支付。
assert.match(creditsRenderer, /data-aksrt-points-pay-modal/);
assert.match(creditsRenderer, /data-aksrt-points-pay-form/);
assert.match(creditsRenderer, /data-aksrt-points-pay-open/);
assert.doesNotMatch(creditsRenderer, /data-aksrt-points-exchange-(open|modal|form|amount|preview|close)/);
assert.match(creditsRenderer, /请选择需购买的积分|请输入需购买的积分数额/);
assert.match(creditsRenderer, /name="points_product"/);
assert.match(creditsRenderer, /name="points_custom"/);
assert.match(creditsRenderer, /name="payment_method"/);
assert.match(creditsRenderer, /立即支付/);
// 卡密渠道受「启用卡密兑换积分功能」控制。
assert.match(creditsRenderer, /points_card_pass\['enabled'\]/);
assert.match(creditsRenderer, /data-controller="payment_method" data-condition="==" data-value="card_pass"/);
assert.match(creditsRenderer, /name="card_pass\[password\]"/);
});
test('购买积分前端:商品/支付方式选中模型 + 余额/卡密提交分流', () => {
assert.match(creditsJs, /function initPointsPay\(\)/);
assert.match(creditsJs, /data-aksrt-points-pay-open/);
assert.match(creditsJs, /\[data-for="points_product"\]/);
// 卡密选中:隐藏 .charge-box、显示卡密输入盒(契约与参考主题一致)。
assert.match(creditsJs, /data-controller="payment_method"\]\[data-value="card_pass"/);
assert.match(creditsJs, /value === 'card_pass' \? 'none' : ''/);
// 余额支付 → /pay/balance/points-exchange(product + custom 契约)。
assert.match(creditsJs, /\/pay\/balance\/points-exchange/);
assert.match(creditsJs, /JSON\.stringify\(\{ product: pkg, custom: validCustom \? custom : '0' \}\)/);
// 卡密支付 → /pay/card-pass/redeem type=credits。
assert.match(creditsJs, /redeemCardPass\('credits'/);
// bootAssets 挂载新函数,无死调用。
assert.match(creditsJs, /function bootAssets\(\)[\s\S]*?initPointsPay\(\);/);
});
test('会员弹窗:卡密渠道注入与直兑分流(不创建支付订单)', () => {
// 后端:available_payment_methods 追加 card_pass + 视图模型透传。
assert.match(vipPackages, /'id' => 'card_pass', 'name' => __\('卡密', 'aksrt'\)/);
assert.match(vipPackages, /function vip_card_pass_context\(\)/);
assert.match(vipPackages, /vip_card_pass_context\(\)/);
assert.match(vipModal, /'card_pass' => \$card_pass/);
assert.match(vipModal, /'card_pass' => \$card_pass,/);
// 支付方式组件与其他两个弹窗同源:logo URL 服务端映射 + 余额/货币符号透传。
assert.match(vipModal, /function payment_method_logo_url\(string \$id\): string/);
assert.match(vipModal, /'card_pass'\s+=>\s+'pay-card-pass-logo\.svg'/);
assert.match(vipModal, /'wallet_balance' => \(string\) \(\$catalog\['wallet_balance'\]/);
assert.match(vipModal, /'currency_mark' =>/);
// 后端防御:purchase() 拒绝 card_pass(卡密直兑走 redeem)。
assert.match(vipPurchase, /aksrt_card_pass_redeem_required/);
// 前端:Zibll initiate_pay_input 同构组件——payment-method-radio logo 行、
// 我的余额盒(data-controller == balance)、卡密盒(password_box 类结构)、
// hidden payment_method、initiate-pay 按钮。
assert.match(payVipJs, /payment-method-radio hollow-radio flex-auto pointer/);
assert.match(payVipJs, /item\.setAttribute\("data-for", "payment_method"\)/);
assert.match(payVipJs, /mb10 muted-box padding-h10/);
assert.match(payVipJs, /"data-condition", "=="/);
assert.match(payVipJs, /"data-value", "balance"/);
assert.match(payVipJs, /mb10 padding-h10 padding-w6/);
assert.match(payVipJs, /"data-value", "card_pass"/);
assert.match(payVipJs, /hidden\.name = "payment_method"/);
assert.match(payVipJs, /mt6 but jb-red initiate-pay btn-block radius/);
assert.doesNotMatch(payVipJs, /uc-vip-shop__method|aksrt_vip_pay_method|aksrt-vip-submit/);
// 按钮字样对齐参考主题:会员弹窗即 initiate_pay_input(4) 默认「立即支付」;
// 无可用支付方式渲染 Zibll 同款 badg 提示(而非禁用按钮/自实现文案)。
assert.doesNotMatch(payVipJs, /cta_label/);
assert.match(payVipJs, /canPurchase \? "立即支付"/);
assert.match(payVipJs, /badg px12 c-yellow-2/, "暂时无法支付,请与客服联系");
// 渠道展示名对齐参考主题 zibpay_get_payment_method_args(余额/微信/支付宝)。
assert.match(aksrtPay, /'balance'\s+=> __\('余额', 'aksrt'\)/);
assert.match(aksrtPay, /'wechatpay'\s+=> __\('微信', 'aksrt'\)/);
// 显隐:通用 data-controller(==/!=)匹配 + 卡密选中隐藏套餐。
assert.match(payVipJs, /function applyVipMethodVisibility\(/);
assert.match(payVipJs, /querySelectorAll\('\[data-controller="payment_method"\]'\)/);
assert.match(payVipJs, /querySelectorAll\("\.charge-box"\)/);
// 提交分流:卡密直兑 /pay/card-pass/redeem type=vip。
assert.match(payVipJs, /function redeemVipCardPass\(/);
assert.match(payVipJs, /\/pay\/card-pass\/redeem/);
assert.match(payVipJs, /type: "vip"/);
assert.match(payVipJs, /view\.card_pass && view\.card_pass\.enabled/);
});
test('REST 契约:type 必填(balance/vip/credits),无空类型自动识别', () => {
assert.match(chargeCard, /'type' => \['type' => 'string', 'required' => true, 'enum' => \[self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS\]\]/);
assert.match(chargeCard, /卡密兑换类型无效/);
assert.match(chargeCard, /REDEEMABLE_TYPES = \[self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS\]/);
// 兼容层:redeem_for_user 仍保留 expected_type 形参(前端恒传明确类型)。
assert.match(chargeCard, /function redeem_for_user\(int \$user_id, string \$password, string \$card = '', string \$expected_type = ''/);
});
test('后端类型闸门:三个独立开关各约束自己的类型', () => {
assert.match(chargeCard, /'balance' => \['enabled' => false, 'desc' => '', 'only_password' => false\]/);
assert.match(chargeCard, /'credits' => \['enabled' => false, 'desc' => '', 'only_password' => false\]/);
assert.match(chargeCard, /'vip'\s+=> \['enabled' => false, 'desc' => '', 'only_password' => false\]/);
// 会员单密码默认关闭(对齐参考主题 default false):未显式开启时卡密盒显示卡号+密码。
assert.doesNotMatch(chargeCard, /'only_password' => true/);
assert.match(read('src', 'Settings', 'PaymentRuntime.php'), /'only_password' => !empty\(\$settings\['pay_vip_pass_charge_only_password'\]\),/);
assert.doesNotMatch(read('src', 'Settings', 'PaymentRuntime.php'), /!isset\(\$settings\['pay_vip_pass_charge_only_password'\]\)/);
assert.match(chargeCard, /卡密兑换余额功能未开启/);
assert.match(chargeCard, /卡密兑换积分功能未开启/);
assert.match(chargeCard, /卡密兑换会员功能未开启/);
assert.match(chargeCard, /积分系统未开启/);
// 收款接口 card_pass 主开关:三开关任一开启即生效。
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
assert.match(paymentRuntime, /\$card_pass_enabled = !empty\(\$settings\['pay_balance_pass_charge_s'\]\)[\s\S]*?\|\| !empty\(\$settings\['pay_vip_pass_charge_s'\]\);/);
});
test('后台 schema:卡密兑换拆分为三个独立开关', () => {
for (const [name, src] of [['SchemaPayment.php', schemaPhp], ['recharge.js', schemaJs]]) {
assert.match(src, /启用卡密兑换余额功能/, name);
assert.match(src, /启用卡密兑换积分功能/, name);
assert.match(src, /启用卡密兑换会员功能/, name);
assert.match(src, /points_pass_exchange_s/, name);
assert.match(src, /points_pass_exchange_desc/, name);
assert.match(src, /points_pass_exchange_only_password/, name);
assert.match(src, /pay_vip_pass_charge_desc/, name);
assert.match(src, /pay_vip_pass_charge_only_password/, name);
// 旧总开关语义不回潮。
assert.doesNotMatch(src, /启用卡密兑换功能'|启用卡密兑换功能"/, name);
}
// recharge.js defaults 补齐三开关字段。
assert.match(schemaJs, /points_pass_exchange_s: false/);
assert.match(schemaJs, /pay_vip_pass_charge_s: false/);
// vip schema 的「会员卡密」组迁移至 recharge 卡密兑换组,原组删除。
assert.doesNotMatch(vipSchemaJs, /title: '会员卡密'/);
assert.doesNotMatch(vipSchemaJs, /pay_vip_pass_charge_only_password/);
const schemaPaymentVip = schemaPhp;
assert.doesNotMatch(schemaPaymentVip, /title' => '会员卡密'/);
});
test('核销订单契约:金额恒为 0,订单名称按 note 生成', () => {
assert.match(chargeCard, /订单金额恒为 0/);
assert.match(userOrders, /if \(\$payment_method === 'card_pass'\)\s*\{\s*\$amount = \$is_points \? '0' : '0\.00';/);
assert.match(userOrders, /if \(!empty\(\$meta\['card_pass'\]\)\)\s*\{[\s\S]*?resolve_card_pass_title\(\$meta\)/);
assert.match(userOrders, /卡密兑换-余额充值/);
assert.match(userOrders, /卡密兑换-积分充值/);
assert.match(userOrders, /购入\[%1\$s\]%2\$s/);
assert.match(userOrders, /永久/);
});
test('多渠道防挤压:图标 flex:none + 行不换行项目收缩成芯片(对齐 Zibll)', () => {
// 图标禁用 flex 压缩并抵消 AKSRT bootstrap 全局 img{max-width:100%}
// (flex 压缩时把图标钳制到被压窄的项宽,实测 5 渠道 400px 弹窗图标
// 被压成 16px 细条)。行保持 Zibll 原样不换行:空间不足时项目收缩为
// 「图标上/文字下」芯片(实测 5 渠道一行 5 个、6 渠道一行 6 个,图标
// 恒为固有比例),绝不允许整行提前断行。
const rule = mainCss.match(/\.payment-method-radio img \{[\s\S]*?\n\}/);
assert.ok(rule, 'main.css 应存在 .payment-method-radio img 规则块');
assert.match(rule[0], /height:\s*35px;/);
assert.match(rule[0], /width:\s*auto;/);
assert.match(rule[0], /max-width:\s*none;/);
assert.match(rule[0], /flex:\s*none;/);
// 方法行标记与参考主题逐字一致(.flex.mb10,无 hh)。
assert.match(wallet, /class="flex mb10">/);
assert.match(creditsRenderer, /class="flex mb10">/);
assert.match(payVipJs, /node\("div", "flex mb10"\)/);
assert.doesNotMatch(payVipJs, /"flex hh mb10"/);
});
test('渠道名称禁折行(三字渠道名对芯片宽度零余量,站点字体差异即触发换行)', () => {
assert.match(mainCss, /\.payment-method-radio > div \{[^}]*white-space:\s*nowrap;/s);
});