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:
1 parent
8843ae4261
commit
116b6b2223
9 files changed
+114
-76
No files matched your search
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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 />
|
||||
<div class="aksrt-sortable__main">
|
||||
<div class="aksrt-sortable__title-row">
|
||||
@@ -67,6 +67,7 @@ import { computed, ref } from 'vue'
|
||||
import FaIconPicker from '@/components/form/FaIconPicker.vue'
|
||||
import AksMediaField from '@/components/form/AksMediaField.vue'
|
||||
import AksSortHandle from '@/components/form/AksSortHandle.vue'
|
||||
import { collapseHeadAttrs } from '@/components/form/useCollapsibleItems.js'
|
||||
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
|
||||
import FloatMoreButtonsEditor from './FloatMoreButtonsEditor.vue'
|
||||
import { FLOAT_BTN_META } from './floating-defaults.js'
|
||||
@@ -77,6 +78,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
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 addIcon = computed({
|
||||
|
||||
@@ -396,12 +396,23 @@
|
||||
>
|
||||
<template #item="{ element: item, index }">
|
||||
<div class="aksrt-sortable__item">
|
||||
<div class="aksrt-sortable__head">
|
||||
<AksSortHandle />
|
||||
<div
|
||||
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__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 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)" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,16 +431,12 @@
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="aksrt-sortable__item">
|
||||
<div
|
||||
class="aksrt-sortable__head"
|
||||
:class="{ 'is-clickable': repeaterCollapsible }"
|
||||
@click="repeaterCollapsible ? toggleRepeaterItem(index) : undefined"
|
||||
>
|
||||
<div class="aksrt-sortable__head" v-bind="repeaterHeadAttrs(repeaterDragKey(element))">
|
||||
<AksSortHandle @click.stop />
|
||||
<span class="aksrt-sortable__index">{{ index + 1 }}</span>
|
||||
<span class="aksrt-sortable__main">
|
||||
<span v-if="repeaterCollapsible && repeaterItemSummary(element)" class="aksrt-sortable__title aksrt-field__repeater-summary">
|
||||
{{ repeaterItemSummary(element) }}
|
||||
<span class="aksrt-sortable__title aksrt-field__repeater-summary">
|
||||
{{ repeaterItemSummary(element) || '未填写' }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="aksrt-sortable__actions" @click.stop>
|
||||
@@ -443,16 +450,14 @@
|
||||
>
|
||||
<template #icon><FaIcon icon="fa fas fa-trash-alt" :size="12" /></template>
|
||||
</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>
|
||||
<FaIcon
|
||||
:icon="isRepeaterItemOpen(repeaterDragKey(element)) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
|
||||
:size="12"
|
||||
class="aksrt-sortable__toggle"
|
||||
/>
|
||||
</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">
|
||||
<AksField
|
||||
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 { createRepeaterRow, repeaterRowSummary } from './repeater-rows.js'
|
||||
import { useFieldRemoteOptions } from './useFieldRemoteOptions.js'
|
||||
import { useCollapsibleItems } from './useCollapsibleItems.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** Field schema definition: { key, label, type, default, options?, help?, ... } */
|
||||
@@ -655,6 +661,9 @@ function toggleFieldsetSection(index) {
|
||||
}
|
||||
|
||||
// ============ Sortable(对象键顺序即排序结果) ============
|
||||
// 带子字段的项默认收起,点击标题行展开。
|
||||
const { isOpen: isListItemOpen, headAttrs: listItemHeadAttrs } = useCollapsibleItems()
|
||||
|
||||
const sortableList = computed({
|
||||
get: () => {
|
||||
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 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())
|
||||
// 每行默认收起,仅显示标题行;按行 key 记录开合,拖拽换位后状态跟随行。
|
||||
const {
|
||||
isOpen: isRepeaterItemOpen,
|
||||
headAttrs: repeaterHeadAttrs,
|
||||
} = useCollapsibleItems()
|
||||
|
||||
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 配置。 */
|
||||
/** 标题行摘要:取自 listPreview;未配置时取首个有值的字段。 */
|
||||
function repeaterItemSummary(item) {
|
||||
if (!repeaterCollapsible.value) return ''
|
||||
return repeaterRowSummary(item, props.schema.listPreview || {})
|
||||
return repeaterRowSummary(item, props.schema.listPreview || {}, props.schema.fields)
|
||||
}
|
||||
|
||||
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
|
||||
|
||||
@@ -41,10 +41,21 @@ function rowDuration(row, cfg) {
|
||||
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 配置。 */
|
||||
export function repeaterRowSummary(row, cfg = {}) {
|
||||
export function repeaterRowSummary(row, cfg = {}, fields = []) {
|
||||
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 || '')
|
||||
if (title === '') return prefix
|
||||
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 }
|
||||
}
|
||||
Reference in new issue
Block a user