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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Repeater (array of objects) -->
|
<!-- Repeater (array of objects) — inline expand (default) -->
|
||||||
<div v-else-if="schema.type === 'repeater'" class="aksrt-field__repeater">
|
<div v-else-if="schema.type === 'repeater' && schema.ui !== 'list-modal'" class="aksrt-field__repeater">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in (modelValue || [])"
|
v-for="(item, index) in (modelValue || [])"
|
||||||
:key="item._id || index"
|
:key="item._id || index"
|
||||||
@@ -400,6 +400,114 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</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 -->
|
<!-- Custom slot -->
|
||||||
<slot v-else-if="schema.type === 'custom'" />
|
<slot v-else-if="schema.type === 'custom'" />
|
||||||
|
|
||||||
@@ -421,7 +529,7 @@
|
|||||||
* AksField — renders a single form field based on its schema definition.
|
* AksField — renders a single form field based on its schema definition.
|
||||||
*
|
*
|
||||||
* Supported types: switch, input/text, textarea, number, select, radio,
|
* 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 { computed, ref, onMounted, watch } from 'vue'
|
||||||
import FaIcon from './FaIcon.vue'
|
import FaIcon from './FaIcon.vue'
|
||||||
@@ -586,17 +694,29 @@ function updateBackground(part, value) {
|
|||||||
|
|
||||||
// ============ Repeater ============
|
// ============ Repeater ============
|
||||||
let repeaterIdCounter = 0
|
let repeaterIdCounter = 0
|
||||||
|
const repeaterModalShow = ref(false)
|
||||||
|
const repeaterEditingIndex = ref(-1)
|
||||||
|
const repeaterDraft = ref({})
|
||||||
|
|
||||||
function addRepeaterItem() {
|
function createRepeaterItem() {
|
||||||
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
|
||||||
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
|
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
|
||||||
// Initialize with defaults from schema.fields
|
|
||||||
for (const field of (props.schema.fields || [])) {
|
for (const field of (props.schema.fields || [])) {
|
||||||
if (field.default !== undefined) {
|
if (field.default !== undefined) {
|
||||||
newItem[field.key] = field.default
|
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)
|
emit('update:modelValue', current)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +731,93 @@ function updateRepeaterItem(index, fieldKey, value) {
|
|||||||
current[index] = { ...current[index], [fieldKey]: value }
|
current[index] = { ...current[index], [fieldKey]: value }
|
||||||
emit('update:modelValue', current)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -821,4 +1028,129 @@ function updateRepeaterItem(index, fieldKey, value) {
|
|||||||
align-items: flex-start;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -712,6 +712,17 @@ export const pageDisplaySchemas = {
|
|||||||
key: 'header_components_icon_cards',
|
key: 'header_components_icon_cards',
|
||||||
label: '图标卡片',
|
label: '图标卡片',
|
||||||
type: 'repeater',
|
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',
|
sanitize_type: 'array',
|
||||||
default: [],
|
default: [],
|
||||||
dependsOn: { key: 'header_components_cards_enabled', value: true },
|
dependsOn: { key: 'header_components_cards_enabled', value: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user