feat(vip): collapse the structured membership groups into accordions
The structured groups introduced in the previous commit rendered every row fully expanded, which buried the fields an admin actually came to edit. They now behave like accordions and start collapsed. - 会员商品, 一级/二级会员续费 and 积分兑换明细 opt in via `collapsible` on the group schema. Each row shows only its header, carrying a summary line built from `listPreview` (价格:99.00 · 3 个月 / 积分:500 · 30 天), and expands on click. `defaultCollapsed` keeps the collapsed default; setting it to false restores the previous always-open behaviour. - 升级价格 splits into its three reference-theme blocks (月费升月费 / 月费升永久 / 永久升永久) through a new `sections` option on the accordion type, each block independently collapsible. - AksField keeps the existing open state keyed by index so adding or removing a row does not disturb the others, and the drag handle, delete button and chevron are excluded from the header toggle. - Non-collapsible groups are untouched: the accordion markup only renders when the schema opts in. Verified: php -l on the changed PHP, 656 Node contract tests passing (including new assertions that every collapsible group is opted in, that the upgrade sections cover exactly the declared fields, and that the SPA mirror matches), and the admin SPA builds.
This commit is contained in:
1 parent
af8dccdc45
commit
05f16d5fd7
5 files changed
+411
-21
No files matched your search
@@ -28,6 +28,7 @@ const packagesTrait = read('src', 'Content', 'VipServicePackages.php');
|
||||
const purchaseSource = read('src', 'Content', 'VipServicePurchase.php');
|
||||
const modalSource = read('src', 'Content', 'VipServiceModal.php');
|
||||
const rendererVue = read('assets', 'admin', 'src', 'components', 'form', 'AksFormRenderer.vue');
|
||||
const fieldVue = read('assets', 'admin', 'src', 'components', 'form', 'AksField.vue');
|
||||
const payVipJs = read('assets', 'js', 'pay-vip.js');
|
||||
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
|
||||
const homeCms = read('src', 'Settings', 'HomeCms.php');
|
||||
@@ -56,6 +57,27 @@ function runHarness() {
|
||||
return JSON.parse(out);
|
||||
}
|
||||
|
||||
/** 在 SPA schema 里按 key 递归查找字段定义。 */
|
||||
function findGroup(schema, key) {
|
||||
const walk = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.key === key) return node
|
||||
if (Array.isArray(node.tabs)) {
|
||||
for (const tab of node.tabs) {
|
||||
const hit = walk(tab.fields || [])
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.fields)) {
|
||||
const hit = walk(node.fields)
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(schema.groups)
|
||||
}
|
||||
|
||||
// 只切 VIP 设置函数:充值/积分设置里的 products_raw 与本文无关。
|
||||
function vipSettingsSlice() {
|
||||
const start = paymentRuntime.indexOf('public static function get_payment_vip_settings');
|
||||
@@ -121,6 +143,56 @@ test('等级 Tab 标题由显示名称动态驱动', () => {
|
||||
assert.match(rendererVue, /:name="tabName\(tab\)"/);
|
||||
assert.match(rendererVue, /:tab="tabName\(tab\)"/);
|
||||
assert.match(rendererVue, /function tabName\(tab\) \{[\s\S]*?tab\.titleKey[\s\S]*?tab\.titleFallback/);
|
||||
// 手风琴:默认收起,点击标题行展开。
|
||||
assert.match(fieldVue, /const repeaterCollapsible = computed\(\(\) => !!props\.schema\.collapsible\)/);
|
||||
assert.match(fieldVue, /const repeaterDefaultOpen = computed\(\(\) => props\.schema\.defaultCollapsed === false\)/);
|
||||
assert.match(fieldVue, /function toggleRepeaterItem\(index\)/);
|
||||
assert.match(fieldVue, /v-show="!repeaterCollapsible \|\| isRepeaterItemOpen\(index\)"/);
|
||||
assert.match(fieldVue, /aksrt-field__repeater-summary/);
|
||||
// accordion/fieldset 的 sections 分块手风琴。
|
||||
assert.match(fieldVue, /const fieldsetSections = computed\(/);
|
||||
assert.match(fieldVue, /function toggleFieldsetSection\(index\)/);
|
||||
assert.match(fieldVue, /aksrt-field__accordion-section-head/);
|
||||
});
|
||||
|
||||
test('结构化分组默认收起,标题行带摘要;升级价格按三块手风琴渲染', () => {
|
||||
const accordion = harness.accordion;
|
||||
// 会员商品 / 续费商品 / 积分兑换明细均默认收起。
|
||||
const expectedGroups = [
|
||||
'vip1/vip_1_product',
|
||||
'vip2/vip_2_product',
|
||||
'renew/vip_1_renew_product',
|
||||
'renew/vip_2_renew_product',
|
||||
'points-exchange/pay_vip_points_exchange_product',
|
||||
];
|
||||
assert.deepStrictEqual(Object.keys(accordion.groups).sort(), [...expectedGroups].sort());
|
||||
for (const key of expectedGroups) {
|
||||
const cfg = accordion.groups[key];
|
||||
assert.strictEqual(cfg.collapsible, true, `${key} 应开启手风琴`);
|
||||
assert.strictEqual(cfg.defaultCollapsed, true, `${key} 应默认收起`);
|
||||
assert.ok(cfg.listPreview && cfg.listPreview.titleKey, `${key} 应配置标题行摘要`);
|
||||
assert.ok(cfg.listPreview.descKey, `${key} 应配置标题行时长摘要`);
|
||||
assert.strictEqual(cfg.listPreview.descUnitKey, 'unit', `${key} 时长摘要应带单位`);
|
||||
}
|
||||
// 会员商品与续费商品摘要前缀为「价格:」,积分兑换为「积分:」。
|
||||
assert.strictEqual(accordion.groups['vip1/vip_1_product'].listPreview.prefix, '价格:');
|
||||
assert.strictEqual(accordion.groups['renew/vip_1_renew_product'].listPreview.prefix, '价格:');
|
||||
assert.strictEqual(accordion.groups['points-exchange/pay_vip_points_exchange_product'].listPreview.prefix, '积分:');
|
||||
assert.strictEqual(accordion.groups['points-exchange/pay_vip_points_exchange_product'].listPreview.titleKey, 'points');
|
||||
|
||||
// 升级价格拆成三块,且每块覆盖的字段并集恰好等于全部字段。
|
||||
const sections = accordion.sections['upgrade/vip_upgrade_product'];
|
||||
assert.strictEqual(sections.length, 3);
|
||||
assert.deepStrictEqual(sections.map((s) => s.title), [
|
||||
'月费会员升级月费会员',
|
||||
'月费会员升级永久会员',
|
||||
'永久会员升级永久会员',
|
||||
]);
|
||||
const covered = sections.flatMap((s) => s.keys).sort();
|
||||
const declared = harness.schema.opt_tabs.upgrade.fields.vip_upgrade_product.fields
|
||||
.map((f) => f.key)
|
||||
.sort();
|
||||
assert.deepStrictEqual(covered, declared, 'sections 必须覆盖升级价格的全部字段');
|
||||
});
|
||||
|
||||
test('会员商品字段集恰好 6 项且标签颜色为参考主题 16 色', () => {
|
||||
@@ -356,6 +428,15 @@ test('SPA 镜像 schema 与 PHP schema 同构', async () => {
|
||||
const points = schema.groups.find((g) => g.key === 'points-exchange').fields[1];
|
||||
assert.strictEqual(points.type, 'group');
|
||||
assert.deepStrictEqual(points.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
|
||||
// 手风琴配置与 PHP 侧一致。
|
||||
for (const key of ['vip_1_product', 'vip_2_product', 'vip_1_renew_product', 'vip_2_renew_product']) {
|
||||
const group = findGroup(schema, key);
|
||||
assert.strictEqual(group.collapsible, true, `${key} 应开启手风琴`);
|
||||
assert.strictEqual(group.defaultCollapsed, true, `${key} 应默认收起`);
|
||||
}
|
||||
assert.strictEqual(findGroup(schema, 'pay_vip_points_exchange_product').collapsible, true);
|
||||
const upgrade = findGroup(schema, 'vip_upgrade_product');
|
||||
assert.strictEqual(upgrade.sections.length, 3);
|
||||
// SPA 默认值与 PHP 侧一致。
|
||||
assert.strictEqual(schema.defaults.vip_1_product[0].price, 99);
|
||||
assert.strictEqual(schema.defaults.vip_1_renew_product[0].price, 69);
|
||||
|
||||
@@ -187,6 +187,52 @@ namespace {
|
||||
'custom' => $custom,
|
||||
'legacy' => $legacy,
|
||||
],
|
||||
'accordion' => (static function (array $groups): array {
|
||||
$out = ['groups' => [], 'sections' => []];
|
||||
foreach ($groups as $group) {
|
||||
foreach ($group['fields'] as $top) {
|
||||
if (($top['type'] ?? '') !== 'tabbed') {
|
||||
continue;
|
||||
}
|
||||
foreach ($top['tabs'] as $tab) {
|
||||
foreach ($tab['fields'] as $field) {
|
||||
if (($field['type'] ?? '') === 'group') {
|
||||
$out['groups'][$tab['key'] . '/' . $field['key']] = [
|
||||
'collapsible' => $field['collapsible'] ?? null,
|
||||
'defaultCollapsed' => $field['defaultCollapsed'] ?? null,
|
||||
'listPreview' => $field['listPreview'] ?? null,
|
||||
];
|
||||
}
|
||||
if (($field['type'] ?? '') === 'accordion') {
|
||||
$out['sections'][$tab['key'] . '/' . $field['key']] = array_map(
|
||||
static fn(array $section): array => [
|
||||
'title' => $section['title'],
|
||||
'keys' => $section['keys'],
|
||||
],
|
||||
$field['sections'] ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($groups as $group) {
|
||||
if ($group['key'] !== 'points-exchange') {
|
||||
continue;
|
||||
}
|
||||
foreach ($group['fields'] as $field) {
|
||||
if (($field['type'] ?? '') !== 'group') {
|
||||
continue;
|
||||
}
|
||||
$out['groups']['points-exchange/' . $field['key']] = [
|
||||
'collapsible' => $field['collapsible'] ?? null,
|
||||
'defaultCollapsed' => $field['defaultCollapsed'] ?? null,
|
||||
'listPreview' => $field['listPreview'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
})($groups),
|
||||
'pricing' => (static function (): array {
|
||||
$probe = new VipPricingProbe();
|
||||
$base = [
|
||||
|
||||
Reference in new issue
Block a user