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:
kate522 committed 2026-10-18 17:51:28 +08:00
1 parent af8dccdc45
commit 05f16d5fd7
5 files changed
+411 -21

No files matched your search

+237 -21
View File
@@ -476,14 +476,43 @@
<!-- Fieldset (single nested object) -->
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
<AksField
v-for="field in visibleNestedFields"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
<template v-if="fieldsetSections.length">
<div
v-for="(section, sIndex) in fieldsetSections"
:key="section.title"
class="aksrt-field__accordion-section"
:class="{ 'is-collapsed': !isFieldsetSectionOpen(sIndex) }"
>
<div class="aksrt-field__accordion-section-head" @click="toggleFieldsetSection(sIndex)">
<span class="aksrt-field__accordion-section-title">{{ section.title }}</span>
<FaIcon
:icon="isFieldsetSectionOpen(sIndex) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-field__accordion-section-toggle"
/>
</div>
<div v-show="isFieldsetSectionOpen(sIndex)" class="aksrt-field__accordion-section-body">
<AksField
v-for="field in section.fields"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
</div>
</div>
</template>
<template v-if="!fieldsetSections.length">
<AksField
v-for="field in visibleNestedFields"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
</template>
</div>
<div v-else-if="schema.type === 'link'" class="aksrt-field__link">
@@ -517,23 +546,38 @@
animation="180"
>
<template #item="{ element, index }">
<div class="aksrt-field__repeater-item">
<div class="aksrt-field__repeater-header">
<div class="aksrt-field__repeater-item" :class="{ 'is-collapsible': repeaterCollapsible, 'is-collapsed': repeaterCollapsible && !isRepeaterItemOpen(index) }">
<div
class="aksrt-field__repeater-header"
:class="{ 'is-clickable': repeaterCollapsible }"
@click="repeaterCollapsible ? toggleRepeaterItem(index) : undefined"
>
<div class="aksrt-field__repeater-title">
<i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true"></i>
<i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true" @click.stop></i>
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
<span v-if="repeaterCollapsible && repeaterItemSummary(element)" class="aksrt-field__repeater-summary">
{{ repeaterItemSummary(element) }}
</span>
</div>
<div class="aksrt-field__repeater-header-actions" @click.stop>
<FaIcon
v-if="repeaterCollapsible"
:icon="isRepeaterItemOpen(index) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-field__repeater-toggle"
/>
<n-button
size="tiny"
type="error"
text
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
<n-button
size="tiny"
type="error"
text
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
<div class="aksrt-field__repeater-fields">
<div v-show="!repeaterCollapsible || isRepeaterItemOpen(index)" class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key">
<AksField
v-if="matchDependency(field.dependsOn, element)"
@@ -730,6 +774,60 @@ const repeaterItems = computed(() => Array.isArray(props.modelValue) ? props.mod
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0))
const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max))
/* ------------------------------------------------------------------ *
* 手风琴模式:schema.collapsible 开启后,每一项默认收起,仅显示标题行,
* 点击标题行展开/收起。defaultCollapsed 为 false 时改为默认全部展开。
* ------------------------------------------------------------------ */
const repeaterCollapsible = computed(() => !!props.schema.collapsible)
const repeaterDefaultOpen = computed(() => props.schema.defaultCollapsed === false)
// 只记录「与默认状态相反」的下标,新增/删除项时其余项的开合状态不受影响。
const toggledRepeaterItems = ref(new Set())
function isRepeaterItemOpen(index) {
if (!repeaterCollapsible.value) return true
return repeaterDefaultOpen.value !== toggledRepeaterItems.value.has(index)
}
function toggleRepeaterItem(index) {
const next = new Set(toggledRepeaterItems.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
toggledRepeaterItems.value = next
}
/** 收起态标题行上的一行摘要,取自 listPreview 配置。 */
function repeaterItemSummary(item) {
if (!repeaterCollapsible.value || !item) return ''
const cfg = getRepeaterPreviewConfig()
const titleKey = cfg.titleKey || 'title'
const title = String(item[titleKey] ?? '').trim()
const prefix = String(cfg.prefix || '')
if (title === '') return prefix
const desc = repeaterItemDuration(item)
return desc ? `${prefix}${title} · ${desc}` : `${prefix}${title}`
}
/** time/unit 形态的时长摘要:0 视为永久,其余按 day/month 补单位。 */
function repeaterItemDuration(item) {
const cfg = getRepeaterPreviewConfig()
const descKey = cfg.descKey
if (!descKey) return ''
const raw = item[descKey]
if (raw === '' || raw == null) return ''
const amount = Number(raw)
if (!Number.isFinite(amount)) return String(raw)
if (amount <= 0) return '永久'
const unitKey = cfg.descUnitKey
if (!unitKey) return String(raw)
const unit = String(item[unitKey] || '')
if (unit === 'month') return `${amount} 个月`
if (unit === 'day') return `${amount} 天`
return String(raw)
}
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
const repeaterDragList = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
@@ -762,6 +860,41 @@ function fieldsetValue(field) {
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
}
/* ------------------------------------------------------------------ *
* accordion/fieldset 的分块手风琴:schema.sections 声明每块的标题与字段
* key,默认全部收起,点击块标题展开。未声明 sections 时保持平铺渲染。
* ------------------------------------------------------------------ */
const fieldsetSections = computed(() => {
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),
}))
})
const openFieldsetSections = ref(new Set())
function isFieldsetSectionOpen(index) {
return openFieldsetSections.value.has(index)
}
function toggleFieldsetSection(index) {
const next = new Set(openFieldsetSections.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
openFieldsetSections.value = next
}
function updateFieldsetValue(key, value) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
@@ -1451,6 +1584,48 @@ function confirmRepeaterModal() {
background: var(--aksrt-color-canvas);
}
.aksrt-field__accordion-section + .aksrt-field__accordion-section {
margin-top: 8px;
}
.aksrt-field__accordion-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--aksrt-color-border);
border-radius: 6px;
background: var(--aksrt-color-surface);
cursor: pointer;
user-select: none;
font-size: 13px;
font-weight: 600;
color: var(--aksrt-color-text);
transition: border-color 0.15s, color 0.15s;
}
.aksrt-field__accordion-section-head:hover {
border-color: var(--aksrt-color-primary);
color: var(--aksrt-color-primary);
}
.aksrt-field__accordion-section-title {
min-width: 0;
}
.aksrt-field__accordion-section-toggle {
flex-shrink: 0;
color: var(--aksrt-color-text-secondary);
}
.aksrt-field__accordion-section-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 2px 2px;
}
.aksrt-field__repeater {
display: flex;
flex-direction: column;
@@ -1479,6 +1654,47 @@ function confirmRepeaterModal() {
min-width: 0;
}
.aksrt-field__repeater-header.is-clickable {
cursor: pointer;
user-select: none;
border-radius: 6px;
margin: -4px -4px 0;
padding: 4px 4px 0;
}
.aksrt-field__repeater-header.is-clickable:hover .aksrt-field__repeater-index {
color: var(--aksrt-color-primary-hover);
}
.aksrt-field__repeater-header-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.aksrt-field__repeater-toggle {
color: var(--aksrt-color-text-secondary);
transition: transform 0.15s;
}
.aksrt-field__repeater-summary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--aksrt-color-text-secondary);
}
.aksrt-field__repeater-item.is-collapsible {
padding: 10px 12px;
}
.aksrt-field__repeater-item.is-collapsed .aksrt-field__repeater-header {
margin-bottom: 0;
}
.aksrt-field__repeater-handle {
cursor: grab;
color: var(--aksrt-color-text-tertiary);
@@ -34,6 +34,20 @@ const vipUnitOptions = [
{ label: '月', value: 'month' },
];
// 会员商品/续费商品的手风琴配置:默认收起,标题行显示价格与时长摘要。
const vipProductGroupOptions = {
collapsible: true,
defaultCollapsed: true,
listPreview: { prefix: '价格:', titleKey: 'price', descKey: 'time', descUnitKey: 'unit' },
};
// 积分兑换明细的手风琴配置:默认收起,标题行显示积分与时长摘要。
const vipPointsGroupOptions = {
collapsible: true,
defaultCollapsed: true,
listPreview: { prefix: '积分:', titleKey: 'points', descKey: 'time', descUnitKey: 'unit' },
};
/**
* 会员商品字段集(参考主题 CFS_Module::vip_product)。
*/
@@ -69,6 +83,7 @@ function vipLevelTabFields(level) {
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
...vipProductGroupOptions,
dependsOn: { all: [dep, { key: `vip_${level}_product_s`, value: true }] },
help: '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
fields: vipProductFields(),
@@ -331,6 +346,7 @@ export const vipSchema = {
],
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
help: '一级会员续费的商品选项',
...vipProductGroupOptions,
fields: vipProductFields(),
},
{
@@ -345,6 +361,7 @@ export const vipSchema = {
],
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
help: '二级会员续费的商品选项',
...vipProductGroupOptions,
fields: vipProductFields(),
},
],
@@ -376,6 +393,11 @@ export const vipSchema = {
},
dependsOn: { key: 'vip_upgrade', value: true },
help: '会员升级的商品选项',
sections: [
{ title: '月费会员升级月费会员', keys: ['_upgrade_unit_notice', 'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class'] },
{ title: '月费会员升级永久会员', keys: ['_upgrade_jump_notice', 'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class'] },
{ title: '永久会员升级永久会员', keys: ['permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class'] },
],
fields: [
{ key: '_upgrade_unit_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价' },
{ key: 'month_to_month_s', label: '月费会员升级月费会员', type: 'switch', default: true },
@@ -418,6 +440,7 @@ export const vipSchema = {
],
dependsOn: { key: 'pay_vip_points_exchange_s', value: true },
help: '每一行为一个可兑换的会员套餐',
...vipPointsGroupOptions,
fields: [
{ key: 'points', label: '使用积分', type: 'number', default: 100, min: 0, unit: '积分' },
{