feat(admin): rebuild page display settings

This commit is contained in:
2026-08-19 00:07:13 +08:00
parent 09b1e8fd35
commit 480e2bf07e
117 changed files with 1815 additions and 9081 deletions
+256 -28
View File
@@ -1,6 +1,6 @@
<template>
<div class="aksrt-field" :class="fieldClasses">
<div class="aksrt-field__label-row">
<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>
@@ -19,14 +19,14 @@
<!-- Switch -->
<n-switch
v-if="schema.type === 'switch'"
v-if="schema.type === 'switch' || schema.type === 'switcher'"
:value="modelValue"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Input (text, email, url, password) -->
<n-input
v-else-if="schema.type === 'input' || schema.type === 'text'"
v-else-if="schema.type === 'input' || schema.type === 'text' || schema.type === 'url'"
:value="modelValue"
:placeholder="schema.placeholder || ''"
:type="schema.inputType || 'text'"
@@ -50,7 +50,7 @@
<!-- Number -->
<n-input-number
v-else-if="schema.type === 'number'"
v-else-if="schema.type === 'number' || schema.type === 'spinner'"
:value="modelValue"
:min="schema.min"
:max="schema.max"
@@ -62,16 +62,44 @@
/>
<!-- Select -->
<n-select
v-else-if="schema.type === '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-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
@@ -157,6 +185,19 @@
@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'"
@@ -167,7 +208,7 @@
<!-- Media upload -->
<AksMediaField
v-else-if="schema.type === 'media'"
v-else-if="schema.type === 'media' || schema.type === 'upload'"
:model-value="modelValue"
:accept="schema.accept"
:placeholder="schema.placeholder || '选择图片'"
@@ -203,7 +244,7 @@
:title="schema.alertTitle"
:closable="false"
>
{{ schema.alertContent || modelValue }}
{{ schema.content || schema.alertContent || modelValue }}
</n-alert>
<!-- Pure HTML content (no form field, just display) -->
@@ -360,9 +401,9 @@
</div>
<!-- Fieldset (single nested object) -->
<div v-else-if="schema.type === 'fieldset'" class="aksrt-field__fieldset">
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
<AksField
v-for="field in (schema.fields || [])"
v-for="field in visibleNestedFields"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
@@ -370,8 +411,29 @@
/>
</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.ui !== 'list-modal'" class="aksrt-field__repeater">
<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"
@@ -383,7 +445,7 @@
size="tiny"
type="error"
text
:disabled="schema.disabled"
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
@@ -392,6 +454,7 @@
<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"
@@ -403,16 +466,16 @@
<n-button
size="small"
dashed
:disabled="schema.disabled"
:disabled="schema.disabled || reachedRepeaterMaximum"
@click="addRepeaterItem"
>
<FaIcon icon="fa fas fa-plus" :size="12" style="margin-right: 6px" />
{{ schema.addText || '添加一项' }}
{{ schema.button_title || schema.addText || '添加一项' }}
</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-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 || [])"
@@ -452,7 +515,7 @@
size="tiny"
quaternary
type="error"
:disabled="schema.disabled"
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
@@ -488,11 +551,11 @@
size="small"
dashed
block
:disabled="schema.disabled"
:disabled="schema.disabled || reachedRepeaterMaximum"
@click="openRepeaterModal(-1)"
>
<FaIcon icon="fa fas fa-plus" :size="12" style="margin-right: 6px" />
{{ schema.addText || '添加一项' }}
{{ schema.button_title || schema.addText || '添加一项' }}
</n-button>
<n-modal
@@ -505,7 +568,7 @@
>
<div class="aksrt-field__repeater-modal-fields">
<AksField
v-for="field in (schema.fields || [])"
v-for="field in (schema.fields || []).filter(field => matchDependency(field.dependsOn, repeaterDraft))"
:key="field.key"
:schema="field"
:model-value="repeaterDraft[field.key]"
@@ -564,6 +627,10 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
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
@@ -584,12 +651,94 @@ const normalizedOptions = computed(() => {
return opts.map(opt => {
if (typeof opt === 'string') return { label: opt, value: opt }
if (typeof opt === 'object' && opt !== null) {
return { label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name }
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 = '') {
if (props.schema.source === 'publish-types') {
asyncOptions.value = normalizedOptions.value
return
}
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) ============
@@ -621,6 +770,9 @@ onMounted(() => {
if (props.schema.type === 'user_select') {
loadInitialUsers()
}
if (props.schema.type === 'async_select') {
loadAsyncOptions()
}
})
// ============ User Select (async search) ============
@@ -740,12 +892,14 @@ function createRepeaterItem() {
}
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)
@@ -807,6 +961,7 @@ function getRepeaterPreviewDesc(item) {
}
function openRepeaterModal(index) {
if (index < 0 && reachedRepeaterMaximum.value) return
if (props.schema.disabled) return
if (index >= 0) {
const item = (props.modelValue || [])[index] || {}
@@ -998,6 +1153,74 @@ function confirmRepeaterModal() {
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 #d8d8d8;
cursor: pointer;
}
.aksrt-field__palette-item.is-active {
box-shadow: 0 0 0 2px #2f8fce;
}
.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 #e6e8eb;
border-radius: 3px;
background: #fafafa;
}
.aksrt-field__sortable-item {
border: 1px solid #e5e5e5;
background: #fff;
}
.aksrt-field__sortable-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #f7f7f7;
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;
@@ -1059,6 +1282,11 @@ function confirmRepeaterModal() {
flex-direction: column;
align-items: flex-start;
}
.aksrt-field__link,
.aksrt-field__sortable-body {
grid-template-columns: 1fr;
}
}
/* Repeater list-modal */
@@ -172,8 +172,16 @@ function matchDependsOn(dependsOn) {
return !!props.formData[dependsOn]
}
if (typeof dependsOn === 'object') {
const { key, value, values, not } = dependsOn
if (Array.isArray(dependsOn.all)) return dependsOn.all.every(matchDependsOn)
if (Array.isArray(dependsOn.any)) return dependsOn.any.some(matchDependsOn)
const { key, value, values, not, operator } = dependsOn
const currentVal = props.formData[key]
if (operator === 'truthy') return !!currentVal
if (operator === 'falsy') return !currentVal
if (operator === 'not_empty') return Array.isArray(currentVal) ? currentVal.length > 0 : currentVal !== '' && currentVal != null
if (operator === 'empty') return Array.isArray(currentVal) ? currentVal.length === 0 : currentVal === '' || currentVal == null
if (operator === 'eq') return currentVal === value
if (operator === 'neq') return currentVal !== value
if (not) {
if (Array.isArray(values)) {
// eslint-disable-next-line eqeqeq