feat(admin): make all sortable/repeater cards collapsible, collapsed by default

- Add useCollapsibleItems: key-based open state, clickable/keyboard head, aria-expanded
- sortable items with sub-fields and every inline repeater/group row now collapse
- Repeater head summary falls back to the first filled field when no listPreview
- Drop per-schema collapsible/defaultCollapsed for repeater groups (JS + PHP)
- Align FloatBtnItemPanel head with the shared collapse attrs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-28 14:12:13 +08:00
1 parent 8843ae4261
commit 116b6b2223
9 files changed
+114 -76

No files matched your search

@@ -1,6 +1,6 @@
<template> <template>
<div class="float-btn-panel"> <div class="float-btn-panel">
<div class="aksrt-sortable__head is-clickable" @click="expanded = !expanded"> <div class="aksrt-sortable__head" v-bind="headAttrs">
<AksSortHandle @click.stop /> <AksSortHandle @click.stop />
<div class="aksrt-sortable__main"> <div class="aksrt-sortable__main">
<div class="aksrt-sortable__title-row"> <div class="aksrt-sortable__title-row">
@@ -67,6 +67,7 @@ import { computed, ref } from 'vue'
import FaIconPicker from '@/components/form/FaIconPicker.vue' import FaIconPicker from '@/components/form/FaIconPicker.vue'
import AksMediaField from '@/components/form/AksMediaField.vue' import AksMediaField from '@/components/form/AksMediaField.vue'
import AksSortHandle from '@/components/form/AksSortHandle.vue' import AksSortHandle from '@/components/form/AksSortHandle.vue'
import { collapseHeadAttrs } from '@/components/form/useCollapsibleItems.js'
import FloatBtnCommonFields from './FloatBtnCommonFields.vue' import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
import FloatMoreButtonsEditor from './FloatMoreButtonsEditor.vue' import FloatMoreButtonsEditor from './FloatMoreButtonsEditor.vue'
import { FLOAT_BTN_META } from './floating-defaults.js' import { FLOAT_BTN_META } from './floating-defaults.js'
@@ -77,6 +78,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const expanded = ref(false) const expanded = ref(false)
const headAttrs = computed(() => collapseHeadAttrs(expanded.value, () => { expanded.value = !expanded.value }))
const meta = computed(() => FLOAT_BTN_META[props.modelValue.type] || { title: props.modelValue.type, panel: '' }) const meta = computed(() => FLOAT_BTN_META[props.modelValue.type] || { title: props.modelValue.type, panel: '' })
const addIcon = computed({ const addIcon = computed({
+35 -45
View File
@@ -396,12 +396,23 @@
> >
<template #item="{ element: item, index }"> <template #item="{ element: item, index }">
<div class="aksrt-sortable__item"> <div class="aksrt-sortable__item">
<div class="aksrt-sortable__head"> <div
<AksSortHandle /> class="aksrt-sortable__head"
v-bind="item.fields?.length ? listItemHeadAttrs(item.key) : {}"
>
<AksSortHandle @click.stop />
<span class="aksrt-sortable__index">{{ index + 1 }}</span> <span class="aksrt-sortable__index">{{ index + 1 }}</span>
<span class="aksrt-sortable__title">{{ item.label }}</span> <span class="aksrt-sortable__main">
<span class="aksrt-sortable__title">{{ item.label }}</span>
</span>
<FaIcon
v-if="item.fields?.length"
:icon="isListItemOpen(item.key) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-sortable__toggle"
/>
</div> </div>
<div v-if="item.fields && item.fields.length" class="aksrt-sortable__body aksrt-field__sortable-body"> <div v-if="item.fields?.length" v-show="isListItemOpen(item.key)" class="aksrt-sortable__body aksrt-field__sortable-body">
<AksField v-for="field in item.fields" :key="field.key" :schema="field" :model-value="modelValue?.[item.key]?.[field.key] ?? field.default" :form-data="formData" @update:model-value="updateSortable(item.key, field.key, $event)" /> <AksField v-for="field in item.fields" :key="field.key" :schema="field" :model-value="modelValue?.[item.key]?.[field.key] ?? field.default" :form-data="formData" @update:model-value="updateSortable(item.key, field.key, $event)" />
</div> </div>
</div> </div>
@@ -420,16 +431,12 @@
> >
<template #item="{ element, index }"> <template #item="{ element, index }">
<div class="aksrt-sortable__item"> <div class="aksrt-sortable__item">
<div <div class="aksrt-sortable__head" v-bind="repeaterHeadAttrs(repeaterDragKey(element))">
class="aksrt-sortable__head"
:class="{ 'is-clickable': repeaterCollapsible }"
@click="repeaterCollapsible ? toggleRepeaterItem(index) : undefined"
>
<AksSortHandle @click.stop /> <AksSortHandle @click.stop />
<span class="aksrt-sortable__index">{{ index + 1 }}</span> <span class="aksrt-sortable__index">{{ index + 1 }}</span>
<span class="aksrt-sortable__main"> <span class="aksrt-sortable__main">
<span v-if="repeaterCollapsible && repeaterItemSummary(element)" class="aksrt-sortable__title aksrt-field__repeater-summary"> <span class="aksrt-sortable__title aksrt-field__repeater-summary">
{{ repeaterItemSummary(element) }} {{ repeaterItemSummary(element) || '未填写' }}
</span> </span>
</span> </span>
<div class="aksrt-sortable__actions" @click.stop> <div class="aksrt-sortable__actions" @click.stop>
@@ -443,16 +450,14 @@
> >
<template #icon><FaIcon icon="fa fas fa-trash-alt" :size="12" /></template> <template #icon><FaIcon icon="fa fas fa-trash-alt" :size="12" /></template>
</n-button> </n-button>
<FaIcon
v-if="repeaterCollapsible"
:icon="isRepeaterItemOpen(index) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-sortable__toggle"
@click="toggleRepeaterItem(index)"
/>
</div> </div>
<FaIcon
:icon="isRepeaterItemOpen(repeaterDragKey(element)) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-sortable__toggle"
/>
</div> </div>
<div v-show="!repeaterCollapsible || isRepeaterItemOpen(index)" class="aksrt-sortable__body aksrt-field__repeater-fields"> <div v-show="isRepeaterItemOpen(repeaterDragKey(element))" class="aksrt-sortable__body aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key"> <template v-for="field in (schema.fields || [])" :key="field.key">
<AksField <AksField
v-if="matchDependency(field.dependsOn, element)" v-if="matchDependency(field.dependsOn, element)"
@@ -536,6 +541,7 @@ import { matchDependency } from './field-dependency.js'
import { SORTABLE_LIST_ATTRS } from './sortable-options.js' import { SORTABLE_LIST_ATTRS } from './sortable-options.js'
import { createRepeaterRow, repeaterRowSummary } from './repeater-rows.js' import { createRepeaterRow, repeaterRowSummary } from './repeater-rows.js'
import { useFieldRemoteOptions } from './useFieldRemoteOptions.js' import { useFieldRemoteOptions } from './useFieldRemoteOptions.js'
import { useCollapsibleItems } from './useCollapsibleItems.js'
const props = defineProps({ const props = defineProps({
/** Field schema definition: { key, label, type, default, options?, help?, ... } */ /** Field schema definition: { key, label, type, default, options?, help?, ... } */
@@ -655,6 +661,9 @@ function toggleFieldsetSection(index) {
} }
// ============ Sortable(对象键顺序即排序结果) ============ // ============ Sortable(对象键顺序即排序结果) ============
// 带子字段的项默认收起,点击标题行展开。
const { isOpen: isListItemOpen, headAttrs: listItemHeadAttrs } = useCollapsibleItems()
const sortableList = computed({ const sortableList = computed({
get: () => { get: () => {
const definitions = new Map((props.schema.items || []).map(item => [item.key, item])) const definitions = new Map((props.schema.items || []).map(item => [item.key, item]))
@@ -678,34 +687,15 @@ const repeaterLength = computed(() => Array.isArray(props.modelValue) ? props.mo
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0)) 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)) const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max))
/* ------------------------------------------------------------------ * // 每行默认收起,仅显示标题行;按行 key 记录开合,拖拽换位后状态跟随行。
* 手风琴模式:schema.collapsible 开启后,每一项默认收起,仅显示标题行, const {
* 点击标题行展开/收起。defaultCollapsed 为 false 时改为默认全部展开。 isOpen: isRepeaterItemOpen,
* ------------------------------------------------------------------ */ headAttrs: repeaterHeadAttrs,
const repeaterCollapsible = computed(() => !!props.schema.collapsible) } = useCollapsibleItems()
const repeaterDefaultOpen = computed(() => props.schema.defaultCollapsed === false)
// 只记录「与默认状态相反」的下标,新增/删除项时其余项的开合状态不受影响。
const toggledRepeaterItems = ref(new Set())
function isRepeaterItemOpen(index) { /** 标题行摘要:取自 listPreview;未配置时取首个有值的字段。 */
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) { function repeaterItemSummary(item) {
if (!repeaterCollapsible.value) return '' return repeaterRowSummary(item, props.schema.listPreview || {}, props.schema.fields)
return repeaterRowSummary(item, props.schema.listPreview || {})
} }
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */ /** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
@@ -41,10 +41,21 @@ function rowDuration(row, cfg) {
return String(raw) return String(raw)
} }
/** 未配置 titleKey 时,取首个值为非空字符串/数字的字段作为标题。 */
function fallbackTitleKey(row, fields) {
if (row.title != null && String(row.title).trim() !== '') return 'title'
for (const field of (Array.isArray(fields) ? fields : [])) {
if (field.ui_only) continue
const value = row[field.key]
if ((typeof value === 'string' && value.trim() !== '') || typeof value === 'number') return field.key
}
return 'title'
}
/** 收起态标题行上的一行摘要,取自 listPreview 配置。 */ /** 收起态标题行上的一行摘要,取自 listPreview 配置。 */
export function repeaterRowSummary(row, cfg = {}) { export function repeaterRowSummary(row, cfg = {}, fields = []) {
if (!row) return '' if (!row) return ''
const title = String(row[cfg.titleKey || 'title'] ?? '').trim() const title = String(row[cfg.titleKey || fallbackTitleKey(row, fields)] ?? '').trim()
const prefix = String(cfg.prefix || '') const prefix = String(cfg.prefix || '')
if (title === '') return prefix if (title === '') return prefix
const duration = rowDuration(row, cfg) const duration = rowDuration(row, cfg)
@@ -0,0 +1,46 @@
/**
* 可排序卡片列表的折叠状态:所有带展开区的卡片默认收起,点击行头展开/收起。
*
* 状态按行的稳定 key 记录(而不是下标),拖拽换位后每张卡片保持自己的开合状态。
* 行头属性统一由 collapseHeadAttrs 生成:可点击、可键盘操作(Enter / Space)、
* 带 aria-expanded,样式见 styles/sortable-list.css。
*/
import { ref } from 'vue'
export function collapseHeadAttrs(open, toggle) {
return {
class: 'is-clickable',
role: 'button',
tabindex: 0,
'aria-expanded': open ? 'true' : 'false',
onClick: toggle,
onKeydown: (event) => {
// 只响应行头本身的按键,行内按钮 / 输入框的 Enter、空格不触发折叠。
if (event.target !== event.currentTarget) return
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
toggle()
},
}
}
export function useCollapsibleItems() {
const openKeys = ref(new Set())
function isOpen(key) {
return openKeys.value.has(key)
}
function toggle(key) {
const next = new Set(openKeys.value)
if (next.has(key)) next.delete(key)
else next.add(key)
openKeys.value = next
}
function headAttrs(key) {
return collapseHeadAttrs(isOpen(key), () => toggle(key))
}
return { isOpen, toggle, headAttrs }
}
@@ -34,17 +34,13 @@ const vipUnitOptions = [
{ label: '月', value: 'month' }, { label: '月', value: 'month' },
]; ];
// 会员商品/续费商品的手风琴配置:默认收起,标题行显示价格与时长摘要。 // 会员商品/续费商品:标题行显示价格与时长摘要(行默认收起由 AksField 统一处理)。
const vipProductGroupOptions = { const vipProductGroupOptions = {
collapsible: true,
defaultCollapsed: true,
listPreview: { prefix: '价格:', titleKey: 'price', descKey: 'time', descUnitKey: 'unit' }, listPreview: { prefix: '价格:', titleKey: 'price', descKey: 'time', descUnitKey: 'unit' },
}; };
// 积分兑换明细的手风琴配置:默认收起,标题行显示积分与时长摘要。 // 积分兑换明细:标题行显示积分与时长摘要。
const vipPointsGroupOptions = { const vipPointsGroupOptions = {
collapsible: true,
defaultCollapsed: true,
listPreview: { prefix: '积分:', titleKey: 'points', descKey: 'time', descUnitKey: 'unit' }, listPreview: { prefix: '积分:', titleKey: 'points', descKey: 'time', descUnitKey: 'unit' },
}; };
@@ -52,6 +52,12 @@
user-select: none; user-select: none;
} }
.aksrt-sortable__head.is-clickable:focus-visible {
outline: 2px solid var(--aksrt-color-primary, #18A058);
outline-offset: -2px;
border-radius: 8px;
}
.aksrt-sortable__head.is-clickable:hover .aksrt-sortable__title, .aksrt-sortable__head.is-clickable:hover .aksrt-sortable__title,
.aksrt-sortable__head.is-clickable:hover .aksrt-sortable__toggle { .aksrt-sortable__head.is-clickable:hover .aksrt-sortable__toggle {
color: var(--aksrt-color-primary, #18A058); color: var(--aksrt-color-primary, #18A058);
+2 -6
View File
@@ -292,8 +292,6 @@ class SchemaPayment {
], ],
'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true], 'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true],
'help' => '每一行为一个可兑换的会员套餐', 'help' => '每一行为一个可兑换的会员套餐',
'collapsible' => true,
'defaultCollapsed' => true,
'listPreview' => ['prefix' => '积分:', 'titleKey' => 'points', 'descKey' => 'time', 'descUnitKey' => 'unit'], 'listPreview' => ['prefix' => '积分:', 'titleKey' => 'points', 'descKey' => 'time', 'descUnitKey' => 'unit'],
'fields' => [ 'fields' => [
['key' => 'points', 'label' => '使用积分', 'type' => 'number', 'default' => 100, 'min' => 0, 'unit' => '积分'], ['key' => 'points', 'label' => '使用积分', 'type' => 'number', 'default' => 100, 'min' => 0, 'unit' => '积分'],
@@ -576,15 +574,13 @@ class SchemaPayment {
} }
/** /**
* 会员商品/续费商品的手风琴配置:默认收起,标题行显示价格与时长摘要。 * 会员商品/续费商品:标题行显示价格与时长摘要(行默认收起由 SPA 统一处理)。
* *
* @return array<string,mixed> * @return array<string,mixed>
*/ */
private static function vip_product_group_options(): array { private static function vip_product_group_options(): array {
return [ return [
'collapsible' => true, 'listPreview' => ['prefix' => '价格:', 'titleKey' => 'price', 'descKey' => 'time', 'descUnitKey' => 'unit'],
'defaultCollapsed' => true,
'listPreview' => ['prefix' => '价格:', 'titleKey' => 'price', 'descKey' => 'time', 'descUnitKey' => 'unit'],
]; ];
} }
@@ -143,12 +143,11 @@ test('等级 Tab 标题由显示名称动态驱动', () => {
assert.match(rendererVue, /:name="tabName\(tab\)"/); assert.match(rendererVue, /:name="tabName\(tab\)"/);
assert.match(rendererVue, /:tab="tabName\(tab\)"/); assert.match(rendererVue, /:tab="tabName\(tab\)"/);
assert.match(rendererVue, /function tabName\(tab\) \{[\s\S]*?tab\.titleKey[\s\S]*?tab\.titleFallback/); assert.match(rendererVue, /function tabName\(tab\) \{[\s\S]*?tab\.titleKey[\s\S]*?tab\.titleFallback/);
// 手风琴:默认收起,点击标题行展开。 // 手风琴:所有 repeater/group 行默认收起,点击标题行展开(按行 key 记录开合)。
assert.match(fieldVue, /const repeaterCollapsible = computed\(\(\) => !!props\.schema\.collapsible\)/); assert.match(fieldVue, /useCollapsibleItems\(\)/);
assert.match(fieldVue, /const repeaterDefaultOpen = computed\(\(\) => props\.schema\.defaultCollapsed === false\)/); assert.match(fieldVue, /v-show="isRepeaterItemOpen\(repeaterDragKey\(element\)\)"/);
assert.match(fieldVue, /function toggleRepeaterItem\(index\)/);
assert.match(fieldVue, /v-show="!repeaterCollapsible \|\| isRepeaterItemOpen\(index\)"/);
assert.match(fieldVue, /aksrt-field__repeater-summary/); assert.match(fieldVue, /aksrt-field__repeater-summary/);
assert.doesNotMatch(fieldVue, /schema\.collapsible|schema\.defaultCollapsed/);
// accordion/fieldset 的 sections 分块手风琴。 // accordion/fieldset 的 sections 分块手风琴。
assert.match(fieldVue, /const fieldsetSections = computed\(/); assert.match(fieldVue, /const fieldsetSections = computed\(/);
assert.match(fieldVue, /function toggleFieldsetSection\(index\)/); assert.match(fieldVue, /function toggleFieldsetSection\(index\)/);
@@ -168,8 +167,6 @@ test('结构化分组默认收起,标题行带摘要;升级价格按三块
assert.deepStrictEqual(Object.keys(accordion.groups).sort(), [...expectedGroups].sort()); assert.deepStrictEqual(Object.keys(accordion.groups).sort(), [...expectedGroups].sort());
for (const key of expectedGroups) { for (const key of expectedGroups) {
const cfg = accordion.groups[key]; 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 && cfg.listPreview.titleKey, `${key} 应配置标题行摘要`);
assert.ok(cfg.listPreview.descKey, `${key} 应配置标题行时长摘要`); assert.ok(cfg.listPreview.descKey, `${key} 应配置标题行时长摘要`);
assert.strictEqual(cfg.listPreview.descUnitKey, 'unit', `${key} 时长摘要应带单位`); assert.strictEqual(cfg.listPreview.descUnitKey, 'unit', `${key} 时长摘要应带单位`);
@@ -470,13 +467,11 @@ test('SPA 镜像 schema 与 PHP schema 同构', async () => {
const points = schema.groups.find((g) => g.key === 'points-exchange').fields[1]; const points = schema.groups.find((g) => g.key === 'points-exchange').fields[1];
assert.strictEqual(points.type, 'group'); assert.strictEqual(points.type, 'group');
assert.deepStrictEqual(points.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']); assert.deepStrictEqual(points.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
// 手风琴配置与 PHP 侧一致。 // 标题行摘要配置与 PHP 侧一致(折叠为渲染器统一行为,不再由 schema 开关)。
for (const key of ['vip_1_product', 'vip_2_product', 'vip_1_renew_product', 'vip_2_renew_product']) { 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(findGroup(schema, key).listPreview.prefix, '价格:', `${key} 摘要前缀`);
assert.strictEqual(group.collapsible, true, `${key} 应开启手风琴`);
assert.strictEqual(group.defaultCollapsed, true, `${key} 应默认收起`);
} }
assert.strictEqual(findGroup(schema, 'pay_vip_points_exchange_product').collapsible, true); assert.strictEqual(findGroup(schema, 'pay_vip_points_exchange_product').listPreview.prefix, '积分:');
const upgrade = findGroup(schema, 'vip_upgrade_product'); const upgrade = findGroup(schema, 'vip_upgrade_product');
assert.strictEqual(upgrade.sections.length, 3); assert.strictEqual(upgrade.sections.length, 3);
// SPA 默认值与 PHP 侧一致。 // SPA 默认值与 PHP 侧一致。
@@ -267,8 +267,6 @@ namespace {
foreach ($tab['fields'] as $field) { foreach ($tab['fields'] as $field) {
if (($field['type'] ?? '') === 'group') { if (($field['type'] ?? '') === 'group') {
$out['groups'][$tab['key'] . '/' . $field['key']] = [ $out['groups'][$tab['key'] . '/' . $field['key']] = [
'collapsible' => $field['collapsible'] ?? null,
'defaultCollapsed' => $field['defaultCollapsed'] ?? null,
'listPreview' => $field['listPreview'] ?? null, 'listPreview' => $field['listPreview'] ?? null,
]; ];
} }
@@ -294,8 +292,6 @@ namespace {
continue; continue;
} }
$out['groups']['points-exchange/' . $field['key']] = [ $out['groups']['points-exchange/' . $field['key']] = [
'collapsible' => $field['collapsible'] ?? null,
'defaultCollapsed' => $field['defaultCollapsed'] ?? null,
'listPreview' => $field['listPreview'] ?? null, 'listPreview' => $field['listPreview'] ?? null,
]; ];
} }