Files
wordpress-theme-aksrt/assets/admin/src/components/form/AksField.vue
T

1465 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="aksrt-field" :class="fieldClasses">
<div v-if="schema.label" class="aksrt-field__label-row">
<label class="aksrt-field__label">
{{ schema.label }}
<span v-if="schema.required" class="aksrt-field__required">*</span>
</label>
<n-tooltip v-if="schema.help" trigger="hover">
<template #trigger>
<FaIcon icon="fa fas fa-question-circle" :size="13" class="aksrt-field__help-icon" />
</template>
{{ schema.help }}
</n-tooltip>
</div>
<p v-if="schema.description && !schema.help" class="aksrt-field__desc">
{{ schema.description }}
</p>
<!-- Switch -->
<n-switch
v-if="schema.type === 'switch' || schema.type === 'switcher'"
:value="modelValue"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Input (text, email, url, password) -->
<div v-else-if="schema.type === 'input' || schema.type === 'text' || schema.type === 'url'" class="aksrt-field__input-wrap">
<n-input
:value="secretDisplayValue"
:placeholder="schema.placeholder || ''"
:type="schema.inputType || 'text'"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
>
<template v-if="schema.prefix" #prefix>{{ schema.prefix }}</template>
<template v-if="schema.suffix" #suffix>{{ schema.suffix }}</template>
</n-input>
<n-button
v-if="schema.secret && (modelValue === '***' || secretMarkedForClear)"
size="small"
secondary
:type="secretMarkedForClear ? 'warning' : 'error'"
@click="toggleSecretClear"
>
{{ secretMarkedForClear ? '取消清除' : '清除已配置密钥' }}
</n-button>
<span v-if="secretMarkedForClear" class="aksrt-field__secret-clear-hint">保存后将删除此密钥</span>
</div>
<!-- Textarea -->
<n-input
v-else-if="schema.type === 'textarea'"
:value="modelValue"
type="textarea"
:placeholder="schema.placeholder || ''"
:rows="schema.rows || 4"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Number -->
<n-input-number
v-else-if="schema.type === 'number' || schema.type === 'spinner'"
:value="modelValue"
:min="schema.min"
:max="schema.max"
:step="schema.step || 1"
:placeholder="schema.placeholder || ''"
:disabled="schema.disabled"
style="width: 100%"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Select -->
<div v-else-if="schema.type === 'select'" class="aksrt-field__ordered-select">
<n-select
:value="modelValue"
:options="normalizedOptions"
:placeholder="schema.placeholder || '请选择'"
:multiple="schema.multiple"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<div v-if="schema.multiple && schema.sortable && Array.isArray(modelValue)" class="aksrt-field__selected-order">
<div v-for="(value, index) in modelValue" :key="value" class="aksrt-field__selected-order-item">
<span>{{ selectedOptionLabel(normalizedOptions, value) }}</span>
<span><n-button text size="tiny" :disabled="index === 0" @click="moveSelectedValue(index, -1)">↑</n-button><n-button text size="tiny" :disabled="index === modelValue.length - 1" @click="moveSelectedValue(index, 1)">↓</n-button></span>
</div>
</div>
</div>
<div v-else-if="schema.type === 'async_select'" class="aksrt-field__ordered-select">
<n-select
:value="schema.multiple === true && !Array.isArray(modelValue) ? [] : modelValue"
:options="asyncOptions"
:loading="asyncLoading"
:placeholder="schema.placeholder || '输入关键词搜索内容'"
:multiple="schema.multiple === true"
filterable
remote
clearable
@search="handleAsyncSearch"
@update:value="emit('update:modelValue', $event)"
/>
<div v-if="schema.multiple && schema.sortable && Array.isArray(modelValue)" class="aksrt-field__selected-order">
<div v-for="(value, index) in modelValue" :key="value" class="aksrt-field__selected-order-item">
<span>{{ selectedOptionLabel(asyncOptions, value) }}</span>
<span><n-button text size="tiny" :disabled="index === 0" @click="moveSelectedValue(index, -1)">↑</n-button><n-button text size="tiny" :disabled="index === modelValue.length - 1" @click="moveSelectedValue(index, 1)">↓</n-button></span>
</div>
</div>
</div>
<!-- Taxonomy Select (async-loaded category/tag terms) -->
<n-select
v-else-if="schema.type === 'taxonomy_select'"
:value="modelValue"
:options="taxonomyOptions"
:loading="taxonomyLoading"
:placeholder="schema.placeholder || '请选择分类'"
:multiple="schema.multiple !== false"
:filterable="true"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<!-- User Select (async search) -->
<n-select
v-else-if="schema.type === 'user_select'"
:value="modelValue"
:options="userOptions"
:loading="userLoading"
:placeholder="schema.placeholder || '搜索用户'"
:multiple="schema.multiple === true"
:filterable="true"
:remote="true"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@search="handleUserSearch"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Radio group -->
<n-radio-group
v-else-if="schema.type === 'radio'"
:value="modelValue"
@update:value="emit('update:modelValue', $event)"
>
<n-space v-if="schema.buttonStyle === false">
<n-radio
v-for="opt in normalizedOptions"
:key="opt.value"
:value="opt.value"
:disabled="schema.disabled"
>
{{ opt.label }}
</n-radio>
</n-space>
<n-radio-button
v-else
v-for="opt in normalizedOptions"
:key="opt.value"
:value="opt.value"
:disabled="schema.disabled"
>
{{ opt.label }}
</n-radio-button>
</n-radio-group>
<!-- Checkbox group -->
<n-checkbox-group
v-else-if="schema.type === 'checkbox'"
:value="modelValue || []"
@update:value="emit('update:modelValue', $event)"
>
<n-space>
<n-checkbox
v-for="opt in normalizedOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:disabled="schema.disabled"
/>
</n-space>
</n-checkbox-group>
<!-- Color picker -->
<n-color-picker
v-else-if="schema.type === 'color'"
:value="modelValue"
:modes="['hex']"
:show-alpha="schema.showAlpha || false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<div v-else-if="schema.type === 'palette'" class="aksrt-field__palette">
<button
v-for="opt in normalizedOptions"
:key="opt.value"
type="button"
class="aksrt-field__palette-item"
:class="{ 'is-active': modelValue === opt.value }"
:style="{ background: opt.label }"
:title="opt.value"
@click="emit('update:modelValue', opt.value)"
/>
</div>
<!-- Icon picker -->
<FaIconPicker
v-else-if="schema.type === 'icon'"
:model-value="modelValue"
:placeholder="schema.placeholder || '选择图标'"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- Media upload -->
<AksMediaField
v-else-if="schema.type === 'media' || schema.type === 'upload'"
:model-value="modelValue"
:accept="schema.accept"
:placeholder="schema.placeholder || '选择图片'"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- WordPress image gallery (attachment ID array) -->
<AksGalleryField
v-else-if="schema.type === 'gallery'"
:model-value="Array.isArray(modelValue) ? modelValue : []"
:title="schema.label || '选择图片'"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- Slider -->
<n-slider
v-else-if="schema.type === 'slider'"
:value="modelValue"
:min="schema.min || 0"
:max="schema.max || 100"
:step="schema.step || 1"
:disabled="schema.disabled"
:marks="schema.marks"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Code editor with syntax highlighting (CodeMirror 6) -->
<AksCodeEditor
v-else-if="schema.type === 'code'"
:model-value="modelValue"
:language="schema.language || 'html'"
:disabled="schema.disabled"
:rows="schema.rows || 12"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- Alert/info content -->
<n-alert
v-else-if="schema.type === 'alert' || schema.type === 'notice'"
:type="schema.alertType || 'info'"
:title="schema.alertTitle"
:closable="false"
>
{{ schema.content || schema.alertContent || modelValue }}
</n-alert>
<!-- Pure HTML content (no form field, just display) -->
<div
v-else-if="schema.type === 'content'"
class="aksrt-field__content"
v-html="schema.content || modelValue || ''"
/>
<!-- Action button -->
<n-button
v-else-if="schema.type === 'button'"
:type="schema.buttonType || 'default'"
:loading="schema.loading"
:disabled="schema.disabled"
@click="emit('action', schema.key)"
>
{{ schema.buttonText || schema.label || '执行' }}
</n-button>
<!-- Date picker -->
<n-date-picker
v-else-if="schema.type === 'date'"
:value="modelValue"
:type="schema.dateType || 'date'"
:disabled="schema.disabled"
style="width: 100%"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Time picker -->
<n-time-picker
v-else-if="schema.type === 'time'"
:value="modelValue"
:disabled="schema.disabled"
style="width: 100%"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Dimensions (width x height) -->
<div v-else-if="schema.type === 'dimensions'" class="aksrt-field__dimensions">
<n-input-number
:value="modelValue?.width"
:min="0"
:placeholder="schema.widthPlaceholder || '宽度'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="updateDimension('width', $event)"
/>
<span class="aksrt-field__dimensions-sep">×</span>
<n-input-number
:value="modelValue?.height"
:min="0"
:placeholder="schema.heightPlaceholder || '高度'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="updateDimension('height', $event)"
/>
<n-select
v-if="schema.unit"
:value="modelValue?.unit"
:options="dimensionUnitOptions"
:disabled="schema.disabled"
style="width: 80px"
@update:value="updateDimension('unit', $event)"
/>
</div>
<!-- Spacing (top right bottom left) -->
<div v-else-if="schema.type === 'spacing'" class="aksrt-field__spacing">
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[0] || '上' }}</div>
<n-input-number
:value="modelValue?.top"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('top', $event)"
/>
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[1] || '右' }}</div>
<n-input-number
:value="modelValue?.right"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('right', $event)"
/>
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[2] || '下' }}</div>
<n-input-number
:value="modelValue?.bottom"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('bottom', $event)"
/>
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[3] || '左' }}</div>
<n-input-number
:value="modelValue?.left"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('left', $event)"
/>
</div>
<!-- Background (color + image + position + repeat) -->
<div v-else-if="schema.type === 'background'" class="aksrt-field__background">
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">背景颜色</label>
<n-color-picker
:value="modelValue?.color"
:show-alpha="true"
:disabled="schema.disabled"
@update:value="updateBackground('color', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">背景图片</label>
<AksMediaField
:model-value="modelValue?.image"
:accept="'image/*'"
:disabled="schema.disabled"
@update:model-value="updateBackground('image', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">图片尺寸</label>
<n-select
:value="modelValue?.size"
:options="bgSizeOptions"
:disabled="schema.disabled"
@update:value="updateBackground('size', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">图片位置</label>
<n-select
:value="modelValue?.position"
:options="bgPositionOptions"
:disabled="schema.disabled"
@update:value="updateBackground('position', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">图片重复</label>
<n-select
:value="modelValue?.repeat"
:options="bgRepeatOptions"
:disabled="schema.disabled"
@update:value="updateBackground('repeat', $event)"
/>
</div>
</div>
<!-- Image select (radio with image preview) -->
<div v-else-if="schema.type === 'image_select'" class="aksrt-field__image-select">
<div
v-for="opt in normalizedOptions"
:key="opt.value"
class="aksrt-field__image-select-item"
:class="{ 'aksrt-field__image-select-item--active': modelValue === opt.value }"
@click="!schema.disabled && emit('update:modelValue', opt.value)"
>
<img v-if="opt.image" :src="opt.image" :alt="opt.label" class="aksrt-field__image-select-img" />
<div v-else class="aksrt-field__image-select-placeholder">
<FaIcon :icon="opt.icon || 'fa fas fa-image'" :size="20" />
</div>
<span class="aksrt-field__image-select-label">{{ opt.label }}</span>
</div>
</div>
<!-- Fieldset (single nested object) -->
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
<AksField
v-for="field in visibleNestedFields"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
</div>
<div v-else-if="schema.type === 'link'" class="aksrt-field__link">
<n-input :value="modelValue?.url || ''" placeholder="https://" @update:value="updateLink('url', $event)" />
<n-select :value="modelValue?.target || ''" :options="linkTargetOptions" @update:value="updateLink('target', $event)" />
<n-input :value="modelValue?.text || ''" placeholder="链接文字(可选)" @update:value="updateLink('text', $event)" />
</div>
<div v-else-if="schema.type === 'sortable'" class="aksrt-field__sortable">
<div v-for="(item, index) in sortableItems" :key="item.key" class="aksrt-field__sortable-item">
<div class="aksrt-field__sortable-head">
<strong>{{ item.label }}</strong>
<span>
<n-button text size="tiny" :disabled="index === 0" @click="moveSortable(index, -1)">↑</n-button>
<n-button text size="tiny" :disabled="index === sortableItems.length - 1" @click="moveSortable(index, 1)">↓</n-button>
</span>
</div>
<div class="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" @update:model-value="updateSortable(item.key, field.key, $event)" />
</div>
</div>
</div>
<!-- Repeater (array of objects) — inline expand (default) -->
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui !== 'list-modal'" class="aksrt-field__repeater">
<div
v-for="(item, index) in (modelValue || [])"
:key="item._id || index"
class="aksrt-field__repeater-item"
>
<div class="aksrt-field__repeater-header">
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
<n-button
size="tiny"
type="error"
text
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
<div class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key">
<AksField
v-if="matchDependency(field.dependsOn, item)"
:schema="field"
:model-value="item[field.key]"
:disabled="schema.disabled"
@update:model-value="updateRepeaterItem(index, field.key, $event)"
/>
</template>
</div>
</div>
<n-button
size="small"
dashed
:disabled="schema.disabled || reachedRepeaterMaximum"
@click="addRepeaterItem"
>
<FaIcon icon="fa fas fa-plus" :size="12" style="margin-right: 6px" />
{{ schema.button_title || schema.addText || '添加一项' }}
</n-button>
</div>
<!-- Repeater list-modal: compact list + modal editor -->
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && 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="fa 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="fa fas fa-arrow-down" :size="12" />
</n-button>
<n-button
size="tiny"
quaternary
type="primary"
:disabled="schema.disabled"
@click="openRepeaterModal(index)"
>
<FaIcon icon="fa fas fa-edit" :size="12" />
编辑
</n-button>
<n-button
size="tiny"
quaternary
type="error"
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :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="fa 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="fa fas fa-inbox" :size="18" />
<span>{{ schema.emptyText || '暂无数据' }}</span>
</div>
<n-button
size="small"
dashed
block
:disabled="schema.disabled || reachedRepeaterMaximum"
@click="openRepeaterModal(-1)"
>
<FaIcon icon="fa fas fa-plus" :size="12" style="margin-right: 6px" />
{{ schema.button_title || 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 || []).filter(field => matchDependency(field.dependsOn, repeaterDraft))"
: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'" />
<!-- Fallback: text input -->
<n-input
v-else
:value="modelValue"
:placeholder="schema.placeholder || ''"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Validation message -->
<p v-if="errorMessage" class="aksrt-field__error">{{ errorMessage }}</p>
</div>
</template>
<script setup>
/**
* AksField — renders a single form field based on its schema definition.
*
* Supported types: switch, input/text, textarea, number, select, radio,
* checkbox, color, icon, media, gallery, slider, code, alert, fieldset, repeater (inline|list-modal), custom (slot).
*/
import { computed, ref, onMounted, watch } from 'vue'
import FaIcon from './FaIcon.vue'
import FaIconPicker from './FaIconPicker.vue'
import AksMediaField from './AksMediaField.vue'
import AksGalleryField from './AksGalleryField.vue'
import AksCodeEditor from './AksCodeEditor.vue'
import api from '@/api'
const props = defineProps({
/** Field schema definition */
schema: {
type: Object,
required: true,
// Expected shape: { key, label, type, default, options?, help?, ... }
},
/** Current field value */
modelValue: { default: null },
/** Error message to display */
errorMessage: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'action'])
const secretMarkedForClear = computed(() => props.schema.secret && props.modelValue === '__AKSRT_CLEAR_SECRET__')
const secretDisplayValue = computed(() => secretMarkedForClear.value ? '' : props.modelValue)
function toggleSecretClear() {
emit('update:modelValue', secretMarkedForClear.value ? '***' : '__AKSRT_CLEAR_SECRET__')
}
const repeaterLength = computed(() => Array.isArray(props.modelValue) ? props.modelValue.length : 0)
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))
function fieldsetValue(field) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
: {}
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
}
function updateFieldsetValue(key, value) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
: {}
emit('update:modelValue', { ...current, [key]: value })
}
/** Normalize options: support both string[] and {label, value}[] */
const normalizedOptions = computed(() => {
const opts = props.schema.options || []
return opts.map(opt => {
if (typeof opt === 'string') return { label: opt, value: opt }
if (typeof opt === 'object' && opt !== null) {
return { ...opt, label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name }
}
return { label: String(opt), value: opt }
})
})
const visibleNestedFields = computed(() => (props.schema.fields || []).filter(field => matchDependency(field.dependsOn, props.modelValue)))
const linkTargetOptions = [{ label: '当前窗口', value: '' }, { label: '当前窗口', value: '_self' }, { label: '新窗口', value: '_blank' }]
function matchDependency(rule, sourceValue) {
if (!rule) return true
const source = sourceValue && typeof sourceValue === 'object' ? sourceValue : {}
if (rule.all) return rule.all.every(item => matchDependency(item, source))
if (rule.any) return rule.any.some(item => matchDependency(item, source))
const value = source[rule.key]
if (rule.operator === 'truthy') return !!value
if (rule.operator === 'falsy') return !value
if (rule.operator === 'not_empty') return Array.isArray(value) ? value.length > 0 : value !== '' && value != null
if (rule.operator === 'empty') return Array.isArray(value) ? value.length === 0 : value === '' || value == null
if (rule.operator === 'eq') return value === rule.value
if (rule.operator === 'neq') return value !== rule.value
return rule.value === undefined || value === rule.value
}
function updateLink(key, value) {
emit('update:modelValue', { ...(props.modelValue || {}), [key]: value })
}
function selectedOptionLabel(options, value) {
return options.find(option => option.value === value)?.label || String(value)
}
function moveSelectedValue(index, delta) {
if (!Array.isArray(props.modelValue)) return
const target = index + delta
if (target < 0 || target >= props.modelValue.length) return
const values = [...props.modelValue]
;[values[index], values[target]] = [values[target], values[index]]
emit('update:modelValue', values)
}
const sortableItems = computed(() => {
const definitions = new Map((props.schema.items || []).map(item => [item.key, item]))
const order = Object.keys(props.modelValue || {}).filter(key => definitions.has(key))
for (const key of definitions.keys()) if (!order.includes(key)) order.push(key)
return order.map(key => definitions.get(key))
})
function updateSortable(itemKey, fieldKey, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [itemKey]: { ...(current[itemKey] || {}), [fieldKey]: value } })
}
function moveSortable(index, delta) {
const items = [...sortableItems.value]
const target = index + delta
if (target < 0 || target >= items.length) return
;[items[index], items[target]] = [items[target], items[index]]
const current = props.modelValue || {}
emit('update:modelValue', Object.fromEntries(items.map(item => [item.key, current[item.key] || {}])))
}
const asyncOptions = ref([])
const asyncLoading = ref(false)
let asyncTimer = null
async function loadAsyncOptions(query = '') {
asyncLoading.value = true
try {
const selected = Array.isArray(props.modelValue) ? props.modelValue : (props.modelValue ? [props.modelValue] : [])
const response = await api.searchPageDisplayOptions(props.schema.source, query, query ? [] : selected)
asyncOptions.value = response?.data || []
} catch (error) {
asyncOptions.value = []
} finally {
asyncLoading.value = false
}
}
function handleAsyncSearch(query) {
clearTimeout(asyncTimer)
asyncTimer = setTimeout(() => loadAsyncOptions(query), 250)
}
// ============ Taxonomy Select (async) ============
const taxonomyOptions = ref([])
const taxonomyLoading = ref(false)
async function loadTaxonomyTerms() {
if (!props.schema.taxonomy) return
taxonomyLoading.value = true
try {
const res = await api.getCategories(props.schema.taxonomy || 'category')
const items = res?.data || res || []
taxonomyOptions.value = (Array.isArray(items) ? items : []).map(t => ({
label: t.name + (t.count ? ' (' + t.count + ')' : ''),
value: t.term_id,
}))
} catch (e) {
console.warn('[AksField] taxonomy load failed:', e)
taxonomyOptions.value = []
} finally {
taxonomyLoading.value = false
}
}
onMounted(() => {
if (props.schema.type === 'taxonomy_select') {
loadTaxonomyTerms()
}
if (props.schema.type === 'user_select') {
loadInitialUsers()
}
if (props.schema.type === 'async_select') {
loadAsyncOptions()
}
})
// ============ User Select (async search) ============
const userOptions = ref([])
const userLoading = ref(false)
let userSearchTimer = null
async function loadInitialUsers() {
userLoading.value = true
try {
const res = await api.searchUsers('', 20)
const items = res?.data || res || []
userOptions.value = (Array.isArray(items) ? items : []).map(u => ({
label: u.display_name + ' (' + u.user_login + ')',
value: u.id,
}))
} catch (e) {
console.warn('[AksField] user load failed:', e)
userOptions.value = []
} finally {
userLoading.value = false
}
}
function handleUserSearch(query) {
clearTimeout(userSearchTimer)
userSearchTimer = setTimeout(async () => {
userLoading.value = true
try {
const res = await api.searchUsers(query || '', 20)
const items = res?.data || res || []
userOptions.value = (Array.isArray(items) ? items : []).map(u => ({
label: u.display_name + ' (' + u.user_login + ')',
value: u.id,
}))
} catch (e) {
userOptions.value = []
} finally {
userLoading.value = false
}
}, 300)
}
const fieldClasses = computed(() => ({
'aksrt-field--disabled': props.schema.disabled,
'aksrt-field--inline': props.schema.inline,
[`aksrt-field--${props.schema.type}`]: !!props.schema.type,
}))
// ============ Dimensions ============
const dimensionUnitOptions = [
{ label: 'px', value: 'px' },
{ label: '%', value: '%' },
{ label: 'em', value: 'em' },
{ label: 'rem', value: 'rem' },
{ label: 'vw', value: 'vw' },
]
function updateDimension(part, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Spacing ============
function updateSpacing(part, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Background ============
const bgSizeOptions = [
{ label: '覆盖', value: 'cover' },
{ label: '包含', value: 'contain' },
{ label: '原始大小', value: 'auto' },
]
const bgPositionOptions = [
{ label: '居中', value: 'center center' },
{ label: '顶部居中', value: 'top center' },
{ label: '底部居中', value: 'bottom center' },
{ label: '左侧居中', value: 'center left' },
{ label: '右侧居中', value: 'center right' },
]
const bgRepeatOptions = [
{ label: '不重复', value: 'no-repeat' },
{ label: '平铺', value: 'repeat' },
{ label: '水平平铺', value: 'repeat-x' },
{ label: '垂直平铺', value: 'repeat-y' },
]
function updateBackground(part, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Repeater ============
let repeaterIdCounter = 0
const repeaterModalShow = ref(false)
const repeaterEditingIndex = ref(-1)
const repeaterDraft = ref({})
function createRepeaterItem() {
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
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] = ''
}
}
return newItem
}
function addRepeaterItem() {
if (reachedRepeaterMaximum.value) return
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.push(createRepeaterItem())
emit('update:modelValue', current)
}
function removeRepeaterItem(index) {
if (reachedRepeaterMinimum.value) return
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.splice(index, 1)
emit('update:modelValue', current)
}
function updateRepeaterItem(index, fieldKey, value) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
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 (index < 0 && reachedRepeaterMaximum.value) return
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>
.aksrt-field {
margin-bottom: 18px;
}
.aksrt-field:last-child {
margin-bottom: 0;
}
.aksrt-field__input-wrap {
display: grid;
gap: 8px;
}
.aksrt-field__input-wrap .n-button {
justify-self: start;
}
.aksrt-field__secret-clear-hint {
color: var(--aksrt-color-warning, #d97706);
font-size: 12px;
}
.aksrt-field__label-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.aksrt-field__label {
font-size: 13px;
font-weight: 500;
color: var(--aksrt-color-text-secondary);
}
.aksrt-field__required {
color: #d03050;
margin-left: 2px;
}
.aksrt-field__help-icon {
color: var(--aksrt-color-border-strong);
cursor: help;
transition: color 0.15s;
}
.aksrt-field__help-icon:hover {
color: var(--aksrt-color-primary);
}
.aksrt-field__desc {
margin: -2px 0 8px;
font-size: 12px;
color: var(--aksrt-color-muted);
line-height: 1.5;
}
.aksrt-field__error {
margin: 4px 0 0;
font-size: 12px;
color: #d03050;
}
.aksrt-field--disabled {
opacity: 0.6;
pointer-events: none;
}
/* Dimensions field */
.aksrt-field__dimensions {
display: flex;
align-items: center;
gap: 8px;
}
.aksrt-field__dimensions-sep {
color: var(--aksrt-color-border-strong);
font-size: 14px;
}
/* Spacing field */
.aksrt-field__spacing {
display: grid;
grid-template-columns: auto 1fr auto 1fr auto 1fr auto 1fr;
gap: 8px;
align-items: center;
}
.aksrt-field__spacing-label {
font-size: 12px;
color: var(--aksrt-color-muted);
text-align: center;
}
/* Background field */
.aksrt-field__background {
display: flex;
flex-direction: column;
gap: 12px;
}
.aksrt-field__background-row {
display: flex;
align-items: center;
gap: 12px;
}
.aksrt-field__sublabel {
font-size: 12px;
color: var(--aksrt-color-muted);
min-width: 70px;
}
/* Image select */
.aksrt-field__image-select {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.aksrt-field__image-select-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px;
border: 2px solid var(--aksrt-color-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
}
.aksrt-field__image-select-item:hover {
border-color: var(--aksrt-color-primary);
}
.aksrt-field__image-select-item--active {
border-color: var(--aksrt-color-primary);
background: var(--aksrt-color-primary-soft);
}
.aksrt-field__image-select-img {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.aksrt-field__image-select-placeholder {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: var(--aksrt-color-canvas);
border-radius: 4px;
color: var(--aksrt-color-border-strong);
}
.aksrt-field__image-select-label {
font-size: 11px;
color: var(--aksrt-color-text-secondary);
text-align: center;
}
.aksrt-field__palette {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.aksrt-field__palette-item {
width: 34px;
height: 26px;
padding: 0;
border: 3px solid #fff;
border-radius: 3px;
box-shadow: 0 0 0 1px var(--aksrt-color-border-strong);
cursor: pointer;
}
.aksrt-field__palette-item.is-active {
box-shadow: 0 0 0 2px var(--aksrt-color-primary);
}
.aksrt-field__link {
display: grid;
grid-template-columns: minmax(220px, 1fr) 120px minmax(140px, .5fr);
gap: 8px;
}
.aksrt-field__sortable {
display: grid;
gap: 8px;
}
.aksrt-field__selected-order {
display: grid;
gap: 5px;
margin-top: 8px;
}
.aksrt-field__selected-order-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 9px;
border: 1px solid var(--aksrt-color-border);
border-radius: 3px;
background: var(--aksrt-color-canvas);
}
.aksrt-field__sortable-item {
border: 1px solid var(--aksrt-color-border);
background: var(--aksrt-color-surface);
}
.aksrt-field__sortable-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--aksrt-color-canvas);
cursor: move;
}
.aksrt-field__sortable-body {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 12px;
}
/* Repeater */
.aksrt-field__fieldset {
padding: 14px;
border: 1px solid var(--aksrt-color-border);
border-radius: 8px;
background: var(--aksrt-color-canvas);
}
.aksrt-field__repeater {
display: flex;
flex-direction: column;
gap: 12px;
}
.aksrt-field__repeater-item {
border: 1px solid var(--aksrt-color-border);
border-radius: 8px;
padding: 12px;
background: var(--aksrt-color-canvas);
}
.aksrt-field__repeater-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.aksrt-field__repeater-index {
font-size: 12px;
font-weight: 600;
color: var(--aksrt-color-primary);
}
.aksrt-field__repeater-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Content field */
.aksrt-field__content {
font-size: 13px;
color: var(--aksrt-color-text-secondary);
line-height: 1.6;
}
/* Mobile */
@media (max-width: 768px) {
.aksrt-field__dimensions {
flex-wrap: wrap;
}
.aksrt-field__spacing {
grid-template-columns: auto 1fr;
}
.aksrt-field__background-row {
flex-direction: column;
align-items: flex-start;
}
.aksrt-field__link,
.aksrt-field__sortable-body {
grid-template-columns: 1fr;
}
}
/* 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 var(--aksrt-color-border);
border-radius: 8px;
background: var(--aksrt-color-canvas);
}
.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: var(--aksrt-color-primary-soft);
color: var(--aksrt-color-primary);
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: var(--aksrt-color-text);
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aksrt-field__repeater-list-desc {
margin-top: 2px;
font-size: 12px;
color: var(--aksrt-color-muted);
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: var(--aksrt-color-border-strong);
font-size: 13px;
border: 1px dashed var(--aksrt-color-border);
border-radius: 8px;
background: var(--aksrt-color-canvas);
}
.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 var(--aksrt-color-border);
}
</style>