Replace the pipe-delimited VIP configuration with structured groups and accordions, matching the reference theme (Zibll) 1:1. Settings: - 会员等级 keeps only the nav buttons and the two level switches, plus the "must enable level 1 first" guard. Level identity fields (display name, intro, benefits HTML, icon) and 会员商品 move into the 一级会员/二级会员 tabs of 会员购买选项, so each level is configured in one place. - Level tab titles bind to the configured display name via a new titleKey/titleFallback hook in AksFormRenderer. - 会员商品 is a repeater with exactly the reference fields: 执行价/原价/ 促销标签/标签颜色/会员有效时间/时长单位. The tag colour uses the full b+jb palette and now reaches the product card instead of a hardcoded jb-yellow. - 会员续费 gains the 自定义商品 type backed by per-level renew-product groups; the discount becomes a 0.1-9.9 slider with float sanitizing. - 会员升级 becomes the reference three-block accordion (month-to-month day-rate, jump to permanent, permanent to permanent), each with its own price, strikethrough price, promo tag and tag colour. - 积分兑换明细 is a repeater (points/level/duration/unit) where the duration accepts the literal Permanent. Runtime: - PaymentRuntime normalizes the structured groups into purchasable rows (money normalization, palette validation, zero-price and out-of-range rows dropped) and no longer emits any *_raw strings. - The catalog builder carries tag_class through and routes renewals to the renew-product group under 自定义商品. - The points-exchange order note carries time/unit so month durations use calendar math instead of a 30-day approximation. Dead code removed: parse_level_products, parse_packages, parse_upgrade_product_line and their pipe-splitting, plus nine zero-call PaymentRuntime proxy methods in HomeCms. No dual-format or migration path was added; SchemaProvider already falls back to defaults when a stored group value is not an array, so existing pipe strings degrade safely. Verified: php -l on every changed PHP file, 655 Node contract tests passing (14 new ones in vip-settings-structured-contract.test.cjs exercising the real schema build and runtime normalization via a PHP harness), and the admin SPA builds.
209 lines
15 KiB
JavaScript
209 lines
15 KiB
JavaScript
/**
|
||
* VIP 后台设置弹窗文案字段契约(对照参考主题 zibll 修正)。
|
||
*
|
||
* 硬约束:
|
||
* 1) 弹窗文案字段更名为「会员介绍」(pay_user_vip_desc, input) 与
|
||
* 「开通会员更多内容」(pay_user_vip_more, textarea rows 4 +
|
||
* sanitize_type html),并从「会员等级」组移到分区末尾的
|
||
* 「界面文案」(modal-copy) 独立分组;
|
||
* 2) 文案默认值对齐 zibll(黄金会员/钻石会员、尊享专属特权、权益四项、
|
||
* 会员介绍与开通会员更多内容默认值),SPA schema / PHP schema /
|
||
* PaymentRuntime 回退三处一致——Settings::get_all() 不合并
|
||
* schema 默认值,运行时回退即实际默认值;
|
||
* 3) vip_renew_desc / vip_upgrade_desc 为参考主题的一句话简介字段,
|
||
* 两份 schema 与 PaymentRuntime 均已定义,并由用户中心 /user/vip/
|
||
* 档位卡渲染消费(vip-tab.php 按续费/升级动作取对应简介);
|
||
* 4) 权益与卡密说明字段保存时保留 HTML(sanitize_type html),
|
||
* 不再被 sanitize_textarea_field 剥掉标签。
|
||
*/
|
||
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 vipSchemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'vip.js');
|
||
const schemaPhp = read('src', 'Admin', 'Schema', 'SchemaPayment.php');
|
||
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
|
||
const rechargeJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'recharge.js');
|
||
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
|
||
const modalSource = read('src', 'Content', 'VipServiceModal.php');
|
||
const payVipJs = read('assets', 'js', 'pay-vip.js');
|
||
|
||
const DESC_DEFAULT = '开通VIP会员,享受会员专属折扣以及多项特权';
|
||
const MORE_DEFAULT = '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>';
|
||
const EQUITY_DEFAULT = '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>';
|
||
|
||
// 取两个标记之间的源码段(分组切片,避免断言误伤其它功能的同名键)。
|
||
function slice(source, from, to) {
|
||
const start = source.indexOf(from);
|
||
assert.notStrictEqual(start, -1, `missing ${from}`);
|
||
const end = source.indexOf(to, start + from.length);
|
||
return source.slice(start, end === -1 ? undefined : end);
|
||
}
|
||
|
||
test('弹窗文案字段更名为会员介绍/开通会员更多内容并改为正确类型', () => {
|
||
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp]]) {
|
||
assert.ok(!src.includes('弹窗顶部会员说明') && !src.includes('弹窗底部补充说明 HTML'), `${name} 旧名称应已移除`);
|
||
assert.ok(src.includes('会员介绍') && src.includes('开通会员更多内容'), `${name} 应含新名称`);
|
||
}
|
||
// 会员介绍为单行 input(zibll text「一句话简介」);开通会员更多内容为 rows 4 的 HTML textarea。
|
||
assert.match(
|
||
vipSchemaJs,
|
||
/key: 'pay_user_vip_desc',\s*\n\s*label: '会员介绍',\s*\n\s*type: 'input',\s*\n\s*default: '开通VIP会员,享受会员专属折扣以及多项特权'/
|
||
);
|
||
assert.match(
|
||
vipSchemaJs,
|
||
/key: 'pay_user_vip_more',\s*\n\s*label: '开通会员更多内容',\s*\n\s*type: 'textarea',\s*\n\s*sanitize_type: 'html'[\s\S]*?rows: 4/
|
||
);
|
||
assert.match(
|
||
schemaPhp,
|
||
/'key' => 'pay_user_vip_desc', 'label' => '会员介绍', 'type' => 'input', 'default' => '开通VIP会员,享受会员专属折扣以及多项特权'/
|
||
);
|
||
assert.match(
|
||
schemaPhp,
|
||
/'key' => 'pay_user_vip_more', 'label' => '开通会员更多内容', 'type' => 'textarea', 'sanitize_type' => 'html'[\s\S]*?'rows' => 4/
|
||
);
|
||
// 帮助文本对齐 zibll 的 desc。
|
||
assert.ok(vipSchemaJs.includes('显示在开通界面顶部一句话简介,可以为会员权益简介或者活动介绍'));
|
||
assert.ok(vipSchemaJs.includes('显示在开通界面底部位置,可以为提醒事项、用户协议等,支持HTML代码'));
|
||
});
|
||
|
||
test('两个文案字段位于分区末尾的界面文案分组且不再属于会员等级组', () => {
|
||
const modalJs = vipSchemaJs.indexOf("key: 'modal-copy'");
|
||
const modalPhp = schemaPhp.indexOf("'key' => 'modal-copy'");
|
||
assert.notStrictEqual(modalJs, -1, 'vip.js 应含 modal-copy 分组');
|
||
assert.notStrictEqual(modalPhp, -1, 'SchemaPayment.php 应含 modal-copy 分组');
|
||
assert.ok(modalJs > vipSchemaJs.indexOf("key: 'points-exchange'"), 'vip.js modal-copy 应在分区末尾');
|
||
assert.ok(modalPhp > schemaPhp.indexOf("'key' => 'points-exchange'"), 'SchemaPayment.php modal-copy 应在分区末尾');
|
||
// modal-copy 之后不再出现其它分组/字段 key。
|
||
// modal-copy 之后(至分区末尾)不再出现其它分组/字段 key;dependsOn 引用的等级开关除外。
|
||
assert.doesNotMatch(vipSchemaJs.slice(modalJs), /key: '(?!modal-copy|pay_user_vip_desc|pay_user_vip_more|pay_user_vip_1_s|pay_user_vip_2_s)/);
|
||
const phpTail = slice(schemaPhp, "'key' => 'modal-copy'", "'revenue'");
|
||
assert.doesNotMatch(phpTail, /'key' => '(?!modal-copy|pay_user_vip_desc|pay_user_vip_more|pay_user_vip_1_s|pay_user_vip_2_s)/);
|
||
// 「会员等级」组不再包含这两个字段。
|
||
assert.doesNotMatch(slice(vipSchemaJs, "key: 'basic'", "key: 'benefit'"), /pay_user_vip_desc|pay_user_vip_more/);
|
||
assert.doesNotMatch(slice(schemaPhp, "'key' => 'basic'", "'key' => 'benefit'"), /pay_user_vip_desc|pay_user_vip_more/);
|
||
// 「会员等级」组只留导航按钮与两个等级开关;等级身份字段全部搬进
|
||
// 「会员购买选项」的等级 Tab(名称/简介/权益/图标)。
|
||
const basicJs = slice(vipSchemaJs, "key: 'basic'", "key: 'benefit'");
|
||
const basicPhp = slice(schemaPhp, "'key' => 'basic'", "'key' => 'benefit'");
|
||
for (const key of ['pay_user_vip_1_name', 'pay_user_vip_2_name', 'pay_user_vip_1_desc', 'pay_user_vip_2_desc', 'vip_1_img_icon', 'vip_2_img_icon']) {
|
||
assert.ok(!basicJs.includes(key), `vip.js 会员等级组不应再含 ${key}`);
|
||
assert.ok(!basicPhp.includes(key), `SchemaPayment.php 会员等级组不应再含 ${key}`);
|
||
}
|
||
assert.ok(basicJs.includes('pay_user_vip_1_s') && basicJs.includes('pay_user_vip_2_s'), 'vip.js 会员等级组应保留两个等级开关');
|
||
assert.ok(basicPhp.includes("'key' => 'pay_user_vip_1_s'") && basicPhp.includes("'key' => 'pay_user_vip_2_s'"), 'SchemaPayment.php 会员等级组应保留两个等级开关');
|
||
// 二级会员先于一级会员开启时的危险提示(参考主题同款)。
|
||
assert.ok(basicJs.includes('vip_level_order_notice') && basicPhp.includes('vip_level_order_notice'));
|
||
// 依赖:任一等级启用时显示(zibll pay_user_vip_1_s 依赖的双等级适配)。
|
||
assert.ok(
|
||
vipSchemaJs.includes("dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] }"),
|
||
'vip.js 应为 any 双等级依赖'
|
||
);
|
||
assert.ok(
|
||
schemaPhp.includes("'dependsOn' => ['any' => [['key' => 'pay_user_vip_1_s', 'value' => true], ['key' => 'pay_user_vip_2_s', 'value' => true]]]"),
|
||
'SchemaPayment.php 应为 any 双等级依赖'
|
||
);
|
||
});
|
||
|
||
test('续费/升级介绍字段存在且被档位卡消费', () => {
|
||
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp]]) {
|
||
assert.ok(src.includes('vip_renew_desc'), `${name} 应含 vip_renew_desc`);
|
||
assert.ok(src.includes('vip_upgrade_desc'), `${name} 应含 vip_upgrade_desc`);
|
||
}
|
||
// 运行时回退默认值(get_all 不合并 schema 默认值,回退即实际默认值)。
|
||
assert.ok(paymentRuntime.includes("$settings['vip_renew_desc'] ?? '立即续费会员,畅享VIP权益'"));
|
||
assert.ok(paymentRuntime.includes("$settings['vip_upgrade_desc'] ?? '升级VIP会员,享更多会员权益'"));
|
||
// 消费方:/user/vip/ 档位卡按动作取续费/升级简介,不得回退成死配置。
|
||
assert.ok(vipTabPhp.includes("$vip_settings['renew']['desc']"));
|
||
assert.ok(vipTabPhp.includes("$vip_settings['upgrade']['desc']"));
|
||
assert.match(vipTabPhp, /\$card_action = 'renew'/);
|
||
assert.match(vipTabPhp, /\$card_action = 'upgrade'/);
|
||
});
|
||
|
||
test('文案默认值三处一致并对齐 zibll', () => {
|
||
// SPA defaults 块与字段级 default。
|
||
assert.ok(vipSchemaJs.includes("pay_user_vip_1_name: '黄金会员'"));
|
||
assert.ok(vipSchemaJs.includes("pay_user_vip_2_name: '钻石会员'"));
|
||
assert.ok(vipSchemaJs.includes("pay_user_vip_1_desc: '尊享专属特权'"));
|
||
assert.ok(vipSchemaJs.includes("pay_user_vip_2_desc: '尊享专属特权'"));
|
||
assert.ok(vipSchemaJs.includes(`pay_user_vip_desc: '${DESC_DEFAULT}'`));
|
||
assert.ok(vipSchemaJs.includes(`pay_user_vip_more: '${MORE_DEFAULT}'`));
|
||
// 权益默认值收敛为常量,两份 schema 与运行时共用同一份字面量。
|
||
assert.ok(vipSchemaJs.includes(`const VIP_EQUITY_DEFAULT = '${EQUITY_DEFAULT}'`));
|
||
assert.ok(vipSchemaJs.includes('default: VIP_EQUITY_DEFAULT'));
|
||
// PHP schema 字段 default。
|
||
assert.ok(schemaPhp.includes("'default' => $level === 1 ? '黄金会员' : '钻石会员'"));
|
||
assert.ok(schemaPhp.includes(EQUITY_DEFAULT));
|
||
assert.ok(schemaPhp.includes(`'default' => '${DESC_DEFAULT}'`));
|
||
assert.ok(schemaPhp.includes(`'default' => '${MORE_DEFAULT}'`));
|
||
// PaymentRuntime 运行时回退(get_all 不合并 schema 默认值,回退即实际默认值)。
|
||
assert.ok(paymentRuntime.includes(`$desc = sanitize_text_field((string) ($settings['pay_user_vip_desc'] ?? __('${DESC_DEFAULT}', 'aksrt')))`));
|
||
assert.ok(paymentRuntime.includes(`$more = (string) ($settings['pay_user_vip_more'] ?? '${MORE_DEFAULT}')`));
|
||
assert.ok(paymentRuntime.includes("$name1 = __('黄金会员', 'aksrt');"));
|
||
assert.ok(paymentRuntime.includes("$name2 = __('钻石会员', 'aksrt');"));
|
||
assert.ok(paymentRuntime.includes("$settings['pay_user_vip_1_desc'] ?? __('尊享专属特权', 'aksrt')"));
|
||
assert.ok(paymentRuntime.includes("$settings['pay_user_vip_2_desc'] ?? __('尊享专属特权', 'aksrt')"));
|
||
assert.ok(paymentRuntime.includes(EQUITY_DEFAULT));
|
||
// 旧默认值不再出现在任何一处。
|
||
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp], ['PaymentRuntime.php', paymentRuntime]]) {
|
||
assert.ok(!/尊享一级会员特权|尊享二级会员特权|免费下载部分资源|更高免费下载额度/.test(src), `${name} 旧默认值应已移除`);
|
||
}
|
||
});
|
||
|
||
test('权益与卡密说明字段保存时保留 HTML', () => {
|
||
// 权益字段已并入「会员购买选项」的等级 Tab,标签统一为「会员权益 HTML」。
|
||
assert.ok(vipSchemaJs.includes("key: `pay_user_vip_${level}_equity`, label: '会员权益 HTML', type: 'textarea', sanitize_type: 'html'"));
|
||
assert.ok(schemaPhp.includes("['key' => 'pay_user_vip_' . $level . '_equity', 'label' => '会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => 'html'"));
|
||
assert.ok(rechargeJs.includes("key: 'pay_vip_pass_charge_desc', label: '卡密兑换会员说明', type: 'textarea', sanitize_type: 'html'"));
|
||
assert.ok(schemaPhp.includes("'key' => 'pay_vip_pass_charge_desc', 'label' => '卡密兑换会员说明', 'type' => 'textarea', 'sanitize_type' => 'html'"));
|
||
});
|
||
|
||
test('会员介绍运行时按单行文本净化', () => {
|
||
// 只收敛 VIP 设置函数:充值等其它设置的 $desc 与本文无关。
|
||
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
|
||
assert.ok(vipSettings.includes("$desc = sanitize_text_field((string) ($settings['pay_user_vip_desc']"));
|
||
assert.ok(!vipSettings.includes('$desc = sanitize_textarea_field'));
|
||
});
|
||
|
||
test('开通会员更多内容只在弹窗内渲染,不裸露在用户中心页面', () => {
|
||
// 参考主题 zibpay_user_page_tab_content_vip 只渲染 pay_user_vip_desc;
|
||
// pay_user_vip_more 仅出现在两个弹窗函数(购买/续费弹窗)中。
|
||
assert.ok(!vipTabPhp.includes("vip_settings['more']"), '用户中心 VIP 页不应渲染 more');
|
||
assert.ok(!vipTabPhp.includes('muted-box padding-10 mini-radius mt15'), '用户中心 VIP 页不应有 more 提示盒');
|
||
// 弹窗侧保持:视图模型透传 more + pay-vip.js 右列渲染。
|
||
assert.match(modalSource, /'more'\s+=>/);
|
||
assert.match(payVipJs, /catalog\.more/);
|
||
});
|
||
|
||
test('more 与卡密说明输出按 post 级 HTML 放行', () => {
|
||
// 保存端已 wp_kses_post,输出端不能再窄白名单/textarea 净化二次剥标签。
|
||
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
|
||
assert.match(vipSettings, /'more'\s+=> wp_kses_post\(\$more\)/);
|
||
assert.doesNotMatch(vipSettings, /\$allowed_equity \+ \['div'/);
|
||
const rechargeSettings = slice(paymentRuntime, 'public static function get_payment_recharge_settings', 'public static function get_payment_vip_settings');
|
||
assert.match(rechargeSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_vip_pass_charge_desc'\]/);
|
||
});
|
||
|
||
test('弹窗内 more 与卡密说明按 HTML 渲染而非纯文本', () => {
|
||
// node() 走 textContent 会把标签显示为字面文本;富文本字段必须走 innerHTML。
|
||
assert.match(payVipJs, /function htmlNode\(tag, className, value\) \{[\s\S]*?el\.innerHTML = text\(value\);/);
|
||
assert.ok(payVipJs.includes('htmlNode("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more)'));
|
||
assert.ok(payVipJs.includes('htmlNode("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc)'));
|
||
});
|
||
|
||
test('积分兑换说明按 post 级 HTML 放行并原样渲染', () => {
|
||
// 参考主题该字段 sanitize=false 且弹窗内原样拼接;AKSRT 对齐为保存/输出
|
||
// 双端 wp_kses_post + 弹窗 wp_kses_post 渲染。
|
||
assert.match(vipSchemaJs, /key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', sanitize_type: 'html'/);
|
||
assert.match(schemaPhp, /'key' => 'pay_vip_points_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'sanitize_type' => 'html'/);
|
||
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
|
||
assert.match(vipSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_vip_points_exchange_desc'\]/);
|
||
// 弹窗渲染端不再 esc_html 剥标签。
|
||
assert.match(modalSource, /<div class="muted-box muted-2-color padding-10 em09 mt20">' \. wp_kses_post\(\$desc_text\)/);
|
||
assert.doesNotMatch(modalSource, /esc_html\(\$desc_text\)/);
|
||
});
|