- 后台全面引入 Naive UI 组件库,统一 UI 规范 - 前台 usePublicApi 切换到 API 调用(GET /api/content/[key]) - 前后台数据源统一(site_content 表) - 产品线统一管理(tour/camp/course 三套编辑器) - 产品数据归一化(itinerary/faq/pricing 格式统一) - 表单提交 API 改写入 submissions 表 - 新增 submissions 标记已读 API - site-content PUT 支持 upsert - 修复前台 bug(stories 路由冲突、占位符警告、selector breadcrumb) - 消除 PageHero 类型警告(useSEO reactive 修复) - 25 个前台页面全部 HTTP 200,展示效果不变 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
124 行
6.3 KiB
Vue
124 行
6.3 KiB
Vue
<template>
|
|
<div style="max-width: 1100px">
|
|
<n-space justify="space-between" align="center" style="margin-bottom: 20px">
|
|
<div>
|
|
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827">景点对比</h1>
|
|
<p style="margin: 0; font-size: 13px; color: #9ca3af">管理「亲子游目的地怎么选」对比页面内容</p>
|
|
</div>
|
|
<n-space>
|
|
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
|
</n-space>
|
|
</n-space>
|
|
|
|
<n-spin :show="!loaded">
|
|
<template v-if="loaded">
|
|
<n-card title="页面信息">
|
|
<n-form label-placement="top">
|
|
<n-form-item label="标题"><n-input v-model:value="data.title" /></n-form-item>
|
|
<n-form-item label="副标题"><n-input v-model:value="data.subtitle" /></n-form-item>
|
|
<n-form-item label="介绍"><n-input v-model:value="data.intro" type="textarea" :rows="3" /></n-form-item>
|
|
</n-form>
|
|
</n-card>
|
|
|
|
<!-- 对比目的地 -->
|
|
<n-card title="对比目的地" style="margin-top: 16px">
|
|
<template #header-extra><n-button size="small" @click="data.destinations.push({ id: '', name: '', tag: '', highlight: '' })">添加</n-button></template>
|
|
<div v-for="(dest, i) in data.destinations" :key="i" style="margin-bottom: 12px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px;">
|
|
<n-space justify="space-between" style="margin-bottom: 8px">
|
|
<span style="font-weight: 500">{{ dest.name || '目的地 ' + (i+1) }}</span>
|
|
<n-button size="tiny" type="error" quaternary @click="data.destinations.splice(i, 1)">删除</n-button>
|
|
</n-space>
|
|
<n-grid :cols="4" :x-gap="12">
|
|
<n-gi><n-form-item label="ID" :show-feedback="false"><n-input v-model:value="dest.id" /></n-form-item></n-gi>
|
|
<n-gi><n-form-item label="名称" :show-feedback="false"><n-input v-model:value="dest.name" /></n-form-item></n-gi>
|
|
<n-gi><n-form-item label="标签" :show-feedback="false"><n-input v-model:value="dest.tag" /></n-form-item></n-gi>
|
|
<n-gi><n-form-item label="推荐高亮" :show-feedback="false"><n-select v-model:value="dest.highlight" :options="[{label:'是',value:true},{label:'否',value:false}]" /></n-form-item></n-gi>
|
|
</n-grid>
|
|
</div>
|
|
</n-card>
|
|
|
|
<!-- 对比维度 -->
|
|
<n-card title="对比维度" style="margin-top: 16px">
|
|
<template #header-extra><n-button size="small" @click="data.dimensions.push({ label: '', icon: '', values: data.destinations.map(() => '') })">添加维度</n-button></template>
|
|
<n-collapse>
|
|
<n-collapse-item v-for="(dim, di) in data.dimensions" :key="di" :name="di">
|
|
<template #header><strong>{{ dim.icon }} {{ dim.label || '维度 ' + (di+1) }}</strong></template>
|
|
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.dimensions.splice(di, 1)">删除</n-button></template>
|
|
<n-grid :cols="2" :x-gap="12">
|
|
<n-gi><n-form-item label="维度名称"><n-input v-model:value="dim.label" /></n-form-item></n-gi>
|
|
<n-gi><n-form-item label="图标"><n-input v-model:value="dim.icon" /></n-form-item></n-gi>
|
|
</n-grid>
|
|
<div v-for="(dest, vi) in data.destinations" :key="vi">
|
|
<n-form-item :label="dest.name || '目的地 ' + (vi+1)">
|
|
<n-input v-model:value="dim.values[vi]" type="textarea" :rows="2" />
|
|
</n-form-item>
|
|
</div>
|
|
</n-collapse-item>
|
|
</n-collapse>
|
|
</n-card>
|
|
|
|
<!-- 坦诚说明 -->
|
|
<n-card title="坦诚说明" style="margin-top: 16px">
|
|
<n-form label-placement="top">
|
|
<n-form-item label="标题"><n-input v-model:value="data.honestNote.title" /></n-form-item>
|
|
<n-form-item label="副标题"><n-input v-model:value="data.honestNote.subtitle" /></n-form-item>
|
|
<n-form-item label="要点列表(每行一项)">
|
|
<n-input :value="(data.honestNote.items||[]).join('\n')" @update:value="v => data.honestNote.items = v.split('\n').filter(Boolean)" type="textarea" :rows="6" placeholder="每行一个要点" />
|
|
</n-form-item>
|
|
</n-form>
|
|
</n-card>
|
|
|
|
<!-- 结语 -->
|
|
<n-card title="结语" style="margin-top: 16px">
|
|
<n-form label-placement="top">
|
|
<n-form-item label="标题"><n-input v-model:value="data.closing.title" /></n-form-item>
|
|
<n-form-item label="内容"><n-input v-model:value="data.closing.text" type="textarea" :rows="3" /></n-form-item>
|
|
</n-form>
|
|
</n-card>
|
|
|
|
<!-- 数据来源 -->
|
|
<n-card title="数据来源" style="margin-top: 16px">
|
|
<n-form-item :show-feedback="false">
|
|
<n-input v-model:value="data.dataSources" type="textarea" :rows="2" />
|
|
</n-form-item>
|
|
</n-card>
|
|
</template>
|
|
</n-spin>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NCollapse, NCollapseItem, NSelect } from 'naive-ui'
|
|
import { useMessage } from 'naive-ui'
|
|
|
|
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
|
|
|
const { adminFetch } = useAdmin()
|
|
const message = useMessage()
|
|
const data = ref({})
|
|
const loaded = ref(false)
|
|
const saving = ref(false)
|
|
|
|
async function load() {
|
|
const res = await adminFetch('/api/admin/site-content?key=destinations')
|
|
data.value = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
|
if (!data.value.destinations) data.value.destinations = []
|
|
if (!data.value.dimensions) data.value.dimensions = []
|
|
if (!data.value.honestNote || typeof data.value.honestNote !== 'object') data.value.honestNote = { title: '', subtitle: '', items: [] }
|
|
if (!data.value.closing || typeof data.value.closing !== 'object') data.value.closing = { title: '', text: '' }
|
|
if (!data.value.dataSources) data.value.dataSources = ''
|
|
loaded.value = true
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
try {
|
|
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'destinations', data: data.value } })
|
|
message.success('已保存')
|
|
} catch (e) { message.error('保存失败') }
|
|
finally { saving.value = false }
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|