feat(admin): rebuild page display settings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-module-canvas">
|
||||
<div class="aksrt-module-canvas__toolbar">
|
||||
<n-space>
|
||||
<n-dropdown :options="addModuleOptions" trigger="click" @select="handleAddModule">
|
||||
<n-button type="primary" size="small">
|
||||
<template #icon><FaIcon icon="fa fas fa-plus" :size="12" /></template>
|
||||
添加模块
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
<n-button size="small" @click="$emit('pick-preset')">
|
||||
<template #icon><FaIcon icon="fa fas fa-magic" :size="12" /></template>
|
||||
应用预设
|
||||
</n-button>
|
||||
<n-button size="small" @click="$emit('export')">
|
||||
<template #icon><FaIcon icon="fa fas fa-download" :size="12" /></template>
|
||||
导出
|
||||
</n-button>
|
||||
<n-button size="small" @click="$emit('import')">
|
||||
<template #icon><FaIcon icon="fa fas fa-upload" :size="12" /></template>
|
||||
导入
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<div v-if="modules.length === 0" class="aksrt-module-canvas__empty">
|
||||
<FaIcon icon="fa fas fa-th-large" :size="32" color="#c0c4cc" />
|
||||
<p>暂无首页模块,点击"添加模块"或"应用预设"开始配置。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="aksrt-module-canvas__list">
|
||||
<div
|
||||
v-for="(mod, index) in modules"
|
||||
:key="mod.id || index"
|
||||
class="aksrt-module-canvas__item"
|
||||
:class="{
|
||||
'aksrt-module-canvas__item--selected': selectedIndex === index,
|
||||
'aksrt-module-canvas__item--disabled': !mod.enabled,
|
||||
'aksrt-module-canvas__item--dragging': dragIndex === index,
|
||||
'aksrt-module-canvas__item--drag-over': dragOverIndex === index && dragOverIndex !== dragIndex,
|
||||
}"
|
||||
:draggable="true"
|
||||
@click="$emit('select', index)"
|
||||
@dragstart="handleDragStart(index, $event)"
|
||||
@dragover.prevent="handleDragOver(index)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop.prevent="handleDrop(index)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<div class="aksrt-module-canvas__item-handle" title="拖拽排序">
|
||||
<FaIcon icon="fa fas fa-bars" :size="12" color="#c0c4cc" />
|
||||
</div>
|
||||
<div class="aksrt-module-canvas__item-icon">
|
||||
<FaIcon :icon="getModuleIcon(mod.type)" :size="14" />
|
||||
</div>
|
||||
<div class="aksrt-module-canvas__item-info">
|
||||
<span class="aksrt-module-canvas__item-label">{{ getModuleLabel(mod.type) }}</span>
|
||||
<span v-if="mod.title" class="aksrt-module-canvas__item-title">{{ mod.title }}</span>
|
||||
</div>
|
||||
<div class="aksrt-module-canvas__item-actions">
|
||||
<n-switch
|
||||
:value="mod.enabled !== false"
|
||||
size="small"
|
||||
@update:value="(val) => $emit('toggle', index, val)"
|
||||
@click.stop
|
||||
/>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
title="复制"
|
||||
@click.stop="$emit('copy', index)"
|
||||
>
|
||||
<FaIcon icon="fa fas fa-copy" :size="11" />
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
title="删除"
|
||||
@click.stop="$emit('remove', index)"
|
||||
>
|
||||
<FaIcon icon="fa fas fa-trash-alt" :size="11" />
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
:disabled="index === 0"
|
||||
title="上移"
|
||||
@click.stop="$emit('move', index, -1)"
|
||||
>
|
||||
<FaIcon icon="fa fas fa-chevron-up" :size="11" />
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
:disabled="index === modules.length - 1"
|
||||
title="下移"
|
||||
@click.stop="$emit('move', index, 1)"
|
||||
>
|
||||
<FaIcon icon="fa fas fa-chevron-down" :size="11" />
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { MODULE_LIBRARY } from '@/utils/home-module-schema'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modules: { type: Array, default: () => [] },
|
||||
selectedIndex: { type: Number, default: -1 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'add', 'remove', 'copy', 'toggle', 'move', 'pick-preset', 'export', 'import'])
|
||||
|
||||
// Drag-and-drop state
|
||||
const dragIndex = ref(-1)
|
||||
const dragOverIndex = ref(-1)
|
||||
|
||||
function handleDragStart(index, event) {
|
||||
dragIndex.value = index
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', String(index))
|
||||
}
|
||||
|
||||
function handleDragOver(index) {
|
||||
if (dragIndex.value !== index) {
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverIndex.value = -1
|
||||
}
|
||||
|
||||
function handleDrop(targetIndex) {
|
||||
if (dragIndex.value >= 0 && dragIndex.value !== targetIndex) {
|
||||
const offset = targetIndex - dragIndex.value
|
||||
emit('move', dragIndex.value, offset)
|
||||
}
|
||||
dragIndex.value = -1
|
||||
dragOverIndex.value = -1
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
dragIndex.value = -1
|
||||
dragOverIndex.value = -1
|
||||
}
|
||||
|
||||
const addModuleOptions = computed(() =>
|
||||
MODULE_LIBRARY.map(m => ({
|
||||
label: m.label,
|
||||
key: m.type,
|
||||
icon: () => {
|
||||
const iconMap = {
|
||||
image: 'fa fas fa-image', rectangle: 'fa fas fa-square', folder: 'fa fas fa-folder',
|
||||
switcher: 'fa fas fa-columns', trophy: 'fa fas fa-trophy', clock: 'fa fas fa-clock',
|
||||
tag: 'fa fas fa-tag', link: 'fa fas fa-link', code: 'fa fas fa-code', fire: 'fa fas fa-fire',
|
||||
grid: 'fa fas fa-th', 'font-colors': 'fa fas fa-font', 'th-large': 'fa fas fa-th-large',
|
||||
swap: 'fa fas fa-arrows-alt-v', 'question-circle': 'fa fas fa-question-circle',
|
||||
table: 'fa fas fa-table', star: 'fa fas fa-star', handshake: 'fa fas fa-handshake',
|
||||
'shopping-bag': 'fa fas fa-shopping-bag',
|
||||
}
|
||||
return h('i', { class: iconMap[m.icon] || 'fa fas fa-cube', style: 'font-size:12px;margin-right:6px;' })
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
function getModuleIcon(type) {
|
||||
const meta = MODULE_LIBRARY.find(m => m.type === type)
|
||||
const iconMap = {
|
||||
image: 'fa fas fa-image', rectangle: 'fa fas fa-square', folder: 'fa fas fa-folder',
|
||||
switcher: 'fa fas fa-columns', trophy: 'fa fas fa-trophy', clock: 'fa fas fa-clock',
|
||||
tag: 'fa fas fa-tag', link: 'fa fas fa-link', code: 'fa fas fa-code', fire: 'fa fas fa-fire',
|
||||
grid: 'fa fas fa-th', 'font-colors': 'fa fas fa-font', 'th-large': 'fa fas fa-th-large',
|
||||
swap: 'fa fas fa-arrows-alt-v', 'question-circle': 'fa fas fa-question-circle',
|
||||
table: 'fa fas fa-table', star: 'fa fas fa-star', handshake: 'fa fas fa-handshake',
|
||||
'shopping-bag': 'fa fas fa-shopping-bag',
|
||||
}
|
||||
return iconMap[meta?.icon] || 'fa fas fa-cube'
|
||||
}
|
||||
|
||||
function getModuleLabel(type) {
|
||||
const meta = MODULE_LIBRARY.find(m => m.type === type)
|
||||
return meta?.label || type
|
||||
}
|
||||
|
||||
function handleAddModule(type) {
|
||||
emit('add', type)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { h } from 'vue'
|
||||
export default {
|
||||
// h is used in addModuleOptions computed icon renderer
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-module-canvas__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
border: 2px dashed #e0e0e0;
|
||||
border-radius: 8px;
|
||||
color: #999;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item:hover {
|
||||
border-color: #d0d0d0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item--selected {
|
||||
border-color: #1890ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item--dragging {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item--drag-over {
|
||||
border-color: #1890ff;
|
||||
border-width: 2px;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-handle {
|
||||
cursor: grab;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: #f5f7fa;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-title {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,399 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-module-inspector">
|
||||
<div v-if="!module" class="aksrt-module-inspector__empty">
|
||||
<FaIcon icon="fa fas fa-mouse-pointer" :size="24" color="#c0c4cc" />
|
||||
<p>选择左侧模块查看和编辑配置</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="aksrt-module-inspector__header">
|
||||
<FaIcon :icon="getModuleIcon(module.type)" :size="16" />
|
||||
<span class="aksrt-module-inspector__type">{{ getModuleLabel(module.type) }}</span>
|
||||
</div>
|
||||
|
||||
<n-scrollbar style="max-height: calc(100vh - 320px);">
|
||||
<n-form
|
||||
:model="localConfig"
|
||||
label-placement="top"
|
||||
size="small"
|
||||
class="aksrt-module-inspector__form"
|
||||
>
|
||||
<!-- Common fields -->
|
||||
<n-form-item label="模块标题">
|
||||
<n-input
|
||||
:value="module.title || ''"
|
||||
placeholder="留空使用默认标题"
|
||||
@update:value="(val) => updateField('title', val)"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="启用模块">
|
||||
<n-switch
|
||||
:value="module.enabled !== false"
|
||||
@update:value="(val) => updateField('enabled', val)"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<!-- Type-specific fields from schema -->
|
||||
<template v-if="metaFields.length > 0">
|
||||
<n-form-item
|
||||
v-for="field in metaFields"
|
||||
:key="field.key"
|
||||
:label="field.label"
|
||||
>
|
||||
<!-- Switch -->
|
||||
<n-switch
|
||||
v-if="field.type === 'switch'"
|
||||
:value="localConfig[field.key] ?? field.default ?? false"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Number -->
|
||||
<n-input-number
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="localConfig[field.key] ?? field.default ?? 0"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
style="width: 100%;"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Text -->
|
||||
<n-input
|
||||
v-else-if="field.type === 'text'"
|
||||
:value="localConfig[field.key] ?? field.default ?? ''"
|
||||
:placeholder="field.placeholder || ''"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Textarea -->
|
||||
<n-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
type="textarea"
|
||||
:value="localConfig[field.key] ?? field.default ?? ''"
|
||||
:rows="field.rows || 3"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Select -->
|
||||
<n-select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="localConfig[field.key] ?? field.default"
|
||||
:options="field.options || []"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Radio -->
|
||||
<n-radio-group
|
||||
v-else-if="field.type === 'radio'"
|
||||
:value="localConfig[field.key] ?? field.default"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
>
|
||||
<n-space>
|
||||
<n-radio
|
||||
v-for="opt in field.options"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
|
||||
<!-- Category (single) -->
|
||||
<n-select
|
||||
v-else-if="field.type === 'cat'"
|
||||
:value="localConfig[field.key] ?? field.default ?? 0"
|
||||
:options="categoryOptions"
|
||||
filterable
|
||||
placeholder="选择分类"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Categories (multiple) -->
|
||||
<n-select
|
||||
v-else-if="field.type === 'cats'"
|
||||
:value="localConfig[field.key] ?? field.default ?? []"
|
||||
:options="categoryOptions"
|
||||
multiple
|
||||
filterable
|
||||
placeholder="选择分类"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Media Picker -->
|
||||
<div v-else-if="field.type === 'media'" class="aksrt-module-inspector__media">
|
||||
<div v-if="localConfig[field.key]" class="aksrt-module-inspector__media-preview">
|
||||
<img :src="localConfig[field.key]" alt="" />
|
||||
<n-button size="tiny" quaternary type="error" @click="updateConfig(field.key, '')">
|
||||
移除
|
||||
</n-button>
|
||||
</div>
|
||||
<n-input
|
||||
v-else
|
||||
:value="localConfig[field.key] ?? ''"
|
||||
placeholder="输入图片 URL 或点击下方选择"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
<n-button size="tiny" quaternary @click="openMediaPicker(field.key)">
|
||||
<template #icon><FaIcon icon="fa fas fa-image" :size="12" /></template>
|
||||
从媒体库选择
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- Repeater -->
|
||||
<div v-else-if="field.type === 'repeater'" class="aksrt-module-inspector__repeater">
|
||||
<div
|
||||
v-for="(item, idx) in (localConfig[field.key] ?? [])"
|
||||
:key="idx"
|
||||
class="aksrt-module-inspector__repeater-item"
|
||||
>
|
||||
<div class="aksrt-module-inspector__repeater-fields">
|
||||
<n-input
|
||||
v-for="sub in (field.fields || [])"
|
||||
:key="sub.key"
|
||||
:value="item[sub.key] ?? ''"
|
||||
:placeholder="sub.placeholder || sub.label || ''"
|
||||
size="small"
|
||||
@update:value="(val) => updateRepeaterItem(field.key, idx, sub.key, val)"
|
||||
/>
|
||||
</div>
|
||||
<n-button size="tiny" quaternary type="error" @click="removeRepeaterItem(field.key, idx)">
|
||||
<FaIcon icon="fa fas fa-times" :size="10" />
|
||||
</n-button>
|
||||
</div>
|
||||
<n-button size="tiny" quaternary @click="addRepeaterItem(field.key, field.fields)">
|
||||
<template #icon><FaIcon icon="fa fas fa-plus" :size="10" /></template>
|
||||
添加一项
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<n-text v-else depth="3" style="font-size: 12px;">
|
||||
此模块类型暂无可配置字段。
|
||||
</n-text>
|
||||
</n-form>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<!-- Image URL input modal (replaces native prompt) -->
|
||||
<n-modal v-model:show="showImageUrlInput" preset="card" title="输入图片 URL" style="max-width: 400px;" :bordered="false">
|
||||
<n-input v-model:value="imageUrlValue" placeholder="https://example.com/image.jpg" @keyup.enter="confirmImageUrl" />
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showImageUrlInput = false">取消</n-button>
|
||||
<n-button type="primary" @click="confirmImageUrl">确定</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { MODULE_LIBRARY } from '@/utils/home-module-schema'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
module: { type: Object, default: null },
|
||||
moduleIndex: { type: Number, default: -1 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const categoryOptions = ref([])
|
||||
|
||||
const meta = computed(() => {
|
||||
if (!props.module) return null
|
||||
return MODULE_LIBRARY.find(m => m.type === props.module.type) || null
|
||||
})
|
||||
|
||||
const metaFields = computed(() => {
|
||||
return meta.value?.fields || []
|
||||
})
|
||||
|
||||
const localConfig = computed(() => {
|
||||
return props.module?.config || {}
|
||||
})
|
||||
|
||||
function getModuleIcon(type) {
|
||||
const m = MODULE_LIBRARY.find(x => x.type === type)
|
||||
const iconMap = {
|
||||
image: 'fa fas fa-image', rectangle: 'fa fas fa-square', folder: 'fa fas fa-folder',
|
||||
switcher: 'fa fas fa-columns', trophy: 'fa fas fa-trophy', clock: 'fa fas fa-clock',
|
||||
tag: 'fa fas fa-tag', link: 'fa fas fa-link', code: 'fa fas fa-code', fire: 'fa fas fa-fire',
|
||||
grid: 'fa fas fa-th', 'font-colors': 'fa fas fa-font', 'th-large': 'fa fas fa-th-large',
|
||||
swap: 'fa fas fa-arrows-alt-v', 'question-circle': 'fa fas fa-question-circle',
|
||||
table: 'fa fas fa-table', star: 'fa fas fa-star', handshake: 'fa fas fa-handshake',
|
||||
'shopping-bag': 'fa fas fa-shopping-bag',
|
||||
}
|
||||
return iconMap[m?.icon] || 'fa fas fa-cube'
|
||||
}
|
||||
|
||||
function getModuleLabel(type) {
|
||||
const m = MODULE_LIBRARY.find(x => x.type === type)
|
||||
return m?.label || type
|
||||
}
|
||||
|
||||
function updateField(key, value) {
|
||||
emit('update', props.moduleIndex, key, value)
|
||||
}
|
||||
|
||||
function updateConfig(key, value) {
|
||||
const newConfig = { ...(props.module?.config || {}), [key]: value }
|
||||
emit('update', props.moduleIndex, 'config', newConfig)
|
||||
}
|
||||
|
||||
// Image URL input state (replaces native prompt)
|
||||
const showImageUrlInput = ref(false)
|
||||
const imageUrlFieldKey = ref('')
|
||||
const imageUrlValue = ref('')
|
||||
|
||||
function openMediaPicker(fieldKey) {
|
||||
if (!window.wp || !window.wp.media) {
|
||||
// Fallback: show inline input instead of native prompt
|
||||
imageUrlFieldKey.value = fieldKey
|
||||
imageUrlValue.value = localConfig.value[fieldKey] || ''
|
||||
showImageUrlInput.value = true
|
||||
return
|
||||
}
|
||||
const frame = wp.media({
|
||||
title: '选择图片',
|
||||
button: { text: '使用此图片' },
|
||||
multiple: false,
|
||||
library: { type: 'image' },
|
||||
})
|
||||
frame.on('select', function () {
|
||||
const attachment = frame.state().get('selection').first().toJSON()
|
||||
updateConfig(fieldKey, attachment.url)
|
||||
})
|
||||
frame.open()
|
||||
}
|
||||
|
||||
function confirmImageUrl() {
|
||||
if (imageUrlValue.value.trim()) {
|
||||
updateConfig(imageUrlFieldKey.value, imageUrlValue.value.trim())
|
||||
}
|
||||
showImageUrlInput.value = false
|
||||
}
|
||||
|
||||
function addRepeaterItem(fieldKey, subFields) {
|
||||
const current = [...(props.module?.config?.[fieldKey] || [])]
|
||||
const newItem = {}
|
||||
;(subFields || []).forEach(f => { newItem[f.key] = f.default ?? '' })
|
||||
current.push(newItem)
|
||||
updateConfig(fieldKey, current)
|
||||
}
|
||||
|
||||
function removeRepeaterItem(fieldKey, index) {
|
||||
const current = [...(props.module?.config?.[fieldKey] || [])]
|
||||
current.splice(index, 1)
|
||||
updateConfig(fieldKey, current)
|
||||
}
|
||||
|
||||
function updateRepeaterItem(fieldKey, index, subKey, value) {
|
||||
const current = [...(props.module?.config?.[fieldKey] || [])]
|
||||
if (current[index]) {
|
||||
current[index] = { ...current[index], [subKey]: value }
|
||||
updateConfig(fieldKey, current)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const res = await api.getHomepageCategories()
|
||||
const cats = res?.data || []
|
||||
categoryOptions.value = cats.map(c => ({
|
||||
label: `${c.name} (${c.count})`,
|
||||
value: c.term_id,
|
||||
}))
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
onMounted(loadCategories)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-module-inspector__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
color: #999;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__type {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__form {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__media {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__media-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__media-preview img {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__repeater {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__repeater-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__repeater-fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,143 +0,0 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
title="应用预设模板"
|
||||
preset="dialog"
|
||||
style="width: 560px;"
|
||||
:show-icon="false"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="aksrt-preset-picker">
|
||||
<p class="aksrt-preset-picker__hint">
|
||||
选择一个预设模板快速配置首页模块。应用预设将替换当前所有模块。
|
||||
</p>
|
||||
|
||||
<div class="aksrt-preset-picker__list">
|
||||
<div
|
||||
v-for="preset in presets"
|
||||
:key="preset.key"
|
||||
class="aksrt-preset-picker__item"
|
||||
:class="{ 'aksrt-preset-picker__item--selected': selectedKey === preset.key }"
|
||||
@click="selectedKey = preset.key"
|
||||
>
|
||||
<div class="aksrt-preset-picker__item-header">
|
||||
<strong>{{ preset.label }}</strong>
|
||||
<n-tag size="small" type="info">{{ preset.modules.length }} 个模块</n-tag>
|
||||
</div>
|
||||
<p class="aksrt-preset-picker__item-desc">{{ preset.desc }}</p>
|
||||
<div class="aksrt-preset-picker__item-modules">
|
||||
<n-tag
|
||||
v-for="(mod, i) in preset.modules"
|
||||
:key="i"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
class="aksrt-preset-picker__module-tag"
|
||||
>
|
||||
{{ getModuleLabel(mod.type) }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="$emit('close')">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!selectedKey"
|
||||
@click="handleApply"
|
||||
>
|
||||
应用预设
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { PRESET_TEMPLATES, MODULE_LIBRARY } from '@/utils/home-module-schema'
|
||||
|
||||
defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'apply'])
|
||||
const selectedKey = ref('')
|
||||
const presets = PRESET_TEMPLATES
|
||||
|
||||
function getModuleLabel(type) {
|
||||
const m = MODULE_LIBRARY.find(x => x.type === type)
|
||||
return m?.label || type
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const preset = presets.find(p => p.key === selectedKey.value)
|
||||
if (preset) {
|
||||
emit('apply', preset)
|
||||
selectedKey.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-preset-picker__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item {
|
||||
padding: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item:hover {
|
||||
border-color: #d0d0d0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item--selected {
|
||||
border-color: #1890ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item-header strong {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item-desc {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item-modules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__module-tag {
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user