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>
这个提交包含在:
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div style="max-width: 900px;">
|
||||
<n-breadcrumb style="margin-bottom: 16px;">
|
||||
<n-breadcrumb-item>
|
||||
<NuxtLink to="/admin/content/destinations-detail">景点详情</NuxtLink>
|
||||
</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ form.name || '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
|
||||
<div style="font-size: 20px; font-weight: 600; color: #111827;">{{ form.name || '编辑景点' }}</div>
|
||||
<n-space>
|
||||
<NuxtLink to="/admin/content/destinations-detail" style="text-decoration: none;">
|
||||
<n-button>返回列表</n-button>
|
||||
</NuxtLink>
|
||||
<n-button type="primary" :loading="saving" :disabled="saving" @click="handleSave">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin v-if="pageLoading" style="display:block; padding: 60px; text-align: center;" />
|
||||
<n-card v-else-if="loadError" style="color: #ef4444; text-align: center; padding: 40px;">{{ loadError }}</n-card>
|
||||
<template v-else>
|
||||
<n-card title="基本信息" style="margin-bottom: 16px;">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="ID">
|
||||
<n-input v-model:value="form.id" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="form.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="form.subtitle" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="标签">
|
||||
<n-input v-model:value="form.tag" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="form.description" type="textarea" :rows="5" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<n-card title="亮点与建议" style="margin-bottom: 16px;">
|
||||
<n-form-item label="亮点(每行一个)">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:value="(form.highlights||[]).join('\n')"
|
||||
@input="form.highlights = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="旅行建议(每行一个)">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:value="(form.tips||[]).join('\n')"
|
||||
@input="form.tips = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="form.seasons" label="可去季节(每行一个)">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:value="(form.seasons||[]).join('\n')"
|
||||
@input="form.seasons = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<n-card v-if="form.bestSeason" title="最佳季节" style="margin-bottom: 16px;">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="季节">
|
||||
<n-input v-model:value="form.bestSeason.season" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="原因">
|
||||
<n-input v-model:value="form.bestSeason.reason" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<n-card v-if="form.seo" title="SEO" style="margin-bottom: 16px;">
|
||||
<n-form-item label="SEO 标题">
|
||||
<n-input v-model:value="form.seo.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="SEO 描述">
|
||||
<n-input v-model:value="form.seo.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="form.seo.keywords" label="关键词">
|
||||
<n-input v-model:value="form.seo.keywords" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<p v-if="formError" style="color: #ef4444; font-size: 13px; margin: 12px 0 0;">{{ formError }}</p>
|
||||
<div class="save-bar">
|
||||
<NuxtLink to="/admin/content/destinations-detail" style="text-decoration: none;">
|
||||
<n-button>返回列表</n-button>
|
||||
</NuxtLink>
|
||||
<n-button type="primary" :loading="saving" :disabled="saving" @click="handleSave">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin', name: 'admin-content-destinations-edit' })
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const idx = computed(() => Number(route.params.idx))
|
||||
const form = ref({})
|
||||
const pageLoading = ref(true); const loadError = ref(''); const saving = ref(false); const formError = ref('')
|
||||
|
||||
async function loadItem() {
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=destinations-detail')
|
||||
const data = res.data
|
||||
const arr = Array.isArray(data) ? data : (data.destinations || [])
|
||||
if (idx.value >= arr.length) { loadError.value = '索引超出范围'; return }
|
||||
form.value = JSON.parse(JSON.stringify(arr[idx.value]))
|
||||
} catch (e) { loadError.value = e.data?.message || '加载失败' }
|
||||
finally { pageLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true; formError.value = ''
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=destinations-detail')
|
||||
const data = res.data
|
||||
if (Array.isArray(data)) {
|
||||
data[idx.value] = form.value
|
||||
} else {
|
||||
data.destinations[idx.value] = form.value
|
||||
}
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'destinations-detail', data } })
|
||||
message.success('保存成功')
|
||||
} catch (e) { formError.value = e.data?.message || '保存失败' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div style="max-width: 900px;">
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<div style="font-size: 20px; font-weight: 600; color: #111827; margin-bottom: 4px;">景点详情</div>
|
||||
<div style="font-size: 13px; color: #9ca3af;">管理各景点的详细介绍 · 对应官网「目的地」详情页</div>
|
||||
</div>
|
||||
<a href="/destinations" target="_blank">
|
||||
<n-button>查看前台</n-button>
|
||||
</a>
|
||||
</n-space>
|
||||
|
||||
<n-spin v-if="!loaded" style="display:block; padding: 60px; text-align: center;" />
|
||||
<template v-else>
|
||||
<n-space vertical :size="8">
|
||||
<NuxtLink
|
||||
v-for="item in items"
|
||||
:key="item._index"
|
||||
:to="`/admin/content/destinations-detail/${item._index}`"
|
||||
style="text-decoration: none;"
|
||||
>
|
||||
<n-card hoverable>
|
||||
<n-space justify="space-between" align="center">
|
||||
<div>
|
||||
<div style="font-size: 15px; font-weight: 600; color: #111827; margin-bottom: 4px;">{{ item.name || '未命名' }}</div>
|
||||
<div v-if="item.subtitle" style="font-size: 13px; color: #6b7280;">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
<n-space align="center">
|
||||
<n-tag v-if="item.tag" type="success" size="small">{{ item.tag }}</n-tag>
|
||||
<span style="font-size: 20px; color: #d1d5db;">›</span>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</NuxtLink>
|
||||
</n-space>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NTag, NSpin } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const items = ref([]); const loaded = ref(false)
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=destinations-detail')
|
||||
const data = res.data
|
||||
const arr = Array.isArray(data) ? data : (data.destinations || [])
|
||||
items.value = arr.map((d, i) => ({
|
||||
_index: i, name: d.name || '', subtitle: d.subtitle || '', tag: d.tag || '', id: d.id || '',
|
||||
}))
|
||||
} catch {}
|
||||
loaded.value = true
|
||||
})
|
||||
</script>
|
||||
在新工单中引用
屏蔽一个用户