feat(admin): list-modal UI for header icon cards repeater

Render icon cards as a compact list with edit/delete actions and
preview on the right; field data is edited in a modal. Opt-in via
schema ui=list-modal so other repeaters stay inline.
This commit is contained in:
2026-07-15 17:27:23 +08:00
parent bfb64211ee
commit ee49d1628a
2 changed files with 350 additions and 7 deletions
+339 -7
View File
@@ -359,8 +359,8 @@
</div>
</div>
<!-- Repeater (array of objects) -->
<div v-else-if="schema.type === 'repeater'" class="aksrt-field__repeater">
<!-- Repeater (array of objects) — inline expand (default) -->
<div v-else-if="schema.type === 'repeater' && schema.ui !== 'list-modal'" class="aksrt-field__repeater">
<div
v-for="(item, index) in (modelValue || [])"
:key="item._id || index"
@@ -400,6 +400,114 @@
</n-button>
</div>
<!-- Repeater list-modal: compact list + modal editor -->
<div v-else-if="schema.type === 'repeater' && schema.ui === 'list-modal'" class="aksrt-field__repeater aksrt-field__repeater--list-modal">
<div v-if="(modelValue || []).length" class="aksrt-field__repeater-list">
<div
v-for="(item, index) in (modelValue || [])"
:key="item._id || index"
class="aksrt-field__repeater-list-item"
>
<div class="aksrt-field__repeater-list-actions">
<n-button
v-if="index > 0"
size="tiny"
quaternary
:disabled="schema.disabled"
@click="moveRepeaterItem(index, -1)"
>
<FaIcon icon="fas fa-arrow-up" :size="12" />
</n-button>
<n-button
v-if="index < (modelValue || []).length - 1"
size="tiny"
quaternary
:disabled="schema.disabled"
@click="moveRepeaterItem(index, 1)"
>
<FaIcon icon="fas fa-arrow-down" :size="12" />
</n-button>
<n-button
size="tiny"
quaternary
type="primary"
:disabled="schema.disabled"
@click="openRepeaterModal(index)"
>
<FaIcon icon="fas fa-edit" :size="12" />
编辑
</n-button>
<n-button
size="tiny"
quaternary
type="error"
:disabled="schema.disabled"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fas fa-trash" :size="12" />
删除
</n-button>
</div>
<div class="aksrt-field__repeater-list-preview">
<div class="aksrt-field__repeater-list-icon" :class="getRepeaterPreviewIconClass(item)">
<span
v-if="getRepeaterPreviewCustomIcon(item)"
class="aksrt-field__repeater-list-icon-html"
v-html="getRepeaterPreviewCustomIcon(item)"
/>
<i v-else-if="getRepeaterPreviewIcon(item)" :class="getRepeaterPreviewIcon(item)" aria-hidden="true" />
<FaIcon v-else icon="fas fa-star" :size="16" />
</div>
<div class="aksrt-field__repeater-list-meta">
<div class="aksrt-field__repeater-list-title">
{{ getRepeaterPreviewTitle(item) }}
</div>
<div class="aksrt-field__repeater-list-desc">
{{ getRepeaterPreviewDesc(item) }}
</div>
</div>
</div>
</div>
</div>
<div v-else class="aksrt-field__repeater-empty">
<FaIcon icon="fas fa-inbox" :size="18" />
<span>{{ schema.emptyText || '暂无数据' }}</span>
</div>
<n-button
size="small"
dashed
block
:disabled="schema.disabled"
@click="openRepeaterModal(-1)"
>
<FaIcon icon="fas fa-plus" :size="12" style="margin-right: 6px" />
{{ schema.addText || '添加一项' }}
</n-button>
<n-modal
v-model:show="repeaterModalShow"
:mask-closable="false"
preset="card"
:title="repeaterEditingIndex >= 0 ? (schema.editText || '编辑项目') : (schema.addText || '添加项目')"
class="aksrt-modal-card"
style="max-width: 640px; width: 92vw;"
>
<div class="aksrt-field__repeater-modal-fields">
<AksField
v-for="field in (schema.fields || [])"
:key="field.key"
:schema="field"
:model-value="repeaterDraft[field.key]"
@update:model-value="updateRepeaterDraft(field.key, $event)"
/>
</div>
<div class="aksrt-modal-footer">
<n-button @click="closeRepeaterModal">取消</n-button>
<n-button type="primary" @click="confirmRepeaterModal">确定</n-button>
</div>
</n-modal>
</div>
<!-- Custom slot -->
<slot v-else-if="schema.type === 'custom'" />
@@ -421,7 +529,7 @@
* AksField — renders a single form field based on its schema definition.
*
* Supported types: switch, input/text, textarea, number, select, radio,
* checkbox, color, icon, media, slider, code, alert, custom (slot).
* checkbox, color, icon, media, slider, code, alert, repeater (inline|list-modal), custom (slot).
*/
import { computed, ref, onMounted, watch } from 'vue'
import FaIcon from './FaIcon.vue'
@@ -586,17 +694,29 @@ function updateBackground(part, value) {
// ============ Repeater ============
let repeaterIdCounter = 0
const repeaterModalShow = ref(false)
const repeaterEditingIndex = ref(-1)
const repeaterDraft = ref({})
function addRepeaterItem() {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
function createRepeaterItem() {
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
// Initialize with defaults from schema.fields
for (const field of (props.schema.fields || [])) {
if (field.default !== undefined) {
newItem[field.key] = field.default
} else if (field.type === 'switch') {
newItem[field.key] = false
} else if (field.type === 'checkbox' || field.type === 'repeater') {
newItem[field.key] = []
} else {
newItem[field.key] = ''
}
}
current.push(newItem)
return newItem
}
function addRepeaterItem() {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.push(createRepeaterItem())
emit('update:modelValue', current)
}
@@ -611,6 +731,93 @@ function updateRepeaterItem(index, fieldKey, value) {
current[index] = { ...current[index], [fieldKey]: value }
emit('update:modelValue', current)
}
function moveRepeaterItem(index, delta) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const target = index + delta
if (target < 0 || target >= current.length) return
const tmp = current[index]
current[index] = current[target]
current[target] = tmp
emit('update:modelValue', current)
}
function getRepeaterPreviewConfig() {
return props.schema.listPreview || {}
}
function getRepeaterPreviewIcon(item) {
const cfg = getRepeaterPreviewConfig()
const key = cfg.iconKey || 'icon'
return String(item?.[key] || '').trim()
}
function getRepeaterPreviewCustomIcon(item) {
const cfg = getRepeaterPreviewConfig()
const key = cfg.customIconKey || 'custom_icon'
return String(item?.[key] || '').trim()
}
function getRepeaterPreviewIconClass(item) {
const cfg = getRepeaterPreviewConfig()
const colorKey = cfg.iconClassKey || 'icon_class'
return String(item?.[colorKey] || '').trim()
}
function getRepeaterPreviewTitle(item) {
const cfg = getRepeaterPreviewConfig()
const key = cfg.titleKey || 'title'
const title = String(item?.[key] || '').trim()
return title || '未命名卡片'
}
function getRepeaterPreviewDesc(item) {
const cfg = getRepeaterPreviewConfig()
const key = cfg.descKey || 'desc'
const desc = String(item?.[key] || '').trim()
if (desc) return desc
const urlKey = cfg.urlKey || 'url'
const url = String(item?.[urlKey] || '').trim()
return url || '暂无描述'
}
function openRepeaterModal(index) {
if (props.schema.disabled) return
if (index >= 0) {
const item = (props.modelValue || [])[index] || {}
repeaterDraft.value = { ...createRepeaterItem(), ...item }
repeaterEditingIndex.value = index
} else {
repeaterDraft.value = createRepeaterItem()
repeaterEditingIndex.value = -1
}
repeaterModalShow.value = true
}
function closeRepeaterModal() {
repeaterModalShow.value = false
repeaterEditingIndex.value = -1
repeaterDraft.value = {}
}
function updateRepeaterDraft(fieldKey, value) {
repeaterDraft.value = { ...repeaterDraft.value, [fieldKey]: value }
}
function confirmRepeaterModal() {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const item = { ...repeaterDraft.value }
if (!item._id) {
item._id = `rpt_${Date.now()}_${++repeaterIdCounter}`
}
if (repeaterEditingIndex.value >= 0 && repeaterEditingIndex.value < current.length) {
current[repeaterEditingIndex.value] = item
} else {
current.push(item)
}
emit('update:modelValue', current)
closeRepeaterModal()
}
</script>
<style scoped>
@@ -821,4 +1028,129 @@ function updateRepeaterItem(index, fieldKey, value) {
align-items: flex-start;
}
}
/* Repeater list-modal */
.aksrt-field__repeater--list-modal {
display: flex;
flex-direction: column;
gap: 10px;
}
.aksrt-field__repeater-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.aksrt-field__repeater-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid #eef1f6;
border-radius: 8px;
background: #fafbfc;
}
.aksrt-field__repeater-list-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
flex-shrink: 0;
}
.aksrt-field__repeater-list-preview {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.aksrt-field__repeater-list-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #eef3ff;
color: #3b82f6;
flex-shrink: 0;
overflow: hidden;
font-size: 16px;
}
.aksrt-field__repeater-list-icon-html {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.aksrt-field__repeater-list-icon-html :deep(svg),
.aksrt-field__repeater-list-icon-html :deep(img) {
width: 20px;
height: 20px;
object-fit: contain;
}
.aksrt-field__repeater-list-meta {
min-width: 0;
flex: 1;
}
.aksrt-field__repeater-list-title {
font-size: 13px;
font-weight: 600;
color: #1f2329;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aksrt-field__repeater-list-desc {
margin-top: 2px;
font-size: 12px;
color: #8a909c;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aksrt-field__repeater-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 22px 12px;
color: #c0c4cc;
font-size: 13px;
border: 1px dashed #e5e7eb;
border-radius: 8px;
background: #fcfcfd;
}
.aksrt-field__repeater-modal-fields {
display: flex;
flex-direction: column;
gap: 4px;
max-height: min(70vh, 560px);
overflow: auto;
padding-right: 2px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #eef1f6;
}
</style>
@@ -712,6 +712,17 @@ export const pageDisplaySchemas = {
key: 'header_components_icon_cards',
label: '图标卡片',
type: 'repeater',
ui: 'list-modal',
listPreview: {
iconKey: 'icon',
customIconKey: 'custom_icon',
iconClassKey: 'icon_class',
titleKey: 'title',
descKey: 'desc',
urlKey: 'url',
},
editText: '编辑卡片',
emptyText: '暂无图标卡片',
sanitize_type: 'array',
default: [],
dependsOn: { key: 'header_components_cards_enabled', value: true },