diff --git a/assets/admin/src/components/form/AksField.vue b/assets/admin/src/components/form/AksField.vue index 962ac9fc..ca3941dd 100644 --- a/assets/admin/src/components/form/AksField.vue +++ b/assets/admin/src/components/form/AksField.vue @@ -493,7 +493,7 @@
{ - const sections = Array.isArray(props.schema.sections) ? props.schema : [] - if (!sections.length) return [] - const byKey = {} - for (const field of (props.schema.fields || [])) { - byKey[field.key] = field - } - return sections.map((section) => ({ - title: String(section.title || ''), - fields: (Array.isArray(section.keys) ? section.keys : []) - .map((key) => byKey[key]) - .filter(Boolean), - })) + return buildFieldsetSections(props.schema) }) const openFieldsetSections = ref(new Set()) @@ -885,6 +875,11 @@ function isFieldsetSectionOpen(index) { return openFieldsetSections.value.has(index) } +/** 分块内字段同样遵循 dependsOn,数据源是本 fieldset 的值对象。 */ +function sectionFields(section) { + return (section.fields || []).filter((field) => matchDependency(field.dependsOn, props.modelValue)) +} + function toggleFieldsetSection(index) { const next = new Set(openFieldsetSections.value) if (next.has(index)) { @@ -1319,6 +1314,15 @@ function confirmRepeaterModal() { margin-bottom: 0; } +/* 结构化分组(会员商品/续费商品/积分兑换/升级价格)与相邻字段之间留出更明确的间隔, + * 避免两个分组上下贴在一起,和参考主题的留白一致。 */ +.aksrt-field--group, +.aksrt-field--repeater, +.aksrt-field--accordion, +.aksrt-field--fieldset { + margin-bottom: 26px; +} + .aksrt-field__input-wrap { display: grid; gap: 8px; @@ -1585,7 +1589,7 @@ function confirmRepeaterModal() { } .aksrt-field__accordion-section + .aksrt-field__accordion-section { - margin-top: 8px; + margin-top: 14px; } .aksrt-field__accordion-section-head { diff --git a/assets/admin/src/components/form/fieldset-sections.js b/assets/admin/src/components/form/fieldset-sections.js new file mode 100644 index 00000000..824c2fe2 --- /dev/null +++ b/assets/admin/src/components/form/fieldset-sections.js @@ -0,0 +1,24 @@ +/** + * 把 accordion / fieldset 的 `schema.sections` 声明展开成「块标题 + 字段定义」。 + * + * 参考主题把「升级价格」这类复合配置拆成若干可折叠块,每块用 keys 声明自己 + * 包含哪些字段。这里是纯函数,方便在 Node 契约测试里直接用真实 schema 校验, + * 避免渲染层的一个小疏忽让整块手风琴静默退化成平铺。 + */ +export function buildFieldsetSections(schema) { + const declared = schema && Array.isArray(schema.sections) ? schema.sections : [] + if (!declared.length) return [] + + const byKey = {} + const nested = Array.isArray(schema.fields) ? schema.fields : [] + for (const field of nested) { + if (field && field.key) byKey[field.key] = field + } + + return declared.map((section) => ({ + title: String((section && section.title) || ''), + fields: (Array.isArray(section && section.keys) ? section.keys : []) + .map((key) => byKey[key]) + .filter(Boolean), + })) +} diff --git a/tests/widget-system/vip-settings-structured-contract.test.cjs b/tests/widget-system/vip-settings-structured-contract.test.cjs index 49edb2ed..fd5475e5 100644 --- a/tests/widget-system/vip-settings-structured-contract.test.cjs +++ b/tests/widget-system/vip-settings-structured-contract.test.cjs @@ -448,3 +448,35 @@ test('SPA 镜像 schema 与 PHP schema 同构', async () => { assert.ok(!vipSchemaJs.includes(key), `vip.js 不应再含 ${key}`); } }); + +test('升级价格 sections 能展开成三块手风琴,且块内字段遵循 dependsOn', async () => { + const { buildFieldsetSections } = await import( + pathToFileURL(path.join(root, 'assets/admin/src/components/form/fieldset-sections.js')).href + ); + const mod = await import(pathToFileURL(path.join(root, 'assets/admin/src/schema/sections/payment/vip.js')).href); + const upgrade = findGroup(mod.vipSchema, 'vip_upgrade_product'); + + const sections = buildFieldsetSections(upgrade); + assert.strictEqual(sections.length, 3, '升级价格应展开成三块'); + assert.deepStrictEqual(sections.map((s) => s.title), [ + '月费会员升级月费会员', + '月费会员升级永久会员', + '永久会员升级永久会员', + ]); + // 每块的字段并集恰好等于声明的全部字段,且顺序与 schema.fields 一致。 + const expanded = sections.flatMap((s) => s.fields.map((f) => f.key)); + assert.deepStrictEqual(expanded, upgrade.fields.map((f) => f.key)); + // 第一块带「已设置为永久会员」之外的提示与开关,字段定义必须是真实对象而非占位。 + assert.ok(sections[0].fields.every((f) => f && typeof f === 'object' && f.key)); + + // 没有 sections 声明的 fieldset 保持平铺,不产生假块。 + assert.deepStrictEqual(buildFieldsetSections({ type: 'fieldset', fields: [{ key: 'a' }] }), []); + assert.deepStrictEqual(buildFieldsetSections(undefined), []); + + // 渲染层把 sections 交给纯函数,并对块内字段套用 dependsOn。 + assert.match(fieldVue, /const fieldsetSections = computed\(\(\) => \{[\s\S]*?buildFieldsetSections\(props\.schema\)/); + assert.match(fieldVue, /function sectionFields\(section\) \{[\s\S]*?matchDependency\(field\.dependsOn, props\.modelValue\)/); + // 结构化分组之间留出间隔,两个分组不会上下贴在一起。 + assert.match(fieldVue, /\.aksrt-field--group,\s*\.aksrt-field--repeater,\s*\.aksrt-field--accordion,\s*\.aksrt-field--fieldset \{[\s\S]*?margin-bottom: 26px;/); + assert.match(fieldVue, /\.aksrt-field__accordion-section \+ \.aksrt-field__accordion-section \{[\s\S]*?margin-top: 14px;/); +});