Mimingguang ee078b61bc feat: 后台管理系统完整重构 + 前后台数据联通
- 后台全面引入 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>
2026-03-24 17:48:02 +08:00

109 行
4.0 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">{{ contentLabel }}</h1>
<p style="margin: 0; font-size: 13px; color: #9ca3af">编辑{{ contentLabel }}的数据内容</p>
</div>
<n-space>
<n-button v-if="previewUrl" tag="a" :href="previewUrl" target="_blank" secondary>查看前台</n-button>
<n-button secondary @click="resetContent">重置</n-button>
<n-button type="primary" :disabled="!!jsonError" :loading="saving" @click="saveContent">保存</n-button>
</n-space>
</n-space>
<n-spin :show="!loaded">
<n-card v-if="loaded">
<n-space style="margin-bottom: 12px" size="small">
<n-button size="small" @click="formatJSON">格式化</n-button>
<n-button size="small" @click="collapseJSON">压缩</n-button>
<span style="margin-left: auto; font-size: 12px; color: #9ca3af">此配置结构较复杂使用 JSON 编辑器</span>
</n-space>
<n-input
v-model:value="jsonContent"
type="textarea"
:rows="30"
font-family="monospace"
:status="jsonError ? 'error' : undefined"
@input="validateJSON"
/>
<n-space justify="space-between" align="center" style="margin-top: 12px">
<n-text v-if="jsonError" type="error" style="font-size: 13px">{{ jsonError }}</n-text>
<n-text v-else type="success" style="font-size: 13px">JSON 格式正确</n-text>
</n-space>
</n-card>
</n-spin>
</div>
</template>
<script setup>
import { NButton, NCard, NSpace, NInput, NSpin, NText } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const route = useRoute()
const { adminFetch } = useAdmin()
const message = useMessage()
const key = computed(() => route.params.key)
const contentLabel = ref('')
const jsonContent = ref('')
const originalContent = ref('')
const jsonError = ref('')
const loaded = ref(false)
const saving = ref(false)
const previewMap = {
products: '/products', 'autumn-products': '/products/autumn', 'winter-products': '/products/winter',
courses: '/summer-camp', 'winter-camp': '/winter-camp', destinations: '/destinations',
'destinations-detail': '/destinations', calendar: '/calendar', guides: '/guides',
'xiaohongshu-wall': '/xiaohongshu', youji: '/youji', about: '/about',
qualifications: '/qualifications', partners: '/partners', contact: '/contact',
pricing: '/pricing', brand: '/', seo: '/', navigation: '/',
}
const previewUrl = computed(() => previewMap[key.value] || '')
async function loadData() {
loaded.value = false
const data = await adminFetch(`/api/admin/site-content?key=${key.value}`)
contentLabel.value = data.label
jsonContent.value = JSON.stringify(data.data, null, 2)
originalContent.value = jsonContent.value
jsonError.value = ''
loaded.value = true
}
function validateJSON() {
try { JSON.parse(jsonContent.value); jsonError.value = '' }
catch (e) { jsonError.value = e.message }
}
function formatJSON() {
try { jsonContent.value = JSON.stringify(JSON.parse(jsonContent.value), null, 2); jsonError.value = '' }
catch (e) { jsonError.value = e.message }
}
function collapseJSON() {
try { jsonContent.value = JSON.stringify(JSON.parse(jsonContent.value)); jsonError.value = '' }
catch (e) { jsonError.value = e.message }
}
function resetContent() { jsonContent.value = originalContent.value; jsonError.value = '' }
async function saveContent() {
if (jsonError.value) return
saving.value = true
try {
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: key.value, data: JSON.parse(jsonContent.value) } })
originalContent.value = jsonContent.value
message.success('保存成功')
} catch (e) { message.error('保存失败:' + (e.data?.message || e.message)) }
finally { saving.value = false }
}
watch(key, loadData, { immediate: true })
</script>