186 lines
4.6 KiB
Vue
186 lines
4.6 KiB
Vue
<template>
|
|
<div class="aksrt-repeater">
|
|
<div v-if="modelValue && modelValue.length" class="aksrt-repeater__list">
|
|
<div
|
|
v-for="(item, index) in modelValue"
|
|
:key="index"
|
|
class="aksrt-repeater__item"
|
|
>
|
|
<div class="aksrt-repeater__item-header">
|
|
<span class="aksrt-repeater__item-index">#{{ index + 1 }}</span>
|
|
<div class="aksrt-repeater__item-actions">
|
|
<n-button
|
|
v-if="index > 0"
|
|
size="tiny"
|
|
quaternary
|
|
@click="moveUp(index)"
|
|
>
|
|
<FaIcon icon="fa fas fa-arrow-up" :size="12" />
|
|
</n-button>
|
|
<n-button
|
|
v-if="index < modelValue.length - 1"
|
|
size="tiny"
|
|
quaternary
|
|
@click="moveDown(index)"
|
|
>
|
|
<FaIcon icon="fa fas fa-arrow-down" :size="12" />
|
|
</n-button>
|
|
<n-button
|
|
size="tiny"
|
|
quaternary
|
|
type="error"
|
|
@click="removeItem(index)"
|
|
>
|
|
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
|
|
</n-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="aksrt-repeater__item-fields">
|
|
<AksField
|
|
v-for="field in fields"
|
|
:key="field.key"
|
|
:schema="field"
|
|
:model-value="item[field.key]"
|
|
@update:model-value="updateField(index, field.key, $event)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="aksrt-repeater__empty">
|
|
<FaIcon icon="fa fas fa-inbox" :size="20" />
|
|
<span>{{ emptyText || '暂无数据' }}</span>
|
|
</div>
|
|
|
|
<n-button
|
|
size="small"
|
|
dashed
|
|
block
|
|
class="aksrt-repeater__add"
|
|
@click="addItem"
|
|
>
|
|
<FaIcon icon="fa fas fa-plus" :size="12" />
|
|
{{ addText || '添加项目' }}
|
|
</n-button>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
/**
|
|
* AksRepeaterField — dynamic add/remove rows of structured data.
|
|
* Each row renders fields based on the provided field schemas.
|
|
*/
|
|
import AksField from './AksField.vue'
|
|
import FaIcon from './FaIcon.vue'
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Array, default: () => [] },
|
|
/** Array of field schemas for each row */
|
|
fields: { type: Array, required: true },
|
|
/** Default value for a new item */
|
|
defaults: { type: Object, default: () => ({}) },
|
|
emptyText: { type: String, default: '' },
|
|
addText: { type: String, default: '' },
|
|
maxItems: { type: Number, default: Infinity },
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
function createItem() {
|
|
const item = {}
|
|
for (const field of props.fields) {
|
|
item[field.key] = field.default ?? (field.type === 'switch' ? false : '')
|
|
}
|
|
return { ...item, ...props.defaults }
|
|
}
|
|
|
|
function addItem() {
|
|
if (props.modelValue.length >= props.maxItems) return
|
|
const list = [...props.modelValue, createItem()]
|
|
emit('update:modelValue', list)
|
|
}
|
|
|
|
function removeItem(index) {
|
|
const list = props.modelValue.filter((_, i) => i !== index)
|
|
emit('update:modelValue', list)
|
|
}
|
|
|
|
function moveUp(index) {
|
|
if (index <= 0) return
|
|
const list = [...props.modelValue]
|
|
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
|
|
emit('update:modelValue', list)
|
|
}
|
|
|
|
function moveDown(index) {
|
|
if (index >= props.modelValue.length - 1) return
|
|
const list = [...props.modelValue]
|
|
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
|
|
emit('update:modelValue', list)
|
|
}
|
|
|
|
function updateField(index, key, value) {
|
|
const list = [...props.modelValue]
|
|
list[index] = { ...list[index], [key]: value }
|
|
emit('update:modelValue', list)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.aksrt-repeater__list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.aksrt-repeater__item {
|
|
border: 1px solid var(--aksrt-color-border);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
background: var(--aksrt-color-canvas);
|
|
}
|
|
|
|
.aksrt-repeater__item-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.aksrt-repeater__item-index {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--aksrt-color-primary);
|
|
background: var(--aksrt-color-primary-soft);
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.aksrt-repeater__item-actions {
|
|
display: flex;
|
|
gap: 2px;
|
|
}
|
|
|
|
.aksrt-repeater__item-fields {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.aksrt-repeater__empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 24px;
|
|
color: var(--aksrt-color-border-strong);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.aksrt-repeater__add {
|
|
margin-top: 4px;
|
|
}
|
|
</style>
|