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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user