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,133 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px">
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item @click="navigateTo('/admin/articles')">最新动态</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ isNew ? '新增' : '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">{{ isNew ? '新增动态' : '编辑动态' }}</h1>
|
||||
<n-space>
|
||||
<n-button secondary @click="navigateTo('/admin/articles')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="loadError" style="text-align: center; color: #ef4444">{{ loadError }}</n-card>
|
||||
<n-spin v-else-if="pageLoading" style="display: flex; justify-content: center; padding: 60px" />
|
||||
|
||||
<template v-else>
|
||||
<div class="form-layout">
|
||||
<div class="form-main">
|
||||
<n-card title="基本信息">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题" required>
|
||||
<n-input v-model:value="form.title" placeholder="输入文章标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Slug" required>
|
||||
<n-input v-model:value="form.slug" placeholder="URL 标识,如 news-2026-spring" />
|
||||
<template #feedback>用于生成文章链接,仅限英文、数字和短横线</template>
|
||||
</n-form-item>
|
||||
<n-form-item label="摘要">
|
||||
<n-input v-model:value="form.summary" type="textarea" :rows="3" placeholder="简要描述文章内容(可选)" />
|
||||
</n-form-item>
|
||||
<n-form-item label="正文">
|
||||
<n-input v-model:value="form.content" type="textarea" :rows="16" placeholder="输入文章正文,支持 HTML" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div class="form-sidebar">
|
||||
<n-card title="发布设置">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="状态">
|
||||
<n-select v-model:value="form.published" :options="[{ label: '已发布', value: true }, { label: '草稿', value: false }]" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分类">
|
||||
<n-select v-model:value="form.category" :options="[{ label: '产品', value: '产品' }, { label: '公告', value: '公告' }, { label: '活动', value: '活动' }]" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="封面图">
|
||||
<AdminImageUpload v-model="form.coverImage" />
|
||||
</n-card>
|
||||
|
||||
<n-card v-if="!isNew" title="其他信息" size="small">
|
||||
<n-descriptions :column="1" label-placement="left" size="small">
|
||||
<n-descriptions-item label="创建时间">{{ form.createdAt?.slice(0, 16).replace('T', ' ') }}</n-descriptions-item>
|
||||
<n-descriptions-item label="更新时间">{{ form.updatedAt?.slice(0, 16).replace('T', ' ') }}</n-descriptions-item>
|
||||
<n-descriptions-item label="ID">{{ route.params.id }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-alert v-if="formError" type="error" style="margin-top: 12px">{{ formError }}</n-alert>
|
||||
|
||||
<div class="save-bar">
|
||||
<n-button secondary @click="navigateTo('/admin/articles')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSelect, NBreadcrumb, NBreadcrumbItem, NSpin, NDescriptions, NDescriptionsItem, NAlert } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const form = ref({
|
||||
title: '', slug: '', category: '产品', summary: '', content: '',
|
||||
coverImage: '', published: true, createdAt: '', updatedAt: '',
|
||||
})
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/articles/${route.params.id}`)
|
||||
form.value = { ...data, published: !!data.published, coverImage: data.coverImage || '' }
|
||||
} catch (e) { loadError.value = e.data?.message || '加载失败' }
|
||||
finally { pageLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.title?.trim()) { formError.value = '标题不能为空'; return }
|
||||
if (!form.value.slug?.trim()) { formError.value = 'Slug 不能为空'; return }
|
||||
saving.value = true; formError.value = ''
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/articles', { method: 'POST', body: { ...form.value, type: 'news' } })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/articles'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/articles/${route.params.id}`, { method: 'PUT', body: form.value })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) { formError.value = e.data?.message || '保存失败' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-layout { display: grid; grid-template-columns: 1fr 300px; gap: 20px; align-items: start; }
|
||||
.form-main { min-width: 0; }
|
||||
.form-sidebar { display: flex; flex-direction: column; gap: 16px; }
|
||||
.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,108 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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 tag="a" href="/news" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/articles/new')">新增动态</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card>
|
||||
<n-space style="margin-bottom: 16px">
|
||||
<n-input v-model:value="search" placeholder="搜索标题、分类..." clearable style="width: 260px" @keyup.enter="page = 1; loadData()">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
|
||||
<n-space justify="end" style="margin-top: 16px" v-if="totalPages > 1">
|
||||
<n-pagination v-model:page="page" :page-count="totalPages" @update:page="loadData" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '标题', key: 'title',
|
||||
render: (row) => h('a', {
|
||||
style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer',
|
||||
onClick: () => navigateTo(`/admin/articles/${row.id}`),
|
||||
}, row.title),
|
||||
},
|
||||
{
|
||||
title: '分类', key: 'category', width: 80,
|
||||
render: (row) => row.category ? h(NTag, { size: 'small', type: 'info', bordered: false }, () => row.category) : '-',
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, {
|
||||
size: 'small', type: row.published ? 'success' : 'default', bordered: false,
|
||||
style: 'cursor: pointer',
|
||||
onClick: () => togglePublish(row),
|
||||
}, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '创建时间', key: 'createdAt', width: 100,
|
||||
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.createdAt?.slice(0, 10)),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/articles/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.title}」?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await adminFetch(`/api/admin/articles?type=news&page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`)
|
||||
list.value = d.items; total.value = d.total; totalPages.value = d.totalPages
|
||||
} catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/articles/${item.id}`, { method: 'PUT', body: { published: !item.published } })
|
||||
item.published = !item.published
|
||||
} catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/articles/${item.id}`, { method: 'DELETE' })
|
||||
message.success('已删除')
|
||||
loadData()
|
||||
} catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px">
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item @click="navigateTo('/admin/blogs')">博客文章</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ isNew ? '新增' : '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">{{ isNew ? '新增博客' : '编辑博客' }}</h1>
|
||||
<n-space>
|
||||
<n-button secondary @click="navigateTo('/admin/blogs')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="loadError" style="text-align: center; color: #ef4444">{{ loadError }}</n-card>
|
||||
<n-spin v-else-if="pageLoading" style="display: flex; justify-content: center; padding: 60px" />
|
||||
|
||||
<template v-else>
|
||||
<div class="form-layout">
|
||||
<div class="form-main">
|
||||
<n-card title="基本信息">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题" required>
|
||||
<n-input v-model:value="form.title" placeholder="输入博客标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Slug" required>
|
||||
<n-input v-model:value="form.slug" placeholder="如 hulunbuir-travel-guide" />
|
||||
<template #feedback>用于生成博客链接</template>
|
||||
</n-form-item>
|
||||
<n-form-item label="摘要">
|
||||
<n-input v-model:value="form.summary" type="textarea" :rows="3" placeholder="简要描述" />
|
||||
</n-form-item>
|
||||
<n-form-item label="正文(支持 HTML)">
|
||||
<n-input v-model:value="form.content" type="textarea" :rows="20" placeholder="输入博客正文" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="SEO 设置" style="margin-top: 16px">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="SEO 标题">
|
||||
<n-input v-model:value="form.seoTitle" placeholder="留空则使用文章标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="SEO 描述">
|
||||
<n-input v-model:value="form.seoDesc" type="textarea" :rows="2" placeholder="搜索引擎描述" />
|
||||
</n-form-item>
|
||||
<n-form-item label="SEO 关键词">
|
||||
<n-input v-model:value="form.seoKeywords" placeholder="逗号分隔" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div class="form-sidebar">
|
||||
<n-card title="发布设置">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="状态">
|
||||
<n-select v-model:value="form.published" :options="[{ label: '已发布', value: true }, { label: '草稿', value: false }]" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分类">
|
||||
<n-select v-model:value="form.category" :options="[{ label: '攻略', value: '攻略' }, { label: '亲子', value: '亲子' }, { label: '游记', value: '游记' }]" />
|
||||
</n-form-item>
|
||||
<n-form-item label="作者">
|
||||
<n-input v-model:value="form.author" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="封面图">
|
||||
<AdminImageUpload v-model="form.coverImage" />
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-alert v-if="formError" type="error" style="margin-top: 12px">{{ formError }}</n-alert>
|
||||
|
||||
<div class="save-bar">
|
||||
<n-button secondary @click="navigateTo('/admin/blogs')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSelect, NBreadcrumb, NBreadcrumbItem, NSpin, NAlert } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const form = ref({
|
||||
title: '', slug: '', category: '攻略', author: '呼籁旅行',
|
||||
summary: '', content: '', coverImage: '',
|
||||
seoTitle: '', seoDesc: '', seoKeywords: '', published: true,
|
||||
})
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const d = await adminFetch(`/api/admin/articles/${route.params.id}`)
|
||||
form.value = { ...d, published: !!d.published, coverImage: d.coverImage || '' }
|
||||
} catch (e) { loadError.value = e.data?.message || '加载失败' }
|
||||
finally { pageLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.title?.trim() || !form.value.slug?.trim()) { formError.value = '标题和 Slug 不能为空'; return }
|
||||
saving.value = true; formError.value = ''
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/articles', { method: 'POST', body: { ...form.value, type: 'blog' } })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/blogs'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/articles/${route.params.id}`, { method: 'PUT', body: form.value })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) { formError.value = e.data?.message || '保存失败' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-layout { display: grid; grid-template-columns: 1fr 300px; gap: 20px; align-items: start; }
|
||||
.form-main { min-width: 0; }
|
||||
.form-sidebar { display: flex; flex-direction: column; gap: 16px; }
|
||||
.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,84 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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 tag="a" href="/blog" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/blogs/new')">新增博客</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card>
|
||||
<n-space style="margin-bottom: 16px">
|
||||
<n-input v-model:value="search" placeholder="搜索标题、分类..." clearable style="width: 260px" @keyup.enter="page = 1; loadData()">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
|
||||
<n-space justify="end" style="margin-top: 16px" v-if="totalPages > 1">
|
||||
<n-pagination v-model:page="page" :page-count="totalPages" @update:page="loadData" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const list = ref([]); const total = ref(0); const page = ref(1); const totalPages = ref(1); const loading = ref(false); const search = ref('')
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '标题', key: 'title',
|
||||
render: (row) => h('a', { style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer', onClick: () => navigateTo(`/admin/blogs/${row.id}`) }, row.title),
|
||||
},
|
||||
{ title: '分类', key: 'category', width: 70, render: (row) => row.category ? h(NTag, { size: 'small', type: 'info', bordered: false }, () => row.category) : '-' },
|
||||
{ title: '作者', key: 'author', width: 80, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.author || '-') },
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.published ? 'success' : 'default', bordered: false, style: 'cursor: pointer', onClick: () => togglePublish(row) }, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{ title: '时间', key: 'createdAt', width: 100, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.createdAt?.slice(0, 10)) },
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/blogs/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.title}」?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try { const d = await adminFetch(`/api/admin/articles?type=blog&page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`); list.value = d.items; total.value = d.total; totalPages.value = d.totalPages }
|
||||
catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try { await adminFetch(`/api/admin/articles/${item.id}`, { method: 'PUT', body: { published: !item.published } }); item.published = !item.published }
|
||||
catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try { await adminFetch(`/api/admin/articles/${item.id}`, { method: 'DELETE' }); message.success('已删除'); loadData() }
|
||||
catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<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>
|
||||
@@ -0,0 +1,92 @@
|
||||
<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">管理品牌名称、slogan、信任数据等基础信息</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/" target="_blank" secondary>查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<n-space vertical :size="16">
|
||||
<n-card title="基础信息">
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="4">
|
||||
<n-gi><n-form-item label="品牌名"><n-input v-model:value="data.name" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="全称"><n-input v-model:value="data.fullName" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="域名"><n-input v-model:value="data.domain" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="网址"><n-input v-model:value="data.url" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="情感 Slogan"><n-input v-model:value="data.slogan.emotional" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="功能 Slogan"><n-input v-model:value="data.slogan.functional" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="ICP 主体"><n-input v-model:value="data.icpEntity" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="ICP 备案号"><n-input v-model:value="data.icp" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="信任数据">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.trustStats.push({ value: '', unit: '', label: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(s, i) in data.trustStats" :key="i" align="center">
|
||||
<n-input v-model:value="s.value" placeholder="数值" style="width: 80px" />
|
||||
<n-input v-model:value="s.unit" placeholder="单位" style="width: 60px" />
|
||||
<n-input v-model:value="s.label" placeholder="标签" style="width: 160px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.trustStats.splice(i, 1)">删</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card title="差异化优势">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.differentiators.push({ title: '', description: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(d, i) in data.differentiators" :key="i" align="center">
|
||||
<n-input v-model:value="d.title" placeholder="标题" style="width: 150px" />
|
||||
<n-input v-model:value="d.description" placeholder="描述" style="width: 400px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.differentiators.splice(i, 1)">删</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ slogan: {}, trustStats: [], differentiators: [] })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=brand')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'brand', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch (e) {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="flex-start" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<div style="font-size: 22px; font-weight: 600; color: #111827; margin-bottom: 4px;">旅行日历</div>
|
||||
<div style="font-size: 13px; color: #9ca3af;">管理12个月份的出行建议,对应官网「旅行日历」页面</div>
|
||||
</div>
|
||||
<n-space>
|
||||
<a href="/calendar" target="_blank" style="text-decoration: none;">
|
||||
<n-button>查看前台 ↗</n-button>
|
||||
</a>
|
||||
<n-button type="primary" :loading="saving" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin v-if="!loaded" style="display:block; padding: 60px; text-align: center;" />
|
||||
<template v-else>
|
||||
<n-card title="页面概述" style="margin-bottom: 20px;">
|
||||
<n-form-item label="页面标题">
|
||||
<n-input v-model:value="data.meta.pageTitle" />
|
||||
</n-form-item>
|
||||
<n-form-item label="最佳时间">
|
||||
<n-input v-model:value="data.meta.bestTime" />
|
||||
</n-form-item>
|
||||
<n-form-item label="页面介绍">
|
||||
<n-input v-model:value="data.meta.pageIntro" type="textarea" :rows="3" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16" responsive="screen" :collapsed-rows="999">
|
||||
<n-gi v-for="m in data.months" :key="m.month">
|
||||
<n-card
|
||||
:style="m.isPeakSeason ? 'border: 2px solid #3a7d44;' : ''"
|
||||
>
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center">
|
||||
<span style="font-size: 15px; font-weight: 600;">{{ m.month }}月 · {{ m.name }}</span>
|
||||
<n-tag size="small">{{ m.season }}</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-form-item label="温度范围" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.tempRange" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="天气特征" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.weatherFeature" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="推荐指数 (1-5)" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input-number v-model:value="m.ratingStars" :min="1" :max="5" size="small" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Tagline" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.tagline" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="穿衣建议" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.clothingTips" type="textarea" :rows="2" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="亮点(每行一个)" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
size="small"
|
||||
:value="(m.highlights||[]).join('\n')"
|
||||
@input="m.highlights = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-space align="center" style="margin-top: 4px;">
|
||||
<n-tag
|
||||
:type="m.isPeakSeason ? 'success' : 'default'"
|
||||
checkable
|
||||
:checked="m.isPeakSeason"
|
||||
@update:checked="m.isPeakSeason = $event"
|
||||
size="small"
|
||||
>
|
||||
旺季
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NFormItem, NSpin, NGrid, NGi, NTag } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ meta: {}, months: [] })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() { const res = await adminFetch('/api/admin/site-content?key=calendar'); data.value = res.data; loaded.value = true }
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'calendar', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch { message.error('保存失败') } finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,127 @@
|
||||
<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 tag="a" href="/contact" target="_blank" secondary>查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<n-space vertical :size="16">
|
||||
<!-- 联系渠道 -->
|
||||
<n-card title="联系渠道">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addChannel">添加渠道</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="12">
|
||||
<div
|
||||
v-for="(ch, idx) in data.channels"
|
||||
:key="idx"
|
||||
style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 14px"
|
||||
>
|
||||
<n-space justify="space-between" align="flex-start">
|
||||
<n-form label-placement="top" style="flex: 1">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="4">
|
||||
<n-gi>
|
||||
<n-form-item label="类型">
|
||||
<n-select
|
||||
v-model:value="ch.type"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="标签">
|
||||
<n-input v-model:value="ch.label" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="值">
|
||||
<n-input v-model:value="ch.value" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="ch.description" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label=" ">
|
||||
<n-space>
|
||||
<n-checkbox v-model:checked="ch.primary">主要</n-checkbox>
|
||||
<n-checkbox v-model:checked="ch.hidden">隐藏</n-checkbox>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi v-if="ch.type === 'wechat' || ch.type === 'miniprogram'" :span="2">
|
||||
<n-form-item label="二维码图片">
|
||||
<AdminImageUpload v-model="ch.qrImage" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
<n-button size="tiny" type="error" quaternary @click="data.channels.splice(idx, 1)">删除</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<n-card title="安全提示文案">
|
||||
<n-input v-model:value="data.securityNotice" type="textarea" :rows="3" />
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NSelect, NCheckbox } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ channels: [], securityNotice: '' })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '微信', value: 'wechat' },
|
||||
{ label: '小程序', value: 'miniprogram' },
|
||||
{ label: '电话', value: 'phone' },
|
||||
{ label: '地址', value: 'address' },
|
||||
{ label: '邮箱', value: 'email' },
|
||||
{ label: '投诉', value: 'complaint' },
|
||||
{ label: '招聘', value: 'hr' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=contact')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function addChannel() {
|
||||
data.value.channels.push({ type: 'phone', label: '', value: '', description: '', primary: false, hidden: false })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'contact', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch (e) {
|
||||
message.error('保存失败:' + (e.data?.message || ''))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">小蒙马夏令营</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理研学课程体系,对应官网「夏令营」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/summer-camp" target="_blank">查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<!-- 教育理念 -->
|
||||
<n-card v-if="data.philosophy" style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.phil = !open.phil">
|
||||
<span>教育理念</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.phil ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.phil">
|
||||
<div v-for="(val, key) in data.philosophy" :key="key" style="margin-bottom: 10px;">
|
||||
<n-form-item :label="key">
|
||||
<n-input
|
||||
v-if="typeof val === 'string'"
|
||||
v-model:value="data.philosophy[key]"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 年龄分组 -->
|
||||
<n-card
|
||||
v-for="(ag, i) in (data.ageGroups || [])"
|
||||
:key="'ag' + i"
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open['ag' + i] = !open['ag' + i]">
|
||||
<span>{{ ag.emoji || '' }} {{ ag.label }} — {{ ag.headline || '' }}</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open['ag' + i] ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open['ag' + i]">
|
||||
<n-grid :cols="2" :x-gap="10" :y-gap="10" style="margin-bottom: 12px;">
|
||||
<n-gi>
|
||||
<n-form-item label="ID">
|
||||
<n-input v-model:value="ag.id" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="标签">
|
||||
<n-input v-model:value="ag.label" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="ag.headline" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="侧重点">
|
||||
<n-input v-model:value="ag.focus" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="ag.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item label="亮点(每行一个)">
|
||||
<n-input
|
||||
:value="(ag.highlights || []).join('\n')"
|
||||
@input="ag.highlights = $event.split('\n').filter(Boolean)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 课程模块 -->
|
||||
<n-card
|
||||
v-for="(m, i) in (data.modules || [])"
|
||||
:key="'m' + i"
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open['m' + i] = !open['m' + i]">
|
||||
<span>{{ m.icon || '' }} {{ m.name }}</span>
|
||||
<n-space>
|
||||
<n-tag size="small" type="success">{{ m.ageRange }}</n-tag>
|
||||
<n-tag size="small" type="info">{{ m.duration }}</n-tag>
|
||||
<span style="color: #999; font-size: 12px;">{{ open['m' + i] ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open['m' + i]">
|
||||
<n-grid :cols="2" :x-gap="10" :y-gap="10" style="margin-bottom: 12px;">
|
||||
<n-gi>
|
||||
<n-form-item label="ID">
|
||||
<n-input v-model:value="m.id" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="m.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="年龄段">
|
||||
<n-input v-model:value="m.ageRange" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="时长">
|
||||
<n-input v-model:value="m.duration" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="简介">
|
||||
<n-input v-model:value="m.shortDesc" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item label="教学目标(每行一个)">
|
||||
<n-input
|
||||
:value="(m.objectives || []).join('\n')"
|
||||
@input="m.objectives = $event.split('\n').filter(Boolean)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="活动内容(每行一个)">
|
||||
<n-input
|
||||
:value="(m.activities || []).join('\n')"
|
||||
@input="m.activities = $event.split('\n').filter(Boolean)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="教学理念">
|
||||
<n-input v-model:value="m.philosophy" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- FAQ -->
|
||||
<n-card v-if="data.faq?.length" style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.faq = !open.faq">
|
||||
<span>常见问题 ({{ data.faq.length }})</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.faq ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.faq">
|
||||
<div
|
||||
v-for="(q, i) in data.faq"
|
||||
:key="i"
|
||||
style="padding: 10px; background: #fafbfc; border-radius: 6px; margin-bottom: 8px;"
|
||||
>
|
||||
<n-form-item label="Q">
|
||||
<n-input v-model:value="q.q" />
|
||||
</n-form-item>
|
||||
<n-form-item label="A">
|
||||
<n-input v-model:value="q.a" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NFormItem, NSpin, NGrid, NGi, NTag } 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)
|
||||
const open = reactive({})
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=courses')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'courses', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<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 tag="a" href="/customize" target="_blank" secondary>查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<n-space vertical :size="16">
|
||||
<!-- 信任数据 -->
|
||||
<n-card title="信任数据">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.trustStats.push({ value: '', unit: '', label: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(s, i) in data.trustStats" :key="i" align="center">
|
||||
<n-input v-model:value="s.value" placeholder="数值" style="width: 80px" />
|
||||
<n-input v-model:value="s.unit" placeholder="单位" style="width: 60px" />
|
||||
<n-input v-model:value="s.label" placeholder="标签" style="width: 160px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.trustStats.splice(i, 1)">删</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 出行天数 + 预算 两列 -->
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-gi>
|
||||
<n-card title="出行天数选项">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.durations.push({ value: '', label: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(d, i) in data.durations" :key="i" align="center">
|
||||
<n-input v-model:value="d.value" placeholder="值" style="width: 80px" />
|
||||
<n-input v-model:value="d.label" placeholder="标签" style="width: 140px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.durations.splice(i, 1)">删</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card title="预算选项">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.budgets.push({ value: '', label: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(b, i) in data.budgets" :key="i" align="center">
|
||||
<n-input v-model:value="b.value" placeholder="值" style="width: 80px" />
|
||||
<n-input v-model:value="b.label" placeholder="标签" style="width: 140px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.budgets.splice(i, 1)">删</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 活动偏好 -->
|
||||
<n-card title="活动偏好选项">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.activities.push({ value: '', label: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space :size="8" style="flex-wrap: wrap">
|
||||
<n-space v-for="(a, i) in data.activities" :key="i" align="center" :size="4" style="background: #f5f5f5; border-radius: 4px; padding: 4px 8px">
|
||||
<n-input v-model:value="a.label" style="width: 100px" size="small" :bordered="false" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.activities.splice(i, 1)">✕</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 定制流程 -->
|
||||
<n-card title="定制流程">
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(p, i) in data.process" :key="i" align="center">
|
||||
<div style="width: 24px; height: 24px; background: #3a7d44; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; flex-shrink: 0">
|
||||
{{ i + 1 }}
|
||||
</div>
|
||||
<n-input v-model:value="p.title" placeholder="步骤标题" style="width: 120px" />
|
||||
<n-input v-model:value="p.desc" placeholder="步骤描述" style="width: 360px" />
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<n-card title="联系方式">
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="微信"><n-input v-model:value="data.contact.wechat" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="提示文案"><n-input v-model:value="data.contact.tip" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ trustStats: [], durations: [], budgets: [], activities: [], process: [], contact: {} })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=customize')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'customize', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,123 @@
|
||||
<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>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="flex-start" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<div style="font-size: 22px; font-weight: 600; color: #111827; margin-bottom: 4px;">出行指南</div>
|
||||
<div style="font-size: 13px; color: #9ca3af;">管理出行准备指南各章节内容,对应官网「出行指南」页面</div>
|
||||
</div>
|
||||
<n-space>
|
||||
<a href="/guides" target="_blank" style="text-decoration: none;">
|
||||
<n-button>查看前台 ↗</n-button>
|
||||
</a>
|
||||
<n-button type="primary" :loading="saving" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin v-if="!loaded" style="display:block; padding: 60px; text-align: center;" />
|
||||
<template v-else>
|
||||
<n-card title="页面介绍" style="margin-bottom: 12px;">
|
||||
<n-input v-model:value="data.pageIntro" type="textarea" :rows="3" />
|
||||
</n-card>
|
||||
|
||||
<n-card
|
||||
v-for="(s, i) in data.sections"
|
||||
:key="i"
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<template #header>
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; cursor: pointer; user-select: none;"
|
||||
@click="open[i] = !open[i]"
|
||||
>
|
||||
<span style="font-size: 15px; color: #333;">{{ s.icon || '' }} {{ s.title }}</span>
|
||||
<n-space align="center">
|
||||
<span style="font-size: 12px; color: #9ca3af;">{{ s.subtitle || '' }}</span>
|
||||
<span style="font-size: 12px; color: #999; transition: transform 0.2s; display: inline-block;" :style="open[i] ? 'transform: rotate(90deg)' : ''">▸</span>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
<div v-show="open[i]">
|
||||
<n-grid :cols="2" :x-gap="10" style="margin-bottom: 12px;">
|
||||
<n-gi>
|
||||
<n-form-item label="ID" :show-feedback="false">
|
||||
<n-input v-model:value="s.id" size="small" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="标题" :show-feedback="false">
|
||||
<n-input v-model:value="s.title" size="small" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="副标题" :show-feedback="false">
|
||||
<n-input v-model:value="s.subtitle" size="small" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="图标" :show-feedback="false">
|
||||
<n-input v-model:value="s.icon" size="small" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="正文内容" :show-feedback="false" style="margin-bottom: 10px;">
|
||||
<n-input v-model:value="s.content" type="textarea" :rows="4" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="s.tips" label="Tips(每行一个)" :show-feedback="false" style="margin-bottom: 10px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:value="(s.tips||[]).join('\n')"
|
||||
@input="s.tips = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<div v-if="s.items && s.items.length" style="margin-top: 14px; border-top: 1px solid #f0f0f0; padding-top: 10px;">
|
||||
<div style="font-size: 14px; color: #555; margin-bottom: 10px; font-weight: 500;">子项列表</div>
|
||||
<n-space v-for="(item, ii) in s.items" :key="ii" align="center" style="margin-bottom: 6px;">
|
||||
<template v-if="typeof item === 'string'">
|
||||
<n-input
|
||||
:value="item"
|
||||
@input="s.items[ii] = $event"
|
||||
size="small"
|
||||
style="flex: 1; min-width: 200px;"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-input v-model:value="item.text" placeholder="文字" size="small" style="flex: 1; min-width: 200px;" />
|
||||
<n-tag
|
||||
:type="item.important ? 'warning' : 'default'"
|
||||
checkable
|
||||
:checked="item.important"
|
||||
@update:checked="item.important = $event"
|
||||
size="small"
|
||||
>
|
||||
重要
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NFormItem, NSpin, NGrid, NGi, NTag } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ pageIntro: '', sections: [] })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
const open = reactive({})
|
||||
|
||||
async function load() { const res = await adminFetch('/api/admin/site-content?key=guides'); data.value = res.data; loaded.value = true }
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'guides', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch { message.error('保存失败') } finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<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-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<n-space vertical :size="16">
|
||||
<n-card v-for="(group, groupKey) in data" :key="groupKey" :title="groupLabels[groupKey] || groupKey">
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16">
|
||||
<n-gi v-for="(val, imgKey) in group" :key="imgKey">
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6b7280; margin-bottom: 6px; font-weight: 500">{{ imgKey }}</div>
|
||||
<AdminImageUpload v-model="data[groupKey][imgKey]" />
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NSpin, NGrid, NGi } 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)
|
||||
|
||||
const groupLabels = {
|
||||
logo: 'Logo', hero: '首页横幅', brandStory: '品牌故事',
|
||||
mascot: '吉祥物', team: '团队', xiaohongshu: '小红书', summerCamp: '夏令营',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=images')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'images', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">合作伙伴</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理政府、协会、学术、景区、酒店等合作伙伴信息</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/partners" target="_blank">查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<n-card
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open[section.key] = !open[section.key]">
|
||||
<span>{{ section.label }} ({{ (data[section.key] || []).length }})</span>
|
||||
<n-space>
|
||||
<n-button size="small" type="primary" @click.stop="addItem(section.key, section.template)">添加</n-button>
|
||||
<span style="color: #999; font-size: 12px;">{{ open[section.key] ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<div v-show="open[section.key]">
|
||||
<div
|
||||
v-for="(item, i) in (data[section.key] || [])"
|
||||
:key="i"
|
||||
style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap;"
|
||||
>
|
||||
<n-input v-model:value="item.name" placeholder="名称" style="flex: 2; min-width: 100px;" />
|
||||
<n-input v-if="item.type !== undefined" v-model:value="item.type" placeholder="类型" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.description !== undefined" v-model:value="item.description" placeholder="描述" style="flex: 2; min-width: 100px;" />
|
||||
<n-input v-if="item.relationship !== undefined" v-model:value="item.relationship" placeholder="关系" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.platform !== undefined" v-model:value="item.platform" placeholder="平台" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.status !== undefined" v-model:value="item.status" placeholder="状态" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.detail !== undefined" v-model:value="item.detail" placeholder="详情" style="flex: 2; min-width: 100px;" />
|
||||
<n-input v-if="item.outlet !== undefined" v-model:value="item.outlet" placeholder="媒体" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.title !== undefined" v-model:value="item.title" placeholder="标题" style="flex: 2; min-width: 100px;" />
|
||||
<n-input v-if="item.date !== undefined" v-model:value="item.date" placeholder="日期" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.url !== undefined" v-model:value="item.url" placeholder="链接" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.summary !== undefined" v-model:value="item.summary" placeholder="摘要" style="flex: 2; min-width: 100px;" />
|
||||
<n-space v-if="item.verified !== undefined" align="center">
|
||||
<n-switch v-model:value="item.verified" size="small" />
|
||||
<span style="font-size: 12px; color: #666; white-space: nowrap;">已认证</span>
|
||||
</n-space>
|
||||
<n-button size="small" type="error" @click="data[section.key].splice(i, 1)">删</n-button>
|
||||
</div>
|
||||
<p v-if="!(data[section.key] || []).length" style="color: #ccc; font-size: 13px; text-align: center; padding: 10px;">暂无数据</p>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NSpin, NSwitch } 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)
|
||||
const open = reactive({})
|
||||
|
||||
const sections = [
|
||||
{ key: 'govPartners', label: '政府合作', template: { name: '', type: '', description: '', verified: false } },
|
||||
{ key: 'associations', label: '行业协会', template: { name: '', type: '', relationship: '', verified: false } },
|
||||
{ key: 'academicCoops', label: '学术合作', template: { name: '', type: '', relationship: '', description: '', verified: false } },
|
||||
{ key: 'scenicPartners', label: '景区合作', template: { name: '', description: '', verified: false } },
|
||||
{ key: 'hotelPartners', label: '酒店合作', template: { name: '', description: '', verified: false } },
|
||||
{ key: 'platformEndorsements', label: '平台认证', template: { platform: '', status: '', detail: '', verified: false } },
|
||||
{ key: 'mediaReports', label: '媒体报道', template: { outlet: '', title: '', date: '', url: '', summary: '' } },
|
||||
]
|
||||
|
||||
function addItem(key, template) {
|
||||
if (!data.value[key]) data.value[key] = []
|
||||
data.value[key].push({ ...template })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=partners')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'partners', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">资质荣誉</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理公司资质、荣誉、保障信息,对应官网「资质荣誉」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/qualifications" target="_blank">查看前台 ↗</n-button>
|
||||
<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="安全保障" style="margin-bottom: 16px;">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addTo('safetyGuarantees', { title: '', detail: '' })">添加</n-button>
|
||||
</template>
|
||||
<div v-for="(s, i) in data.safetyGuarantees" :key="i" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px;">
|
||||
<n-input v-model:value="s.title" placeholder="标题" style="width: 160px;" />
|
||||
<n-input v-model:value="s.detail" placeholder="详情" style="flex: 1;" />
|
||||
<n-button size="small" type="error" @click="data.safetyGuarantees.splice(i, 1)">删</n-button>
|
||||
</div>
|
||||
<n-button dashed block @click="addTo('safetyGuarantees', { title: '', detail: '' })">+ 添加安全保障</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- 资质证照 -->
|
||||
<n-card title="资质证照" style="margin-bottom: 16px;">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addTo('licenses', { holder: '', issuer: '', licenseNo: '', validPeriod: '', note: '' })">添加</n-button>
|
||||
</template>
|
||||
<div v-for="(l, i) in data.licenses" :key="i" style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px; margin-bottom: 8px; display: flex; gap: 8px; align-items: flex-start;">
|
||||
<div style="flex: 1; display: flex; flex-wrap: wrap; gap: 8px;">
|
||||
<n-input v-model:value="l.holder" placeholder="持有人" style="flex: 1; min-width: 100px;" />
|
||||
<n-input v-model:value="l.issuer" placeholder="颁发机构" style="flex: 1; min-width: 100px;" />
|
||||
<n-input v-model:value="l.licenseNo" placeholder="证号" style="flex: 1; min-width: 100px;" />
|
||||
<n-input v-model:value="l.validPeriod" placeholder="有效期" style="flex: 1; min-width: 100px;" />
|
||||
<n-input v-model:value="l.note" placeholder="备注" style="flex: 1; min-width: 100px;" />
|
||||
</div>
|
||||
<n-button size="small" type="error" @click="data.licenses.splice(i, 1)">删</n-button>
|
||||
</div>
|
||||
<n-button dashed block @click="addTo('licenses', { holder: '', issuer: '', licenseNo: '', validPeriod: '', note: '' })">+ 添加资质证照</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- 荣誉奖项 -->
|
||||
<n-card title="荣誉奖项" style="margin-bottom: 16px;">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addTo('awards', { year: '', title: '', detail: '', issuer: '' })">添加</n-button>
|
||||
</template>
|
||||
<div v-for="(a, i) in data.awards" :key="i" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px;">
|
||||
<n-input v-model:value="a.year" placeholder="年份" style="width: 70px;" />
|
||||
<n-input v-model:value="a.title" placeholder="奖项名称" style="width: 200px;" />
|
||||
<n-input v-model:value="a.issuer" placeholder="颁发方" style="width: 130px;" />
|
||||
<n-input v-model:value="a.detail" placeholder="详情" style="flex: 1;" />
|
||||
<n-button size="small" type="error" @click="data.awards.splice(i, 1)">删</n-button>
|
||||
</div>
|
||||
<n-button dashed block @click="addTo('awards', { year: '', title: '', detail: '', issuer: '' })">+ 添加荣誉奖项</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- 保险信息 -->
|
||||
<n-card v-if="data.insurance" title="保险信息" style="margin-bottom: 16px;">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="data.insurance.title" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="保险公司">
|
||||
<n-input v-model:value="data.insurance.insurer" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi :span="2">
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="data.insurance.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="保障范围">
|
||||
<n-input v-model:value="data.insurance.policyScope" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="理赔联系">
|
||||
<n-input v-model:value="data.insurance.claimContact" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NFormItem, NSpin, NGrid, NGi } 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=qualifications')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function addTo(key, template) {
|
||||
if (!data.value[key]) data.value[key] = []
|
||||
data.value[key].push({ ...template })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'qualifications', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div style="max-width: 800px;">
|
||||
<NBreadcrumb style="margin-bottom: 16px;">
|
||||
<NBreadcrumbItem>
|
||||
<NuxtLink to="/admin/content/selector">产品选择器</NuxtLink>
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem>Q{{ idx + 1 }}: {{ form.text || '编辑' }}</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 24px;">
|
||||
<div style="font-size: 20px; font-weight: 600; color: #111827;">编辑问题 Q{{ idx + 1 }}</div>
|
||||
<NSpace :size="8">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回列表</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="pageLoading">
|
||||
<template v-if="!pageLoading">
|
||||
<NCard style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<span style="font-size: 14px; font-weight: 600; color: #374151;">问题设置</span>
|
||||
</template>
|
||||
<NForm label-placement="top">
|
||||
<NFormItem label="问题文本">
|
||||
<NInput v-model:value="form.text" />
|
||||
</NFormItem>
|
||||
<NFormItem label="提示语">
|
||||
<NInput v-model:value="form.hint" />
|
||||
</NFormItem>
|
||||
<NGrid v-if="form.condition" :cols="2" :x-gap="12">
|
||||
<NGi>
|
||||
<NFormItem label="条件:依赖问题">
|
||||
<NInput v-model:value="form.condition.key" placeholder="如 q2" />
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<NFormItem label="条件:选择值">
|
||||
<NInput v-model:value="form.condition.value" />
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NForm>
|
||||
</NCard>
|
||||
|
||||
<NCard style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<NSpace justify="space-between" align="center">
|
||||
<span style="font-size: 14px; font-weight: 600; color: #374151;">选项 ({{ form.options?.length || 0 }})</span>
|
||||
<NButton size="small" type="primary" @click="addOption">+ 添加选项</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
<NSpace vertical :size="10">
|
||||
<div
|
||||
v-for="(opt, i) in (form.options || [])"
|
||||
:key="i"
|
||||
style="display: flex; gap: 12px; align-items: flex-start; padding: 14px; background: #f9fafb; border: 1px solid #f3f4f6; border-radius: 8px;"
|
||||
>
|
||||
<div style="width: 28px; height: 28px; background: #e5e7eb; color: #374151; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; flex-shrink: 0; margin-top: 4px;">
|
||||
{{ i + 1 }}
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<NGrid :cols="2" :x-gap="8">
|
||||
<NGi>
|
||||
<NFormItem label="标签" label-style="font-size: 13px;" style="margin-bottom: 8px;">
|
||||
<NInput v-model:value="opt.label" size="small" />
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<NFormItem label="值" label-style="font-size: 13px;" style="margin-bottom: 8px;">
|
||||
<NInput v-model:value="opt.value" size="small" />
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
<NGi :span="2">
|
||||
<NFormItem label="描述" label-style="font-size: 13px;" style="margin-bottom: 0;">
|
||||
<NInput v-model:value="opt.desc" size="small" />
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
<NButton size="small" type="error" ghost @click="form.options.splice(i, 1)" style="flex-shrink: 0; margin-top: 4px;">删除</NButton>
|
||||
</div>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<div v-if="formError" style="color: #ef4444; font-size: 13px; margin-bottom: 12px;">{{ formError }}</div>
|
||||
|
||||
<div class="save-bar">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回列表</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</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-selector-edit' })
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const idx = computed(() => Number(route.params.idx))
|
||||
const form = ref({})
|
||||
const pageLoading = ref(true)
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
async function loadItem() {
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=selector')
|
||||
const q = res.data.questions?.[idx.value]
|
||||
if (!q) { formError.value = '不存在'; return }
|
||||
form.value = JSON.parse(JSON.stringify(q))
|
||||
} catch (e) {
|
||||
formError.value = '加载失败'
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
if (!form.value.options) form.value.options = []
|
||||
form.value.options.push({ label: '', value: '', desc: '' })
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
formError.value = ''
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=selector')
|
||||
res.data.questions[idx.value] = form.value
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'selector', data: res.data } })
|
||||
message.success('保存成功')
|
||||
} catch (e) {
|
||||
formError.value = e.data?.message || '保存失败'
|
||||
message.error(formError.value)
|
||||
} 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,89 @@
|
||||
<template>
|
||||
<div style="max-width: 900px;">
|
||||
<NSpace justify="space-between" align="start" 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="/selector" target="_blank">
|
||||
<NButton>查看前台</NButton>
|
||||
</a>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<!-- 问题列表 -->
|
||||
<div style="font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 10px;">
|
||||
问题列表 ({{ data.questions?.length || 0 }})
|
||||
</div>
|
||||
<NSpace vertical :size="8" style="margin-bottom: 24px;">
|
||||
<NuxtLink
|
||||
v-for="(q, i) in data.questions"
|
||||
:key="i"
|
||||
:to="`/admin/content/selector/${i}`"
|
||||
style="text-decoration: none; color: inherit; display: block;"
|
||||
>
|
||||
<NCard
|
||||
size="small"
|
||||
hoverable
|
||||
style="cursor: pointer;"
|
||||
:content-style="{ padding: '14px 20px' }"
|
||||
>
|
||||
<NSpace justify="space-between" align="center">
|
||||
<NSpace align="center" :size="14">
|
||||
<div style="width: 36px; height: 36px; background: #3a7d44; color: #fff; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0;">
|
||||
Q{{ i + 1 }}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 14px; font-weight: 600; color: #111827; margin-bottom: 2px;">{{ q.text }}</div>
|
||||
<div style="font-size: 12px; color: #9ca3af;">{{ q.hint }}</div>
|
||||
</div>
|
||||
</NSpace>
|
||||
<NSpace align="center" :size="8">
|
||||
<NTag type="success" size="small">{{ q.options?.length || 0 }} 个选项</NTag>
|
||||
<NTag v-if="q.condition" type="warning" size="small">条件显示</NTag>
|
||||
<span style="font-size: 20px; color: #d1d5db;">›</span>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</NuxtLink>
|
||||
</NSpace>
|
||||
|
||||
<!-- 评分矩阵 -->
|
||||
<div style="font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 10px;">评分矩阵</div>
|
||||
<NCard style="margin-bottom: 24px;" :content-style="{ padding: '20px' }">
|
||||
<div style="font-size: 13px; color: #6b7280; margin-bottom: 12px;">每个问题的每个选项对应各产品的评分权重,用于计算最终推荐排名。</div>
|
||||
<NuxtLink to="/admin/content/selector/scoring" style="color: #3a7d44; font-size: 14px; font-weight: 500; text-decoration: none;">
|
||||
编辑评分矩阵 ›
|
||||
</NuxtLink>
|
||||
</NCard>
|
||||
|
||||
<!-- 推荐理由 -->
|
||||
<div style="font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 10px;">推荐理由</div>
|
||||
<NCard :content-style="{ padding: '20px' }">
|
||||
<div style="font-size: 13px; color: #6b7280; margin-bottom: 12px;">每个产品在不同匹配情况下的推荐文案。</div>
|
||||
<NuxtLink to="/admin/content/selector/reasons" style="color: #3a7d44; font-size: 14px; font-weight: 500; text-decoration: none;">
|
||||
编辑推荐理由 ›
|
||||
</NuxtLink>
|
||||
</NCard>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NTag, NSpin } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const data = ref({ questions: [] })
|
||||
const loaded = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await adminFetch('/api/admin/site-content?key=selector')
|
||||
data.value = r.data
|
||||
} catch {}
|
||||
loaded.value = true
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div style="max-width: 900px;">
|
||||
<NBreadcrumb style="margin-bottom: 16px;">
|
||||
<NBreadcrumbItem>
|
||||
<NuxtLink to="/admin/content/selector">产品选择器</NuxtLink>
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem>推荐理由</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 16px;">
|
||||
<div style="font-size: 20px; font-weight: 600; color: #111827;">推荐理由</div>
|
||||
<NSpace :size="8">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<div style="font-size: 13px; color: #6b7280; margin-bottom: 20px;">
|
||||
每个产品在不同匹配情况下展示的推荐文案。
|
||||
</div>
|
||||
|
||||
<NCard
|
||||
v-for="(reasons, pid) in matchReasons"
|
||||
:key="pid"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<template #header>
|
||||
<span style="font-size: 14px; font-weight: 600; color: #374151;">{{ pid }}</span>
|
||||
</template>
|
||||
<NForm label-placement="top">
|
||||
<NFormItem
|
||||
v-for="(text, rKey) in reasons"
|
||||
:key="rKey"
|
||||
:label="rKey"
|
||||
:label-style="{ fontFamily: 'monospace', fontSize: '12px', color: '#9ca3af' }"
|
||||
>
|
||||
<NInput v-model:value="matchReasons[pid][rKey]" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
|
||||
<div class="save-bar">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const message = useMessage()
|
||||
const { adminFetch } = useAdmin()
|
||||
const fullData = ref({})
|
||||
const matchReasons = ref({})
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=selector')
|
||||
fullData.value = res.data
|
||||
matchReasons.value = JSON.parse(JSON.stringify(res.data.matchReasons || {}))
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
fullData.value.matchReasons = matchReasons.value
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'selector', data: fullData.value } })
|
||||
message.success('保存成功')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</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,136 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px;">
|
||||
<NBreadcrumb style="margin-bottom: 16px;">
|
||||
<NBreadcrumbItem>
|
||||
<NuxtLink to="/admin/content/selector">产品选择器</NuxtLink>
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem>评分矩阵</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 16px;">
|
||||
<div style="font-size: 20px; font-weight: 600; color: #111827;">评分矩阵</div>
|
||||
<NSpace :size="8">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<div style="font-size: 13px; color: #6b7280; margin-bottom: 20px;">
|
||||
每个问题的每个选项对应各产品的评分(0-10),分数越高越推荐该产品。
|
||||
</div>
|
||||
|
||||
<NCard
|
||||
v-for="(qScores, qId) in scoring"
|
||||
:key="qId"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<template #header>
|
||||
<span style="font-size: 14px; font-weight: 600; color: #374151;">
|
||||
{{ qId }} — {{ questionTexts[qId] || '' }}
|
||||
</span>
|
||||
</template>
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 6px 8px; border: 1px solid #e5e7eb; background: #f9fafb; font-weight: 600; color: #374151; font-size: 11px; white-space: nowrap; text-align: center;">选项</th>
|
||||
<th
|
||||
v-for="pid in productIds"
|
||||
:key="pid"
|
||||
style="padding: 6px 8px; border: 1px solid #e5e7eb; background: #f9fafb; font-weight: 600; color: #374151; font-size: 11px; white-space: nowrap; text-align: center;"
|
||||
>{{ pid }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(products, optValue) in qScores" :key="optValue">
|
||||
<td style="padding: 6px 8px; border: 1px solid #e5e7eb; text-align: left; font-weight: 500; color: #111827; white-space: nowrap; min-width: 100px;">{{ optValue }}</td>
|
||||
<td v-for="pid in productIds" :key="pid" style="padding: 6px 8px; border: 1px solid #e5e7eb; text-align: center;">
|
||||
<NInputNumber
|
||||
:value="products[pid] || 0"
|
||||
:min="0"
|
||||
:max="10"
|
||||
size="small"
|
||||
style="width: 64px;"
|
||||
:show-button="false"
|
||||
@update:value="val => setScore(qId, optValue, pid, Number(val || 0))"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<div class="save-bar">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInputNumber, NSpin, NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const message = useMessage()
|
||||
const { adminFetch } = useAdmin()
|
||||
const fullData = ref({})
|
||||
const scoring = ref({})
|
||||
const questionTexts = ref({})
|
||||
const productIds = ref([])
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=selector')
|
||||
fullData.value = res.data
|
||||
scoring.value = JSON.parse(JSON.stringify(res.data.scoring || {}))
|
||||
// 收集所有产品 ID
|
||||
const pids = new Set()
|
||||
for (const q of Object.values(scoring.value)) {
|
||||
for (const opts of Object.values(q)) {
|
||||
for (const pid of Object.keys(opts)) pids.add(pid)
|
||||
}
|
||||
}
|
||||
productIds.value = [...pids].sort()
|
||||
// 问题文本映射
|
||||
for (const q of (res.data.questions || [])) {
|
||||
questionTexts.value[q.id] = q.text
|
||||
}
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function setScore(qId, optValue, pid, val) {
|
||||
if (!scoring.value[qId][optValue]) scoring.value[qId][optValue] = {}
|
||||
if (val > 0) scoring.value[qId][optValue][pid] = val
|
||||
else delete scoring.value[qId][optValue][pid]
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
fullData.value.scoring = scoring.value
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'selector', data: fullData.value } })
|
||||
message.success('保存成功')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</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,81 @@
|
||||
<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">SEO 配置</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #9ca3af">管理各页面的 SEO 标题、描述、关键词</p>
|
||||
</div>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<n-space vertical :size="16">
|
||||
<n-card v-for="(seo, pageKey) in data.pages" :key="pageKey">
|
||||
<template #header>
|
||||
<n-space align="center" :size="8">
|
||||
<span>{{ pageLabels[pageKey] || pageKey }}</span>
|
||||
<n-tag size="small" :bordered="false" style="font-size: 11px; color: #aaa; background: #f0f0f0">{{ pageKey }}</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="Title">
|
||||
<n-input v-model:value="seo.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="H1">
|
||||
<n-input v-model:value="seo.h1" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Description">
|
||||
<n-input v-model:value="seo.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Keywords">
|
||||
<n-input v-model:value="seo.keywords" placeholder="逗号分隔" />
|
||||
</n-form-item>
|
||||
<n-form-item label="OG Image">
|
||||
<AdminImageUpload v-model="seo.ogImage" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NTag } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ pages: {} })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const pageLabels = {
|
||||
home: '首页', products: '产品页', 'summer-camp': '夏令营', 'winter-camp': '冬令营',
|
||||
destinations: '目的地', guides: '出行指南', calendar: '旅行日历', faq: '常见问答',
|
||||
reviews: '客户评价', gallery: '旅拍', about: '关于我们', contact: '联系我们',
|
||||
news: '最新动态', qualifications: '资质荣誉', blog: '博客', pricing: '价格',
|
||||
customize: '定制', selector: '产品选择器', stories: '客户故事', xiaohongshu: '小红书',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=seo')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'seo', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch (e) {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">小蒙马冬令营</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理冬令营产品信息,对应官网「冬令营」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/winter-camp" target="_blank">查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<!-- 基本信息 -->
|
||||
<n-card style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.base = !open.base">
|
||||
<span>基本信息</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.base ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.base">
|
||||
<n-grid :cols="2" :x-gap="10" :y-gap="10">
|
||||
<n-gi>
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="data.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="定位">
|
||||
<n-input v-model:value="data.positioning" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="天数">
|
||||
<n-input-number v-model:value="data.days" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="晚数">
|
||||
<n-input-number v-model:value="data.nights" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="最大家庭数">
|
||||
<n-input-number v-model:value="data.maxFamilies" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="年龄段">
|
||||
<n-input v-model:value="data.ageRange" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="定金">
|
||||
<n-input v-model:value="data.deposit" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="路线">
|
||||
<n-input v-model:value="data.route" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 行程 -->
|
||||
<n-card style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.itin = !open.itin">
|
||||
<span>每日行程 ({{ data.itinerary?.length || 0 }}天)</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.itin ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.itin">
|
||||
<div
|
||||
v-for="(day, i) in (data.itinerary || [])"
|
||||
:key="i"
|
||||
style="display: flex; gap: 12px; margin-bottom: 10px; padding: 10px; background: #fafbfc; border-radius: 6px;"
|
||||
>
|
||||
<div style="width: 36px; height: 36px; background: #3a7d44; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; flex-shrink: 0;">
|
||||
D{{ day.day || i + 1 }}
|
||||
</div>
|
||||
<div style="flex: 1; display: flex; flex-direction: column; gap: 8px;">
|
||||
<n-form-item label="当日标题" :show-feedback="false">
|
||||
<n-input v-model:value="day.title" placeholder="当日标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="行程概要" :show-feedback="false">
|
||||
<n-input v-model:value="day.summary" placeholder="行程概要" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="day.highlights" label="亮点(每行一个)" :show-feedback="false">
|
||||
<n-input
|
||||
:value="(day.highlights || []).join('\n')"
|
||||
@input="day.highlights = $event.split('\n').filter(Boolean)"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="day.hotel !== undefined" label="住宿" :show-feedback="false">
|
||||
<n-input v-model:value="day.hotel" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 住宿安排 -->
|
||||
<n-card style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.hotel = !open.hotel">
|
||||
<span>住宿安排 ({{ data.hotels?.length || 0 }})</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.hotel ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.hotel">
|
||||
<div
|
||||
v-for="(h, i) in (data.hotels || [])"
|
||||
:key="i"
|
||||
style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px; margin-bottom: 10px;"
|
||||
>
|
||||
<n-grid :cols="2" :x-gap="10" :y-gap="10" style="margin-bottom: 10px;">
|
||||
<n-gi>
|
||||
<n-form-item label="酒店名">
|
||||
<n-input v-model:value="h.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="星级">
|
||||
<n-input v-model:value="h.star" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="住几晚">
|
||||
<n-input-number v-model:value="h.nights" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="h.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- FAQ -->
|
||||
<n-card v-if="data.faq?.length" style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.faq = !open.faq">
|
||||
<span>常见问题 ({{ data.faq.length }})</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.faq ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.faq">
|
||||
<div
|
||||
v-for="(q, i) in data.faq"
|
||||
:key="i"
|
||||
style="padding: 10px; background: #fafbfc; border-radius: 6px; margin-bottom: 8px;"
|
||||
>
|
||||
<n-form-item label="Q">
|
||||
<n-input v-model:value="q.q" />
|
||||
</n-form-item>
|
||||
<n-form-item label="A">
|
||||
<n-input v-model:value="q.a" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NFormItem, NSpin, NGrid, NGi } 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)
|
||||
const open = reactive({ base: true })
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=winter-camp')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'winter-camp', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">小红书游记</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理小红书内容墙,对应官网「小红书游记」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/xiaohongshu" target="_blank">查看前台 ↗</n-button>
|
||||
<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="页面信息" style="margin-bottom: 16px;">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="data.title" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="data.subtitle" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="账号名称">
|
||||
<n-input v-model:value="data.account.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="账号链接">
|
||||
<n-input v-model:value="data.account.url" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="粉丝数">
|
||||
<n-input v-model:value="data.account.followers" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="获赞数">
|
||||
<n-input v-model:value="data.account.likes" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- 笔记列表 -->
|
||||
<n-card style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center">
|
||||
<span>笔记列表 ({{ data.notes?.length || 0 }})</span>
|
||||
<n-button size="small" type="primary" @click="addNote">添加笔记</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-grid :cols="3" :x-gap="14" :y-gap="14" responsive="screen" cols="1 m:2 l:3">
|
||||
<n-gi v-for="(n, i) in data.notes" :key="i">
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px;">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<AdminImageUpload v-model="n.cover" />
|
||||
</div>
|
||||
<n-form-item label="标题" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.title" placeholder="标题" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="作者" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.author" placeholder="作者" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="小红书链接" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.url" placeholder="小红书链接" size="small" />
|
||||
</n-form-item>
|
||||
<n-space style="margin-bottom: 6px;">
|
||||
<n-form-item label="点赞" :show-feedback="false">
|
||||
<n-input-number v-model:value="n.likes" placeholder="点赞" size="small" style="width: 90px;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="版式" :show-feedback="false">
|
||||
<n-select v-model:value="n.ratio" size="small" style="width: 90px;" :options="[{label:'竖版',value:'vertical'},{label:'横版',value:'horizontal'}]" />
|
||||
</n-form-item>
|
||||
</n-space>
|
||||
<n-form-item label="摘要" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.summary" placeholder="摘要" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="标签(逗号分隔)" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input
|
||||
:value="(n.tags || []).join(', ')"
|
||||
@input="n.tags = $event.split(',').map(t => t.trim()).filter(Boolean)"
|
||||
placeholder="标签(逗号分隔)"
|
||||
size="small"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-button size="small" type="error" @click="data.notes.splice(i, 1)">删除</n-button>
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-button dashed block style="margin-top: 14px;" @click="addNote">+ 添加笔记</n-button>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSpin, NGrid, NGi, NSelect } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ title: '', subtitle: '', account: {}, notes: [] })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=xiaohongshu-wall')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function addNote() {
|
||||
data.value.notes.push({ id: `xhs-${Date.now()}`, title: '', cover: '', author: '', likes: 0, url: '', tags: [], ratio: 'vertical', summary: '' })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'xiaohongshu-wall', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">客户游记</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理客户在各平台发布的游记,对应官网「客户游记」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/youji" target="_blank">查看前台 ↗</n-button>
|
||||
<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="页面信息" style="margin-bottom: 16px;">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="data.title" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="data.subtitle" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- 游记列表 -->
|
||||
<n-card style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center">
|
||||
<span>游记列表 ({{ data.notes?.length || 0 }})</span>
|
||||
<n-button size="small" type="primary" @click="addNote">添加游记</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-grid :cols="3" :x-gap="14" :y-gap="14" responsive="screen" cols="1 m:2 l:3">
|
||||
<n-gi v-for="(n, i) in data.notes" :key="i">
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px;">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<AdminImageUpload v-model="n.cover" />
|
||||
</div>
|
||||
<n-form-item label="标题" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.title" placeholder="标题" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="作者" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.author" placeholder="作者" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="平台" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-select
|
||||
v-model:value="n.platform"
|
||||
size="small"
|
||||
:options="[
|
||||
{ label: '美篇', value: '美篇' },
|
||||
{ label: '微信公众号', value: '微信公众号' },
|
||||
{ label: '抖音', value: '抖音' },
|
||||
{ label: '微博', value: '微博' },
|
||||
{ label: '小红书', value: '小红书' },
|
||||
]"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="原文链接" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.url" placeholder="原文链接" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="摘要" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.summary" placeholder="摘要" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="版式" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-select
|
||||
v-model:value="n.ratio"
|
||||
size="small"
|
||||
:options="[{ label: '竖版', value: 'vertical' }, { label: '横版', value: 'horizontal' }]"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-button size="small" type="error" @click="data.notes.splice(i, 1)">删除</n-button>
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-button dashed block style="margin-top: 14px;" @click="addNote">+ 添加游记</n-button>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NSelect } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ title: '', subtitle: '', notes: [] })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=youji')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function addNote() {
|
||||
data.value.notes.push({ id: `yj-${Date.now()}`, title: '', cover: '', author: '', platform: '美篇', url: '', tags: [], ratio: 'horizontal', summary: '' })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'youji', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px">
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item @click="navigateTo('/admin/faqs')">常见问答</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ isNew ? '新增' : '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">{{ isNew ? '新增问答' : '编辑问答' }}</h1>
|
||||
<n-space>
|
||||
<n-button secondary @click="navigateTo('/admin/faqs')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="loadError" style="text-align: center; color: #ef4444">{{ loadError }}</n-card>
|
||||
<n-spin v-else-if="pageLoading" style="display: flex; justify-content: center; padding: 60px" />
|
||||
|
||||
<template v-else>
|
||||
<n-card style="max-width: 700px">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="问题" required>
|
||||
<n-input v-model:value="form.question" placeholder="输入常见问题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="答案" required>
|
||||
<n-input v-model:value="form.answer" type="textarea" :rows="8" placeholder="输入答案内容" />
|
||||
</n-form-item>
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="form.sortOrder" :min="0" placeholder="数字越小越靠前" style="width: 200px" />
|
||||
</n-form-item>
|
||||
<n-form-item label="状态">
|
||||
<n-select v-model:value="form.published" :options="[{ label: '已发布', value: true }, { label: '草稿', value: false }]" style="width: 200px" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-alert v-if="formError" type="error" style="margin-top: 12px; max-width: 700px">{{ formError }}</n-alert>
|
||||
|
||||
<div class="save-bar">
|
||||
<n-button secondary @click="navigateTo('/admin/faqs')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSelect, NBreadcrumb, NBreadcrumbItem, NSpin, NAlert } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const form = ref({ question: '', answer: '', sortOrder: 0, published: true })
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/faqs/${route.params.id}`)
|
||||
form.value = { ...data, published: !!data.published }
|
||||
} catch (e) { loadError.value = e.data?.message || '加载失败' }
|
||||
finally { pageLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.question?.trim()) { formError.value = '问题不能为空'; return }
|
||||
if (!form.value.answer?.trim()) { formError.value = '答案不能为空'; return }
|
||||
saving.value = true; formError.value = ''
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/faqs', { method: 'POST', body: form.value })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/faqs'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/faqs/${route.params.id}`, { method: 'PUT', body: form.value })
|
||||
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; max-width: 700px; }
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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">管理FAQ条目 · 对应官网「常见问答」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/faq" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/faqs/new')">新增问答</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card>
|
||||
<n-space style="margin-bottom: 16px">
|
||||
<n-input v-model:value="search" placeholder="搜索问题、答案..." clearable style="width: 260px" @keyup.enter="page = 1; loadData()">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
|
||||
<n-space justify="end" style="margin-top: 16px" v-if="totalPages > 1">
|
||||
<n-pagination v-model:page="page" :page-count="totalPages" @update:page="loadData" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const list = ref([]); const total = ref(0); const page = ref(1); const totalPages = ref(1); const loading = ref(false); const search = ref('')
|
||||
|
||||
const columns = [
|
||||
{ title: '排序', key: 'sortOrder', width: 60, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.sortOrder) },
|
||||
{
|
||||
title: '问题', key: 'question',
|
||||
render: (row) => h('a', { style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer', onClick: () => navigateTo(`/admin/faqs/${row.id}`) }, row.question),
|
||||
},
|
||||
{ title: '答案摘要', key: 'answer', render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.answer?.slice(0, 60)) },
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.published ? 'success' : 'default', bordered: false, style: 'cursor: pointer', onClick: () => togglePublish(row) }, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/faqs/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.question}」?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try { const d = await adminFetch(`/api/admin/faqs?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`); list.value = d.items; total.value = d.total; totalPages.value = d.totalPages }
|
||||
catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try { await adminFetch(`/api/admin/faqs/${item.id}`, { method: 'PUT', body: { published: !item.published } }); item.published = !item.published }
|
||||
catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try { await adminFetch(`/api/admin/faqs/${item.id}`, { method: 'DELETE' }); message.success('已删除'); loadData() }
|
||||
catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px">
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item @click="navigateTo('/admin/gallery')">呼籁旅拍</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ isNew ? '新增' : '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">{{ isNew ? '新增照片' : '编辑照片' }}</h1>
|
||||
<n-space>
|
||||
<n-button secondary @click="navigateTo('/admin/gallery')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="loadError" style="text-align: center; color: #ef4444">{{ loadError }}</n-card>
|
||||
<n-spin v-else-if="pageLoading" style="display: flex; justify-content: center; padding: 60px" />
|
||||
|
||||
<template v-else>
|
||||
<div class="form-layout">
|
||||
<div class="form-main">
|
||||
<n-card title="照片信息">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题" required>
|
||||
<n-input v-model:value="form.title" placeholder="输入照片标题" />
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-gi>
|
||||
<n-form-item label="分类">
|
||||
<n-select v-model:value="form.category" :options="categoryOptions" placeholder="选择分类" clearable />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="地点">
|
||||
<n-input v-model:value="form.location" placeholder="拍摄地点" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="form.description" type="textarea" :rows="4" placeholder="照片描述(可选)" />
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-gi>
|
||||
<n-form-item label="方向">
|
||||
<n-select v-model:value="form.orientation" :options="[{ label: '横屏', value: 'landscape' }, { label: '竖屏', value: 'portrait' }]" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="form.sortOrder" :min="0" style="width: 100%" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div class="form-sidebar">
|
||||
<n-card title="发布设置">
|
||||
<n-form-item label="状态">
|
||||
<n-select v-model:value="form.published" :options="[{ label: '已发布', value: true }, { label: '草稿', value: false }]" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
<n-card title="照片">
|
||||
<AdminImageUpload v-model="form.image" />
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-alert v-if="formError" type="error" style="margin-top: 12px">{{ formError }}</n-alert>
|
||||
|
||||
<div class="save-bar">
|
||||
<n-button secondary @click="navigateTo('/admin/gallery')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSelect, NBreadcrumb, NBreadcrumbItem, NSpin, NAlert, NGrid, NGi } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: '草原', value: '草原' }, { label: '森林', value: '森林' },
|
||||
{ label: '湖泊', value: '湖泊' }, { label: '冬季', value: '冬季' },
|
||||
{ label: '人文', value: '人文' }, { label: '其他', value: '其他' },
|
||||
]
|
||||
|
||||
const form = ref({
|
||||
title: '', category: '', location: '', description: '',
|
||||
image: '', orientation: 'landscape', published: true, sortOrder: 0,
|
||||
})
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/gallery/${route.params.id}`)
|
||||
form.value = { ...data, published: !!data.published, image: data.image || '' }
|
||||
} catch (e) { loadError.value = e.data?.message || '加载失败' }
|
||||
finally { pageLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.title?.trim()) { formError.value = '标题不能为空'; return }
|
||||
if (!form.value.image?.trim()) { formError.value = '请上传照片'; return }
|
||||
saving.value = true; formError.value = ''
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/gallery', { method: 'POST', body: form.value })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/gallery'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/gallery/${route.params.id}`, { method: 'PUT', body: form.value })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) { formError.value = e.data?.message || '保存失败' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-layout { display: grid; grid-template-columns: 1fr 300px; gap: 20px; align-items: start; }
|
||||
.form-main { min-width: 0; }
|
||||
.form-sidebar { display: flex; flex-direction: column; gap: 16px; }
|
||||
.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,135 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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 tag="a" href="/gallery" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/gallery/new')">新增照片</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card>
|
||||
<n-space style="margin-bottom: 16px">
|
||||
<n-input v-model:value="search" placeholder="搜索标题、分类..." clearable style="width: 260px" @keyup.enter="loadData">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="(row) => row.id"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
striped
|
||||
/>
|
||||
|
||||
<n-space justify="end" style="margin-top: 16px" v-if="totalPages > 1">
|
||||
<n-pagination
|
||||
v-model:page="page"
|
||||
:page-count="totalPages"
|
||||
@update:page="loadData"
|
||||
/>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NImage, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '缩略',
|
||||
key: 'image',
|
||||
width: 60,
|
||||
render: (row) => row.image
|
||||
? h(NImage, { src: row.image, width: 48, height: 36, objectFit: 'cover', style: 'border-radius: 4px', lazy: true })
|
||||
: h('div', { style: 'width:48px;height:36px;border-radius:4px;background:#f3f4f6' }),
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
render: (row) => h('a', {
|
||||
style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer',
|
||||
onClick: () => navigateTo(`/admin/gallery/${row.id}`),
|
||||
}, row.title),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
key: 'category',
|
||||
width: 80,
|
||||
render: (row) => row.category ? h(NTag, { size: 'small', type: 'info', bordered: false }, () => row.category) : '-',
|
||||
},
|
||||
{ title: '地点', key: 'location', width: 100, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.location || '-') },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'published',
|
||||
width: 70,
|
||||
render: (row) => h(NTag, {
|
||||
size: 'small',
|
||||
type: row.published ? 'success' : 'default',
|
||||
bordered: false,
|
||||
style: 'cursor: pointer',
|
||||
onClick: () => togglePublish(row),
|
||||
}, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/gallery/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.title}」?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await adminFetch(`/api/admin/gallery?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`)
|
||||
list.value = d.items; total.value = d.total; totalPages.value = d.totalPages
|
||||
} catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/gallery/${item.id}`, { method: 'PUT', body: { published: !item.published } })
|
||||
item.published = !item.published
|
||||
message.success(item.published ? '已发布' : '已下架')
|
||||
} catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/gallery/${item.id}`, { method: 'DELETE' })
|
||||
message.success('已删除')
|
||||
loadData()
|
||||
} catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; color: #111827; font-weight: 600">数据概览</h1>
|
||||
|
||||
<n-spin :show="!stats">
|
||||
<n-grid v-if="stats" :cols="4" :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<n-gi v-for="item in statCards" :key="item.key" span="4 s:2 m:1">
|
||||
<n-card hoverable style="cursor: pointer" @click="item.to && navigateTo(item.to)">
|
||||
<n-statistic :label="item.label" :value="item.value">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px; color: #9ca3af">{{ item.sub }}</span>
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<template v-if="stats">
|
||||
<h2 style="font-size: 18px; color: #333; margin: 32px 0 16px; font-weight: 600">快捷操作</h2>
|
||||
<n-grid :cols="4" :x-gap="12" :y-gap="12" responsive="screen" item-responsive>
|
||||
<n-gi v-for="q in quickLinks" :key="q.to" span="4 s:2 m:1">
|
||||
<n-card hoverable size="small" style="cursor: pointer" @click="navigateTo(q.to)">
|
||||
<n-space align="center">
|
||||
<div>
|
||||
<div style="font-size: 14px; font-weight: 500; color: #333">{{ q.title }}</div>
|
||||
<div style="font-size: 12px; color: #999">{{ q.desc }}</div>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NCard, NGrid, NGi, NStatistic, NSpace, NSpin } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const stats = ref(null)
|
||||
|
||||
const statCards = computed(() => {
|
||||
if (!stats.value) return []
|
||||
return [
|
||||
{ key: 'articles', label: '文章总数', value: stats.value.articles, sub: '新闻 + 博客', to: '/admin/articles' },
|
||||
{ key: 'reviews', label: '客户评价', value: stats.value.reviews, sub: '来自真实客户', to: '/admin/reviews' },
|
||||
{ key: 'faqs', label: '常见问答', value: stats.value.faqs, sub: 'FAQ 条目', to: '/admin/faqs' },
|
||||
{ key: 'gallery', label: '旅拍照片', value: stats.value.gallery, sub: '呼籁旅拍', to: '/admin/gallery' },
|
||||
{ key: 'stories', label: '客户故事', value: stats.value.stories, sub: '深度体验', to: '/admin/stories' },
|
||||
{ key: 'submissions', label: '表单提交', value: stats.value.submissions, sub: '联系 + 定制咨询', to: '/admin/submissions' },
|
||||
{ key: 'siteContent', label: '站点配置', value: stats.value.siteContent, sub: '可编辑配置项' },
|
||||
]
|
||||
})
|
||||
|
||||
const quickLinks = [
|
||||
{ title: '产品线管理', desc: '管理所有产品线', to: '/admin/products' },
|
||||
{ title: '导航菜单', desc: '编辑顶部和底部导航', to: '/admin/navigation' },
|
||||
{ title: '查看表单', desc: '客户咨询和留言', to: '/admin/submissions' },
|
||||
{ title: 'SEO 配置', desc: '页面标题和描述', to: '/admin/content/seo' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try { stats.value = await adminFetch('/api/admin/stats') } catch (e) { console.error('加载统计失败', e) }
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f0f2f5;">
|
||||
<n-config-provider :theme-overrides="themeOverrides">
|
||||
<n-card style="width: 380px" :bordered="false" size="large">
|
||||
<h1 style="text-align: center; margin: 0 0 30px; font-size: 22px; color: #111827">呼籁后台管理</h1>
|
||||
<n-form @submit.prevent="handleLogin">
|
||||
<n-form-item label="用户名">
|
||||
<n-input v-model:value="username" placeholder="请输入用户名" autofocus />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input v-model:value="password" type="password" show-password-on="click" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
||||
</n-form-item>
|
||||
<n-alert v-if="error" type="error" style="margin-bottom: 16px">{{ error }}</n-alert>
|
||||
<n-button type="primary" block :loading="loading" @click="handleLogin">登录</n-button>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NConfigProvider, NCard, NForm, NFormItem, NInput, NButton, NAlert } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const { login, isLoggedIn } = useAdmin()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
navigateTo('/admin')
|
||||
}
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const themeOverrides = {
|
||||
common: {
|
||||
primaryColor: '#3a7d44',
|
||||
primaryColorHover: '#2d6235',
|
||||
primaryColorPressed: '#245c2b',
|
||||
primaryColorSuppl: '#3a7d44',
|
||||
},
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await login(username.value, password.value)
|
||||
navigateTo('/admin')
|
||||
} catch (e) {
|
||||
error.value = e.data?.message || '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<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 secondary @click="loadData">重置</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="saveNavigation">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<!-- 顶部导航 -->
|
||||
<n-card title="顶部导航 (Header)">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addHeaderItem">添加菜单项</n-button>
|
||||
</template>
|
||||
|
||||
<div v-for="(item, idx) in headerItems" :key="idx" style="border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 10px; overflow: hidden;">
|
||||
<n-space align="center" justify="space-between" style="padding: 10px 12px; background: #fafbfc;">
|
||||
<n-space>
|
||||
<n-input v-model:value="item.text" placeholder="文字" size="small" style="width: 140px" />
|
||||
<n-input v-model:value="item.to" placeholder="链接" size="small" style="width: 160px" />
|
||||
</n-space>
|
||||
<n-space size="small">
|
||||
<n-button size="tiny" quaternary @click="addChild(item)">+ 子菜单</n-button>
|
||||
<n-button size="tiny" quaternary @click="moveUp(headerItems, idx)" :disabled="idx === 0">上</n-button>
|
||||
<n-button size="tiny" quaternary @click="moveDown(headerItems, idx)" :disabled="idx === headerItems.length - 1">下</n-button>
|
||||
<n-button size="tiny" type="error" quaternary @click="headerItems.splice(idx, 1)">删除</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
<div v-if="item.children?.length" style="padding: 8px 12px 8px 32px; border-top: 1px solid #f0f0f0;">
|
||||
<n-space v-for="(child, cidx) in item.children" :key="cidx" align="center" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="child.text" placeholder="文字" size="small" style="width: 120px" />
|
||||
<n-input v-model:value="child.to" placeholder="链接" size="small" style="width: 140px" />
|
||||
<n-input v-model:value="child.season" placeholder="季节" size="small" style="width: 70px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="item.children.splice(cidx, 1)">删除</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<n-card title="底部导航 (Footer)" style="margin-top: 16px">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addFooterGroup">添加分组</n-button>
|
||||
</template>
|
||||
|
||||
<div v-for="(group, gidx) in footerItems" :key="gidx" style="border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 12px; padding: 12px;">
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 10px;">
|
||||
<n-input v-model:value="group.title" placeholder="分组标题" size="small" style="width: 200px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="footerItems.splice(gidx, 1)">删除分组</n-button>
|
||||
</n-space>
|
||||
<div style="padding-left: 16px;">
|
||||
<n-space v-for="(link, lidx) in group.links" :key="lidx" align="center" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="link.text" placeholder="文字" size="small" style="width: 120px" />
|
||||
<n-input v-model:value="link.to" placeholder="链接" size="small" style="width: 160px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="group.links.splice(lidx, 1)">删除</n-button>
|
||||
</n-space>
|
||||
<n-button size="tiny" dashed @click="group.links.push({ text: '', to: '' })">+ 添加链接</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NSpin } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const headerItems = ref([])
|
||||
const footerItems = ref([])
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function loadData() {
|
||||
loaded.value = false
|
||||
const data = await adminFetch('/api/admin/site-content?key=navigation')
|
||||
headerItems.value = JSON.parse(JSON.stringify(data.data.header || []))
|
||||
footerItems.value = JSON.parse(JSON.stringify(data.data.footer || []))
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function addHeaderItem() { headerItems.value.push({ text: '', to: '/' }) }
|
||||
function addChild(item) { if (!item.children) item.children = []; item.children.push({ text: '', to: '' }) }
|
||||
function addFooterGroup() { footerItems.value.push({ title: '', links: [{ text: '', to: '' }] }) }
|
||||
|
||||
function moveUp(arr, idx) { if (idx > 0) { [arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]] } }
|
||||
function moveDown(arr, idx) { if (idx < arr.length - 1) { [arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]] } }
|
||||
|
||||
async function saveNavigation() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: 'navigation', data: { header: headerItems.value, footer: footerItems.value } },
|
||||
})
|
||||
message.success('导航菜单已保存')
|
||||
} catch (e) { message.error('保存失败:' + (e.data?.message || e.message)) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,544 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item @click="navigateTo('/admin/products')">产品线管理</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ meta?.name || route.params.key }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">
|
||||
{{ meta?.name || route.params.key }}
|
||||
<n-tag v-if="meta?.version" size="small" type="info" :bordered="false" style="margin-left: 8px">{{ meta.version }}</n-tag>
|
||||
<n-tag v-if="meta?.type" size="small" :bordered="false" style="margin-left: 4px">{{ typeLabel }}</n-tag>
|
||||
</h1>
|
||||
<n-space>
|
||||
<n-button secondary @click="navigateTo('/admin/products')">返回</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<template v-if="data">
|
||||
<!-- ============ TOUR 类型编辑器(夏/秋/冬季线路产品)============ -->
|
||||
<n-tabs v-if="editorType === 'tour'" type="line" animated>
|
||||
<n-tab-pane name="basic" tab="基本信息">
|
||||
<n-card>
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="品牌"><n-input v-model:value="data.brand" placeholder="额吉的故乡" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="版本号"><n-input v-model:value="data.version" placeholder="V9" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="季节"><n-input v-model:value="data.season" placeholder="summer/autumn/winter" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="产品介绍"><n-input v-model:value="data.narrative" type="textarea" :rows="4" /></n-form-item>
|
||||
<n-form-item label="产品风格"><n-input v-model:value="data.style" placeholder="产品风格描述" /></n-form-item>
|
||||
<n-form-item label="价格哲学"><n-input v-model:value="data.pricingPhilosophy" type="textarea" :rows="3" placeholder="价格透明说明" /></n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
<!-- 产品亮点 -->
|
||||
<n-card title="产品亮点" style="margin-top: 16px">
|
||||
<template #header-extra><n-button size="small" @click="addToArray(data, 'highlights', {title:'',description:''})">添加</n-button></template>
|
||||
<div v-for="(h, i) in data.highlights" :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">亮点 {{ i+1 }}</span><n-button size="tiny" type="error" quaternary @click="data.highlights.splice(i,1)">删除</n-button></n-space>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="标题" :show-feedback="false"><n-input v-model:value="h.title" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="描述" :show-feedback="false"><n-input v-model:value="h.description" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</div>
|
||||
</n-card>
|
||||
<!-- 南线/北线特色(秋冬) -->
|
||||
<template v-if="data.southHighlights !== undefined">
|
||||
<n-card title="南线特色" style="margin-top: 16px">
|
||||
<template #header-extra><n-button size="small" @click="addToArray(data, 'southHighlights', {title:'',description:''})">添加</n-button></template>
|
||||
<div v-for="(h, i) in data.southHighlights" :key="i" style="margin-bottom: 8px; display: flex; gap: 8px; align-items: center;">
|
||||
<n-input v-model:value="h.title" placeholder="标题" style="width: 200px" /><n-input v-model:value="h.description" placeholder="描述" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.southHighlights.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card title="北线特色" style="margin-top: 16px">
|
||||
<template #header-extra><n-button size="small" @click="addToArray(data, 'northHighlights', {title:'',description:''})">添加</n-button></template>
|
||||
<div v-for="(h, i) in data.northHighlights" :key="i" style="margin-bottom: 8px; display: flex; gap: 8px; align-items: center;">
|
||||
<n-input v-model:value="h.title" placeholder="标题" style="width: 200px" /><n-input v-model:value="h.description" placeholder="描述" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.northHighlights.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 版本管理 -->
|
||||
<n-tab-pane name="versions" tab="版本管理">
|
||||
<n-space style="margin-bottom: 16px"><n-button type="primary" @click="addVersion">新增版本</n-button></n-space>
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(ver, vi) in data.versions" :key="vi" :name="vi">
|
||||
<template #header><n-space align="center"><strong>{{ ver.name || '版本 '+(vi+1) }}</strong><n-tag v-if="ver.tag" size="small" :bordered="false">{{ ver.tag }}</n-tag><span style="color:#9ca3af;font-size:12px">{{ ver.days }}天{{ ver.nights }}晚</span></n-space></template>
|
||||
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.versions.splice(vi,1)">删除</n-button></template>
|
||||
<n-card>
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="版本ID"><n-input v-model:value="ver.id" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="名称"><n-input v-model:value="ver.name" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="标签"><n-input v-model:value="ver.tag" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-gi><n-form-item label="天数"><n-input-number v-model:value="ver.days" :min="1" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="晚数"><n-input-number v-model:value="ver.nights" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="线路"><n-input v-model:value="ver.line" placeholder="南线/北线" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="受众"><n-input v-model:value="ver.audience" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="路线"><n-input v-model:value="ver.route" /></n-form-item>
|
||||
<n-form-item label="描述"><n-input v-model:value="ver.description" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item label="亮点"><n-dynamic-input v-model:value="ver.highlights" placeholder="输入亮点" /></n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
<n-card title="行程安排" style="margin-top:12px">
|
||||
<div v-for="(it, ii) in ver.itinerary" :key="ii" style="margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
|
||||
<n-space justify="space-between" style="margin-bottom:8px"><n-tag size="small">Day {{ it.day }}</n-tag><n-button size="tiny" type="error" quaternary @click="ver.itinerary.splice(ii,1)">删除</n-button></n-space>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="标题" :show-feedback="false"><n-input v-model:value="it.title" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="住宿" :show-feedback="false"><n-input v-model:value="it.hotel" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="概要" :show-feedback="false" style="margin-top:8px"><n-input v-model:value="it.summary" type="textarea" :rows="2" /></n-form-item>
|
||||
</div>
|
||||
<n-button dashed block @click="addItineraryDay(ver)">添加行程日</n-button>
|
||||
</n-card>
|
||||
<n-card title="价格配置" style="margin-top:12px">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="价格标签"><n-input v-model:value="ver.pricing.label" placeholder="2580元起" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="备注"><n-input v-model:value="ver.pricing.note" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-gi><n-form-item label="起步价"><n-input-number v-model:value="ver.pricing.startPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="旺季价"><n-input-number v-model:value="ver.pricing.peakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="高档起步"><n-input-number v-model:value="ver.pricing.highStartPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="高档旺季"><n-input-number v-model:value="ver.pricing.highPeakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
<n-card title="费用说明" style="margin-top:12px">
|
||||
<n-form-item label="住宿"><n-input v-model:value="ver.included.accommodation" /></n-form-item>
|
||||
<n-form-item label="交通"><n-input v-model:value="ver.included.transport" /></n-form-item>
|
||||
<n-form-item label="门票"><n-input v-model:value="ver.included.tickets" /></n-form-item>
|
||||
<n-form-item label="活动"><n-input v-model:value="ver.included.activities" /></n-form-item>
|
||||
</n-card>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<n-empty v-if="!data.versions?.length" description="暂无版本" style="padding:40px 0" />
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 选择指南 -->
|
||||
<n-tab-pane name="guide" tab="选择指南">
|
||||
<n-card title="按假期长度推荐">
|
||||
<template #header-extra><n-button size="small" @click="ensureArray(data, 'selectionGuide.byVacationLength'); data.selectionGuide.byVacationLength.push({condition:'',recommendation:'',reason:''})">添加</n-button></template>
|
||||
<div v-for="(g, i) in data.selectionGuide?.byVacationLength || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
|
||||
<n-input v-model:value="g.condition" placeholder="条件" style="width:120px" />
|
||||
<n-input v-model:value="g.recommendation" placeholder="推荐产品ID" style="width:160px" />
|
||||
<n-input v-model:value="g.reason" placeholder="原因" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.selectionGuide.byVacationLength.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card title="版本迭代时间线" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="addToArray(data, 'timeline', {year:'',title:'',description:''})">添加</n-button></template>
|
||||
<div v-for="(t, i) in data.timeline || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
|
||||
<n-input v-model:value="t.year" placeholder="年份" style="width:80px" />
|
||||
<n-input v-model:value="t.title" placeholder="标题" style="width:160px" />
|
||||
<n-input v-model:value="t.description" placeholder="描述" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.timeline.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="faq" tab="常见问答">
|
||||
<faq-editor :faq="data.faq" @update="data.faq = $event" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<!-- ============ CAMP 类型编辑器(夏令营/冬令营)============ -->
|
||||
<n-tabs v-else-if="editorType === 'camp'" type="line" animated>
|
||||
<n-tab-pane name="basic" tab="基本信息">
|
||||
<n-card>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="营地定位"><n-input v-model:value="data.positioning" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-gi><n-form-item label="天数"><n-input-number v-model:value="data.days" :min="1" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="晚数"><n-input-number v-model:value="data.nights" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="每期家庭数"><n-input-number v-model:value="data.maxFamilies" :min="1" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="总期数"><n-input-number v-model:value="data.totalSessions" :min="1" style="width:100%" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="适龄范围"><n-input v-model:value="data.ageRange" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="订金"><n-input v-model:value="data.deposit" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="季节"><n-input v-model:value="data.season" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="路线"><n-input v-model:value="data.route" /></n-form-item>
|
||||
<n-form-item v-if="data.whyHulunbuir !== undefined" label="为什么选呼伦贝尔"><n-input v-model:value="data.whyHulunbuir" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item v-if="data.differenceFromV9 !== undefined" label="与V9系列的区别"><n-input v-model:value="data.differenceFromV9" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item v-if="data.closingNote !== undefined" label="结语"><n-input v-model:value="data.closingNote" type="textarea" :rows="3" /></n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
<!-- 营地优势/原则 -->
|
||||
<n-card v-if="data.campAdvantages !== undefined" title="亲子营优势" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="data.campAdvantages.push('')">添加</n-button></template>
|
||||
<div v-for="(_, i) in data.campAdvantages" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
|
||||
<n-input v-model:value="data.campAdvantages[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.campAdvantages.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card v-if="data.principles !== undefined" title="四大原则" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="data.principles.push('')">添加</n-button></template>
|
||||
<div v-for="(_, i) in data.principles" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
|
||||
<n-input v-model:value="data.principles[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.principles.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card v-if="data.sessions !== undefined" title="期数统计" style="margin-top:16px">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="2025年期数"><n-input-number v-model:value="data.sessions['2025']" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="2026年计划"><n-input-number v-model:value="data.sessions['2026plan']" style="width:100%" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="itinerary" tab="行程安排">
|
||||
<n-card>
|
||||
<template #header-extra><n-button size="small" type="primary" @click="addCampItinerary">添加行程日</n-button></template>
|
||||
<div v-for="(it, i) in data.itinerary || []" :key="i" style="margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
|
||||
<n-space justify="space-between" style="margin-bottom:8px"><n-tag size="small">Day {{ it.day || i+1 }}</n-tag><n-button size="tiny" type="error" quaternary @click="data.itinerary.splice(i,1)">删除</n-button></n-space>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="标题" :show-feedback="false"><n-input v-model:value="it.title" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="住宿" :show-feedback="false"><n-input v-model:value="it.hotel" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="概要" :show-feedback="false" style="margin-top:8px"><n-input v-model:value="it.summary" type="textarea" :rows="2" /></n-form-item>
|
||||
<n-form-item v-if="it.highlights" label="亮点" :show-feedback="false" style="margin-top:8px"><n-dynamic-input v-model:value="it.highlights" placeholder="行程亮点" /></n-form-item>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane v-if="data.hotels !== undefined" name="hotels" tab="住宿安排">
|
||||
<n-card>
|
||||
<template #header-extra><n-button size="small" @click="data.hotels.push({name:'',star:'',nights:'',description:''})">添加酒店</n-button></template>
|
||||
<div v-for="(h, i) in data.hotels" :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">酒店 {{ i+1 }}</span><n-button size="tiny" type="error" quaternary @click="data.hotels.splice(i,1)">删除</n-button></n-space>
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="名称"><n-input v-model:value="h.name" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="星级"><n-input v-model:value="h.star" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="住宿晚数"><n-input v-model:value="h.nights" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="描述"><n-input v-model:value="h.description" type="textarea" :rows="2" /></n-form-item>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane v-if="data.photographer !== undefined || data.winterClothing !== undefined || data.serviceConfig !== undefined" name="service" tab="服务配置">
|
||||
<n-card v-if="data.photographer" title="摄影师">
|
||||
<n-form-item label="姓名"><n-input v-model:value="data.photographer.name" /></n-form-item>
|
||||
<n-form-item label="介绍"><n-input v-model:value="data.photographer.description" type="textarea" :rows="2" /></n-form-item>
|
||||
<n-form-item label="特别日程"><n-input v-model:value="data.photographer.specialDay2" /></n-form-item>
|
||||
</n-card>
|
||||
<n-card v-if="data.winterClothing" title="穿衣指南" style="margin-top:16px">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="上身"><n-input v-model:value="data.winterClothing.upper" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="下身"><n-input v-model:value="data.winterClothing.lower" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="鞋子"><n-input v-model:value="data.winterClothing.shoes" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="配件"><n-input v-model:value="data.winterClothing.accessories" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
<n-card v-if="data.serviceConfig" title="随行服务" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="data.serviceConfig.push('')">添加</n-button></template>
|
||||
<div v-for="(_, i) in data.serviceConfig" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
|
||||
<n-input v-model:value="data.serviceConfig[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.serviceConfig.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card v-if="data.coreActivities !== undefined" title="核心体验" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="data.coreActivities.push('')">添加</n-button></template>
|
||||
<div v-for="(_, i) in data.coreActivities" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
|
||||
<n-input v-model:value="data.coreActivities[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.coreActivities.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="faq" tab="常见问答">
|
||||
<faq-editor :faq="data.faq" @update="data.faq = $event" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<!-- ============ COURSE 类型编辑器(研学课程)============ -->
|
||||
<n-tabs v-else-if="editorType === 'course'" type="line" animated>
|
||||
<n-tab-pane name="philosophy" tab="教育理念">
|
||||
<n-card>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题"><n-input v-model:value="data.philosophy.headline" /></n-form-item>
|
||||
<n-form-item label="副标题"><n-input v-model:value="data.philosophy.tagline" /></n-form-item>
|
||||
<n-form-item label="描述"><n-input v-model:value="data.philosophy.description" 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.philosophy.values.push({label:'',desc:''})">添加</n-button></template>
|
||||
<div v-for="(v, i) in data.philosophy.values" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
|
||||
<n-input v-model:value="v.label" placeholder="标签" style="width:120px" /><n-input v-model:value="v.desc" placeholder="描述" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.philosophy.values.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="ageGroups" tab="年龄分组">
|
||||
<n-card>
|
||||
<template #header-extra><n-button size="small" type="primary" @click="data.ageGroups.push({id:'',label:'',headline:'',focus:'',description:'',highlights:[],recommendedModules:[]})">添加分组</n-button></template>
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(g, gi) in data.ageGroups" :key="gi" :name="gi">
|
||||
<template #header><strong>{{ g.label || '分组 '+(gi+1) }}</strong></template>
|
||||
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.ageGroups.splice(gi,1)">删除</n-button></template>
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="ID"><n-input v-model:value="g.id" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="标签"><n-input v-model:value="g.label" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="Emoji"><n-input v-model:value="g.emoji" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="标题"><n-input v-model:value="g.headline" /></n-form-item>
|
||||
<n-form-item label="焦点"><n-input v-model:value="g.focus" /></n-form-item>
|
||||
<n-form-item label="描述"><n-input v-model:value="g.description" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item label="能力清单(每行一项)"><n-input v-model:value="highlightsText[gi]" type="textarea" :rows="4" @blur="g.highlights = highlightsText[gi].split('\n').filter(Boolean)" /></n-form-item>
|
||||
<n-form-item label="推荐模块ID(逗号分隔)"><n-input :value="g.recommendedModules?.join(',')" @update:value="v => g.recommendedModules = v.split(',').map(s=>s.trim()).filter(Boolean)" /></n-form-item>
|
||||
</n-form>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="modules" tab="课程模块">
|
||||
<n-card>
|
||||
<template #header-extra><n-button size="small" type="primary" @click="data.modules.push({id:'',icon:'',name:'',ageRange:'',duration:'',shortDesc:'',objectives:[],activities:[],philosophy:''})">添加模块</n-button></template>
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(m, mi) in data.modules" :key="mi" :name="mi">
|
||||
<template #header><strong>{{ m.name || '模块 '+(mi+1) }}</strong> <span style="color:#9ca3af;font-size:12px;margin-left:8px">{{ m.ageRange }}</span></template>
|
||||
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.modules.splice(mi,1)">删除</n-button></template>
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-gi><n-form-item label="ID"><n-input v-model:value="m.id" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="图标"><n-input v-model:value="m.icon" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="适龄"><n-input v-model:value="m.ageRange" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="时长"><n-input v-model:value="m.duration" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="名称"><n-input v-model:value="m.name" /></n-form-item>
|
||||
<n-form-item label="简述"><n-input v-model:value="m.shortDesc" type="textarea" :rows="2" /></n-form-item>
|
||||
<n-form-item label="学习目标(每行一项)"><n-input :value="m.objectives?.join('\n')" @update:value="v => m.objectives = v.split('\n').filter(Boolean)" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item label="活动内容(每行一项)"><n-input :value="m.activities?.join('\n')" @update:value="v => m.activities = v.split('\n').filter(Boolean)" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item label="教育理念引言"><n-input v-model:value="m.philosophy" type="textarea" :rows="2" /></n-form-item>
|
||||
</n-form>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="instructors" tab="导师团队">
|
||||
<n-card>
|
||||
<template #header-extra><n-button size="small" @click="ensureObj(data,'instructors',{placeholder:[]}); data.instructors.placeholder.push({name:'',title:'',credentials:'',bio:''})">添加导师</n-button></template>
|
||||
<div v-for="(inst, i) in data.instructors?.placeholder || []" :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">导师 {{ i+1 }}</span><n-button size="tiny" type="error" quaternary @click="data.instructors.placeholder.splice(i,1)">删除</n-button></n-space>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="姓名"><n-input v-model:value="inst.name" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="头衔"><n-input v-model:value="inst.title" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="资质"><n-input v-model:value="inst.credentials" /></n-form-item>
|
||||
<n-form-item label="简介"><n-input v-model:value="inst.bio" type="textarea" :rows="2" /></n-form-item>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="faq" tab="常见问答">
|
||||
<faq-editor :faq="data.faq" @update="data.faq = $event" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
</n-spin>
|
||||
|
||||
<div class="save-bar" v-if="data">
|
||||
<n-button secondary @click="navigateTo('/admin/products')">返回</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import {
|
||||
NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NForm, NFormItem,
|
||||
NInput, NInputNumber, NBreadcrumb, NBreadcrumbItem,
|
||||
NTabs, NTabPane, NCollapse, NCollapseItem, NDynamicInput, NEmpty,
|
||||
} from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const data = ref(null)
|
||||
const meta = ref(null)
|
||||
const highlightsText = ref([])
|
||||
|
||||
const editorType = computed(() => {
|
||||
const type = meta.value?.type
|
||||
if (type === 'camp') return 'camp'
|
||||
if (type === 'course') return 'course'
|
||||
return 'tour'
|
||||
})
|
||||
|
||||
const typeLabel = computed(() => ({ tour: '线路', camp: '营地', course: '课程' }[editorType.value] || ''))
|
||||
|
||||
// ── FAQ 子组件(内联)──
|
||||
const FaqEditor = {
|
||||
props: ['faq'],
|
||||
emits: ['update'],
|
||||
setup(props, { emit }) {
|
||||
const items = computed({
|
||||
get: () => props.faq || [],
|
||||
set: (v) => emit('update', v),
|
||||
})
|
||||
return () => h(NCard, null, {
|
||||
default: () => [
|
||||
...items.value.map((f, i) =>
|
||||
h('div', { style: 'margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;', key: i }, [
|
||||
h(NSpace, { justify: 'space-between', style: 'margin-bottom:8px' }, () => [
|
||||
h('span', { style: 'font-weight:500' }, `问答 ${i + 1}`),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => { const arr = [...items.value]; arr.splice(i, 1); emit('update', arr) } }, () => '删除'),
|
||||
]),
|
||||
h(NFormItem, { label: '问题', showFeedback: false, style: 'margin-bottom:8px' }, () => h(NInput, { value: f.question, 'onUpdate:value': v => { f.question = v } })),
|
||||
h(NFormItem, { label: '答案', showFeedback: false }, () => h(NInput, { value: f.answer, 'onUpdate:value': v => { f.answer = v }, type: 'textarea', rows: 3 })),
|
||||
])
|
||||
),
|
||||
h(NButton, { dashed: true, block: true, onClick: () => emit('update', [...items.value, { question: '', answer: '' }]) }, () => '添加问答'),
|
||||
],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ── 数据加载 ──
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const plRes = await adminFetch('/api/admin/site-content?key=product-lines')
|
||||
const productLines = typeof plRes.data === 'string' ? JSON.parse(plRes.data) : plRes.data
|
||||
meta.value = productLines?.find(p => p.key === route.params.key)
|
||||
|
||||
const res = await adminFetch(`/api/admin/site-content?key=${route.params.key}`)
|
||||
const raw = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
data.value = normalizeData(raw)
|
||||
|
||||
// Init highlights text for course ageGroups
|
||||
if (data.value.ageGroups) {
|
||||
highlightsText.value = data.value.ageGroups.map(g => (g.highlights || []).join('\n'))
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('加载失败: ' + (e.data?.message || e.message))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeData(raw) {
|
||||
const d = { ...raw }
|
||||
// Normalize FAQ
|
||||
if (Array.isArray(d.faq)) {
|
||||
d.faq = d.faq.map(f => ({ question: f.question || f.q || '', answer: f.answer || f.a || '' }))
|
||||
} else {
|
||||
d.faq = []
|
||||
}
|
||||
// Normalize highlights
|
||||
if (Array.isArray(d.highlights)) {
|
||||
d.highlights = d.highlights.map(h => typeof h === 'string' ? { title: h, description: '' } : h)
|
||||
}
|
||||
// Normalize versions (tour type)
|
||||
if (Array.isArray(d.versions)) {
|
||||
d.versions = d.versions.map(v => normalizeVersion(v))
|
||||
}
|
||||
// Ensure objects
|
||||
if (!d.selectionGuide) d.selectionGuide = {}
|
||||
if (!d.philosophy && editorType.value === 'course') d.philosophy = { headline: '', tagline: '', description: '', values: [] }
|
||||
if (!d.ageGroups && editorType.value === 'course') d.ageGroups = []
|
||||
if (!d.modules && editorType.value === 'course') d.modules = []
|
||||
return d
|
||||
}
|
||||
|
||||
function normalizeVersion(v) {
|
||||
return {
|
||||
id: v.id || '', name: v.name || '', tag: v.tag || '',
|
||||
days: v.days || 0, nights: v.nights || 0, line: v.line || '', route: v.route || '',
|
||||
audience: v.audience || '', description: v.description || '',
|
||||
highlights: Array.isArray(v.highlights) ? v.highlights : [],
|
||||
itinerary: normalizeItinerary(v.itinerary || []),
|
||||
pricing: {
|
||||
label: v.pricing?.label || '', note: v.pricing?.note || '',
|
||||
startPrice: v.pricing?.startPrice || 0, peakPrice: v.pricing?.peakPrice || 0,
|
||||
highStartPrice: v.pricing?.highStartPrice || 0, highPeakPrice: v.pricing?.highPeakPrice || 0,
|
||||
tiers: v.pricing?.tiers || [],
|
||||
},
|
||||
included: {
|
||||
accommodation: v.included?.accommodation || '', transport: v.included?.transport || '',
|
||||
tickets: v.included?.tickets || '', activities: v.included?.activities || '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeItinerary(items) {
|
||||
return items.map((item, i) => {
|
||||
if (typeof item === 'string') {
|
||||
const match = item.match(/^(?:D|Day)\s*(\d+)\s*(.*)$/i)
|
||||
const day = match ? parseInt(match[1]) : i + 1
|
||||
const rest = match ? match[2].trim() : item
|
||||
return { day, title: rest, summary: '', hotel: '' }
|
||||
}
|
||||
return { day: item.day || i + 1, title: item.title || '', summary: item.summary || '', hotel: item.hotel || '', highlights: item.highlights }
|
||||
})
|
||||
}
|
||||
|
||||
// ── 工具函数 ──
|
||||
function addVersion() {
|
||||
if (!data.value.versions) data.value.versions = []
|
||||
data.value.versions.push(normalizeVersion({ id: '', name: '', days: 5, nights: 4 }))
|
||||
}
|
||||
function addItineraryDay(ver) {
|
||||
if (!ver.itinerary) ver.itinerary = []
|
||||
ver.itinerary.push({ day: ver.itinerary.length + 1, title: '', summary: '', hotel: '' })
|
||||
}
|
||||
function addCampItinerary() {
|
||||
if (!data.value.itinerary) data.value.itinerary = []
|
||||
data.value.itinerary.push({ day: data.value.itinerary.length + 1, title: '', summary: '', hotel: '', highlights: [] })
|
||||
}
|
||||
function addToArray(obj, key, item) {
|
||||
if (!obj[key]) obj[key] = []
|
||||
obj[key].push(typeof item === 'object' ? { ...item } : item)
|
||||
}
|
||||
function ensureArray(obj, path) {
|
||||
const parts = path.split('.')
|
||||
let cur = obj
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]]) cur[parts[i]] = {}
|
||||
cur = cur[parts[i]]
|
||||
}
|
||||
if (!Array.isArray(cur[parts[parts.length - 1]])) cur[parts[parts.length - 1]] = []
|
||||
}
|
||||
function ensureObj(obj, key, defaultVal) {
|
||||
if (!obj[key]) obj[key] = defaultVal
|
||||
}
|
||||
|
||||
// ── 保存 ──
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: route.params.key, data: data.value },
|
||||
})
|
||||
message.success('保存成功')
|
||||
} catch (e) {
|
||||
message.error('保存失败: ' + (e.data?.message || e.message))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</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,193 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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-button type="primary" @click="showAddModal = true">新增产品线</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<n-grid :cols="1" :y-gap="12">
|
||||
<n-gi v-for="(item, index) in productLines" :key="item.key">
|
||||
<n-card hoverable>
|
||||
<n-space justify="space-between" align="center">
|
||||
<n-space align="center" :size="16">
|
||||
<span style="font-size: 14px; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: #f0f7f1; border-radius: 8px; font-weight: 600; color: #3a7d44">{{ item.name?.charAt(0) || 'P' }}</span>
|
||||
<div>
|
||||
<div style="font-size: 16px; font-weight: 600; color: #111827">
|
||||
{{ item.name }}
|
||||
<n-tag v-if="item.version" size="small" type="info" :bordered="false" style="margin-left: 8px">{{ item.version }}</n-tag>
|
||||
<n-tag size="small" :type="seasonType(item.season)" :bordered="false" style="margin-left: 4px">{{ seasonLabel(item.season) }}</n-tag>
|
||||
<n-tag size="small" :bordered="false" style="margin-left: 4px">{{ item.type === 'camp' ? '营地' : '线路' }}</n-tag>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px">
|
||||
key: {{ item.key }} · 排序: {{ item.sortOrder }}
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-button size="small" @click="moveUp(index)" :disabled="index === 0">上移</n-button>
|
||||
<n-button size="small" @click="moveDown(index)" :disabled="index === productLines.length - 1">下移</n-button>
|
||||
<n-button size="small" type="primary" @click="navigateTo(`/admin/products/${item.key}`)">编辑内容</n-button>
|
||||
<n-popconfirm @positive-click="handleDelete(index)">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error" quaternary>删除</n-button>
|
||||
</template>
|
||||
确定删除「{{ item.name }}」产品线?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-empty v-if="!loading && productLines.length === 0" description="暂无产品线" style="padding: 60px 0" />
|
||||
</n-spin>
|
||||
|
||||
<!-- 新增弹窗 -->
|
||||
<n-modal v-model:show="showAddModal" preset="dialog" title="新增产品线" positive-text="确定" negative-text="取消" @positive-click="handleAdd">
|
||||
<n-form label-placement="top" style="margin-top: 16px">
|
||||
<n-form-item label="产品线名称" required>
|
||||
<n-input v-model:value="newItem.name" placeholder="如:额吉的故乡" />
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="Key(唯一标识)" required>
|
||||
<n-input v-model:value="newItem.key" placeholder="如:spring-products" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="版本号">
|
||||
<n-input v-model:value="newItem.version" placeholder="如:V1" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="季节">
|
||||
<n-select v-model:value="newItem.season" :options="seasonOptions" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="类型">
|
||||
<n-select v-model:value="newItem.type" :options="[{ label: '线路产品', value: 'tour' }, { label: '营地/研学', value: 'camp' }]" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NEmpty, NModal, NForm, NFormItem, NInput, NSelect, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const productLines = ref([])
|
||||
const showAddModal = ref(false)
|
||||
|
||||
const seasonOptions = [
|
||||
{ label: '春季', value: 'spring' },
|
||||
{ label: '夏季', value: 'summer' },
|
||||
{ label: '秋季', value: 'autumn' },
|
||||
{ label: '冬季', value: 'winter' },
|
||||
{ label: '全年', value: 'all' },
|
||||
]
|
||||
|
||||
const newItem = ref({ name: '', key: '', version: '', season: 'summer', type: 'tour' })
|
||||
|
||||
function seasonLabel(s) {
|
||||
return { spring: '春', summer: '夏', autumn: '秋', winter: '冬', all: '全年' }[s] || s
|
||||
}
|
||||
|
||||
function seasonType(s) {
|
||||
return { spring: 'success', summer: 'success', autumn: 'warning', winter: 'info', all: 'default' }[s] || 'default'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=product-lines')
|
||||
productLines.value = typeof res.data === 'string' ? JSON.parse(res.data) : (res.data || [])
|
||||
} catch { message.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function saveList() {
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: 'product-lines', data: productLines.value },
|
||||
})
|
||||
} catch { message.error('保存失败') }
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!newItem.value.name?.trim() || !newItem.value.key?.trim()) {
|
||||
message.warning('名称和 Key 不能为空')
|
||||
return false
|
||||
}
|
||||
if (productLines.value.some(p => p.key === newItem.value.key)) {
|
||||
message.warning('Key 已存在')
|
||||
return false
|
||||
}
|
||||
|
||||
// Add to list
|
||||
productLines.value.push({
|
||||
...newItem.value,
|
||||
sortOrder: productLines.value.length + 1,
|
||||
})
|
||||
await saveList()
|
||||
|
||||
// Create empty product data entry in site_content
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
key: newItem.value.key,
|
||||
label: newItem.value.name,
|
||||
data: {
|
||||
narrative: '',
|
||||
highlights: [],
|
||||
versions: [],
|
||||
faq: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
message.success('已添加')
|
||||
newItem.value = { name: '', key: '', version: '', season: 'summer', type: 'tour', icon: '🌿' }
|
||||
showAddModal.value = false
|
||||
}
|
||||
|
||||
function moveUp(index) {
|
||||
if (index <= 0) return
|
||||
const arr = productLines.value
|
||||
;[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]]
|
||||
arr.forEach((item, i) => { item.sortOrder = i + 1 })
|
||||
saveList()
|
||||
}
|
||||
|
||||
function moveDown(index) {
|
||||
if (index >= productLines.value.length - 1) return
|
||||
const arr = productLines.value
|
||||
;[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]]
|
||||
arr.forEach((item, i) => { item.sortOrder = i + 1 })
|
||||
saveList()
|
||||
}
|
||||
|
||||
async function handleDelete(index) {
|
||||
productLines.value.splice(index, 1)
|
||||
productLines.value.forEach((item, i) => { item.sortOrder = i + 1 })
|
||||
await saveList()
|
||||
message.success('已删除')
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="edit-page">
|
||||
<!-- 面包屑 -->
|
||||
<NBreadcrumb class="page-breadcrumb">
|
||||
<NBreadcrumbItem>
|
||||
<NuxtLink to="/admin/reviews">客户评价</NuxtLink>
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem>{{ isNew ? '新增' : '编辑' }}</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<h1>{{ isNew ? '新增评价' : '编辑评价' }}</h1>
|
||||
<div class="header-actions">
|
||||
<NButton @click="navigateTo('/admin/reviews')">取消</NButton>
|
||||
<NButton type="primary" :loading="saving" :disabled="saving" @click="handleSave">
|
||||
保存
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载 / 错误状态 -->
|
||||
<div v-if="loadError" style="color: #ef4444; padding: 16px;">{{ loadError }}</div>
|
||||
<div v-else-if="pageLoading" style="text-align: center; padding: 60px;">
|
||||
<NSpin size="large" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 双栏布局 -->
|
||||
<NGrid :cols="4" :x-gap="20" :y-gap="20" class="form-grid">
|
||||
<!-- 主区(3列) -->
|
||||
<NGi :span="3">
|
||||
<NCard title="基本信息">
|
||||
<NForm label-placement="top" :show-feedback="false">
|
||||
<NFormItem label="客户" required>
|
||||
<NInput v-model:value="form.author" placeholder="输入客户名称" />
|
||||
</NFormItem>
|
||||
<NFormItem label="评分">
|
||||
<NRate v-model:value="form.rating" :count="5" />
|
||||
</NFormItem>
|
||||
<NFormItem label="产品版本">
|
||||
<NInput v-model:value="form.title" placeholder="输入产品版本" />
|
||||
</NFormItem>
|
||||
<NFormItem label="出行日期">
|
||||
<NInput v-model:value="form.date" placeholder="如:2026年3月" />
|
||||
</NFormItem>
|
||||
<NFormItem label="内容" required>
|
||||
<NInput
|
||||
v-model:value="form.content"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="输入评价内容"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</NGi>
|
||||
|
||||
<!-- 侧栏(1列) -->
|
||||
<NGi :span="1">
|
||||
<NCard title="发布设置">
|
||||
<NForm label-placement="top" :show-feedback="false">
|
||||
<NFormItem label="状态">
|
||||
<NSelect
|
||||
v-model:value="form.published"
|
||||
:options="publishedOptions"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="已验证">
|
||||
<NSwitch v-model:value="form.verified" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<!-- 底部 sticky 保存栏 -->
|
||||
<div class="save-bar">
|
||||
<NButton @click="navigateTo('/admin/reviews')">取消</NButton>
|
||||
<NButton type="primary" :loading="saving" :disabled="saving" @click="handleSave">
|
||||
保存
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
NButton, NCard, NForm, NFormItem, NInput, NSelect, NSwitch, NRate,
|
||||
NBreadcrumb, NBreadcrumbItem, NGrid, NGi, NSpin, useMessage,
|
||||
} from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
author: '',
|
||||
title: '',
|
||||
date: '',
|
||||
content: '',
|
||||
rating: 5,
|
||||
published: true,
|
||||
verified: false,
|
||||
})
|
||||
|
||||
const publishedOptions = [
|
||||
{ label: '已发布', value: true },
|
||||
{ label: '草稿', value: false },
|
||||
]
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/reviews/${route.params.id}`)
|
||||
form.value = {
|
||||
...data,
|
||||
rating: data.rating ?? 5,
|
||||
published: !!data.published,
|
||||
verified: !!data.verified,
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = e.data?.message || '加载失败'
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.author?.trim()) { message.error('客户名称不能为空'); return }
|
||||
if (!form.value.content?.trim()) { message.error('内容不能为空'); return }
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/reviews', { method: 'POST', body: form.value })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/reviews'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/reviews/${route.params.id}`, { method: 'PUT', body: form.value })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.data?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edit-page {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.page-breadcrumb {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.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,110 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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 tag="a" href="/reviews" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/reviews/new')">新增评价</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card>
|
||||
<n-space style="margin-bottom: 16px">
|
||||
<n-input v-model:value="search" placeholder="搜索客户、内容..." clearable style="width: 260px" @keyup.enter="page = 1; loadData()">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
|
||||
<n-space justify="end" style="margin-top: 16px" v-if="totalPages > 1">
|
||||
<n-pagination v-model:page="page" :page-count="totalPages" @update:page="loadData" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm, NRate } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '客户', key: 'author',
|
||||
render: (row) => h('a', {
|
||||
style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer',
|
||||
onClick: () => navigateTo(`/admin/reviews/${row.id}`),
|
||||
}, row.author),
|
||||
},
|
||||
{
|
||||
title: '评分', key: 'rating', width: 120,
|
||||
render: (row) => h(NRate, { value: row.rating || 5, readonly: true, size: 'small' }),
|
||||
},
|
||||
{ title: '产品版本', key: 'title', width: 100 },
|
||||
{
|
||||
title: '内容摘要', key: 'content',
|
||||
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.content?.slice(0, 50)),
|
||||
},
|
||||
{ title: '出行日期', key: 'date', width: 90, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.date) },
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, {
|
||||
size: 'small', type: row.published ? 'success' : 'default', bordered: false,
|
||||
style: 'cursor: pointer',
|
||||
onClick: () => togglePublish(row),
|
||||
}, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/reviews/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.author}」的评价?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await adminFetch(`/api/admin/reviews?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`)
|
||||
list.value = d.items; total.value = d.total; totalPages.value = d.totalPages
|
||||
} catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'PUT', body: { published: !item.published } })
|
||||
item.published = !item.published
|
||||
} catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'DELETE' })
|
||||
message.success('已删除')
|
||||
loadData()
|
||||
} catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px">
|
||||
<!-- 面包屑 -->
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item>
|
||||
<NuxtLink to="/admin/stories">客户故事</NuxtLink>
|
||||
</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ isNew ? '新增' : '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<!-- 页头 -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">
|
||||
{{ isNew ? '新增故事' : '编辑故事' }}
|
||||
</h1>
|
||||
<n-space>
|
||||
<NuxtLink to="/admin/stories">
|
||||
<n-button>取消</n-button>
|
||||
</NuxtLink>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<!-- 加载/错误状态 -->
|
||||
<n-card v-if="loadError" style="text-align: center; color: #ef4444; padding: 40px">
|
||||
{{ loadError }}
|
||||
</n-card>
|
||||
<n-card v-else-if="pageLoading" style="text-align: center; padding: 60px; color: #9ca3af">
|
||||
加载中...
|
||||
</n-card>
|
||||
|
||||
<template v-else>
|
||||
<!-- 双栏布局 -->
|
||||
<n-grid :cols="4" :x-gap="20" :y-gap="0" style="align-items: start">
|
||||
<!-- 主内容区(占 3 列) -->
|
||||
<n-gi :span="3">
|
||||
<!-- 基本信息 -->
|
||||
<n-card title="基本信息">
|
||||
<n-form label-placement="top" :show-feedback="false">
|
||||
<n-form-item label="标题" required>
|
||||
<n-input v-model:value="form.title" placeholder="输入故事标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Slug" required>
|
||||
<n-input v-model:value="form.slug" placeholder="URL 标识,如 wang-family-trip" />
|
||||
</n-form-item>
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="form.subtitle" placeholder="输入副标题(可选)" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 家庭信息 -->
|
||||
<n-card title="家庭信息" style="margin-top: 16px">
|
||||
<n-form label-placement="top" :show-feedback="false">
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-gi>
|
||||
<n-form-item label="家庭类型">
|
||||
<n-input v-model:value="form.familyType" placeholder="如:三口之家" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="孩子年龄">
|
||||
<n-input v-model:value="form.childrenAge" placeholder="如:5岁" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="出发地">
|
||||
<n-input v-model:value="form.fromCity" placeholder="如:上海" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="出行日期">
|
||||
<n-input v-model:value="form.travelDate" placeholder="如:2026年3月" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="产品">
|
||||
<n-input v-model:value="form.tripProduct" placeholder="如:呼伦贝尔亲子6日游" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 故事章节 -->
|
||||
<n-card title="故事章节" style="margin-top: 16px">
|
||||
<div
|
||||
v-for="(sec, i) in form.sections"
|
||||
:key="i"
|
||||
style="margin-bottom: 16px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px"
|
||||
>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 8px">
|
||||
<span style="font-weight: 500">章节 {{ i + 1 }}</span>
|
||||
<n-button size="tiny" type="error" quaternary @click="form.sections.splice(i, 1)">
|
||||
删除
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-form label-placement="top" :show-feedback="false">
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="sec.heading" placeholder="章节标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="内容">
|
||||
<n-input
|
||||
v-model:value="sec.content"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="章节内容"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
<n-button dashed block @click="form.sections.push({ heading: '', content: '' })">
|
||||
添加章节
|
||||
</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- 精彩瞬间 -->
|
||||
<n-card title="精彩瞬间" style="margin-top: 16px">
|
||||
<n-dynamic-input
|
||||
v-model:value="form.keyMoments"
|
||||
placeholder="输入一个精彩瞬间描述"
|
||||
:min="0"
|
||||
show-sort-button
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<!-- 侧栏(占 1 列) -->
|
||||
<n-gi :span="1">
|
||||
<n-card title="发布设置">
|
||||
<n-form label-placement="top" :show-feedback="false">
|
||||
<n-form-item label="状态">
|
||||
<n-select
|
||||
v-model:value="form.published"
|
||||
:options="[
|
||||
{ label: '已发布', value: true },
|
||||
{ label: '草稿', value: false },
|
||||
]"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="封面图" style="margin-top: 16px">
|
||||
<AdminImageUpload v-model="form.coverImage" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 底部 sticky 保存栏 -->
|
||||
<div
|
||||
style="
|
||||
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;
|
||||
"
|
||||
>
|
||||
<NuxtLink to="/admin/stories">
|
||||
<n-button>取消</n-button>
|
||||
</NuxtLink>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
slug: '',
|
||||
subtitle: '',
|
||||
familyType: '',
|
||||
childrenAge: '',
|
||||
fromCity: '',
|
||||
travelDate: '',
|
||||
tripProduct: '',
|
||||
published: true,
|
||||
coverImage: '',
|
||||
sections: [],
|
||||
keyMoments: [],
|
||||
})
|
||||
|
||||
// 加载数据(编辑模式)
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/stories/${route.params.id}`)
|
||||
form.value = {
|
||||
...data,
|
||||
published: !!data.published,
|
||||
coverImage: data.coverImage || '',
|
||||
sections:
|
||||
typeof data.sections === 'string'
|
||||
? JSON.parse(data.sections || '[]')
|
||||
: data.sections || [],
|
||||
keyMoments:
|
||||
typeof data.keyMoments === 'string'
|
||||
? JSON.parse(data.keyMoments || '[]')
|
||||
: data.keyMoments || [],
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = e.data?.message || '加载失败'
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.title?.trim()) {
|
||||
message.error('标题不能为空')
|
||||
return
|
||||
}
|
||||
if (!form.value.slug?.trim()) {
|
||||
message.error('Slug 不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
const payload = {
|
||||
...form.value,
|
||||
sections: JSON.stringify(form.value.sections),
|
||||
keyMoments: JSON.stringify(form.value.keyMoments),
|
||||
}
|
||||
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/stories', { method: 'POST', body: payload })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/stories'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/stories/${route.params.id}`, { method: 'PUT', body: payload })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.data?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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 tag="a" href="/stories" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/stories/new')">新增故事</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card>
|
||||
<n-space style="margin-bottom: 16px">
|
||||
<n-input v-model:value="search" placeholder="搜索标题、家庭类型..." clearable style="width: 260px" @keyup.enter="page = 1; loadData()">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
|
||||
<n-space justify="end" style="margin-top: 16px" v-if="totalPages > 1">
|
||||
<n-pagination v-model:page="page" :page-count="totalPages" @update:page="loadData" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const list = ref([]); const total = ref(0); const page = ref(1); const totalPages = ref(1); const loading = ref(false); const search = ref('')
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '标题', key: 'title',
|
||||
render: (row) => h('a', { style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer', onClick: () => navigateTo(`/admin/stories/${row.id}`) }, row.title),
|
||||
},
|
||||
{ title: '家庭类型', key: 'familyType', width: 90, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.familyType || '-') },
|
||||
{ title: '出发地', key: 'fromCity', width: 80, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.fromCity || '-') },
|
||||
{ title: '产品', key: 'tripProduct', width: 100, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.tripProduct || '-') },
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.published ? 'success' : 'default', bordered: false, style: 'cursor: pointer', onClick: () => togglePublish(row) }, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/stories/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.title}」?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try { const d = await adminFetch(`/api/admin/stories?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`); list.value = d.items; total.value = d.total; totalPages.value = d.totalPages }
|
||||
catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try { await adminFetch(`/api/admin/stories/${item.id}`, { method: 'PUT', body: { published: !item.published } }); item.published = !item.published }
|
||||
catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try { await adminFetch(`/api/admin/stories/${item.id}`, { method: 'DELETE' }); message.success('已删除'); loadData() }
|
||||
catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px;">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<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>
|
||||
|
||||
<NSpace vertical :size="16">
|
||||
<NSpace align="center" justify="space-between">
|
||||
<NButtonGroup>
|
||||
<NButton
|
||||
v-for="tab in tabs" :key="tab.value"
|
||||
:type="filterType === tab.value ? 'primary' : 'default'"
|
||||
@click="onFilterChange(tab.value)"
|
||||
>{{ tab.label }}</NButton>
|
||||
</NButtonGroup>
|
||||
<NInput
|
||||
v-model:value="search"
|
||||
placeholder="搜索姓名、手机..."
|
||||
clearable
|
||||
style="width: 240px;"
|
||||
@update:value="onSearch"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="row => row.id"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
/>
|
||||
|
||||
<NSpace justify="end" v-if="totalPages > 1">
|
||||
<NPagination
|
||||
:page="page"
|
||||
:page-count="totalPages"
|
||||
:page-size="20"
|
||||
@update:page="onPageChange"
|
||||
/>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<NDrawer v-model:show="drawerVisible" :width="480">
|
||||
<NDrawerContent title="提交详情" closable>
|
||||
<template v-if="currentItem">
|
||||
<NDescriptions bordered :column="1" label-placement="left">
|
||||
<NDescriptionsItem label="ID">{{ currentItem.id }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="类型">
|
||||
<NTag :type="currentItem.type === 'contact' ? 'info' : 'warning'" size="small">
|
||||
{{ currentItem.type === 'contact' ? '留言' : '定制' }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="姓名">{{ currentItem.data?.name }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="手机">{{ currentItem.data?.phone }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="邮箱" v-if="currentItem.data?.email">{{ currentItem.data?.email }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="内容">
|
||||
<div style="white-space: pre-wrap;">{{ currentItem.data?.message }}</div>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="状态">
|
||||
<NTag :type="currentItem.read ? 'success' : 'warning'" size="small">
|
||||
{{ currentItem.read ? '已读' : '未读' }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="提交时间">{{ formatTime(currentItem.createdAt) }}</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
<!-- 显示 data 中除 name/phone/email/message 外的其他字段 -->
|
||||
<template v-if="extraFields.length > 0">
|
||||
<h4 style="margin: 16px 0 8px;">其他信息</h4>
|
||||
<NDescriptions bordered :column="1" label-placement="left">
|
||||
<NDescriptionsItem v-for="field in extraFields" :key="field.key" :label="field.key">
|
||||
{{ field.value }}
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
</template>
|
||||
</template>
|
||||
<template #footer>
|
||||
<NButton
|
||||
v-if="currentItem && !currentItem.read"
|
||||
type="primary"
|
||||
@click="markRead(currentItem)"
|
||||
>标记已读</NButton>
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NTag, NDataTable, NInput, NSpace, NDrawer, NDrawerContent, NDescriptions, NDescriptionsItem, NBadge, NButtonGroup, NPagination } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const filterType = ref('')
|
||||
const drawerVisible = ref(false)
|
||||
const currentItem = ref(null)
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '留言', value: 'contact' },
|
||||
{ label: '定制', value: 'customize' },
|
||||
]
|
||||
|
||||
const extraFields = computed(() => {
|
||||
if (!currentItem.value?.data) return []
|
||||
const knownKeys = ['name', 'phone', 'email', 'message']
|
||||
return Object.entries(currentItem.value.data)
|
||||
.filter(([k]) => !knownKeys.includes(k))
|
||||
.map(([key, value]) => ({ key, value: typeof value === 'object' ? JSON.stringify(value) : String(value) }))
|
||||
})
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return ''
|
||||
return t.slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
render(row) {
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 80,
|
||||
render(row) {
|
||||
return h(NTag, {
|
||||
type: row.type === 'contact' ? 'info' : 'warning',
|
||||
size: 'small',
|
||||
}, () => row.type === 'contact' ? '留言' : '定制')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
render(row) {
|
||||
return row.data?.name || ''
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '手机',
|
||||
key: 'phone',
|
||||
width: 130,
|
||||
render(row) {
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.data?.phone || '')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
key: 'message',
|
||||
ellipsis: { tooltip: true },
|
||||
render(row) {
|
||||
const msg = row.data?.message || ''
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, msg.length > 50 ? msg.slice(0, 50) + '...' : msg)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
key: 'createdAt',
|
||||
width: 150,
|
||||
render(row) {
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, formatTime(row.createdAt))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'read',
|
||||
width: 70,
|
||||
render(row) {
|
||||
return h(NTag, {
|
||||
type: row.read ? 'success' : 'warning',
|
||||
size: 'small',
|
||||
}, () => row.read ? '已读' : '未读')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render(row) {
|
||||
return h(NSpace, { size: 8 }, () => [
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
quaternary: true,
|
||||
type: 'info',
|
||||
onClick: () => openDetail(row),
|
||||
}, () => '查看'),
|
||||
!row.read
|
||||
? h(NButton, {
|
||||
size: 'small',
|
||||
quaternary: true,
|
||||
type: 'primary',
|
||||
onClick: () => markRead(row),
|
||||
}, () => '标记已读')
|
||||
: null,
|
||||
])
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
let searchTimer = null
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
let url = `/api/admin/submissions?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`
|
||||
if (filterType.value) url += `&type=${filterType.value}`
|
||||
const d = await adminFetch(url)
|
||||
list.value = d.items
|
||||
total.value = d.total
|
||||
totalPages.value = d.totalPages
|
||||
} catch (e) {
|
||||
message.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onPageChange(p) {
|
||||
page.value = p
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onFilterChange(type) {
|
||||
filterType.value = type
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
currentItem.value = row
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function markRead(row) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/submissions/${row.id}`, {
|
||||
method: 'PUT',
|
||||
body: { read: true },
|
||||
})
|
||||
row.read = true
|
||||
message.success('已标记为已读')
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
在新工单中引用
屏蔽一个用户