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,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>
|
||||
在新工单中引用
屏蔽一个用户