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;/);
+});