feat(vip): restructure membership settings into structured groups

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.
This commit is contained in:
kate522 committed 2026-10-18 17:14:00 +08:00
1 parent dcb80cff6e
commit af8dccdc45
15 files changed
+1461 -489

No files matched your search

@@ -10,8 +10,9 @@
* 会员介绍与开通会员更多内容默认值),SPA schema / PHP schema /
* PaymentRuntime 回退三处一致——Settings::get_all() 不合并
* schema 默认值,运行时回退即实际默认值;
* 3) vip_renew_desc / vip_upgrade_desc 为死配置(AKSRT 无续费/升级
* 卡渲染面,全仓零消费方),两份 schema 与 PaymentRuntime 均已删除;
* 3) vip_renew_desc / vip_upgrade_desc 为参考主题的一句话简介字段,
* 两份 schema 与 PaymentRuntime 均已定义,并由用户中心 /user/vip/
* 档位卡渲染消费(vip-tab.php 按续费/升级动作取对应简介);
* 4) 权益与卡密说明字段保存时保留 HTML(sanitize_type html),
* 不再被 sanitize_textarea_field 剥掉标签。
*/
@@ -85,6 +86,18 @@ test('两个文案字段位于分区末尾的界面文案分组且不再属于
// 「会员等级」组不再包含这两个字段。
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 }] }"),
@@ -96,11 +109,19 @@ test('两个文案字段位于分区末尾的界面文案分组且不再属于
);
});
test('续费/升级说明死配置已从两份 schema 与运行时删除', () => {
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp], ['PaymentRuntime.php', paymentRuntime]]) {
assert.ok(!src.includes('vip_renew_desc'), `${name} 不应再含 vip_renew_desc`);
assert.ok(!src.includes('vip_upgrade_desc'), `${name} 不应再含 vip_upgrade_desc`);
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', () => {
@@ -111,10 +132,12 @@ test('文案默认值三处一致并对齐 zibll', () => {
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}'`));
assert.ok(vipSchemaJs.includes(`default: '${EQUITY_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' => '黄金会员'"));
assert.ok(schemaPhp.includes("'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 默认值,回退即实际默认值)。
@@ -132,10 +155,9 @@ test('文案默认值三处一致并对齐 zibll', () => {
});
test('权益与卡密说明字段保存时保留 HTML', () => {
assert.ok(vipSchemaJs.includes("key: 'pay_user_vip_1_equity', label: '一级会员权益 HTML', type: 'textarea', sanitize_type: 'html'"));
assert.ok(vipSchemaJs.includes("key: 'pay_user_vip_2_equity', label: '二级会员权益 HTML', type: 'textarea', sanitize_type: 'html'"));
assert.ok(schemaPhp.includes("'key' => 'pay_user_vip_1_equity', 'label' => '一级会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => 'html'"));
assert.ok(schemaPhp.includes("'key' => 'pay_user_vip_2_equity', 'label' => '二级会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => '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'"));
});