feat: 官网代码全量更新 (2026-04-18)
这个提交包含在:
+4
-2
@@ -2,7 +2,7 @@
|
||||
<div class="page-about">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1 || '关于呼籁旅行'"
|
||||
subtitle="一个深耕呼伦贝尔的旅行品牌"
|
||||
:subtitle="heroes?.about || '一个深耕呼伦贝尔的旅行品牌'"
|
||||
:breadcrumbs="[{ text: '关于我们' }]"
|
||||
background-image="/images/seasons/summer-hero.jpg"
|
||||
/>
|
||||
@@ -80,7 +80,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 价值观 -->
|
||||
<CommonSectionTitle title="呼籁文化理念" />
|
||||
<CommonSectionTitle :title="st('aboutPage.culture', '呼籁文化理念')" />
|
||||
<div class="culture-values">
|
||||
<div v-for="value in about.culture.values" :key="value.name" class="culture-value-card">
|
||||
<h4 class="culture-value-card__name">{{ value.name }}</h4>
|
||||
@@ -119,6 +119,8 @@ const { data: about } = await usePublicApi('about')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
const { data: images } = await usePublicApi('images')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const seo = useSEO('about')
|
||||
useScrollReveal()
|
||||
|
||||
@@ -32,7 +32,12 @@
|
||||
<n-input v-model:value="form.summary" type="textarea" :rows="3" placeholder="简要描述文章内容(可选)" />
|
||||
</n-form-item>
|
||||
<n-form-item label="正文">
|
||||
<n-input v-model:value="form.content" type="textarea" :rows="16" placeholder="输入文章正文,支持 HTML" />
|
||||
<ClientOnly>
|
||||
<AdminRichTextEditor v-model="form.content" placeholder="在这里写文章正文,工具栏可加标题/加粗/列表/链接/图片" />
|
||||
<template #fallback>
|
||||
<n-input v-model:value="form.content" type="textarea" :rows="16" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
@@ -31,8 +31,13 @@
|
||||
<n-form-item label="摘要">
|
||||
<n-input v-model:value="form.summary" type="textarea" :rows="3" placeholder="简要描述" />
|
||||
</n-form-item>
|
||||
<n-form-item label="正文(支持 HTML)">
|
||||
<n-input v-model:value="form.content" type="textarea" :rows="20" placeholder="输入博客正文" />
|
||||
<n-form-item label="正文">
|
||||
<ClientOnly>
|
||||
<AdminRichTextEditor v-model="form.content" placeholder="在这里写文章正文,工具栏可加标题/加粗/列表/链接/图片" />
|
||||
<template #fallback>
|
||||
<n-input v-model:value="form.content" type="textarea" :rows="20" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<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="旅行服务主体"><n-input v-model:value="data.serviceEntity" placeholder="内蒙古呼籁国际旅行社有限公司" /></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-gi><n-form-item label="ICP 链接"><n-input v-model:value="data.icpUrl" placeholder="https://beian.miit.gov.cn" /></n-form-item></n-gi>
|
||||
@@ -57,6 +58,19 @@
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card title="全站悬浮「咨询客服」按钮">
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="是否启用"><n-switch v-model:value="floatingEnabled" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="按钮文字"><n-input v-model:value="floatingText" placeholder="咨询客服" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="跳转链接"><n-input v-model:value="floatingTo" placeholder="/contact" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="隐藏路径(一行一条,如 /contact 或 /admin)">
|
||||
<n-input v-model:value="floatingHideOnPathsText" type="textarea" :rows="2" placeholder="/contact" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="CTA 按钮">
|
||||
<n-form label-placement="top">
|
||||
<div v-for="(btnKey, btnLabel) in ctaLabels" :key="btnKey" style="margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0;">
|
||||
@@ -88,7 +102,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSpin, NGrid, NGi } from 'naive-ui'
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSpin, NGrid, NGi, NSwitch } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
@@ -111,6 +125,29 @@ function addConversionStep() {
|
||||
data.value.conversionPath.push({ step: nextStep, label: '', to: '' })
|
||||
}
|
||||
|
||||
const floatingEnabled = computed({
|
||||
get() { return data.value?.floatingCTA?.enabled !== false },
|
||||
set(v) {
|
||||
if (!data.value.floatingCTA) data.value.floatingCTA = {}
|
||||
data.value.floatingCTA.enabled = v
|
||||
}
|
||||
})
|
||||
const floatingText = computed({
|
||||
get() { return data.value?.floatingCTA?.text || '' },
|
||||
set(v) { if (!data.value.floatingCTA) data.value.floatingCTA = {}; data.value.floatingCTA.text = v }
|
||||
})
|
||||
const floatingTo = computed({
|
||||
get() { return data.value?.floatingCTA?.to || '' },
|
||||
set(v) { if (!data.value.floatingCTA) data.value.floatingCTA = {}; data.value.floatingCTA.to = v }
|
||||
})
|
||||
const floatingHideOnPathsText = computed({
|
||||
get() { return (data.value?.floatingCTA?.hideOnPaths || []).join('\n') },
|
||||
set(v) {
|
||||
if (!data.value.floatingCTA) data.value.floatingCTA = {}
|
||||
data.value.floatingCTA.hideOnPaths = v.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
})
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=brand')
|
||||
const d = res.data
|
||||
@@ -122,6 +159,7 @@ async function load() {
|
||||
if (!d.conversionPath) d.conversionPath = []
|
||||
if (!d.icpUrl) d.icpUrl = ''
|
||||
if (!d.eContract) d.eContract = ''
|
||||
if (!d.floatingCTA) d.floatingCTA = { enabled: true, text: '咨询客服', to: '/contact', hideOnPaths: ['/contact'] }
|
||||
data.value = d
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
@@ -67,13 +67,18 @@
|
||||
@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 v-if="form.seasons" label="可去季节">
|
||||
<n-checkbox-group
|
||||
:value="form.seasons || []"
|
||||
@update:value="form.seasons = $event"
|
||||
>
|
||||
<n-space>
|
||||
<n-checkbox value="spring">春季</n-checkbox>
|
||||
<n-checkbox value="summer">夏季</n-checkbox>
|
||||
<n-checkbox value="autumn">秋季</n-checkbox>
|
||||
<n-checkbox value="winter">冬季</n-checkbox>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
@@ -118,7 +123,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NBreadcrumb, NBreadcrumbItem, NCheckbox, NCheckboxGroup } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin', name: 'admin-content-destinations-edit' })
|
||||
|
||||
@@ -5,53 +5,139 @@
|
||||
<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-button v-if="!sortMode" @click="enterSortMode">调整排序</n-button>
|
||||
<template v-else>
|
||||
<n-button @click="cancelSort">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="saveSort">保存排序</n-button>
|
||||
</template>
|
||||
<a href="/destinations" target="_blank">
|
||||
<n-button>查看前台</n-button>
|
||||
</a>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-alert v-if="sortMode" type="info" :show-icon="false" style="margin-bottom: 12px;">
|
||||
排序模式:使用 ↑ ↓ 按钮调整顺序,完成后点「保存排序」。取消则不保存。
|
||||
</n-alert>
|
||||
|
||||
<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;"
|
||||
>
|
||||
<template v-for="(item, idx) in items" :key="item._originalIndex">
|
||||
<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 style="flex: 1; min-width: 0;">
|
||||
<div style="font-size: 15px; font-weight: 600; color: #111827; margin-bottom: 4px;">
|
||||
<span v-if="sortMode" style="color: #9ca3af; margin-right: 8px; font-weight: 400;">#{{ idx + 1 }}</span>
|
||||
{{ 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>
|
||||
<!-- 排序模式:显示上下移动按钮 -->
|
||||
<template v-if="sortMode">
|
||||
<n-button size="tiny" :disabled="idx === 0" @click="moveUp(idx)">↑</n-button>
|
||||
<n-button size="tiny" :disabled="idx === items.length - 1" @click="moveDown(idx)">↓</n-button>
|
||||
</template>
|
||||
<!-- 编辑模式:显示跳转箭头 -->
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="`/admin/content/destinations-detail/${item._originalIndex}`"
|
||||
style="text-decoration: none; color: #d1d5db; font-size: 20px; padding: 0 8px;"
|
||||
>›</NuxtLink>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</n-space>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NTag, NSpin } from 'naive-ui'
|
||||
import { NButton, NCard, NSpace, NTag, NSpin, NAlert } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const items = ref([]); const loaded = ref(false)
|
||||
onMounted(async () => {
|
||||
const message = useMessage()
|
||||
|
||||
const items = ref([])
|
||||
const loaded = ref(false)
|
||||
const sortMode = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// 原始完整数据(用于保存时回写完整结构)
|
||||
const rawDestinations = ref([])
|
||||
const originalOrder = ref([])
|
||||
|
||||
async function loadData() {
|
||||
loaded.value = false
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=destinations-detail')
|
||||
const data = res.data
|
||||
const arr = Array.isArray(data) ? data : (data.destinations || [])
|
||||
rawDestinations.value = arr
|
||||
items.value = arr.map((d, i) => ({
|
||||
_index: i, name: d.name || '', subtitle: d.subtitle || '', tag: d.tag || '', id: d.id || '',
|
||||
_originalIndex: i,
|
||||
name: d.name || '',
|
||||
subtitle: d.subtitle || '',
|
||||
tag: d.tag || '',
|
||||
id: d.id || '',
|
||||
}))
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
message.error('加载失败')
|
||||
}
|
||||
loaded.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function enterSortMode() {
|
||||
originalOrder.value = items.value.map(i => i._originalIndex)
|
||||
sortMode.value = true
|
||||
}
|
||||
|
||||
function cancelSort() {
|
||||
// 按原始顺序还原
|
||||
const restored = originalOrder.value.map(origIdx => {
|
||||
return items.value.find(i => i._originalIndex === origIdx)
|
||||
})
|
||||
items.value = restored
|
||||
sortMode.value = false
|
||||
}
|
||||
|
||||
function moveUp(idx) {
|
||||
if (idx === 0) return
|
||||
const arr = [...items.value]
|
||||
;[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]]
|
||||
items.value = arr
|
||||
}
|
||||
|
||||
function moveDown(idx) {
|
||||
if (idx >= items.value.length - 1) return
|
||||
const arr = [...items.value]
|
||||
;[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]]
|
||||
items.value = arr
|
||||
}
|
||||
|
||||
async function saveSort() {
|
||||
saving.value = true
|
||||
try {
|
||||
// 按当前显示顺序重组完整数据
|
||||
const reordered = items.value.map(i => rawDestinations.value[i._originalIndex])
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: 'destinations-detail', data: { destinations: reordered } },
|
||||
})
|
||||
message.success('排序已保存')
|
||||
sortMode.value = false
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
message.error('保存失败:' + (e.data?.message || e.message))
|
||||
}
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<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">编辑官网「/guide-custom」定制游对比文章内容</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/guide-custom" 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">
|
||||
<!-- 标题 & TL;DR -->
|
||||
<n-card title="基础信息">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="文章标题(Hero 大标题)">
|
||||
<n-input v-model:value="data.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="TL;DR 摘要(顶部绿色边框内的一段话)">
|
||||
<n-input v-model:value="data.tldr" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- SEO -->
|
||||
<n-card title="SEO 配置">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="页面 Title (<title>)">
|
||||
<n-input v-model:value="data.seo.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Meta Description">
|
||||
<n-input v-model:value="data.seo.description" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Meta Keywords">
|
||||
<n-input v-model:value="data.seo.keywords" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 文章章节 -->
|
||||
<n-card title="文章章节">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button size="small" @click="addSection('text')">+ 段落</n-button>
|
||||
<n-button size="small" @click="addSection('list')">+ 列表</n-button>
|
||||
<n-button size="small" @click="addSection('table')">+ 对比表</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-space vertical :size="16">
|
||||
<div
|
||||
v-for="(sec, sIdx) in data.sections"
|
||||
:key="sIdx"
|
||||
style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 14px; background: #fafafa"
|
||||
>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 10px">
|
||||
<n-tag :bordered="false" size="small">{{ typeLabel(sec.type) }} #{{ sIdx + 1 }}</n-tag>
|
||||
<n-space :size="4">
|
||||
<n-button size="tiny" :disabled="sIdx === 0" @click="moveSection(sIdx, -1)">↑</n-button>
|
||||
<n-button size="tiny" :disabled="sIdx === data.sections.length - 1" @click="moveSection(sIdx, 1)">↓</n-button>
|
||||
<n-button size="tiny" type="error" quaternary @click="data.sections.splice(sIdx, 1)">删除</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="h2 标题">
|
||||
<n-input v-model:value="sec.heading" />
|
||||
</n-form-item>
|
||||
|
||||
<!-- 文本段落 -->
|
||||
<template v-if="sec.type === 'text'">
|
||||
<n-form-item label="段落(每行一段)">
|
||||
<n-space vertical :size="8" style="width: 100%">
|
||||
<div v-for="(_, pIdx) in sec.paragraphs" :key="pIdx" style="display: flex; gap: 8px">
|
||||
<n-input
|
||||
v-model:value="sec.paragraphs[pIdx]"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<n-button size="small" type="error" quaternary @click="sec.paragraphs.splice(pIdx, 1)">删</n-button>
|
||||
</div>
|
||||
<n-button size="small" dashed @click="sec.paragraphs.push('')">+ 添加段落</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 列表 -->
|
||||
<template v-else-if="sec.type === 'list'">
|
||||
<n-form-item label="引言(列表上方的文字,可空)">
|
||||
<n-input v-model:value="sec.intro" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" />
|
||||
</n-form-item>
|
||||
<n-form-item label="列表项">
|
||||
<n-space vertical :size="6" style="width: 100%">
|
||||
<div v-for="(_, iIdx) in sec.items" :key="iIdx" style="display: flex; gap: 8px">
|
||||
<n-input v-model:value="sec.items[iIdx]" style="flex: 1" />
|
||||
<n-button size="small" type="error" quaternary @click="sec.items.splice(iIdx, 1)">删</n-button>
|
||||
</div>
|
||||
<n-button size="small" dashed @click="sec.items.push('')">+ 添加条目</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item label="结语(列表下方的文字,可空)">
|
||||
<n-input v-model:value="sec.outro" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" />
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 对比表 -->
|
||||
<template v-else-if="sec.type === 'table'">
|
||||
<n-form-item label="列名(从左到右,首列会被高亮绿色)">
|
||||
<n-space vertical :size="6" style="width: 100%">
|
||||
<div v-for="(_, cIdx) in sec.columns" :key="cIdx" style="display: flex; gap: 8px">
|
||||
<n-input v-model:value="sec.columns[cIdx]" style="flex: 1" />
|
||||
<n-button size="small" type="error" quaternary @click="removeColumn(sec, cIdx)">删列</n-button>
|
||||
</div>
|
||||
<n-button size="small" dashed @click="addColumn(sec)">+ 添加列</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item label="行">
|
||||
<n-space vertical :size="10" style="width: 100%">
|
||||
<div
|
||||
v-for="(row, rIdx) in sec.rows"
|
||||
:key="rIdx"
|
||||
style="display: flex; flex-direction: column; gap: 6px; border: 1px dashed #d1d5db; padding: 10px; border-radius: 4px"
|
||||
>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<span style="font-size: 12px; color: #6b7280; min-width: 40px">行标:</span>
|
||||
<n-input v-model:value="row.label" style="flex: 1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="sec.rows.splice(rIdx, 1)">删行</n-button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(_, vIdx) in row.values"
|
||||
:key="vIdx"
|
||||
style="display: flex; gap: 8px; align-items: center"
|
||||
>
|
||||
<span style="font-size: 12px; color: #6b7280; min-width: 40px">{{ sec.columns[vIdx] || `列${vIdx + 1}` }}:</span>
|
||||
<n-input v-model:value="row.values[vIdx]" style="flex: 1" />
|
||||
</div>
|
||||
</div>
|
||||
<n-button size="small" dashed @click="addRow(sec)">+ 添加行</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</template>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- CTA -->
|
||||
<n-card title="底部 CTA">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="data.cta.heading" />
|
||||
</n-form-item>
|
||||
<n-form-item label="按钮">
|
||||
<n-space vertical :size="10" style="width: 100%">
|
||||
<div
|
||||
v-for="(link, lIdx) in data.cta.links"
|
||||
:key="lIdx"
|
||||
style="border: 1px dashed #d1d5db; padding: 10px; border-radius: 4px"
|
||||
>
|
||||
<n-grid :cols="2" :x-gap="10">
|
||||
<n-gi>
|
||||
<n-form-item label="按钮文字" :show-feedback="false">
|
||||
<n-input v-model:value="link.text" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="跳转路径" :show-feedback="false">
|
||||
<n-input v-model:value="link.to" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-space style="margin-top: 8px" justify="space-between" align="center">
|
||||
<n-checkbox v-model:checked="link.primary">主按钮(绿色填充)</n-checkbox>
|
||||
<n-button size="tiny" type="error" quaternary @click="data.cta.links.splice(lIdx, 1)">删除</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-button size="small" dashed @click="data.cta.links.push({ text: '', to: '/', primary: false })">+ 添加按钮</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NCheckbox, NTag } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const defaultData = () => ({
|
||||
title: '',
|
||||
tldr: '',
|
||||
seo: { title: '', description: '', keywords: '' },
|
||||
sections: [],
|
||||
cta: { heading: '', links: [] },
|
||||
})
|
||||
|
||||
const data = ref(defaultData())
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
function typeLabel(type) {
|
||||
return { text: '段落', list: '列表', table: '对比表' }[type] || type
|
||||
}
|
||||
|
||||
function addSection(type) {
|
||||
const templates = {
|
||||
text: { type: 'text', heading: '', paragraphs: [''] },
|
||||
list: { type: 'list', heading: '', intro: '', items: [''], outro: '' },
|
||||
table: { type: 'table', heading: '', columns: ['列1', '列2'], rows: [{ label: '', values: ['', ''] }] },
|
||||
}
|
||||
data.value.sections.push(templates[type])
|
||||
}
|
||||
|
||||
function moveSection(idx, delta) {
|
||||
const target = idx + delta
|
||||
if (target < 0 || target >= data.value.sections.length) return
|
||||
const arr = data.value.sections
|
||||
;[arr[idx], arr[target]] = [arr[target], arr[idx]]
|
||||
}
|
||||
|
||||
function addColumn(sec) {
|
||||
sec.columns.push('')
|
||||
for (const row of sec.rows) row.values.push('')
|
||||
}
|
||||
|
||||
function removeColumn(sec, cIdx) {
|
||||
sec.columns.splice(cIdx, 1)
|
||||
for (const row of sec.rows) row.values.splice(cIdx, 1)
|
||||
}
|
||||
|
||||
function addRow(sec) {
|
||||
sec.rows.push({ label: '', values: sec.columns.map(() => '') })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=guide-custom')
|
||||
const d = res.data || {}
|
||||
// 合并默认结构,防止字段缺失
|
||||
data.value = {
|
||||
title: d.title || '',
|
||||
tldr: d.tldr || '',
|
||||
seo: { title: '', description: '', keywords: '', ...(d.seo || {}) },
|
||||
sections: d.sections || [],
|
||||
cta: {
|
||||
heading: d.cta?.heading || '',
|
||||
links: d.cta?.links || [],
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
data.value = defaultData()
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: 'guide-custom', data: data.value },
|
||||
})
|
||||
message.success('已保存')
|
||||
} catch (e) {
|
||||
message.error('保存失败:' + (e.data?.message || ''))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
+281
-44
@@ -39,6 +39,7 @@
|
||||
</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">
|
||||
@@ -62,9 +63,11 @@
|
||||
</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-input v-model:value="s.content" type="textarea" :rows="3" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="s.tips" label="Tips(每行一个)" :show-feedback="false" style="margin-bottom: 10px;">
|
||||
|
||||
<!-- tips(documents / packing) -->
|
||||
<n-form-item v-if="'tips' in s" label="Tips(每行一个)" :show-feedback="false" style="margin-bottom: 10px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
@@ -73,19 +76,20 @@
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<!-- items(documents:{text, important}) -->
|
||||
<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;">
|
||||
<n-space v-for="(item, ii) in s.items" :key="ii" align="center" style="margin-bottom: 6px; width: 100%;">
|
||||
<template v-if="typeof item === 'string'">
|
||||
<n-input
|
||||
:value="item"
|
||||
@input="s.items[ii] = $event"
|
||||
size="small"
|
||||
style="flex: 1; min-width: 200px;"
|
||||
style="flex: 1; min-width: 300px;"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-input v-model:value="item.text" placeholder="文字" size="small" style="flex: 1; min-width: 200px;" />
|
||||
<n-input v-model:value="item.text" placeholder="文字" size="small" style="flex: 1; min-width: 300px;" />
|
||||
<n-tag
|
||||
:type="item.important ? 'warning' : 'default'"
|
||||
checkable
|
||||
@@ -96,47 +100,239 @@
|
||||
重要
|
||||
</n-tag>
|
||||
</template>
|
||||
<n-button size="tiny" quaternary type="error" @click="s.items.splice(ii, 1)">删除</n-button>
|
||||
</n-space>
|
||||
<n-button size="small" dashed @click="addItem(s)" style="margin-top: 6px;">+ 添加子项</n-button>
|
||||
</div>
|
||||
|
||||
<n-card v-if="s.months" title="每月穿搭" size="small" style="margin-top: 12px;">
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(m, mi) in s.months" :key="mi" :name="mi">
|
||||
<template #header>{{ m.name }}</template>
|
||||
<n-form-item label="描述" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.desc" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="日间温度" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.tempDay" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="夜间温度" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.tempNight" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="成人穿搭" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.adultWear" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="儿童穿搭" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.kidsWear" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="亮点" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.highlight" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.note" />
|
||||
</n-form-item>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
<!-- 穿搭:months + extraTips -->
|
||||
<template v-if="s.id === 'clothing'">
|
||||
<n-card title="每月穿搭" size="small" style="margin-top: 12px;">
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(m, mi) in s.months" :key="mi" :name="mi">
|
||||
<template #header>{{ m.name }}</template>
|
||||
<n-form-item label="月份名称" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.name" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.desc" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="日间温度" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.tempDay" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="夜间温度" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.tempNight" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="成人穿搭" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.adultWear" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="儿童穿搭" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.kidsWear" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="亮点" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.highlight" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.note" />
|
||||
</n-form-item>
|
||||
<div style="text-align: right;">
|
||||
<n-button size="tiny" quaternary type="error" @click="s.months.splice(mi, 1)">删除此月份</n-button>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<n-button size="small" dashed @click="s.months.push({name:'',desc:'',tempDay:'',tempNight:'',adultWear:'',kidsWear:'',highlight:'',note:''})" style="margin-top: 10px;">+ 添加月份</n-button>
|
||||
</n-card>
|
||||
<n-form-item label="额外提示(每行一个)" :show-feedback="false" style="margin-top: 12px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:value="(s.extraTips||[]).join('\n')"
|
||||
@input="s.extraTips = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 行李清单:categories + provided + dontBring -->
|
||||
<template v-if="s.id === 'packing'">
|
||||
<n-card title="分类清单" size="small" style="margin-top: 12px;">
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(cat, ci) in s.categories" :key="ci" :name="ci">
|
||||
<template #header>{{ cat.name || '(未命名)' }}</template>
|
||||
<n-form-item label="分类名称" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="cat.name" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注说明" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="cat.note" />
|
||||
</n-form-item>
|
||||
<n-form-item label="条目(每行一个)" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:value="(cat.items||[]).join('\n')"
|
||||
@input="cat.items = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<div style="text-align: right;">
|
||||
<n-button size="tiny" quaternary type="error" @click="s.categories.splice(ci, 1)">删除此分类</n-button>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<n-button size="small" dashed @click="s.categories.push({name:'',note:'',items:[]})" style="margin-top: 10px;">+ 添加分类</n-button>
|
||||
</n-card>
|
||||
|
||||
<n-card title="已为您准备好的" size="small" style="margin-top: 12px;">
|
||||
<n-form-item label="标题" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="s.provided.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="s.provided.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item label="物品列表(逗号或空格分隔的文字)" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="s.provided.items" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<n-form-item label="不用带(每行一个)" :show-feedback="false" style="margin-top: 12px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:value="(s.dontBring||[]).join('\n')"
|
||||
@input="s.dontBring = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 活动安全:activities -->
|
||||
<template v-if="s.id === 'activities'">
|
||||
<n-card title="活动卡片" size="small" style="margin-top: 12px;">
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(a, ai) in s.activities" :key="ai" :name="ai">
|
||||
<template #header>{{ a.icon || '' }} {{ a.name || '(未命名)' }}</template>
|
||||
<n-grid :cols="2" :x-gap="10">
|
||||
<n-gi>
|
||||
<n-form-item label="图标" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="a.icon" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="活动名称" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="a.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="安全要点(每行一个)" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
:value="(a.points||[]).join('\n')"
|
||||
@input="a.points = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<div style="text-align: right;">
|
||||
<n-button size="tiny" quaternary type="error" @click="s.activities.splice(ai, 1)">删除此活动</n-button>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<n-button size="small" dashed @click="s.activities.push({icon:'',name:'',points:[]})" style="margin-top: 10px;">+ 添加活动</n-button>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<!-- 实用信息:topics + apps + appNote -->
|
||||
<template v-if="s.id === 'practical'">
|
||||
<n-card title="常见话题" size="small" style="margin-top: 12px;">
|
||||
<n-space v-for="(t, ti) in s.topics" :key="ti" align="start" style="margin-bottom: 8px; width: 100%;">
|
||||
<n-input v-model:value="t.name" placeholder="话题名称" size="small" style="width: 160px;" />
|
||||
<n-input v-model:value="t.content" type="textarea" :rows="2" placeholder="内容" size="small" style="flex: 1; min-width: 400px;" />
|
||||
<n-button size="tiny" quaternary type="error" @click="s.topics.splice(ti, 1)">删除</n-button>
|
||||
</n-space>
|
||||
<n-button size="small" dashed @click="s.topics.push({name:'',content:''})">+ 添加话题</n-button>
|
||||
</n-card>
|
||||
|
||||
<n-card title="推荐 App" size="small" style="margin-top: 12px;">
|
||||
<n-space v-for="(app, pi) in s.apps" :key="pi" align="center" style="margin-bottom: 6px; width: 100%;">
|
||||
<n-input v-model:value="app.category" placeholder="类别" size="small" style="width: 140px;" />
|
||||
<n-input v-model:value="app.names" placeholder="App 名称" size="small" style="flex: 1; min-width: 360px;" />
|
||||
<n-button size="tiny" quaternary type="error" @click="s.apps.splice(pi, 1)">删除</n-button>
|
||||
</n-space>
|
||||
<n-button size="small" dashed @click="s.apps.push({category:'',names:''})">+ 添加 App</n-button>
|
||||
</n-card>
|
||||
|
||||
<n-form-item label="App 说明 / 额外提示" :show-feedback="false" style="margin-top: 12px;">
|
||||
<n-input v-model:value="s.appNote" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 幼童指南:notes / dining / equipment / conditional / recommended / notRecommended -->
|
||||
<template v-if="s.id === 'toddler'">
|
||||
<n-card title="注意事项 notes" size="small" style="margin-top: 12px;">
|
||||
<n-space v-for="(n, ni) in s.notes" :key="ni" align="start" style="margin-bottom: 8px; width: 100%;">
|
||||
<n-input v-model:value="n.title" placeholder="标题" size="small" style="width: 180px;" />
|
||||
<n-input v-model:value="n.content" type="textarea" :rows="2" placeholder="内容" size="small" style="flex: 1; min-width: 400px;" />
|
||||
<n-button size="tiny" quaternary type="error" @click="s.notes.splice(ni, 1)">删除</n-button>
|
||||
</n-space>
|
||||
<n-button size="small" dashed @click="s.notes.push({title:'',content:''})">+ 添加</n-button>
|
||||
</n-card>
|
||||
|
||||
<n-form-item label="餐饮 dining(每行一个)" :show-feedback="false" style="margin-top: 12px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:value="(s.dining||[]).join('\n')"
|
||||
@input="s.dining = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-card title="装备 equipment" size="small" style="margin-top: 12px;">
|
||||
<n-space v-for="(e, ei) in s.equipment" :key="ei" align="start" style="margin-bottom: 8px; width: 100%;">
|
||||
<n-input v-model:value="e.name" placeholder="名称" size="small" style="width: 140px;" />
|
||||
<n-input v-model:value="e.desc" type="textarea" :rows="2" placeholder="描述" size="small" style="flex: 1; min-width: 300px;" />
|
||||
<n-tag
|
||||
:type="e.provided ? 'success' : 'default'"
|
||||
checkable
|
||||
:checked="!!e.provided"
|
||||
@update:checked="e.provided = $event"
|
||||
size="small"
|
||||
>
|
||||
呼籁提供
|
||||
</n-tag>
|
||||
<n-button size="tiny" quaternary type="error" @click="s.equipment.splice(ei, 1)">删除</n-button>
|
||||
</n-space>
|
||||
<n-button size="small" dashed @click="s.equipment.push({name:'',desc:'',provided:false})">+ 添加装备</n-button>
|
||||
</n-card>
|
||||
|
||||
<n-card
|
||||
v-for="key in ['recommended','conditional','notRecommended']"
|
||||
:key="key"
|
||||
:title="toddlerGroupTitle(key)"
|
||||
size="small"
|
||||
style="margin-top: 12px;"
|
||||
>
|
||||
<n-form-item label="标题" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="s[key].title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="s[key].desc" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item label="条目(每行一个)" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:value="(s[key].items||[]).join('\n')"
|
||||
@input="s[key].items = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
</template>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -155,7 +351,48 @@ 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 }
|
||||
function toddlerGroupTitle(key) {
|
||||
return { recommended: '推荐参与 recommended', conditional: '视情况而定 conditional', notRecommended: '不建议 notRecommended' }[key]
|
||||
}
|
||||
|
||||
function addItem(s) {
|
||||
const sample = s.items[0]
|
||||
if (typeof sample === 'string' || sample === undefined) s.items.push('')
|
||||
else s.items.push({ text: '', important: false })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=guides')
|
||||
const d = res.data || { pageIntro: '', sections: [] }
|
||||
// 兜底补齐各 section 缺失的字段,避免 v-model 绑定到 undefined
|
||||
for (const s of d.sections || []) {
|
||||
if (s.id === 'packing') {
|
||||
s.categories = s.categories || []
|
||||
s.provided = s.provided || { title: '', description: '', items: '' }
|
||||
s.dontBring = s.dontBring || []
|
||||
}
|
||||
if (s.id === 'clothing') {
|
||||
s.months = s.months || []
|
||||
s.extraTips = s.extraTips || []
|
||||
}
|
||||
if (s.id === 'activities') s.activities = s.activities || []
|
||||
if (s.id === 'practical') {
|
||||
s.topics = s.topics || []
|
||||
s.apps = s.apps || []
|
||||
s.appNote = s.appNote || ''
|
||||
}
|
||||
if (s.id === 'toddler') {
|
||||
s.notes = s.notes || []
|
||||
s.dining = s.dining || []
|
||||
s.equipment = s.equipment || []
|
||||
s.recommended = s.recommended || { title: '', desc: '', items: [] }
|
||||
s.conditional = s.conditional || { title: '', desc: '', items: [] }
|
||||
s.notRecommended = s.notRecommended || { title: '', desc: '', items: [] }
|
||||
}
|
||||
}
|
||||
data.value = d
|
||||
loaded.value = true
|
||||
}
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<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">统一管理各页面顶部 Hero 区域的副标题文案</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<n-space vertical :size="12">
|
||||
<n-card
|
||||
v-for="item in entries"
|
||||
:key="item.key"
|
||||
size="small"
|
||||
>
|
||||
<template #header>
|
||||
<n-space align="center" :size="12">
|
||||
<span style="font-size: 14px; font-weight: 600; color: #111827">{{ item.label }}</span>
|
||||
<n-button
|
||||
size="tiny"
|
||||
tag="a"
|
||||
:href="item.preview"
|
||||
target="_blank"
|
||||
quaternary
|
||||
style="color: #6b7280"
|
||||
>
|
||||
查看前台 ↗
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-input
|
||||
v-model:value="data[item.key]"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||
:placeholder="`${item.label} 的副标题`"
|
||||
/>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NSpin } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
// 23 个页面的元信息:key(对应 heroes.json 的字段)、显示名、前台路径
|
||||
const entries = [
|
||||
{ key: 'about', label: '关于我们', preview: '/about' },
|
||||
{ key: 'reviews', label: '客户评价', preview: '/reviews' },
|
||||
{ key: 'guides', label: '出行指南', preview: '/guides' },
|
||||
{ key: 'guide-custom', label: '定制游怎么选', preview: '/guide-custom' },
|
||||
{ key: 'youji', label: '客户游记列表', preview: '/youji' },
|
||||
{ key: 'xiaohongshu', label: '小红书游记列表', preview: '/xiaohongshu' },
|
||||
{ key: 'stories', label: '客户故事列表', preview: '/stories' },
|
||||
{ key: 'winter-camp', label: '冬令营', preview: '/winter-camp' },
|
||||
{ key: 'summer-camp', label: '夏令营', preview: '/summer-camp' },
|
||||
{ key: 'calendar', label: '旅行日历', preview: '/calendar' },
|
||||
{ key: 'customize', label: '定制入口', preview: '/customize' },
|
||||
{ key: 'blog', label: '博客列表', preview: '/blog' },
|
||||
{ key: 'courses', label: '亲子研学课程', preview: '/courses' },
|
||||
{ key: 'partners', label: '合作伙伴', preview: '/partners' },
|
||||
{ key: 'contact', label: '联系我们', preview: '/contact' },
|
||||
{ key: 'destinations', label: '目的地列表', preview: '/destinations' },
|
||||
{ key: 'news', label: '最新动态', preview: '/news' },
|
||||
{ key: 'products', label: '产品总览', preview: '/products' },
|
||||
{ key: 'products-winter', label: '冬季产品', preview: '/products/winter' },
|
||||
{ key: 'qualifications', label: '资质荣誉', preview: '/qualifications' },
|
||||
{ key: 'selector', label: '产品选择器', preview: '/selector' },
|
||||
{ key: 'pricing', label: '价格公开', preview: '/pricing' },
|
||||
{ key: 'gallery', label: '呼籁旅拍', preview: '/gallery' },
|
||||
]
|
||||
|
||||
const data = ref({})
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=heroes')
|
||||
data.value = res.data || {}
|
||||
} catch (e) {
|
||||
data.value = {}
|
||||
} finally {
|
||||
// 保证 23 个 key 都存在,避免 v-model 报错
|
||||
for (const item of entries) {
|
||||
if (typeof data.value[item.key] !== 'string') data.value[item.key] = ''
|
||||
}
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: 'heroes', data: data.value },
|
||||
})
|
||||
message.success('已保存')
|
||||
} catch (e) {
|
||||
message.error('保存失败:' + (e.data?.message || ''))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -142,7 +142,16 @@ const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=pricing')
|
||||
data.value = res.data
|
||||
const d = res.data || {}
|
||||
// 兜底:确保所有字段都存在,避免 v-model 绑定到 undefined 导致面板崩溃
|
||||
data.value = {
|
||||
intro: d.intro || '',
|
||||
products: d.products || [],
|
||||
included: d.included || [],
|
||||
excluded: d.excluded || [],
|
||||
philosophy: { title: '', content: '', promise: '', ...(d.philosophy || {}) },
|
||||
cta: { title: '', description: '', to: '', button: '', ...(d.cta || {}) },
|
||||
}
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<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">统一管理各页面内部每个内容板块的标题与副标题(CommonSectionTitle)</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<n-space vertical :size="20">
|
||||
<n-card
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
>
|
||||
<template #header>
|
||||
<n-space align="center" :size="10">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #111827">{{ group.label }}</span>
|
||||
<n-button
|
||||
v-if="group.preview"
|
||||
size="tiny"
|
||||
tag="a"
|
||||
:href="group.preview"
|
||||
target="_blank"
|
||||
quaternary
|
||||
style="color: #6b7280"
|
||||
>
|
||||
查看前台 ↗
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-space vertical :size="14">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px 14px; background: #fafafa"
|
||||
>
|
||||
<n-tag :bordered="false" size="small" style="margin-bottom: 10px">{{ item.label }}</n-tag>
|
||||
<n-form label-placement="top" :show-feedback="false" style="gap: 10px">
|
||||
<n-form-item label="主标题">
|
||||
<n-input
|
||||
v-model:value="ensureEntry(group.key, item.key).title"
|
||||
placeholder="主标题"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="item.hasLabel" label="英文标签(label)" style="margin-top: 10px">
|
||||
<n-input
|
||||
v-model:value="ensureEntry(group.key, item.key).label"
|
||||
placeholder="英文标签"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="副标题" style="margin-top: 10px">
|
||||
<n-input
|
||||
v-model:value="ensureEntry(group.key, item.key).subtitle"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 3 }"
|
||||
placeholder="副标题"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-space>
|
||||
</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()
|
||||
|
||||
// 分组配置:key 对应数据结构里的顶层分组
|
||||
const groups = [
|
||||
{
|
||||
key: 'home', label: '首页', preview: '/',
|
||||
items: [
|
||||
{ key: 'productTree', label: '怎么找到适合你的行程', hasLabel: true },
|
||||
{ key: 'knowledgeHub', label: '知识库', hasLabel: true },
|
||||
{ key: 'faq', label: '常见问答', hasLabel: true },
|
||||
{ key: 'productPreview', label: '旅行产品预览', hasLabel: false },
|
||||
{ key: 'differentiator', label: '为什么选择呼籁', hasLabel: true },
|
||||
{ key: 'reviewHighlights', label: '客户怎么说', hasLabel: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'products', label: '产品页(额吉的故乡)', preview: '/products',
|
||||
items: [
|
||||
{ key: 'v9Series', label: 'V9系列主标题' },
|
||||
{ key: 'evolutionStats', label: '产品进化' },
|
||||
{ key: 'upgrades', label: '2026核心升级' },
|
||||
{ key: 'highlights', label: '更多V9亮点' },
|
||||
{ key: 'compareV8V9', label: 'V8→V9 对比' },
|
||||
{ key: 'philosophy', label: '产品哲学' },
|
||||
{ key: 'pricingPhilosophy', label: '价格透明' },
|
||||
{ key: 'selectionGuide', label: '选版指南' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'productsAutumn', label: '秋季产品', preview: '/products/autumn',
|
||||
items: [
|
||||
{ key: 'highlights', label: '秋季不可错过' },
|
||||
{ key: 'southLine', label: '南线产品' },
|
||||
{ key: 'southHighlights', label: '南线独有体验' },
|
||||
{ key: 'northLine', label: '北线产品' },
|
||||
{ key: 'northHighlights', label: '北线独有体验' },
|
||||
{ key: 'selection', label: '选版指南' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'productsWinter', label: '冬季产品', preview: '/products/winter',
|
||||
items: [
|
||||
{ key: 'versions', label: 'V3系列' },
|
||||
{ key: 'highlights', label: '冬季行程亮点' },
|
||||
{ key: 'south', label: '南线特色' },
|
||||
{ key: 'north', label: '北线特色' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'summerCamp', label: '夏令营', preview: '/summer-camp',
|
||||
items: [
|
||||
{ key: 'principles', label: '行程设计原则' },
|
||||
{ key: 'itinerary', label: '行程概览' },
|
||||
{ key: 'activities', label: '核心体验' },
|
||||
{ key: 'compare', label: '营 vs 额吉的故乡' },
|
||||
{ key: 'faq', label: '常见问题' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'winterCamp', label: '冬令营', preview: '/winter-camp',
|
||||
items: [
|
||||
{ key: 'whyHulunbuir', label: '为什么是呼伦贝尔' },
|
||||
{ key: 'advantages', label: '亲子营优势' },
|
||||
{ key: 'hotels', label: '住宿安排' },
|
||||
{ key: 'itinerary', label: '行程概览' },
|
||||
{ key: 'photographer', label: '随行摄影师' },
|
||||
{ key: 'clothing', label: '穿衣指南' },
|
||||
{ key: 'serviceConfig', label: '服务配置' },
|
||||
{ key: 'faq', label: '常见问题' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'qualifications', label: '资质荣誉', preview: '/qualifications',
|
||||
items: [
|
||||
{ key: 'safety', label: '服务保障' },
|
||||
{ key: 'awards', label: '荣誉与认证' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'courses', label: '研学课程', preview: '/courses',
|
||||
items: [
|
||||
{ key: 'byAge', label: '按年龄找课程' },
|
||||
{ key: 'modules', label: '6大课程模块' },
|
||||
{ key: 'mentors', label: '课程导师团队' },
|
||||
{ key: 'faq', label: '常见问题' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'partners', label: '合作伙伴', preview: '/partners',
|
||||
items: [
|
||||
{ key: 'platform', label: '平台认证' },
|
||||
{ key: 'government', label: '政府资质' },
|
||||
{ key: 'universities', label: '校企合作' },
|
||||
{ key: 'scenic', label: '景区合作' },
|
||||
{ key: 'media', label: '媒体报道' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'contact', label: '联系我们', preview: '/contact',
|
||||
items: [
|
||||
{ key: 'primary', label: '立即联系' },
|
||||
{ key: 'more', label: '更多联系方式' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'gallery', label: '相册', preview: '/gallery',
|
||||
items: [
|
||||
{ key: 'intro', label: '开篇' },
|
||||
{ key: 'works', label: '作品墙' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pricing', label: '价格页', preview: '/pricing',
|
||||
items: [
|
||||
{ key: 'reference', label: '价格参考' },
|
||||
{ key: 'explanation', label: '费用说明' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'aboutPage', label: '关于我们(页内)', preview: '/about',
|
||||
items: [
|
||||
{ key: 'culture', label: '呼籁文化理念' },
|
||||
{ key: 'xiaohongshu', label: '小红书上的呼籁' },
|
||||
{ key: 'team', label: '我们的团队' },
|
||||
{ key: 'copyright', label: '知识产权' },
|
||||
{ key: 'qualCert', label: '资质认证' },
|
||||
{ key: 'qualService', label: '服务保障(页内)' },
|
||||
{ key: 'cultureValues', label: '企业文化' },
|
||||
{ key: 'mascot', label: '品牌IP·籁小咩' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const data = ref({})
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
function ensureEntry(groupKey, itemKey) {
|
||||
if (!data.value[groupKey]) data.value[groupKey] = {}
|
||||
if (!data.value[groupKey][itemKey]) data.value[groupKey][itemKey] = { title: '', subtitle: '', label: '' }
|
||||
// 补齐字段,避免 v-model 访问 undefined
|
||||
const e = data.value[groupKey][itemKey]
|
||||
if (e.title === undefined) e.title = ''
|
||||
if (e.subtitle === undefined) e.subtitle = ''
|
||||
if (e.label === undefined) e.label = ''
|
||||
return e
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=section-titles')
|
||||
data.value = res.data || {}
|
||||
} catch (e) {
|
||||
data.value = {}
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: 'section-titles', data: data.value },
|
||||
})
|
||||
message.success('已保存')
|
||||
} catch (e) {
|
||||
message.error('保存失败:' + (e.data?.message || ''))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
+10
-1
@@ -39,8 +39,17 @@ const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const list = ref([]); const total = ref(0); const page = ref(1); const totalPages = ref(1); const loading = ref(false); const search = ref('')
|
||||
|
||||
const { moveUp, moveDown } = useAdminSort('/api/admin/faqs')
|
||||
|
||||
const columns = [
|
||||
{ title: '排序', key: 'sortOrder', width: 60, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.sortOrder) },
|
||||
{
|
||||
title: '排序', key: 'sortOrder', width: 110,
|
||||
render: (row, idx) => h(NSpace, { size: 4, align: 'center' }, () => [
|
||||
h('span', { style: 'color: #9ca3af; font-size: 12px; min-width: 20px' }, row.sortOrder),
|
||||
h(NButton, { size: 'tiny', disabled: idx === 0, onClick: async () => { await moveUp(list.value, idx); message.success('已上移') } }, () => '↑'),
|
||||
h(NButton, { size: 'tiny', disabled: idx === list.value.length - 1, onClick: async () => { await moveDown(list.value, idx); message.success('已下移') } }, () => '↓'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '问题', key: 'question',
|
||||
render: (row) => h('a', { style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer', onClick: () => navigateTo(`/admin/faqs/${row.id}`) }, row.question),
|
||||
|
||||
@@ -83,6 +83,7 @@ import { useMessage } from 'naive-ui'
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const { moveUp, moveDown } = useAdminSort('/api/admin/gallery')
|
||||
const message = useMessage()
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
@@ -133,6 +134,14 @@ async function saveConfig() {
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '排序', key: 'sortOrder', width: 110,
|
||||
render: (row, idx) => h(NSpace, { size: 4, align: 'center' }, () => [
|
||||
h('span', { style: 'color: #9ca3af; font-size: 12px; min-width: 20px' }, row.sortOrder),
|
||||
h(NButton, { size: 'tiny', disabled: idx === 0, onClick: async () => { await moveUp(list.value, idx); message.success('已上移') } }, () => '↑'),
|
||||
h(NButton, { size: 'tiny', disabled: idx === list.value.length - 1, onClick: async () => { await moveDown(list.value, idx); message.success('已下移') } }, () => '↓'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '缩略',
|
||||
key: 'image',
|
||||
|
||||
+245
-33
@@ -1,31 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; color: #111827; font-weight: 600">数据概览</h1>
|
||||
<div class="page-head">
|
||||
<h1>数据概览</h1>
|
||||
<span v-if="stats" class="updated-at">更新于 {{ nowText }}</span>
|
||||
</div>
|
||||
|
||||
<n-spin :show="!stats">
|
||||
<n-grid v-if="stats" :cols="4" :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<n-gi v-for="item in statCards" :key="item.key" span="4 s:2 m:1">
|
||||
<n-card hoverable style="cursor: pointer" @click="item.to && navigateTo(item.to)">
|
||||
<n-statistic :label="item.label" :value="item.value">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px; color: #9ca3af">{{ item.sub }}</span>
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<template v-if="stats">
|
||||
<h2 style="font-size: 18px; color: #333; margin: 32px 0 16px; font-weight: 600">快捷操作</h2>
|
||||
<!-- 统计卡片 -->
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<n-gi v-for="item in statCards" :key="item.key" span="4 s:2 m:1">
|
||||
<n-card
|
||||
hoverable
|
||||
class="stat-card"
|
||||
:class="{ 'stat-card--alert': item.alert }"
|
||||
@click="item.to && navigateTo(item.to)"
|
||||
>
|
||||
<div class="stat-label">
|
||||
{{ item.label }}
|
||||
<n-tag v-if="item.badge" size="tiny" :type="item.badgeType || 'default'" :bordered="false" class="stat-badge">
|
||||
{{ item.badge }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<div class="stat-value">{{ item.value }}</div>
|
||||
<div class="stat-sub">{{ item.sub }}</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 近 7 天动态 + 待办 -->
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16" responsive="screen" item-responsive style="margin-top: 24px">
|
||||
<n-gi span="3 s:3 m:1">
|
||||
<n-card title="近 7 天真实咨询">
|
||||
<template #header-extra>
|
||||
<span class="card-hint">仅统计用户提交</span>
|
||||
</template>
|
||||
<div class="trend-row"><span>总提交</span><strong>{{ stats.recent.submissions7d }}</strong></div>
|
||||
<div class="trend-row"><span>联系表单</span><strong>{{ stats.recent.contact7d }}</strong></div>
|
||||
<div class="trend-row"><span>定制咨询</span><strong>{{ stats.recent.customize7d }}</strong></div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi span="3 s:3 m:1">
|
||||
<n-card title="最近提交" :segmented="{ content: true }">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" text @click="navigateTo('/admin/submissions')">查看全部 →</n-button>
|
||||
</template>
|
||||
<div v-if="!stats.recentSubmissions.length" class="empty">暂无提交</div>
|
||||
<div v-for="s in stats.recentSubmissions" :key="s.id" class="list-row">
|
||||
<n-tag size="tiny" :type="s.type === 'customize' ? 'warning' : 'default'" :bordered="false">
|
||||
{{ s.type === 'customize' ? '定制' : '联系' }}
|
||||
</n-tag>
|
||||
<span class="list-title">#{{ s.id }}</span>
|
||||
<span class="list-meta">{{ fmtDate(s.createdAt) }}</span>
|
||||
<n-tag v-if="!s.read" size="tiny" type="error" :bordered="false">未读</n-tag>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi span="3 s:3 m:1">
|
||||
<n-card title="最近评价" :segmented="{ content: true }">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" text @click="navigateTo('/admin/reviews')">查看全部 →</n-button>
|
||||
</template>
|
||||
<div v-if="!stats.recentReviews.length" class="empty">暂无评价</div>
|
||||
<div v-for="r in stats.recentReviews" :key="r.id" class="list-row">
|
||||
<span class="list-star">{{ '★'.repeat(r.rating || 5) }}</span>
|
||||
<span class="list-title">{{ r.author }}</span>
|
||||
<span class="list-meta">{{ fmtDate(r.createdAt) }}</span>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<h2 class="section-title">快捷操作</h2>
|
||||
<n-grid :cols="4" :x-gap="12" :y-gap="12" responsive="screen" item-responsive>
|
||||
<n-gi v-for="q in quickLinks" :key="q.to" span="4 s:2 m:1">
|
||||
<n-card hoverable size="small" style="cursor: pointer" @click="navigateTo(q.to)">
|
||||
<n-space align="center">
|
||||
<div>
|
||||
<div style="font-size: 14px; font-weight: 500; color: #333">{{ q.title }}</div>
|
||||
<div style="font-size: 12px; color: #999">{{ q.desc }}</div>
|
||||
</div>
|
||||
</n-space>
|
||||
<n-card hoverable size="small" class="quick-card" @click="navigateTo(q.to)">
|
||||
<div class="quick-title">{{ q.title }}</div>
|
||||
<div class="quick-desc">{{ q.desc }}</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
@@ -35,34 +87,194 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NCard, NGrid, NGi, NStatistic, NSpace, NSpin } from 'naive-ui'
|
||||
import { NCard, NGrid, NGi, NSpace, NSpin, NTag, NButton } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const stats = ref(null)
|
||||
const nowText = ref('')
|
||||
|
||||
const statCards = computed(() => {
|
||||
if (!stats.value) return []
|
||||
const s = stats.value
|
||||
return [
|
||||
{ key: 'articles', label: '文章总数', value: stats.value.articles, sub: '新闻 + 博客', to: '/admin/articles' },
|
||||
{ key: 'reviews', label: '客户评价', value: stats.value.reviews, sub: '来自真实客户', to: '/admin/reviews' },
|
||||
{ key: 'faqs', label: '常见问答', value: stats.value.faqs, sub: 'FAQ 条目', to: '/admin/faqs' },
|
||||
{ key: 'gallery', label: '旅拍照片', value: stats.value.gallery, sub: '呼籁旅拍', to: '/admin/gallery' },
|
||||
{ key: 'stories', label: '客户故事', value: stats.value.stories, sub: '深度体验', to: '/admin/stories' },
|
||||
{ key: 'submissions', label: '表单提交', value: stats.value.submissions, sub: '联系 + 定制咨询', to: '/admin/submissions' },
|
||||
{ key: 'siteContent', label: '站点配置', value: stats.value.siteContent, sub: '可编辑配置项' },
|
||||
{
|
||||
key: 'articles', label: '文章总数', value: s.articles.total,
|
||||
sub: `新闻 ${s.articles.news} · 博客 ${s.articles.blog}`,
|
||||
to: '/admin/articles',
|
||||
},
|
||||
{
|
||||
key: 'submissions', label: '表单提交', value: s.submissions.total,
|
||||
sub: s.submissions.unread > 0 ? `${s.submissions.unread} 条未读` : '已全部查看',
|
||||
badge: s.submissions.unread > 0 ? `${s.submissions.unread} 未读` : null,
|
||||
badgeType: 'error',
|
||||
alert: s.submissions.unread > 0,
|
||||
to: '/admin/submissions',
|
||||
},
|
||||
{ key: 'reviews', label: '客户评价', value: s.reviews, sub: '来自真实客户', to: '/admin/reviews' },
|
||||
{ key: 'faqs', label: '常见问答', value: s.faqs, sub: 'FAQ 条目', to: '/admin/faqs' },
|
||||
{ key: 'gallery', label: '旅拍照片', value: s.gallery, sub: '呼籁旅拍', to: '/admin/gallery' },
|
||||
{ key: 'stories', label: '客户故事', value: s.stories, sub: '深度体验', to: '/admin/stories' },
|
||||
{ key: 'productLines', label: '产品线', value: s.productLines, sub: '夏/秋/冬 + 营地课程', to: '/admin/products' },
|
||||
{ key: 'siteContent', label: '站点配置', value: s.siteContent, sub: '可编辑配置项', to: '/admin/content/brand' },
|
||||
]
|
||||
})
|
||||
|
||||
const quickLinks = [
|
||||
{ title: '产品线管理', desc: '管理所有产品线', to: '/admin/products' },
|
||||
{ title: '导航菜单', desc: '编辑顶部和底部导航', to: '/admin/navigation' },
|
||||
{ title: '查看表单', desc: '客户咨询和留言', to: '/admin/submissions' },
|
||||
{ title: '产品线管理', desc: '所有产品线概览', to: '/admin/products' },
|
||||
{ title: '文章管理', desc: '新闻 + 博客', to: '/admin/articles' },
|
||||
{ title: '博客管理', desc: '品牌博客', to: '/admin/blogs' },
|
||||
{ title: '客户评价', desc: '评价审核 / 排序', to: '/admin/reviews' },
|
||||
{ title: 'FAQ 管理', desc: '常见问答维护', to: '/admin/faqs' },
|
||||
{ title: '图库管理', desc: '旅拍照片', to: '/admin/gallery' },
|
||||
{ title: '客户故事', desc: '深度体验稿件', to: '/admin/stories' },
|
||||
{ title: '表单提交', desc: '联系 + 定制咨询', to: '/admin/submissions' },
|
||||
{ title: '导航菜单', desc: '顶部和底部导航', to: '/admin/navigation' },
|
||||
{ title: 'SEO 配置', desc: '页面标题和描述', to: '/admin/content/seo' },
|
||||
{ title: '品牌信息', desc: '联系方式 / 悬浮按钮', to: '/admin/content/brand' },
|
||||
{ title: '首页配置', desc: 'Hero / 栏目文案', to: '/admin/content/heroes' },
|
||||
]
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const today = new Date()
|
||||
const isSameDay = d.toDateString() === today.toDateString()
|
||||
if (isSameDay) return d.toTimeString().slice(0, 5)
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${m}-${day}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try { stats.value = await adminFetch('/api/admin/stats') } catch (e) { console.error('加载统计失败', e) }
|
||||
try {
|
||||
stats.value = await adminFetch('/api/admin/stats')
|
||||
nowText.value = new Date().toTimeString().slice(0, 5)
|
||||
} catch (e) { console.error('加载统计失败', e) }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.page-head h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.updated-at {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
color: #111827;
|
||||
margin: 32px 0 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.stat-card--alert {
|
||||
border-color: #f87171;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.stat-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.stat-sub {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.trend-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
.trend-row + .trend-row {
|
||||
border-top: 1px dashed #e5e7eb;
|
||||
}
|
||||
.trend-row strong {
|
||||
color: #3a7d44;
|
||||
font-size: 16px;
|
||||
}
|
||||
.card-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
.list-row + .list-row {
|
||||
border-top: 1px dashed #e5e7eb;
|
||||
}
|
||||
.list-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.list-meta {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
.list-star {
|
||||
color: #f59e0b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.quick-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.quick-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.quick-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
.quick-desc {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+109
-25
@@ -90,28 +90,66 @@
|
||||
<n-form-item label="亮点"><n-dynamic-input v-model:value="ver.highlights" placeholder="输入亮点" /></n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
<n-alert v-if="ver._v3Itinerary || ver._v3Pricing" type="info" style="margin-top:12px" :show-icon="true" title="此版本使用 V3 结构(分天字符串行程 + 分日期报价)">
|
||||
<template #default>
|
||||
行程用纯文本分天表达,报价按日期区间记录。可直接修改,不会破坏原始结构。如需深度改动(加字段/重排结构),请联系开发。
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-card title="行程安排" style="margin-top:12px">
|
||||
<div v-for="(it, ii) in ver.itinerary" :key="ii" style="margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
|
||||
<n-space justify="space-between" style="margin-bottom:8px"><n-tag size="small">Day {{ it.day }}</n-tag><n-button size="tiny" type="error" quaternary @click="ver.itinerary.splice(ii,1)">删除</n-button></n-space>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="标题" :show-feedback="false"><n-input v-model:value="it.title" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="住宿" :show-feedback="false"><n-input v-model:value="it.hotel" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="概要" :show-feedback="false" style="margin-top:8px"><n-input v-model:value="it.summary" type="textarea" :rows="2" /></n-form-item>
|
||||
<n-space justify="space-between" style="margin-bottom:8px"><n-tag size="small">Day {{ it.day }}</n-tag><n-button size="tiny" type="error" quaternary @click="ver.itinerary.splice(ii,1); if(ver._v3Itinerary) ver._v3Itinerary.splice(ii,1)">删除</n-button></n-space>
|
||||
<n-form-item v-if="ver._v3Itinerary" label="当日行程(纯文本)" :show-feedback="false">
|
||||
<n-input v-model:value="it.title" type="textarea" :rows="2" placeholder="D1 各地→海拉尔(接机+自由活动)" />
|
||||
</n-form-item>
|
||||
<template v-else>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="标题" :show-feedback="false"><n-input v-model:value="it.title" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="住宿" :show-feedback="false"><n-input v-model:value="it.hotel" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="概要" :show-feedback="false" style="margin-top:8px"><n-input v-model:value="it.summary" type="textarea" :rows="2" /></n-form-item>
|
||||
</template>
|
||||
</div>
|
||||
<n-button dashed block @click="addItineraryDay(ver)">添加行程日</n-button>
|
||||
</n-card>
|
||||
<n-card title="价格配置" style="margin-top:12px">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="价格标签"><n-input v-model:value="ver.pricing.label" placeholder="2580元起" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="备注"><n-input v-model:value="ver.pricing.note" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-gi><n-form-item label="起步价"><n-input-number v-model:value="ver.pricing.startPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="旺季价"><n-input-number v-model:value="ver.pricing.peakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="高档起步"><n-input-number v-model:value="ver.pricing.highStartPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="高档旺季"><n-input-number v-model:value="ver.pricing.highPeakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<template v-if="ver._v3Pricing">
|
||||
<n-alert type="warning" :show-icon="false" style="margin-bottom:12px">
|
||||
此产品使用 V3 分日期报价,请在下方表格直接编辑。空日期或非正数价格会保留但不会生效。
|
||||
</n-alert>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead>
|
||||
<tr style="background:#f5f7fa">
|
||||
<th style="padding:8px;text-align:left;border:1px solid #e5e7eb;width:40%">出行日期</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid #e5e7eb">成人价</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid #e5e7eb">儿童价</th>
|
||||
<th style="padding:8px;border:1px solid #e5e7eb;width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, ri) in ver._v3Pricing" :key="ri">
|
||||
<td style="padding:4px;border:1px solid #e5e7eb"><n-input v-model:value="row.date" placeholder="9月1日-9月9日" size="small" /></td>
|
||||
<td style="padding:4px;border:1px solid #e5e7eb"><n-input-number v-model:value="row.adult" :min="0" size="small" style="width:100%" /></td>
|
||||
<td style="padding:4px;border:1px solid #e5e7eb"><n-input-number v-model:value="row.child" :min="0" size="small" style="width:100%" /></td>
|
||||
<td style="padding:4px;border:1px solid #e5e7eb;text-align:center"><n-button size="tiny" type="error" quaternary @click="ver._v3Pricing.splice(ri,1)">删</n-button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<n-button dashed block size="small" style="margin-top:8px" @click="ver._v3Pricing.push({date:'',adult:0,child:0})">添加一行</n-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="价格标签"><n-input v-model:value="ver.pricing.label" placeholder="2580元起" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="备注"><n-input v-model:value="ver.pricing.note" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-gi><n-form-item label="起步价"><n-input-number v-model:value="ver.pricing.startPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="旺季价"><n-input-number v-model:value="ver.pricing.peakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="高档起步"><n-input-number v-model:value="ver.pricing.highStartPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="高档旺季"><n-input-number v-model:value="ver.pricing.highPeakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
</n-card>
|
||||
<n-card title="费用说明" style="margin-top:12px">
|
||||
<n-form-item label="住宿"><n-input v-model:value="ver.included.accommodation" /></n-form-item>
|
||||
@@ -398,7 +436,7 @@ import { h } from 'vue'
|
||||
import {
|
||||
NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NForm, NFormItem,
|
||||
NInput, NInputNumber, NBreadcrumb, NBreadcrumbItem,
|
||||
NTabs, NTabPane, NCollapse, NCollapseItem, NDynamicInput, NEmpty, NSwitch,
|
||||
NTabs, NTabPane, NCollapse, NCollapseItem, NDynamicInput, NEmpty, NSwitch, NAlert,
|
||||
} from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
@@ -497,19 +535,33 @@ function normalizeData(raw) {
|
||||
return d
|
||||
}
|
||||
|
||||
// 判断是否为 V3 新结构(pricing 为数组 / itinerary 为字符串数组)
|
||||
function isV3Pricing(p) { return Array.isArray(p) }
|
||||
function isV3Itinerary(it) { return Array.isArray(it) && it.length > 0 && typeof it[0] === 'string' }
|
||||
|
||||
function normalizeVersion(v) {
|
||||
const v3Pricing = isV3Pricing(v.pricing) ? v.pricing : null
|
||||
const v3Itinerary = isV3Itinerary(v.itinerary) ? [...v.itinerary] : null
|
||||
|
||||
return {
|
||||
id: v.id || '', name: v.name || '', tag: v.tag || '',
|
||||
days: v.days || 0, nights: v.nights || 0, line: v.line || '', route: v.route || '',
|
||||
audience: v.audience || '', description: v.description || '', isMainProduct: v.isMainProduct || false,
|
||||
highlights: Array.isArray(v.highlights) ? v.highlights : [],
|
||||
itinerary: normalizeItinerary(v.itinerary || []),
|
||||
pricing: {
|
||||
label: v.pricing?.label || '', note: v.pricing?.note || '',
|
||||
startPrice: v.pricing?.startPrice || 0, peakPrice: v.pricing?.peakPrice || 0,
|
||||
highStartPrice: v.pricing?.highStartPrice || 0, highPeakPrice: v.pricing?.highPeakPrice || 0,
|
||||
tiers: v.pricing?.tiers || [],
|
||||
},
|
||||
// 保留原始 V3 结构,编辑后原样回写,避免数据丢失
|
||||
_v3Pricing: v3Pricing,
|
||||
_v3Itinerary: v3Itinerary,
|
||||
itinerary: v3Itinerary
|
||||
? v3Itinerary.map((str, i) => ({ day: i + 1, title: str, summary: '', hotel: '', _v3: true }))
|
||||
: normalizeItinerary(v.itinerary || []),
|
||||
pricing: v3Pricing
|
||||
? { _v3: true, label: '', note: '', startPrice: 0, peakPrice: 0, highStartPrice: 0, highPeakPrice: 0, tiers: [] }
|
||||
: {
|
||||
label: v.pricing?.label || '', note: v.pricing?.note || '',
|
||||
startPrice: v.pricing?.startPrice || 0, peakPrice: v.pricing?.peakPrice || 0,
|
||||
highStartPrice: v.pricing?.highStartPrice || 0, highPeakPrice: v.pricing?.highPeakPrice || 0,
|
||||
tiers: v.pricing?.tiers || [],
|
||||
},
|
||||
included: {
|
||||
accommodation: v.included?.accommodation || '', transport: v.included?.transport || '',
|
||||
tickets: v.included?.tickets || '', activities: v.included?.activities || '',
|
||||
@@ -529,6 +581,38 @@ function normalizeItinerary(items) {
|
||||
})
|
||||
}
|
||||
|
||||
// 保存前把 V3 版本的 pricing/itinerary 还原为原始结构,并剥离 UI 临时字段
|
||||
function serializeForSave(raw) {
|
||||
const clone = JSON.parse(JSON.stringify(raw))
|
||||
if (Array.isArray(clone.versions)) {
|
||||
clone.versions = clone.versions.map(v => {
|
||||
const out = { ...v }
|
||||
// V3 行程:恢复为字符串数组(但允许用户编辑文本)
|
||||
if (Array.isArray(v._v3Itinerary)) {
|
||||
out.itinerary = (v.itinerary || []).map(it => it.title || '').filter(Boolean)
|
||||
}
|
||||
// V3 报价:整份保留,不从空对象覆盖
|
||||
if (Array.isArray(v._v3Pricing)) {
|
||||
out.pricing = v._v3Pricing
|
||||
}
|
||||
delete out._v3Pricing
|
||||
delete out._v3Itinerary
|
||||
if (out.itinerary && Array.isArray(out.itinerary)) {
|
||||
out.itinerary = out.itinerary.map(it => {
|
||||
if (it && typeof it === 'object') { const { _v3, ...rest } = it; return rest }
|
||||
return it
|
||||
})
|
||||
}
|
||||
if (out.pricing && typeof out.pricing === 'object' && !Array.isArray(out.pricing)) {
|
||||
const { _v3, ...rest } = out.pricing
|
||||
out.pricing = rest
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// ── 工具函数 ──
|
||||
function addVersion() {
|
||||
if (!data.value.versions) data.value.versions = []
|
||||
@@ -565,7 +649,7 @@ async function handleSave() {
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: route.params.key, data: data.value },
|
||||
body: { key: route.params.key, data: serializeForSave(data.value) },
|
||||
})
|
||||
message.success('保存成功')
|
||||
} catch (e) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<n-spin :show="loading">
|
||||
<n-grid :cols="1" :y-gap="12">
|
||||
<n-gi v-for="(item, index) in productLines" :key="item.key">
|
||||
<n-card hoverable>
|
||||
<n-card hoverable translate="no">
|
||||
<n-space justify="space-between" align="center">
|
||||
<n-space align="center" :size="16">
|
||||
<span style="font-size: 14px; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: #f0f7f1; border-radius: 8px; font-weight: 600; color: #3a7d44">{{ item.name?.charAt(0) || 'P' }}</span>
|
||||
@@ -20,10 +20,10 @@
|
||||
{{ item.name }}
|
||||
<n-tag v-if="item.version" size="small" type="info" :bordered="false" style="margin-left: 8px">{{ item.version }}</n-tag>
|
||||
<n-tag size="small" :type="seasonType(item.season)" :bordered="false" style="margin-left: 4px">{{ seasonLabel(item.season) }}</n-tag>
|
||||
<n-tag size="small" :bordered="false" style="margin-left: 4px">{{ item.type === 'camp' ? '营地' : '线路' }}</n-tag>
|
||||
<n-tag size="small" :bordered="false" style="margin-left: 4px">{{ typeLabel(item.type) }}</n-tag>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px">
|
||||
key: {{ item.key }} · 排序: {{ item.sortOrder }}
|
||||
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px" translate="no">
|
||||
排序 {{ item.sortOrder }}
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
@@ -72,7 +72,7 @@
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="类型">
|
||||
<n-select v-model:value="newItem.type" :options="[{ label: '线路产品', value: 'tour' }, { label: '营地/研学', value: 'camp' }]" />
|
||||
<n-select v-model:value="newItem.type" :options="[{ label: '线路产品', value: 'tour' }, { label: '营地/研学', value: 'camp' }, { label: '课程', value: 'course' }]" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
@@ -106,6 +106,9 @@ const newItem = ref({ name: '', key: '', version: '', season: 'summer', type: 't
|
||||
function seasonLabel(s) {
|
||||
return { spring: '春', summer: '夏', autumn: '秋', winter: '冬', all: '全年' }[s] || s
|
||||
}
|
||||
function typeLabel(t) {
|
||||
return { tour: '线路', camp: '营地', course: '课程' }[t] || t
|
||||
}
|
||||
|
||||
function seasonType(s) {
|
||||
return { spring: 'success', summer: 'success', autumn: 'warning', winter: 'info', all: 'default' }[s] || 'default'
|
||||
|
||||
+104
-6
@@ -44,6 +44,20 @@
|
||||
<NFormItem label="出行日期">
|
||||
<NInput v-model:value="form.date" placeholder="如:2026年3月" />
|
||||
</NFormItem>
|
||||
<NFormItem label="评价截图">
|
||||
<div class="screenshot-wrapper">
|
||||
<AdminImageUpload v-model="form.screenshot" />
|
||||
<div v-if="form.screenshot" class="screenshot-actions">
|
||||
<NButton size="tiny" @click="previewOpen = true">🔍 查看大图</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
:loading="ocrLoading"
|
||||
:disabled="ocrLoading"
|
||||
@click="handleOcr"
|
||||
>✨ 从截图识别文字</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem label="内容" required>
|
||||
<NInput
|
||||
v-model:value="form.content"
|
||||
@@ -54,6 +68,31 @@
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
|
||||
<NCard title="分类标签" style="margin-top: 16px;">
|
||||
<NForm label-placement="top" :show-feedback="false">
|
||||
<NFormItem label="出行场景(多选)">
|
||||
<NSelect
|
||||
v-model:value="form.scenes"
|
||||
multiple
|
||||
filterable
|
||||
tag
|
||||
:options="SCENE_OPTIONS"
|
||||
placeholder="选择或输入场景"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="关心维度(多选)">
|
||||
<NSelect
|
||||
v-model:value="form.concerns"
|
||||
multiple
|
||||
filterable
|
||||
tag
|
||||
:options="CONCERN_OPTIONS"
|
||||
placeholder="选择或输入关心点"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</NGi>
|
||||
|
||||
<!-- 侧栏(1列) -->
|
||||
@@ -61,14 +100,17 @@
|
||||
<NCard title="发布设置">
|
||||
<NForm label-placement="top" :show-feedback="false">
|
||||
<NFormItem label="状态">
|
||||
<NSelect
|
||||
v-model:value="form.published"
|
||||
:options="publishedOptions"
|
||||
/>
|
||||
<NSelect v-model:value="form.published" :options="publishedOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="已验证">
|
||||
<NSwitch v-model:value="form.verified" />
|
||||
</NFormItem>
|
||||
<NFormItem label="排序">
|
||||
<NInputNumber v-model:value="form.sortOrder" :min="0" style="width:100%" />
|
||||
<div style="font-size: 11px; color: #9ca3af; margin-top: 4px;">
|
||||
数字越小越靠前(0 为默认)
|
||||
</div>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</NGi>
|
||||
@@ -81,6 +123,11 @@
|
||||
保存
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- 截图 Lightbox -->
|
||||
<NModal v-model:show="previewOpen" preset="card" style="max-width: 640px;" :bordered="false" title="评价截图">
|
||||
<img v-if="form.screenshot" :src="form.screenshot" style="width:100%;display:block;" />
|
||||
</NModal>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,7 +135,8 @@
|
||||
<script setup>
|
||||
import {
|
||||
NButton, NCard, NForm, NFormItem, NInput, NSelect, NSwitch, NRate,
|
||||
NBreadcrumb, NBreadcrumbItem, NGrid, NGi, NSpin, useMessage,
|
||||
NBreadcrumb, NBreadcrumbItem, NGrid, NGi, NSpin, NInputNumber, NModal,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
@@ -101,13 +149,19 @@ const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const ocrLoading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
author: '',
|
||||
title: '',
|
||||
date: '',
|
||||
content: '',
|
||||
screenshot: '',
|
||||
scenes: [],
|
||||
concerns: [],
|
||||
rating: 5,
|
||||
sortOrder: 0,
|
||||
published: true,
|
||||
verified: false,
|
||||
})
|
||||
@@ -117,14 +171,30 @@ const publishedOptions = [
|
||||
{ label: '草稿', value: false },
|
||||
]
|
||||
|
||||
const SCENE_OPTIONS = [
|
||||
'家庭出游', '亲子', '带老人', '摄影', '蜜月', '毕业游', '多家庭',
|
||||
].map(v => ({ label: v, value: v }))
|
||||
|
||||
const CONCERN_OPTIONS = [
|
||||
'领队服务', '行程安排', '住宿体验', '摄影旅拍', '错峰不排队',
|
||||
'性价比', '安全放心', '特色体验', '饮食',
|
||||
].map(v => ({ label: v, value: v }))
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/reviews/${route.params.id}`)
|
||||
form.value = {
|
||||
...data,
|
||||
author: data.author || '',
|
||||
title: data.title || '',
|
||||
date: data.date || '',
|
||||
content: data.content || '',
|
||||
screenshot: data.screenshot || '',
|
||||
scenes: Array.isArray(data.scenes) ? data.scenes : [],
|
||||
concerns: Array.isArray(data.concerns) ? data.concerns : [],
|
||||
rating: data.rating ?? 5,
|
||||
sortOrder: data.sortOrder || 0,
|
||||
published: !!data.published,
|
||||
verified: !!data.verified,
|
||||
}
|
||||
@@ -156,6 +226,24 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOcr() {
|
||||
if (!form.value.screenshot) { message.warning('请先上传截图'); return }
|
||||
ocrLoading.value = true
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/ocr', { method: 'POST', body: { image: form.value.screenshot } })
|
||||
if (res?.text) {
|
||||
form.value.content = res.text.trim()
|
||||
message.success('已识别,可在内容区继续调整')
|
||||
} else {
|
||||
message.warning('未识别到文字')
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.data?.message || 'OCR 失败')
|
||||
} finally {
|
||||
ocrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
@@ -191,6 +279,16 @@ onMounted(loadItem)
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.screenshot-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.screenshot-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
|
||||
+212
-18
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<div style="max-width: 1400px">
|
||||
<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>
|
||||
@@ -12,17 +12,81 @@
|
||||
</n-space>
|
||||
|
||||
<n-card>
|
||||
<n-space style="margin-bottom: 16px">
|
||||
<n-input v-model:value="search" placeholder="搜索客户、内容..." clearable style="width: 260px" @keyup.enter="page = 1; loadData()">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-row">
|
||||
<n-input
|
||||
v-model:value="search"
|
||||
placeholder="搜索客户、内容..."
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="resetAndLoad"
|
||||
>
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
<n-select
|
||||
v-model:value="filters.published"
|
||||
:options="publishedFilterOptions"
|
||||
placeholder="发布状态"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
@update:value="resetAndLoad"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="filters.hasScreenshot"
|
||||
:options="screenshotFilterOptions"
|
||||
placeholder="截图"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
@update:value="resetAndLoad"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="filters.scene"
|
||||
:options="sceneOptions"
|
||||
placeholder="场景"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@update:value="resetAndLoad"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="filters.concern"
|
||||
:options="concernOptions"
|
||||
placeholder="关心维度"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@update:value="resetAndLoad"
|
||||
/>
|
||||
<n-button @click="resetAndLoad">搜索</n-button>
|
||||
<n-button quaternary @click="clearFilters">重置</n-button>
|
||||
</div>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
<!-- 批量操作工具栏 -->
|
||||
<div v-if="checkedIds.length" class="bulk-toolbar">
|
||||
<span class="bulk-count">已选 {{ checkedIds.length }} 条</span>
|
||||
<n-button size="small" @click="bulk('publish')">批量发布</n-button>
|
||||
<n-button size="small" @click="bulk('unpublish')">批量下线</n-button>
|
||||
<n-popconfirm @positive-click="bulk('delete')">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error">批量删除</n-button>
|
||||
</template>
|
||||
确定删除选中的 {{ checkedIds.length }} 条评价?
|
||||
</n-popconfirm>
|
||||
<n-button size="tiny" quaternary @click="checkedIds = []">清空选择</n-button>
|
||||
</div>
|
||||
|
||||
<n-space justify="end" style="margin-top: 16px" v-if="totalPages > 1">
|
||||
<n-pagination v-model:page="page" :page-count="totalPages" @update:page="loadData" />
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="(row) => row.id"
|
||||
v-model:checked-row-keys="checkedIds"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
striped
|
||||
/>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-top: 16px" v-if="total">
|
||||
<span style="color: #9ca3af; font-size: 12px;">共 {{ total }} 条</span>
|
||||
<n-pagination v-if="totalPages > 1" v-model:page="page" :page-count="totalPages" @update:page="loadData" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
@@ -30,39 +94,111 @@
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm, NRate } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import {
|
||||
NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm,
|
||||
NRate, NSelect, NInputNumber, useMessage,
|
||||
} from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const { moveUp, moveDown } = useAdminSort('/api/admin/reviews')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const checkedIds = ref([])
|
||||
|
||||
const filters = reactive({
|
||||
published: null,
|
||||
hasScreenshot: null,
|
||||
scene: null,
|
||||
concern: null,
|
||||
})
|
||||
|
||||
const publishedFilterOptions = [
|
||||
{ label: '已发布', value: 'true' },
|
||||
{ label: '草稿', value: 'false' },
|
||||
]
|
||||
const screenshotFilterOptions = [
|
||||
{ label: '有截图', value: 'true' },
|
||||
{ label: '无截图', value: 'false' },
|
||||
]
|
||||
const sceneOptions = [
|
||||
'家庭出游', '亲子', '带老人', '摄影', '蜜月', '毕业游', '多家庭',
|
||||
].map(v => ({ label: v, value: v }))
|
||||
const concernOptions = [
|
||||
'领队服务', '行程安排', '住宿体验', '摄影旅拍', '错峰不排队',
|
||||
'性价比', '安全放心', '特色体验', '饮食',
|
||||
].map(v => ({ label: v, value: v }))
|
||||
|
||||
function clearFilters() {
|
||||
search.value = ''
|
||||
filters.published = null
|
||||
filters.hasScreenshot = null
|
||||
filters.scene = null
|
||||
filters.concern = null
|
||||
resetAndLoad()
|
||||
}
|
||||
|
||||
function resetAndLoad() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ type: 'selection', width: 40 },
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '客户', key: 'author',
|
||||
title: '截图', key: 'screenshot', width: 72,
|
||||
render: (row) => row.screenshot
|
||||
? h('img', {
|
||||
src: row.screenshot,
|
||||
style: 'width: 48px; height: 48px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid #e5e7eb;',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onClick: () => window.open(row.screenshot, '_blank'),
|
||||
})
|
||||
: h('span', { style: 'color: #d1d5db; font-size: 11px;' }, '无图'),
|
||||
},
|
||||
{
|
||||
title: '客户', key: 'author', width: 100,
|
||||
render: (row) => h('a', {
|
||||
style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer',
|
||||
onClick: () => navigateTo(`/admin/reviews/${row.id}`),
|
||||
}, row.author),
|
||||
},
|
||||
{
|
||||
title: '评分', key: 'rating', width: 120,
|
||||
title: '评分', key: 'rating', width: 110,
|
||||
render: (row) => h(NRate, { value: row.rating || 5, readonly: true, size: 'small' }),
|
||||
},
|
||||
{ title: '产品版本', key: 'title', width: 100 },
|
||||
{ title: '产品版本', key: 'title', width: 160, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '内容摘要', key: 'content',
|
||||
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.content?.slice(0, 50)),
|
||||
title: '内容摘要', key: 'content', minWidth: 200,
|
||||
render: (row) => h('span', { style: 'color: #6b7280; font-size: 12px' }, row.content?.slice(0, 60)),
|
||||
},
|
||||
{
|
||||
title: '标签', key: 'tags', width: 220,
|
||||
render: (row) => h('div', { style: 'display: flex; flex-wrap: wrap; gap: 4px;' }, [
|
||||
...(row.scenes || []).map(s => h(NTag, { size: 'small', type: 'info', bordered: false }, () => s)),
|
||||
...(row.concerns || []).map(c => h(NTag, { size: 'small', type: 'success', bordered: false }, () => c)),
|
||||
]),
|
||||
},
|
||||
{ title: '出行日期', key: 'date', width: 90, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.date) },
|
||||
{
|
||||
title: '排序', key: 'sortOrder', width: 140,
|
||||
render: (row, idx) => h(NSpace, { size: 4, align: 'center' }, () => [
|
||||
h(NInputNumber, {
|
||||
value: row.sortOrder || 0, size: 'tiny', min: 0, showButton: false,
|
||||
style: 'width: 52px;',
|
||||
'onUpdate:value': (v) => updateSortOrder(row, v),
|
||||
}),
|
||||
h(NButton, { size: 'tiny', disabled: idx === 0, onClick: async () => { await moveUp(list.value, idx); message.success('已上移') } }, () => '↑'),
|
||||
h(NButton, { size: 'tiny', disabled: idx === list.value.length - 1, onClick: async () => { await moveDown(list.value, idx); message.success('已下移') } }, () => '↓'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, {
|
||||
@@ -72,7 +208,7 @@ const columns = [
|
||||
}, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
title: '操作', key: 'actions', width: 110, fixed: 'right',
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/reviews/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
@@ -86,8 +222,19 @@ const columns = [
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await adminFetch(`/api/admin/reviews?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`)
|
||||
list.value = d.items; total.value = d.total; totalPages.value = d.totalPages
|
||||
const params = new URLSearchParams({
|
||||
page: page.value,
|
||||
limit: 20,
|
||||
search: search.value,
|
||||
})
|
||||
if (filters.published) params.set('published', filters.published)
|
||||
if (filters.hasScreenshot) params.set('hasScreenshot', filters.hasScreenshot)
|
||||
if (filters.scene) params.set('scene', filters.scene)
|
||||
if (filters.concern) params.set('concern', filters.concern)
|
||||
const d = await adminFetch(`/api/admin/reviews?${params}`)
|
||||
list.value = d.items
|
||||
total.value = d.total
|
||||
totalPages.value = d.totalPages
|
||||
} catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -98,6 +245,14 @@ async function togglePublish(item) {
|
||||
} catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function updateSortOrder(item, value) {
|
||||
const v = Number(value) || 0
|
||||
item.sortOrder = v
|
||||
try {
|
||||
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'PUT', body: { sortOrder: v } })
|
||||
} catch { message.error('排序更新失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'DELETE' })
|
||||
@@ -106,5 +261,44 @@ async function handleDelete(item) {
|
||||
} catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
async function bulk(action) {
|
||||
if (!checkedIds.value.length) return
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/reviews/bulk', {
|
||||
method: 'POST',
|
||||
body: { action, ids: checkedIds.value },
|
||||
})
|
||||
message.success(`已${ {publish:'发布',unpublish:'下线',delete:'删除'}[action] } ${res.affected} 条`)
|
||||
checkedIds.value = []
|
||||
loadData()
|
||||
} catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.bulk-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.bulk-count {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #065f46;
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+41
-5
@@ -46,6 +46,29 @@
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="form.subtitle" placeholder="输入副标题(可选)" />
|
||||
</n-form-item>
|
||||
<n-form-item label="卡片摘要">
|
||||
<n-input
|
||||
v-model:value="form.summary"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="在客户故事墙卡片上展示的 1-2 句话(代替副标题)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-gi>
|
||||
<n-form-item label="作者 / 客户昵称">
|
||||
<n-input v-model:value="form.author" placeholder="如:张女士" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="外部来源 URL">
|
||||
<n-input v-model:value="form.sourceUrl" placeholder="如小红书链接,有则卡片外跳" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="标签">
|
||||
<n-dynamic-tags v-model:value="form.tags" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
@@ -169,6 +192,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
NBreadcrumb, NBreadcrumbItem, NButton, NSpace, NCard, NGrid, NGi,
|
||||
NForm, NFormItem, NInput, NSelect, NDynamicInput, NDynamicTags,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
@@ -184,6 +213,10 @@ const form = ref({
|
||||
title: '',
|
||||
slug: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
author: '',
|
||||
sourceUrl: '',
|
||||
tags: [],
|
||||
familyType: '',
|
||||
childrenAge: '',
|
||||
fromCity: '',
|
||||
@@ -205,6 +238,12 @@ async function loadItem() {
|
||||
...data,
|
||||
published: !!data.published,
|
||||
coverImage: data.coverImage || '',
|
||||
summary: data.summary || '',
|
||||
author: data.author || '',
|
||||
sourceUrl: data.sourceUrl || '',
|
||||
tags: Array.isArray(data.tags)
|
||||
? data.tags
|
||||
: (typeof data.tags === 'string' ? (() => { try { return JSON.parse(data.tags || '[]') } catch { return [] } })() : []),
|
||||
sections:
|
||||
typeof data.sections === 'string'
|
||||
? JSON.parse(data.sections || '[]')
|
||||
@@ -233,11 +272,8 @@ async function handleSave() {
|
||||
|
||||
saving.value = true
|
||||
|
||||
const payload = {
|
||||
...form.value,
|
||||
sections: JSON.stringify(form.value.sections),
|
||||
keyMoments: JSON.stringify(form.value.keyMoments),
|
||||
}
|
||||
// 直接传数组,由服务端统一 stringify,避免双重编码
|
||||
const payload = { ...form.value }
|
||||
|
||||
try {
|
||||
if (isNew.value) {
|
||||
|
||||
@@ -37,10 +37,19 @@ definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const { moveUp, moveDown } = useAdminSort('/api/admin/stories')
|
||||
const list = ref([]); const total = ref(0); const page = ref(1); const totalPages = ref(1); const loading = ref(false); const search = ref('')
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '排序', key: 'sortOrder', width: 110,
|
||||
render: (row, idx) => h(NSpace, { size: 4, align: 'center' }, () => [
|
||||
h('span', { style: 'color: #9ca3af; font-size: 12px; min-width: 20px' }, row.sortOrder),
|
||||
h(NButton, { size: 'tiny', disabled: idx === 0, onClick: async () => { await moveUp(list.value, idx); message.success('已上移') } }, () => '↑'),
|
||||
h(NButton, { size: 'tiny', disabled: idx === list.value.length - 1, onClick: async () => { await moveDown(list.value, idx); message.success('已下移') } }, () => '↓'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '标题', key: 'title',
|
||||
render: (row) => h('a', { style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer', onClick: () => navigateTo(`/admin/stories/${row.id}`) }, row.title),
|
||||
|
||||
+275
-94
@@ -2,18 +2,27 @@
|
||||
<div style="max-width: 1200px;">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827;">表单提交</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #9ca3af;">查看客户提交的联系留言和定制咨询</p>
|
||||
<p style="margin: 0; font-size: 13px; color: #9ca3af;">查看、编辑、回复、删除客户提交的联系留言和定制咨询</p>
|
||||
</div>
|
||||
|
||||
<NSpace vertical :size="16">
|
||||
<NSpace align="center" justify="space-between">
|
||||
<NButtonGroup>
|
||||
<NButton
|
||||
v-for="tab in tabs" :key="tab.value"
|
||||
:type="filterType === tab.value ? 'primary' : 'default'"
|
||||
@click="onFilterChange(tab.value)"
|
||||
>{{ tab.label }}</NButton>
|
||||
</NButtonGroup>
|
||||
<NSpace :size="8" align="center">
|
||||
<NButtonGroup>
|
||||
<NButton
|
||||
v-for="tab in tabs" :key="tab.value"
|
||||
:type="filterType === tab.value ? 'primary' : 'default'"
|
||||
@click="onFilterChange(tab.value)"
|
||||
>{{ tab.label }}</NButton>
|
||||
</NButtonGroup>
|
||||
<NButtonGroup>
|
||||
<NButton
|
||||
v-for="tab in statusTabs" :key="tab.value"
|
||||
:type="filterStatus === tab.value ? 'primary' : 'default'"
|
||||
@click="onStatusChange(tab.value)"
|
||||
>{{ tab.label }}</NButton>
|
||||
</NButtonGroup>
|
||||
</NSpace>
|
||||
<NInput
|
||||
v-model:value="search"
|
||||
placeholder="搜索姓名、手机..."
|
||||
@@ -25,7 +34,7 @@
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:data="filteredList"
|
||||
:loading="loading"
|
||||
:row-key="row => row.id"
|
||||
:bordered="false"
|
||||
@@ -43,10 +52,10 @@
|
||||
</NSpace>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<NDrawer v-model:show="drawerVisible" :width="480">
|
||||
<NDrawer v-model:show="drawerVisible" :width="520">
|
||||
<NDrawerContent title="提交详情" closable>
|
||||
<template v-if="currentItem">
|
||||
<NDescriptions bordered :column="1" label-placement="left">
|
||||
<NDescriptions bordered :column="1" label-placement="left" size="small">
|
||||
<NDescriptionsItem label="ID">{{ currentItem.id }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="类型">
|
||||
<NTag :type="currentItem.type === 'contact' ? 'info' : 'warning'" size="small">
|
||||
@@ -56,20 +65,24 @@
|
||||
<NDescriptionsItem label="姓名">{{ currentItem.data?.name }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="手机">{{ currentItem.data?.phone }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="邮箱" v-if="currentItem.data?.email">{{ currentItem.data?.email }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="微信" v-if="currentItem.data?.wechat">{{ currentItem.data?.wechat }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="内容">
|
||||
<div style="white-space: pre-wrap;">{{ currentItem.data?.message }}</div>
|
||||
<div style="white-space: pre-wrap;">{{ currentItem.data?.message || currentItem.data?.remarks || '(无)' }}</div>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="状态">
|
||||
<NTag :type="currentItem.read ? 'success' : 'warning'" size="small">
|
||||
{{ currentItem.read ? '已读' : '未读' }}
|
||||
</NTag>
|
||||
<NSpace :size="4">
|
||||
<NTag :type="statusTag(currentItem).type" size="small">{{ statusTag(currentItem).label }}</NTag>
|
||||
<NTag v-if="currentItem.repliedAt" type="default" size="small">
|
||||
回复于 {{ formatTime(currentItem.repliedAt) }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="提交时间">{{ formatTime(currentItem.createdAt) }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="IP" v-if="currentItem.ip">{{ currentItem.ip }}</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
<!-- 显示 data 中除 name/phone/email/message 外的其他字段 -->
|
||||
<template v-if="extraFields.length > 0">
|
||||
<h4 style="margin: 16px 0 8px;">其他信息</h4>
|
||||
<NDescriptions bordered :column="1" label-placement="left">
|
||||
<NDescriptions bordered :column="1" label-placement="left" size="small">
|
||||
<NDescriptionsItem v-for="field in extraFields" :key="field.key" :label="field.key">
|
||||
{{ field.value }}
|
||||
</NDescriptionsItem>
|
||||
@@ -77,24 +90,77 @@
|
||||
</template>
|
||||
</template>
|
||||
<template #footer>
|
||||
<NButton
|
||||
v-if="currentItem && !currentItem.read"
|
||||
type="primary"
|
||||
@click="markRead(currentItem)"
|
||||
>标记已读</NButton>
|
||||
<NSpace>
|
||||
<NButton
|
||||
v-if="currentItem && !currentItem.read"
|
||||
@click="markRead(currentItem)"
|
||||
>标记已读</NButton>
|
||||
<NButton
|
||||
v-if="currentItem && !currentItem.replied"
|
||||
type="primary"
|
||||
@click="markReplied(currentItem)"
|
||||
>标记已回复</NButton>
|
||||
<NButton
|
||||
v-if="currentItem && currentItem.replied"
|
||||
@click="unmarkReplied(currentItem)"
|
||||
>撤销已回复</NButton>
|
||||
<NButton @click="openEdit(currentItem)">编辑</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<NModal v-model:show="editVisible" preset="card" title="编辑提交" style="width: 540px;">
|
||||
<NForm
|
||||
ref="editFormRef"
|
||||
:model="editForm"
|
||||
:rules="editRules"
|
||||
label-placement="left"
|
||||
label-width="70"
|
||||
>
|
||||
<NFormItem label="姓名" path="name">
|
||||
<NInput v-model:value="editForm.name" placeholder="姓名" />
|
||||
</NFormItem>
|
||||
<NFormItem label="手机" path="phone">
|
||||
<NInput v-model:value="editForm.phone" placeholder="11 位手机号" />
|
||||
</NFormItem>
|
||||
<NFormItem label="邮箱">
|
||||
<NInput v-model:value="editForm.email" placeholder="可选" />
|
||||
</NFormItem>
|
||||
<NFormItem label="微信">
|
||||
<NInput v-model:value="editForm.wechat" placeholder="可选" />
|
||||
</NFormItem>
|
||||
<NFormItem label="内容">
|
||||
<NInput
|
||||
v-model:value="editForm.message"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="留言内容 / 备注"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="editVisible = false">取消</NButton>
|
||||
<NButton type="primary" :loading="saving" @click="saveEdit">保存</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NTag, NDataTable, NInput, NSpace, NDrawer, NDrawerContent, NDescriptions, NDescriptionsItem, NBadge, NButtonGroup, NPagination } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import {
|
||||
NButton, NTag, NDataTable, NInput, NSpace, NDrawer, NDrawerContent,
|
||||
NDescriptions, NDescriptionsItem, NButtonGroup, NPagination,
|
||||
NModal, NForm, NFormItem, useDialog, useMessage
|
||||
} from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
@@ -103,18 +169,53 @@ const totalPages = ref(1)
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const filterType = ref('')
|
||||
const filterStatus = ref('')
|
||||
const drawerVisible = ref(false)
|
||||
const currentItem = ref(null)
|
||||
const editVisible = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const saving = ref(false)
|
||||
const editingId = ref(null)
|
||||
const editForm = reactive({ name: '', phone: '', email: '', wechat: '', message: '' })
|
||||
|
||||
const editRules = {
|
||||
name: { required: true, message: '请输入姓名', trigger: 'blur' },
|
||||
phone: {
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
validator(_rule, v) {
|
||||
if (!v) return new Error('请输入手机号')
|
||||
if (!/^\d{6,15}$/.test(v)) return new Error('手机号格式不正确')
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '留言', value: 'contact' },
|
||||
{ label: '定制', value: 'customize' },
|
||||
]
|
||||
const statusTabs = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '未读', value: 'unread' },
|
||||
{ label: '已读未回', value: 'read' },
|
||||
{ label: '已回复', value: 'replied' },
|
||||
]
|
||||
|
||||
const filteredList = computed(() => {
|
||||
if (!filterStatus.value) return list.value
|
||||
return list.value.filter(r => {
|
||||
if (filterStatus.value === 'unread') return !r.read && !r.replied
|
||||
if (filterStatus.value === 'read') return r.read && !r.replied
|
||||
if (filterStatus.value === 'replied') return r.replied
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const extraFields = computed(() => {
|
||||
if (!currentItem.value?.data) return []
|
||||
const knownKeys = ['name', 'phone', 'email', 'message']
|
||||
const knownKeys = ['name', 'phone', 'email', 'wechat', 'message', 'remarks']
|
||||
return Object.entries(currentItem.value.data)
|
||||
.filter(([k]) => !knownKeys.includes(k))
|
||||
.map(([key, value]) => ({ key, value: typeof value === 'object' ? JSON.stringify(value) : String(value) }))
|
||||
@@ -125,90 +226,60 @@ function formatTime(t) {
|
||||
return t.slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
function statusTag(row) {
|
||||
if (row.replied) return { type: 'success', label: '已回复' }
|
||||
if (row.read) return { type: 'info', label: '已读' }
|
||||
return { type: 'warning', label: '未读' }
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
render(row) {
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.id)
|
||||
}
|
||||
title: 'ID', key: 'id', width: 60,
|
||||
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.id)
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 80,
|
||||
render(row) {
|
||||
return h(NTag, {
|
||||
type: row.type === 'contact' ? 'info' : 'warning',
|
||||
size: 'small',
|
||||
}, () => row.type === 'contact' ? '留言' : '定制')
|
||||
}
|
||||
title: '类型', key: 'type', width: 70,
|
||||
render: (row) => h(NTag, {
|
||||
type: row.type === 'contact' ? 'info' : 'warning',
|
||||
size: 'small',
|
||||
}, () => row.type === 'contact' ? '留言' : '定制')
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
render(row) {
|
||||
return row.data?.name || ''
|
||||
}
|
||||
title: '姓名', key: 'name', width: 100,
|
||||
render: (row) => row.data?.name || ''
|
||||
},
|
||||
{
|
||||
title: '手机',
|
||||
key: 'phone',
|
||||
width: 130,
|
||||
render(row) {
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.data?.phone || '')
|
||||
}
|
||||
title: '手机', key: 'phone', width: 130,
|
||||
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.data?.phone || '')
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
key: 'message',
|
||||
ellipsis: { tooltip: true },
|
||||
render(row) {
|
||||
const msg = row.data?.message || ''
|
||||
title: '内容', key: 'message', ellipsis: { tooltip: true },
|
||||
render: (row) => {
|
||||
const msg = row.data?.message || row.data?.remarks || ''
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, msg.length > 50 ? msg.slice(0, 50) + '...' : msg)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
key: 'createdAt',
|
||||
width: 150,
|
||||
render(row) {
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, formatTime(row.createdAt))
|
||||
title: '提交时间', key: 'createdAt', width: 150,
|
||||
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px;' }, formatTime(row.createdAt))
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'status', width: 80,
|
||||
render: (row) => {
|
||||
const s = statusTag(row)
|
||||
return h(NTag, { type: s.type, size: 'small' }, () => s.label)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'read',
|
||||
width: 70,
|
||||
title: '操作', key: 'actions', width: 260,
|
||||
render(row) {
|
||||
return h(NTag, {
|
||||
type: row.read ? 'success' : 'warning',
|
||||
size: 'small',
|
||||
}, () => row.read ? '已读' : '未读')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render(row) {
|
||||
return h(NSpace, { size: 8 }, () => [
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
quaternary: true,
|
||||
type: 'info',
|
||||
onClick: () => openDetail(row),
|
||||
}, () => '查看'),
|
||||
!row.read
|
||||
? h(NButton, {
|
||||
size: 'small',
|
||||
quaternary: true,
|
||||
type: 'primary',
|
||||
onClick: () => markRead(row),
|
||||
}, () => '标记已读')
|
||||
: null,
|
||||
return h(NSpace, { size: 4 }, () => [
|
||||
h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '查看'),
|
||||
h(NButton, { size: 'small', quaternary: true, onClick: () => openEdit(row) }, () => '编辑'),
|
||||
!row.replied
|
||||
? h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => markReplied(row) }, () => '标记已回复')
|
||||
: h(NButton, { size: 'small', quaternary: true, onClick: () => unmarkReplied(row) }, () => '撤销回复'),
|
||||
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => confirmDelete(row) }, () => '删除'),
|
||||
])
|
||||
}
|
||||
},
|
||||
@@ -250,23 +321,133 @@ function onFilterChange(type) {
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onStatusChange(status) {
|
||||
filterStatus.value = status
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
currentItem.value = row
|
||||
drawerVisible.value = true
|
||||
// 查看即标记已读
|
||||
if (!row.read) markRead(row, { silent: true })
|
||||
}
|
||||
|
||||
async function markRead(row) {
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
editForm.name = row.data?.name || ''
|
||||
editForm.phone = row.data?.phone || ''
|
||||
editForm.email = row.data?.email || ''
|
||||
editForm.wechat = row.data?.wechat || ''
|
||||
editForm.message = row.data?.message || row.data?.remarks || ''
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editFormRef.value) return
|
||||
try {
|
||||
await editFormRef.value.validate()
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const row = list.value.find(r => r.id === editingId.value)
|
||||
if (!row) {
|
||||
message.error('记录不存在')
|
||||
editVisible.value = false
|
||||
return
|
||||
}
|
||||
// 合并:保留其它 data 字段,只覆盖已编辑字段
|
||||
const nextData = { ...(row.data || {}) }
|
||||
nextData.name = editForm.name
|
||||
nextData.phone = editForm.phone
|
||||
if (editForm.email !== undefined) nextData.email = editForm.email
|
||||
if (editForm.wechat !== undefined) nextData.wechat = editForm.wechat
|
||||
// message 字段:原始用 message 或 remarks,按原来有哪个更新哪个
|
||||
if ('remarks' in nextData && !('message' in nextData)) {
|
||||
nextData.remarks = editForm.message
|
||||
} else {
|
||||
nextData.message = editForm.message
|
||||
}
|
||||
|
||||
const updated = await adminFetch(`/api/admin/submissions/${editingId.value}`, {
|
||||
method: 'PUT',
|
||||
body: { data: nextData },
|
||||
})
|
||||
Object.assign(row, updated)
|
||||
if (currentItem.value?.id === editingId.value) currentItem.value = updated
|
||||
message.success('已保存')
|
||||
editVisible.value = false
|
||||
} catch (e) {
|
||||
message.error('保存失败:' + (e.data?.message || e.message || '未知错误'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(row, opts = {}) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/submissions/${row.id}`, {
|
||||
method: 'PUT',
|
||||
body: { read: true },
|
||||
})
|
||||
row.read = true
|
||||
message.success('已标记为已读')
|
||||
if (!opts.silent) message.success('已标记为已读')
|
||||
} catch (e) {
|
||||
if (!opts.silent) message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function markReplied(row) {
|
||||
try {
|
||||
const updated = await adminFetch(`/api/admin/submissions/${row.id}`, {
|
||||
method: 'PUT',
|
||||
body: { replied: true },
|
||||
})
|
||||
Object.assign(row, updated)
|
||||
if (currentItem.value?.id === row.id) currentItem.value = updated
|
||||
message.success('已标记为已回复')
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function unmarkReplied(row) {
|
||||
try {
|
||||
const updated = await adminFetch(`/api/admin/submissions/${row.id}`, {
|
||||
method: 'PUT',
|
||||
body: { replied: false },
|
||||
})
|
||||
Object.assign(row, updated)
|
||||
if (currentItem.value?.id === row.id) currentItem.value = updated
|
||||
message.success('已撤销回复')
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(row) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定要删除来自「${row.data?.name || '匿名'}」(ID ${row.id}) 的提交记录吗?此操作不可恢复。`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await adminFetch(`/api/admin/submissions/${row.id}`, { method: 'DELETE' })
|
||||
list.value = list.value.filter(r => r.id !== row.id)
|
||||
if (currentItem.value?.id === row.id) {
|
||||
drawerVisible.value = false
|
||||
currentItem.value = null
|
||||
}
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
message.success('已删除')
|
||||
} catch (e) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@
|
||||
<div class="sidebar-card sidebar-card--cta">
|
||||
<h3 class="sidebar-card__title">免费获取定制方案</h3>
|
||||
<p class="sidebar-card__desc">看完攻略有疑问?扫码添加定制师微信,一对一解答。</p>
|
||||
<img src="/images/contact/qrcode-wechat.jpg" alt="定制师微信二维码" class="sidebar-qrcode" />
|
||||
<img :src="'/images/contact/qrcode-wechat.jpg'" alt="定制师微信二维码" class="sidebar-qrcode" />
|
||||
<span class="sidebar-qrcode-label">扫码添加定制师微信</span>
|
||||
<CommonInternalLink to="/contact" text="其他联系方式" />
|
||||
</div>
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
<div class="page-blog">
|
||||
<LayoutPageHero
|
||||
title="旅行攻略"
|
||||
subtitle="呼伦贝尔旅行的干货指南,帮你把每一趟出行都过得值"
|
||||
:subtitle="heroes?.blog || '呼伦贝尔旅行的干货指南,帮你把每一趟出行都过得值'"
|
||||
:breadcrumbs="[{ text: '攻略' }]"
|
||||
background-image="/images/seasons/summer-river.jpg"
|
||||
/>
|
||||
@@ -87,11 +87,12 @@ import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
import useScrollReveal from '~/composables/useScrollReveal'
|
||||
|
||||
const { data: blogData } = await usePublicApi('blog')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
|
||||
useHead({
|
||||
title: '呼伦贝尔旅行攻略 - 亲子游·避坑·穿搭·行程干货 - 呼籁旅行',
|
||||
meta: [
|
||||
{ name: 'description', content: '呼籁旅行旅行攻略频道:呼伦贝尔亲子游完全指南、几月份去最好、旅行花费明细、避坑指南、草原旅拍穿搭,深耕11年的实用干货。' },
|
||||
{ name: 'description', content: '呼籁旅行旅行攻略频道:呼伦贝尔亲子游综合指南、几月份去合适、旅行花费明细、避坑指南、草原旅拍穿搭,深耕11年的实用干货。' },
|
||||
{ name: 'keywords', content: '呼伦贝尔旅游攻略,呼伦贝尔亲子游攻略,呼伦贝尔几月去,呼伦贝尔旅游花费,草原旅拍穿搭' },
|
||||
{ property: 'og:title', content: '呼伦贝尔旅行攻略 - 呼籁旅行' },
|
||||
{ property: 'og:description', content: '呼伦贝尔亲子游·避坑指南·穿搭攻略,深耕11年的实用干货。' },
|
||||
|
||||
+15
-14
@@ -2,7 +2,7 @@
|
||||
<div class="page-calendar">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="呼伦贝尔什么时候去最好?基于11年带队数据,为您提供每月详细出行参考"
|
||||
:subtitle="heroes?.calendar || '呼伦贝尔什么时候去合适?基于11年带队数据,为您提供每月出行参考'"
|
||||
:breadcrumbs="[{ text: '行程日历' }]"
|
||||
background-image="/images/seasons/autumn-lake.jpg"
|
||||
/>
|
||||
@@ -11,7 +11,7 @@
|
||||
<section class="page-tldr" aria-label="行程日历摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼伦贝尔逐月出行指南:基于11年带队数据,详解1-12月天气、景色、适合活动和推荐行程。帮你选择最佳出行时间。</p>
|
||||
<p>呼伦贝尔逐月出行指南:基于11年带队数据,详解1-12月天气、景色、适合活动和推荐行程。帮你选择合适的出行时间。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<!-- Quick recommendation summary -->
|
||||
<div class="cal-summary">
|
||||
<h2 class="cal-summary-title">呼伦贝尔最佳出行时间一览</h2>
|
||||
<h2 class="cal-summary-title">呼伦贝尔出行时间一览</h2>
|
||||
<div class="summary-grid">
|
||||
<div
|
||||
v-for="rec in calData.meta.quickRecommendations"
|
||||
@@ -38,7 +38,7 @@
|
||||
<!-- 出行时段建议 -->
|
||||
<div class="advice-section">
|
||||
<h2 class="advice-title">什么时候来呼伦贝尔?</h2>
|
||||
<p class="advice-subtitle">来呼伦贝尔一趟还是比较折腾的,希望大家能在每个季节相对最美的时候过来</p>
|
||||
<p class="advice-subtitle">来呼伦贝尔一趟还是比较折腾的,希望大家能在每个季节景色较好的时候过来</p>
|
||||
<div class="advice-timeline">
|
||||
<div
|
||||
v-for="item in calData.meta.travelAdvice"
|
||||
@@ -116,16 +116,16 @@
|
||||
|
||||
<!-- SEO text answer section -->
|
||||
<div class="cal-answer">
|
||||
<h2>呼伦贝尔几月份去最好?详细解答</h2>
|
||||
<h2>呼伦贝尔几月份去合适?详细解答</h2>
|
||||
<p>
|
||||
呼伦贝尔全年皆可出行,但来一趟毕竟不轻松,建议选对时间再出发。<strong>6月中下旬</strong>性价比最高,草原已非常美、各项活动全面开展,且尚未进入旺季价格。<strong>7月初至8月28日</strong>景色和活动巅峰,但价格也相对较高。<strong>9月20日至国庆</strong>是看大兴安岭秋景的最佳时机,非常推荐。<strong>冬季(12月至次年2月)</strong>是完全另一种美,冰雪那达慕、驯鹿部落值得体验。5月底可以提前出发避人群,但5月初和3-4月不太推荐。
|
||||
呼伦贝尔全年皆可出行,但来一趟毕竟不轻松,建议选对时间再出发。<strong>6月中下旬</strong>性价比不错,草原已非常美、各项活动全面开展,且尚未进入旺季价格。<strong>7月初至8月28日</strong>景色和活动巅峰,但价格也相对较高。<strong>9月20日至国庆</strong>是看大兴安岭秋景的推荐时机,非常推荐。<strong>冬季(12月至次年2月)</strong>是另一种风貌,冰雪那达慕、驯鹿部落值得体验。5月底可以提前出发避人群,但5月初和3-4月不太推荐。
|
||||
</p>
|
||||
<div class="cal-links">
|
||||
<CommonInternalLink to="/guides" text="出行指南(穿搭 · 行李清单)" />
|
||||
<CommonInternalLink to="/products" text="夏季产品:额吉的故乡V9" />
|
||||
<CommonInternalLink to="/products/autumn" text="秋季产品:游牧的森林" />
|
||||
<CommonInternalLink to="/products/winter" text="冬季产品:嗨冰雪" />
|
||||
<CommonInternalLink to="/contact" text="咨询最佳出行方案" />
|
||||
<CommonInternalLink to="/contact" text="咨询出行方案" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
<CommonWechatCTA
|
||||
title="确定了出行时间?扫码找定制师"
|
||||
description="告诉定制师你的出行日期和家庭情况,帮你匹配最合适的行程方案。"
|
||||
description="告诉定制师你的出行日期和家庭情况,帮你匹配合适的行程方案。"
|
||||
:links="[{ to: '/products', text: '查看产品线' }]"
|
||||
/>
|
||||
</div>
|
||||
@@ -144,6 +144,7 @@
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: calData } = await usePublicApi('calendar')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
|
||||
const seo = useSEO('calendar')
|
||||
|
||||
@@ -161,7 +162,7 @@ useHead({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'TouristDestination',
|
||||
name: '呼伦贝尔大草原',
|
||||
description: '呼伦贝尔是中国最美草原之一,位于内蒙古自治区东北部,拥有莫日格勒河、额尔古纳湿地、白桦林、呼伦湖等核心景观。最佳旅游时节为6-9月,夏季草原翠绿,秋季白桦金黄。',
|
||||
description: '呼伦贝尔是中国著名草原之一,位于内蒙古自治区东北部,拥有莫日格勒河、额尔古纳湿地、白桦林、呼伦湖等核心景观。推荐旅游时节为6-9月,夏季草原翠绿,秋季白桦金黄。',
|
||||
url: 'https://1814.love/calendar',
|
||||
image: 'https://1814.love/images/team/hero-grassland.jpg',
|
||||
geo: {
|
||||
@@ -177,10 +178,10 @@ useHead({
|
||||
},
|
||||
touristType: ['家庭游客', '亲子家庭', '摄影爱好者', '自然爱好者'],
|
||||
includesAttraction: [
|
||||
{ '@type': 'TouristAttraction', name: '莫日格勒河(额吉的故乡)', description: '呼伦贝尔最美河湾,夏季翠绿如画,是呼籁旅行夏季核心景点' },
|
||||
{ '@type': 'TouristAttraction', name: '额尔古纳湿地', description: '中国最大湿地之一,鸟类天堂,秋季倒影绝美' },
|
||||
{ '@type': 'TouristAttraction', name: '莫日格勒河(额吉的故乡)', description: '呼伦贝尔著名河湾,夏季翠绿如画,是呼籁旅行夏季核心景点' },
|
||||
{ '@type': 'TouristAttraction', name: '额尔古纳湿地', description: '中国大型湿地之一,鸟类天堂,秋季倒影优美' },
|
||||
{ '@type': 'TouristAttraction', name: '白桦林', description: '九月金黄,摄影圣地,游牧的森林秋季产品核心景点' },
|
||||
{ '@type': 'TouristAttraction', name: '呼伦湖', description: '中国北方第一大湖,冬季冰钓胜地,夏季湖畔骑马' },
|
||||
{ '@type': 'TouristAttraction', name: '呼伦湖', description: '中国北方大型湖泊,冬季冰钓胜地,夏季湖畔骑马' },
|
||||
{ '@type': 'TouristAttraction', name: '中俄边境公路', description: '室韦至黑山头沿线,风景壮阔,国境线骑行体验独特' },
|
||||
],
|
||||
}),
|
||||
@@ -191,8 +192,8 @@ useHead({
|
||||
const seasonalEvents = [
|
||||
{
|
||||
key: 'event-summer',
|
||||
name: '呼伦贝尔夏季旅游最佳期(6-8月)',
|
||||
description: '呼伦贝尔草原夏季旅游高峰,绿草如茵,那达慕传统节庆,骑马、露营、亲子出行最佳时段。呼籁旅行提供额吉的故乡V9系列定制游及小蒙马亲子游学营。',
|
||||
name: '呼伦贝尔夏季旅游推荐期(6-8月)',
|
||||
description: '呼伦贝尔草原夏季旅游高峰,绿草如茵,那达慕传统节庆,骑马、露营、亲子出行推荐时段。呼籁旅行提供额吉的故乡V9系列定制游及小蒙马亲子游学营。',
|
||||
startDate: '2026-06-01',
|
||||
endDate: '2026-08-31',
|
||||
},
|
||||
|
||||
+28
-4
@@ -2,7 +2,7 @@
|
||||
<div v-if="contact" class="page-contact">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="选择最方便的方式联系我们"
|
||||
:subtitle="heroes?.contact || '选择最方便的方式联系我们'"
|
||||
:breadcrumbs="[{ text: '联系我们' }]"
|
||||
background-image="/images/summer-camp/grassland-group.jpg"
|
||||
/>
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- 主要联系方式:微信 + 电话 -->
|
||||
<section class="section reveal" aria-label="主要联系方式">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="立即联系" subtitle="微信或电话,开启你的呼伦贝尔之旅" />
|
||||
<CommonSectionTitle :title="st('contact.primary', '立即联系')" :subtitle="st('contact.primary', '微信或电话,开启你的呼伦贝尔之旅', 'subtitle')" />
|
||||
<div class="contact-primary">
|
||||
<!-- 微信客服 -->
|
||||
<div class="contact-card contact-card--wechat">
|
||||
@@ -62,7 +62,7 @@
|
||||
<!-- 其他联系方式 -->
|
||||
<section class="section section--gray reveal" aria-label="更多联系方式">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="更多联系方式" subtitle="商务合作、投诉建议、加入团队" />
|
||||
<CommonSectionTitle :title="st('contact.more', '更多联系方式')" :subtitle="st('contact.more', '商务合作、投诉建议、加入团队', 'subtitle')" />
|
||||
<div class="contact-grid">
|
||||
<div v-for="ch in secondaryChannels" :key="ch.type" class="contact-grid-item">
|
||||
<div class="contact-grid-item__icon">
|
||||
@@ -126,7 +126,9 @@
|
||||
import { useOrganizationSchema, useBreadcrumbSchema, useContactPageSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const visibleChannels = computed(() => (contact.value?.channels || []).filter(ch => !ch.hidden))
|
||||
const wechatChannel = computed(() => visibleChannels.value.find(ch => ch.type === 'wechat'))
|
||||
@@ -148,13 +150,35 @@ function scrollToTop() {
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// ===== 紧凑 hero:因为本页核心是下方的微信/电话 CTA,hero 不能占满首屏 =====
|
||||
.page-contact :deep(.page-hero--has-bg) {
|
||||
padding: @space-lg 0 @space-xl;
|
||||
min-height: 140px;
|
||||
|
||||
.respond-md({
|
||||
padding: @space-xl 0 @space-2xl;
|
||||
min-height: 200px;
|
||||
});
|
||||
}
|
||||
|
||||
// TL;DR 段落也压缩,把 CTA 卡片推上首屏
|
||||
.page-contact :deep(.page-tldr) {
|
||||
padding: @space-md 0;
|
||||
}
|
||||
|
||||
// 立即联系 section 缩减上下 padding,让 CTA 更快出现
|
||||
.page-contact > section:nth-of-type(2) {
|
||||
padding-top: @space-lg;
|
||||
padding-bottom: @space-xl;
|
||||
}
|
||||
|
||||
// ===== 主要联系方式 =====
|
||||
.contact-primary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-xl;
|
||||
max-width: 800px;
|
||||
margin: @space-xl auto 0;
|
||||
margin: @space-md auto 0;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
+18
-22
@@ -2,7 +2,7 @@
|
||||
<div class="page-courses">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="小蒙马亲子研学·6大课程模块·3-12岁分龄设计"
|
||||
:subtitle="heroes?.courses || '小蒙马亲子研学·6大课程模块·3-12岁分龄设计'"
|
||||
:breadcrumbs="[{ text: '小蒙马亲子游学营', to: '/summer-camp' }, { text: '研学课程体系' }]"
|
||||
background-image="/images/summer-camp/birch-group.jpg"
|
||||
/>
|
||||
@@ -43,8 +43,8 @@
|
||||
<section v-if="courses" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="按年龄找适合的课程"
|
||||
subtitle="不同年龄段,有不同的学习目标和课程深度"
|
||||
:title="st('courses.byAge', '按年龄找适合的课程')"
|
||||
:subtitle="st('courses.byAge', '不同年龄段,有不同的学习目标和课程深度', 'subtitle')"
|
||||
/>
|
||||
<div class="age-tabs">
|
||||
<button
|
||||
@@ -105,8 +105,8 @@
|
||||
<section v-if="courses" class="section section--gray reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="6大课程模块"
|
||||
subtitle="每个模块有明确的学习目标、活动设计和教育理念"
|
||||
:title="st('courses.modules', '6大课程模块')"
|
||||
:subtitle="st('courses.modules', '每个模块有明确的学习目标、活动设计和教育理念', 'subtitle')"
|
||||
/>
|
||||
<div class="modules-grid">
|
||||
<div
|
||||
@@ -153,26 +153,17 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 师资介绍(占位) -->
|
||||
<section v-if="courses" class="section reveal">
|
||||
<!-- 师资介绍:仅在有真实导师数据时显示 -->
|
||||
<section v-if="courses && realInstructors.length" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="课程导师团队"
|
||||
subtitle="自然教育导师 + 草原领队,全程陪同每一位孩子"
|
||||
:title="st('courses.mentors', '课程导师团队')"
|
||||
:subtitle="st('courses.mentors', '自然教育导师 + 草原领队,全程陪同每一位孩子', 'subtitle')"
|
||||
/>
|
||||
<div class="instructor-placeholder-box">
|
||||
<span class="instructor-placeholder-icon">⚠️</span>
|
||||
<div class="instructor-placeholder-text">
|
||||
<p class="instructor-placeholder-title">此处需补充真实导师信息</p>
|
||||
<p class="instructor-placeholder-desc">
|
||||
请提供导师姓名、资质认证(自然教育导师证/急救证等)、从业经历和个人介绍,由运营团队填充。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="instructor-cards">
|
||||
<div v-for="inst in courses.instructors?.placeholder || []" :key="inst.title" class="instructor-card">
|
||||
<div class="instructor-avatar-placeholder">
|
||||
<span>⚠️</span>
|
||||
<div v-for="inst in realInstructors" :key="inst.name" class="instructor-card">
|
||||
<div v-if="inst.avatar" class="instructor-avatar">
|
||||
<img :src="inst.avatar" :alt="inst.name" loading="lazy" />
|
||||
</div>
|
||||
<div class="instructor-info">
|
||||
<p class="instructor-name">{{ inst.name }}</p>
|
||||
@@ -188,7 +179,7 @@
|
||||
<!-- 家长常见问题 -->
|
||||
<section v-if="courses" class="section section--warm">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="家长常见问题" />
|
||||
<CommonSectionTitle :title="st('courses.faq', '家长常见问题')" />
|
||||
<div class="courses-faq">
|
||||
<FaqItem
|
||||
v-for="(item, i) in courses.faq"
|
||||
@@ -217,6 +208,8 @@
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: courses } = await usePublicApi('courses')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const seo = useSEO('courses')
|
||||
useScrollReveal()
|
||||
@@ -275,6 +268,9 @@ useHead({
|
||||
})
|
||||
|
||||
// 年龄分层选择
|
||||
const realInstructors = computed(() =>
|
||||
(courses.value?.instructors?.list || []).filter(i => i?.name && !String(JSON.stringify(i)).includes('⚠️'))
|
||||
)
|
||||
const activeGroup = ref('child')
|
||||
const activeGroupData = computed(() => (courses.value?.ageGroups || []).find((g) => g.id === activeGroup.value))
|
||||
const activeGroupModules = computed(() =>
|
||||
|
||||
+5
-4
@@ -2,7 +2,7 @@
|
||||
<div class="page-customize">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="填写出行需求,定制师2小时内回复,免费咨询"
|
||||
:subtitle="heroes?.customize || '填写出行需求,定制师2小时内回复,免费咨询'"
|
||||
:breadcrumbs="[{ text: '在线定制' }]"
|
||||
background-image="/images/seasons/summer-hero.jpg"
|
||||
/>
|
||||
@@ -46,7 +46,7 @@
|
||||
</p>
|
||||
<div class="success-wechat">
|
||||
<span class="wechat-label">也可主动添加定制师微信</span>
|
||||
<img src="/images/contact/qrcode-wechat.jpg" alt="定制师微信二维码" class="wechat-qrcode wechat-qrcode--success" />
|
||||
<img :src="'/images/contact/qrcode-wechat.jpg'" alt="定制师微信二维码" class="wechat-qrcode wechat-qrcode--success" />
|
||||
</div>
|
||||
<p class="success-tip">{{ customize.contact.tip }}</p>
|
||||
<button class="btn-reset" @click="resetForm">重新填写</button>
|
||||
@@ -250,7 +250,7 @@
|
||||
<div class="aside-card aside-wechat">
|
||||
<h3 class="aside-title">不想填表?</h3>
|
||||
<p class="aside-desc">扫码添加定制师微信,随时咨询:</p>
|
||||
<img src="/images/contact/qrcode-wechat.jpg" alt="定制师微信二维码" class="wechat-qrcode" />
|
||||
<img :src="'/images/contact/qrcode-wechat.jpg'" alt="定制师微信二维码" class="wechat-qrcode" />
|
||||
<p class="wechat-tip">{{ customize.contact.tip }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -262,7 +262,7 @@
|
||||
<!-- 内链闭环 -->
|
||||
<CommonSectionCTA
|
||||
title="想先了解产品?"
|
||||
description="查看我们的行程版本,找到最适合您家庭的天数"
|
||||
description="查看我们的行程版本,找到适合您家庭的天数"
|
||||
:links="[
|
||||
{ to: '/products', text: '查看4-7天行程版本' },
|
||||
{ to: '/faq', text: '查看常见问题解答' },
|
||||
@@ -276,6 +276,7 @@
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: customize } = await usePublicApi('customize')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
|
||||
const seo = useSEO('customize')
|
||||
useBreadcrumbSchema([
|
||||
|
||||
@@ -30,16 +30,8 @@
|
||||
|
||||
<section class="dest-season">
|
||||
<div class="container">
|
||||
<h2>最佳游览季节</h2>
|
||||
<h2>推荐游览季节</h2>
|
||||
<div class="season-card">
|
||||
<div class="season-primary">
|
||||
<span class="season-label">最佳时节</span>
|
||||
<span class="season-value">{{ dest.bestSeason.primary }}</span>
|
||||
</div>
|
||||
<div class="season-secondary" v-if="dest.bestSeason.secondary">
|
||||
<span class="season-label">次佳时节</span>
|
||||
<span class="season-value">{{ dest.bestSeason.secondary }}</span>
|
||||
</div>
|
||||
<p class="season-desc">{{ dest.bestSeason.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+11
-4
@@ -2,7 +2,7 @@
|
||||
<div class="page-destinations">
|
||||
<LayoutPageHero
|
||||
title="呼伦贝尔核心景区攻略"
|
||||
subtitle="深耕呼伦贝尔11年,带你发现最真实的草原秘境"
|
||||
:subtitle="heroes?.destinations || '深耕呼伦贝尔11年,带你发现最真实的草原秘境'"
|
||||
:breadcrumbs="[{ text: '景区攻略' }]"
|
||||
background-image="/images/seasons/summer-river.jpg"
|
||||
/>
|
||||
@@ -11,7 +11,7 @@
|
||||
<section class="page-tldr" aria-label="景区攻略摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼伦贝尔核心景区覆盖草原、湿地、森林、边境、火山地质等多种景观类型。本页梳理了呼籁旅行三大产品线(额吉的故乡·夏季、游牧的森林·秋季、嗨冰雪·冬季)覆盖的所有核心景区,包括莫日格勒河、额尔古纳湿地、白桦林、恩和室韦、黑山头、满洲里、阿尔山国家森林公园、大兴安岭莫尔道嘎、敖鲁古雅驯鹿部落、根河等,提供每处景区的实用攻略、最佳时间和旅行贴士。</p>
|
||||
<p>呼伦贝尔核心景区覆盖草原、湿地、森林、边境、火山地质等多种景观类型。本页梳理了呼籁旅行三大产品线(额吉的故乡·夏季、游牧的森林·秋季、嗨冰雪·冬季)覆盖的所有核心景区,包括莫日格勒河、额尔古纳湿地、白桦林、恩和室韦、黑山头、满洲里、阿尔山国家森林公园、大兴安岭莫尔道嘎、敖鲁古雅驯鹿部落、根河等,提供每处景区的实用攻略、推荐时间和旅行贴士。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -69,6 +69,7 @@
|
||||
<span v-for="(h, i) in dest.highlights.slice(0, 2)" :key="i" class="dest-card__highlight">{{ h.split('——')[0] }}</span>
|
||||
</div>
|
||||
<div class="dest-card__seasons">
|
||||
<span v-if="dest.seasons.includes('spring')" class="season-dot season-dot--spring" title="春季">春</span>
|
||||
<span v-if="dest.seasons.includes('summer')" class="season-dot season-dot--summer" title="额吉的故乡·夏季">夏</span>
|
||||
<span v-if="dest.seasons.includes('autumn')" class="season-dot season-dot--autumn" title="游牧的森林·秋季">秋</span>
|
||||
<span v-if="dest.seasons.includes('winter')" class="season-dot season-dot--winter" title="嗨冰雪·冬季">冬</span>
|
||||
@@ -80,7 +81,7 @@
|
||||
<!-- CTA -->
|
||||
<div class="dest-cta">
|
||||
<h2>不确定去哪些景区?让定制师帮你组合</h2>
|
||||
<p>呼籁旅行定制师深耕呼伦贝尔11年,根据你的时间、季节、孩子年龄,帮你搭配最合适的景区组合和行程节奏。</p>
|
||||
<p>呼籁旅行定制师深耕呼伦贝尔11年,根据你的时间、季节、孩子年龄,帮你搭配合适的景区组合和行程节奏。</p>
|
||||
<div class="dest-cta__links">
|
||||
<CommonInternalLink to="/contact" text="免费咨询定制师" />
|
||||
<CommonInternalLink to="/products" text="查看定制游产品" />
|
||||
@@ -97,6 +98,7 @@ import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
import useScrollReveal from '~/composables/useScrollReveal'
|
||||
|
||||
const { data: destinationsData } = await usePublicApi('destinations-detail')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
|
||||
const destinations = computed(() => destinationsData.value?.destinations || [])
|
||||
|
||||
@@ -130,7 +132,7 @@ const filteredDestinations = computed(() => {
|
||||
|
||||
useSEO('destinations', {
|
||||
title: '呼伦贝尔核心景区攻略 - 莫日格勒河 额尔古纳 阿尔山等景点详解 | 呼籁旅行',
|
||||
description: '呼籁旅行深耕呼伦贝尔11年,为你梳理三大产品线覆盖的核心景区攻略:莫日格勒河、额尔古纳湿地、白桦林、恩和室韦、黑山头、满洲里、阿尔山、大兴安岭、敖鲁古雅驯鹿部落、根河,含实用贴士和最佳时间。',
|
||||
description: '呼籁旅行深耕呼伦贝尔11年,为你梳理三大产品线覆盖的核心景区攻略:莫日格勒河、额尔古纳湿地、白桦林、恩和室韦、黑山头、满洲里、阿尔山、大兴安岭、敖鲁古雅驯鹿部落、根河,含实用贴士和推荐时间。',
|
||||
})
|
||||
|
||||
useHead({
|
||||
@@ -410,6 +412,11 @@ useScrollReveal()
|
||||
font-size: 11px;
|
||||
font-weight: @font-weight-medium;
|
||||
|
||||
&--spring {
|
||||
background: #FCE4EC;
|
||||
color: #AD1457;
|
||||
}
|
||||
|
||||
&--summer {
|
||||
background: #E8F5E9;
|
||||
color: #2E7D32;
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
<!-- 页面标题 -->
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
:subtitle="`${totalFAQ}个高频问题详细解答,帮您做出最好的选择`"
|
||||
:subtitle="`${totalFAQ}个高频问题详细解答,帮您做出适合的选择`"
|
||||
:breadcrumbs="[{ text: '常见问答' }]"
|
||||
background-image="/images/summer-camp/grassland-group.jpg"
|
||||
/>
|
||||
@@ -12,7 +12,7 @@
|
||||
<section class="page-tldr" aria-label="常见问答摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>本页汇总呼籁旅行被问到最多的{{ totalFAQ }}个问题,涵盖价格区间、最佳出行时间、适合人群、行程安排、安全保障、退改条款等核心关切,按分类整理并支持关键词搜索。还有疑问可直接联系定制师。</p>
|
||||
<p>本页汇总呼籁旅行常被问到的{{ totalFAQ }}个问题,涵盖价格区间、推荐出行时间、适合人群、行程安排、安全保障、退改条款等核心关切,按分类整理并支持关键词搜索。还有疑问可直接联系定制师。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+9
-7
@@ -2,7 +2,7 @@
|
||||
<div class="page-gallery">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="自营摄影团队 · 200+件蒙古服饰 · 专属秘境取景地"
|
||||
:subtitle="heroes?.gallery || '自营摄影团队 · 200+件蒙古服饰 · 专属秘境取景地'"
|
||||
:breadcrumbs="[{ text: '旅拍作品' }]"
|
||||
background-image="/images/seasons/autumn-forest.jpg"
|
||||
/>
|
||||
@@ -33,8 +33,8 @@
|
||||
<section v-if="gallery" class="gallery-intro section">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="呼籁旅拍 · 草原最美的记忆"
|
||||
subtitle="随行拍摄,无需单独预约,每组旅拍都是独一无二的故事"
|
||||
:title="st('gallery.intro', '呼籁旅拍 · 草原动人的记忆')"
|
||||
:subtitle="st('gallery.intro', '随行拍摄,无需单独预约,每组旅拍都有独特的故事', 'subtitle')"
|
||||
align="center"
|
||||
/>
|
||||
<p class="gallery-intro__desc">{{ gallery.intro.description }}</p>
|
||||
@@ -50,13 +50,13 @@
|
||||
<span class="gallery-highlight__icon">📷</span>
|
||||
<div>
|
||||
<strong>专业旅拍摄影师</strong>
|
||||
<p>全程随行,把握每一个自然光线最好的瞬间</p>
|
||||
<p>全程随行,把握每一个自然光线理想的瞬间</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery-highlight">
|
||||
<span class="gallery-highlight__icon">🏔️</span>
|
||||
<div>
|
||||
<strong>独家秘境取景地</strong>
|
||||
<strong>专属秘境取景地</strong>
|
||||
<p>莫日格勒河源头、营地花海、白桦林深处,不对外开放</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,8 +68,8 @@
|
||||
<section v-if="gallery" class="section section--gray">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="旅拍作品"
|
||||
subtitle="每一帧都是真实客户的草原记忆"
|
||||
:title="st('gallery.works', '旅拍作品')"
|
||||
:subtitle="st('gallery.works', '每一帧都是真实客户的草原记忆', 'subtitle')"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
@@ -171,6 +171,8 @@ import { useBreadcrumbSchema, useGallerySchema } from '~/composables/useJsonLd'
|
||||
import useScrollReveal from '~/composables/useScrollReveal'
|
||||
|
||||
const { data: gallery } = await usePublicApi('gallery')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const seo = useSEO('gallery')
|
||||
useScrollReveal()
|
||||
|
||||
+66
-98
@@ -1,114 +1,79 @@
|
||||
<template>
|
||||
<div class="page-guide-custom">
|
||||
<LayoutPageHero
|
||||
title="呼伦贝尔定制游怎么选"
|
||||
subtitle="定制游 vs 跟团游 vs 自由行,一篇讲透"
|
||||
:title="article?.title || '呼伦贝尔定制游怎么选'"
|
||||
:subtitle="heroes?.['guide-custom'] || '定制游 vs 跟团游 vs 自由行,一篇讲透'"
|
||||
:breadcrumbs="[{ text: '出行指南', to: '/guides' }, { text: '定制游怎么选' }]"
|
||||
background-image="/images/seasons/summer-river.jpg"
|
||||
/>
|
||||
|
||||
<section class="page-tldr" aria-label="定制游摘要">
|
||||
<section v-if="article?.tldr" class="page-tldr" aria-label="定制游摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼伦贝尔定制游是一家一单一车的私家出行方式,区别于传统跟团游的拼团大巴和走马观花。本文解释定制游的优势、适合人群、价格构成,帮你判断是否适合选择定制游。</p>
|
||||
<p>{{ article.tldr }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article class="section">
|
||||
<div class="container article-body">
|
||||
<template v-for="(section, sIdx) in article?.sections || []" :key="sIdx">
|
||||
<!-- 文本段落型 -->
|
||||
<template v-if="section.type === 'text'">
|
||||
<h2 v-if="section.heading">{{ section.heading }}</h2>
|
||||
<p v-for="(p, i) in section.paragraphs" :key="i">{{ p }}</p>
|
||||
</template>
|
||||
|
||||
<h2>什么是定制游</h2>
|
||||
<p>定制游的核心是「一家一单一车」——你的家庭独享一辆车和一位领队,行程根据你的需求量身安排,不与陌生人拼团。出发时间、游玩节奏、停留时长都可以灵活调整,孩子累了可以多休息,遇到好风景可以多停一会儿。</p>
|
||||
<p>这不是高价版的跟团游,而是完全不同的旅行方式。</p>
|
||||
<!-- 对比表格型 -->
|
||||
<template v-else-if="section.type === 'table'">
|
||||
<h2 v-if="section.heading">{{ section.heading }}</h2>
|
||||
<div class="compare-table-wrap">
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th v-for="col in section.columns" :key="col">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rIdx) in section.rows" :key="rIdx">
|
||||
<td class="row-label">{{ row.label }}</td>
|
||||
<td
|
||||
v-for="(val, vIdx) in row.values"
|
||||
:key="vIdx"
|
||||
:class="{ highlight: vIdx === 0 }"
|
||||
>
|
||||
{{ val }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<h2>定制游 vs 跟团游 vs 自由行</h2>
|
||||
<div class="compare-table-wrap">
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>定制游</th>
|
||||
<th>跟团游</th>
|
||||
<th>自由行</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="row-label">用车</td>
|
||||
<td class="highlight">一家一车,专属司机</td>
|
||||
<td>拼团大巴,30-50人</td>
|
||||
<td>自驾或包车</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="row-label">行程</td>
|
||||
<td class="highlight">按家庭需求定制</td>
|
||||
<td>固定路线,统一安排</td>
|
||||
<td>完全自己规划</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="row-label">节奏</td>
|
||||
<td class="highlight">随走随停,灵活调整</td>
|
||||
<td>按时间表走,赶景点</td>
|
||||
<td>自由但费心</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="row-label">领队</td>
|
||||
<td class="highlight">专属领队全程陪同</td>
|
||||
<td>导游带大团</td>
|
||||
<td>无</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="row-label">购物</td>
|
||||
<td class="highlight">全程零购物</td>
|
||||
<td>可能有购物店</td>
|
||||
<td>无</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="row-label">适合</td>
|
||||
<td class="highlight">亲子家庭、品质出行</td>
|
||||
<td>预算有限、不想操心</td>
|
||||
<td>经验丰富的老手</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 列表型 -->
|
||||
<template v-else-if="section.type === 'list'">
|
||||
<h2 v-if="section.heading">{{ section.heading }}</h2>
|
||||
<p v-if="section.intro">{{ section.intro }}</p>
|
||||
<ul v-if="section.items && section.items.length">
|
||||
<li v-for="(item, i) in section.items" :key="i">{{ item }}</li>
|
||||
</ul>
|
||||
<p v-if="section.outro">{{ section.outro }}</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<h2>为什么亲子家庭更适合定制游</h2>
|
||||
<p>带孩子旅行和大人出行完全不同。孩子需要按自己的节奏来——可能早上起不来,可能中午要午睡,可能在某个地方玩嗨了不想走。跟团游的固定时间表很难适配孩子的节奏,自由行又需要父母花大量精力规划路线。</p>
|
||||
<p>定制游解决的正是这个问题:专业的定制师提前根据孩子年龄安排行程节奏,专属领队全程照顾,家长只需要带着孩子享受旅行。</p>
|
||||
|
||||
<h2>定制游的价格构成</h2>
|
||||
<p>定制游的价格包含车辆、住宿、领队服务、活动体验和保险,正餐自理。看起来比跟团游贵,但每一分钱都花在了体验上:</p>
|
||||
<ul>
|
||||
<li>没有购物店——不会被拉去消费</li>
|
||||
<li>没有隐形消费——费用明细清晰透明</li>
|
||||
<li>没有赶场式行程——每个地方都停够、看够</li>
|
||||
<li>所有资源专属调配——你的领队只服务你一家</li>
|
||||
</ul>
|
||||
<p>以呼籁旅行为例,5天4晚行程舒适套餐2580元/人起,高档套餐3380元/人起,价格随出行日期浮动。定金500元/人,出发前7天可无条件退改。</p>
|
||||
|
||||
<h2>怎么判断自己适不适合定制游</h2>
|
||||
<p>如果你符合以下任一条件,定制游大概率是更好的选择:</p>
|
||||
<ul>
|
||||
<li>带3-12岁孩子出行,需要灵活节奏</li>
|
||||
<li>三代同行,需要照顾老人和孩子</li>
|
||||
<li>对住宿品质有要求,不想住快捷酒店</li>
|
||||
<li>不想和陌生人拼团,注重私密性</li>
|
||||
<li>第一次去呼伦贝尔,不了解路线</li>
|
||||
<li>想深度体验而不是走马观花</li>
|
||||
</ul>
|
||||
|
||||
<h2>呼籁旅行的定制游有什么不同</h2>
|
||||
<p>呼籁旅行深耕呼伦贝尔11年,是本地自营的亲子游品牌,不是中间商转手。我们有自己的车队、自己的营地、自己的旅拍团队,从产品设计到服务交付全链路自己把控。</p>
|
||||
<p>目前提供额吉的故乡V9系列共6个版本(5天4晚至7天6晚),每个版本都是针对不同家庭需求专门设计的,不是简单增减天数。每个家庭配备5人服务团队:定制师+房务管家+车务管家+客服管家+领队。</p>
|
||||
|
||||
<div class="article-cta">
|
||||
<h3>想了解具体行程?</h3>
|
||||
<div v-if="article?.cta" class="article-cta">
|
||||
<h3 v-if="article.cta.heading">{{ article.cta.heading }}</h3>
|
||||
<div class="cta-links">
|
||||
<NuxtLink to="/products" class="cta-btn cta-btn--primary">查看6个版本行程</NuxtLink>
|
||||
<NuxtLink to="/selector" class="cta-btn cta-btn--outline">1分钟匹配最适合的版本</NuxtLink>
|
||||
<NuxtLink to="/contact" class="cta-btn cta-btn--outline">联系定制师免费咨询</NuxtLink>
|
||||
<NuxtLink
|
||||
v-for="(link, i) in article.cta.links || []"
|
||||
:key="i"
|
||||
:to="link.to"
|
||||
class="cta-btn"
|
||||
:class="link.primary ? 'cta-btn--primary' : 'cta-btn--outline'"
|
||||
>
|
||||
{{ link.text }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,15 +82,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema, useFAQPageSchema } from '~/composables/useJsonLd'
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
useHead({
|
||||
title: '呼伦贝尔定制游怎么选 - 定制游vs跟团游vs自由行 - 呼籁旅行',
|
||||
const { data: article } = await usePublicApi('guide-custom')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
|
||||
useHead(() => ({
|
||||
title: article.value?.seo?.title || '呼伦贝尔定制游怎么选 - 定制游vs跟团游vs自由行 - 呼籁旅行',
|
||||
meta: [
|
||||
{ name: 'description', content: '呼伦贝尔定制游是一家一单一车的私家出行方式。本文对比定制游、跟团游、自由行的区别,解释定制游的价格构成和适合人群,帮你判断是否适合选择呼伦贝尔定制游。' },
|
||||
{ name: 'keywords', content: '呼伦贝尔定制游,定制游和跟团游的区别,呼伦贝尔旅游怎么选,呼伦贝尔一家一车,呼伦贝尔私家团,呼伦贝尔亲子游' },
|
||||
{ name: 'description', content: article.value?.seo?.description || '呼伦贝尔定制游是一家一单一车的私家出行方式。本文对比定制游、跟团游、自由行的区别,解释定制游的价格构成和适合人群,帮你判断是否适合选择呼伦贝尔定制游。' },
|
||||
{ name: 'keywords', content: article.value?.seo?.keywords || '呼伦贝尔定制游,定制游和跟团游的区别,呼伦贝尔旅游怎么选,呼伦贝尔一家一车,呼伦贝尔私家团,呼伦贝尔亲子游' },
|
||||
],
|
||||
})
|
||||
}))
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
<div class="page-guides">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="证件·穿搭·行李清单·活动安全·幼童专属指南,帮您轻松准备呼伦贝尔之行"
|
||||
:subtitle="heroes?.guides || '证件·穿搭·行李清单·活动安全·幼童专属指南,帮您轻松准备呼伦贝尔之行'"
|
||||
:breadcrumbs="[{ text: '出行指南' }]"
|
||||
background-image="/images/seasons/summer-car.jpg"
|
||||
/>
|
||||
@@ -173,6 +173,7 @@
|
||||
import { useBreadcrumbSchema, useHowToSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: guidesData } = await usePublicApi('guides')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
|
||||
const seo = useSEO('guides')
|
||||
|
||||
|
||||
+5
-4
@@ -21,7 +21,7 @@
|
||||
<!-- 产品语义树(帮助 AI 按维度理解产品分类)-->
|
||||
<section class="section section--gray reveal" aria-label="产品分类导航">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="怎么找到适合你的行程" label="FIND YOUR TRIP" subtitle="按天数 · 按人群 · 按季节 三个维度快速定位" />
|
||||
<CommonSectionTitle :title="st('home.productTree', '怎么找到适合你的行程')" :label="st('home.productTree', 'FIND YOUR TRIP', 'label')" :subtitle="st('home.productTree', '按天数 · 按人群 · 按季节 三个维度快速定位', 'subtitle')" />
|
||||
<div class="product-tree-grid">
|
||||
<div class="product-tree-card product-tree-card--days">
|
||||
<div class="product-tree-icon">
|
||||
@@ -74,14 +74,14 @@
|
||||
<!-- 知识库入口(告诉 AI 这是持续产出内容的权威站点)-->
|
||||
<section class="section reveal" aria-label="旅行知识库">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="11年积累的呼伦贝尔知识库" label="KNOWLEDGE" subtitle="从规划到出发,每个问题都有答案" />
|
||||
<CommonSectionTitle :title="st('home.knowledgeHub', '11年积累的呼伦贝尔知识库')" :label="st('home.knowledgeHub', 'KNOWLEDGE', 'label')" :subtitle="st('home.knowledgeHub', '从规划到出发,每个问题都有答案', 'subtitle')" />
|
||||
<div class="knowledge-hub-grid">
|
||||
<NuxtLink to="/calendar" class="knowledge-hub-card">
|
||||
<div class="knowledge-hub-card__icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</div>
|
||||
<h3 class="knowledge-hub-card__title">出行时间日历</h3>
|
||||
<p class="knowledge-hub-card__desc">每月天气、景色、推荐指数,找到你的最佳出行窗口</p>
|
||||
<p class="knowledge-hub-card__desc">每月天气、景色、推荐指数,找到你的出行窗口</p>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/guides" class="knowledge-hub-card">
|
||||
<div class="knowledge-hub-card__icon">
|
||||
@@ -111,7 +111,7 @@
|
||||
<!-- FAQ预览 -->
|
||||
<section v-if="faq" class="section section--gray reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="常见问答" label="FAQ" subtitle="您可能想了解的问题" />
|
||||
<CommonSectionTitle :title="st('home.faq', '常见问答')" :label="st('home.faq', 'FAQ', 'label')" :subtitle="st('home.faq', '您可能想了解的问题', 'subtitle')" />
|
||||
<div class="faq-preview">
|
||||
<details v-for="q in featuredFAQ" :key="q.id" class="faq-preview-item">
|
||||
<summary class="faq-preview-q">
|
||||
@@ -152,6 +152,7 @@ const { data: faq } = await usePublicApi('faq')
|
||||
const { data: reviews } = await usePublicApi('reviews')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
const { data: navData } = await usePublicApi('navigation')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
// SEO
|
||||
const seo = useSEO('home')
|
||||
|
||||
+55
-10
@@ -2,16 +2,16 @@
|
||||
<div class="page-news">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="品牌动态、产品发布、活动公告,第一时间了解呼籁旅行最新资讯"
|
||||
:breadcrumbs="[{ text: '最新动态' }]"
|
||||
:subtitle="heroes?.news || '品牌动态、产品发布、活动公告,及时了解呼籁旅行最新资讯'"
|
||||
:breadcrumbs="[{ text: '品牌资讯' }]"
|
||||
background-image="/images/seasons/autumn-hero.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="最新动态摘要">
|
||||
<section class="page-tldr" aria-label="品牌资讯摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行最新动态:品牌公告、产品发布、活动资讯、行业合作等信息,第一时间了解呼伦贝尔亲子游最新资讯。</p>
|
||||
<p>呼籁旅行品牌资讯:品牌公告、产品发布、活动资讯、行业合作等信息,及时了解呼伦贝尔亲子游相关资讯。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -37,13 +37,24 @@
|
||||
v-for="article in filteredArticles"
|
||||
:key="article.id"
|
||||
class="news-card"
|
||||
:class="{ 'news-card--with-cover': article.coverImage }"
|
||||
>
|
||||
<div class="news-meta">
|
||||
<span :class="['news-cat', `news-cat--${article.category}`]">{{ article.category }}</span>
|
||||
<time class="news-date" :datetime="article.date || article.createdAt">{{ formatDate(article.date || article.createdAt) }}</time>
|
||||
<div v-if="article.coverImage" class="news-card__cover">
|
||||
<img
|
||||
:src="article.coverImage"
|
||||
:alt="article.title"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div class="news-card__body">
|
||||
<div class="news-meta">
|
||||
<span :class="['news-cat', `news-cat--${article.category}`]">{{ article.category }}</span>
|
||||
<time class="news-date" :datetime="article.date || article.createdAt">{{ formatDate(article.date || article.createdAt) }}</time>
|
||||
</div>
|
||||
<h2 class="news-title">{{ article.title }}</h2>
|
||||
<p class="news-summary">{{ article.summary }}</p>
|
||||
</div>
|
||||
<h2 class="news-title">{{ article.title }}</h2>
|
||||
<p class="news-summary">{{ article.summary }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -67,12 +78,13 @@
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: newsData } = await usePublicApi('news')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
|
||||
const seo = useSEO('news')
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '最新动态' },
|
||||
{ text: '品牌资讯' },
|
||||
])
|
||||
|
||||
// NewsArticle JSON-LD for each article
|
||||
@@ -167,6 +179,39 @@ function formatDate(dateStr) {
|
||||
}
|
||||
}
|
||||
|
||||
.news-card--with-cover {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-md;
|
||||
padding: @space-lg;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: @space-xl;
|
||||
});
|
||||
}
|
||||
|
||||
.news-card__cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: @border-radius-sm;
|
||||
background: @color-bg-gray;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.news-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+17
-23
@@ -2,7 +2,7 @@
|
||||
<div class="page-partners">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="行业认证 · 平台背书 · 景区合作 · 高校联合"
|
||||
:subtitle="heroes?.partners || '行业认证 · 平台背书 · 景区合作 · 高校联合'"
|
||||
:breadcrumbs="[{ text: '关于我们', to: '/about' }, { text: '合作伙伴' }]"
|
||||
background-image="/images/team/campsite-team.jpg"
|
||||
/>
|
||||
@@ -20,8 +20,8 @@
|
||||
<section v-if="partners" class="section section-platforms">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="平台认证与荣誉"
|
||||
subtitle="官方认证账号,平台公开可查"
|
||||
:title="st('partners.platform', '平台认证与荣誉')"
|
||||
:subtitle="st('partners.platform', '官方认证账号,平台公开可查', 'subtitle')"
|
||||
/>
|
||||
<div class="platforms-grid reveal">
|
||||
<div
|
||||
@@ -42,8 +42,8 @@
|
||||
<section v-if="partners" class="section section-gov section-alt">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="政府资质与行业背书"
|
||||
subtitle="来自官方机构的认可与合作"
|
||||
:title="st('partners.government', '政府资质与行业背书')"
|
||||
:subtitle="st('partners.government', '来自官方机构的认可与合作', 'subtitle')"
|
||||
/>
|
||||
<div class="partners-list reveal">
|
||||
<div class="partners-list__group">
|
||||
@@ -59,9 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="partners-list__group">
|
||||
<h3 class="partners-list__group-title">行业协会
|
||||
<span class="group-title-note">⚠️ 以下信息需确认</span>
|
||||
</h3>
|
||||
<h3 class="partners-list__group-title">行业协会</h3>
|
||||
<div class="partners-items">
|
||||
<div
|
||||
v-for="item in partners.associations.slice(1)"
|
||||
@@ -85,8 +83,8 @@
|
||||
<section v-if="partners" class="section section-academic">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="校企合作"
|
||||
subtitle="联合高校,培养本地旅游人才"
|
||||
:title="st('partners.universities', '校企合作')"
|
||||
:subtitle="st('partners.universities', '联合高校,培养本地旅游人才', 'subtitle')"
|
||||
/>
|
||||
<div class="academic-grid reveal">
|
||||
<div
|
||||
@@ -110,8 +108,8 @@
|
||||
<section v-if="partners" class="section section-scenic section-alt">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="景区合作"
|
||||
subtitle="与呼伦贝尔核心景区深度合作,确保专属体验"
|
||||
:title="st('partners.scenic', '景区合作')"
|
||||
:subtitle="st('partners.scenic', '与呼伦贝尔核心景区深度合作,确保专属体验', 'subtitle')"
|
||||
/>
|
||||
<div class="scenic-grid reveal">
|
||||
<div
|
||||
@@ -123,23 +121,17 @@
|
||||
<p class="scenic-card__desc">{{ scenic.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="unverifiedScenicCount > 0" class="section-placeholder-note">
|
||||
⚠️ 还有 {{ unverifiedScenicCount }} 个合作景区待确认,请补充完整
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 媒体报道 -->
|
||||
<section v-if="partners" class="section section-media">
|
||||
<!-- 媒体报道(有真实报道才展示) -->
|
||||
<section v-if="partners?.mediaReports?.length" class="section section-media">
|
||||
<div class="container">
|
||||
<div class="section-header-with-note reveal">
|
||||
<CommonSectionTitle
|
||||
title="媒体报道"
|
||||
subtitle="第三方媒体对呼籁旅行的报道与采访"
|
||||
:title="st('partners.media', '媒体报道')"
|
||||
:subtitle="st('partners.media', '第三方媒体对呼籁旅行的报道与采访', 'subtitle')"
|
||||
/>
|
||||
<div class="placeholder-banner">
|
||||
⚠️ 本栏目所有内容均为占位框架,需补充真实媒体报道链接后方可正式上线
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-grid reveal">
|
||||
<div
|
||||
@@ -152,7 +144,7 @@
|
||||
<p class="media-card__summary">{{ report.summary }}</p>
|
||||
<div class="media-card__footer">
|
||||
<span class="media-card__date">{{ report.date }}</span>
|
||||
<span class="media-card__link-placeholder">⚠️ 链接待补充</span>
|
||||
<a v-if="report.url" :href="report.url" target="_blank" rel="noopener" class="media-card__link">查看原文 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,8 +167,10 @@
|
||||
import { useOrganizationSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: partners } = await usePublicApi('partners')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const seo = useSEO('partners')
|
||||
useScrollReveal()
|
||||
|
||||
+7
-5
@@ -2,7 +2,7 @@
|
||||
<div class="page-pricing">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="不藏价格,不绕弯子"
|
||||
:subtitle="heroes?.pricing || '不藏价格,不绕弯子'"
|
||||
:breadcrumbs="[{ text: '价格参考' }]"
|
||||
background-image="/images/team/hero-grassland.jpg"
|
||||
/>
|
||||
@@ -35,8 +35,8 @@
|
||||
<section class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="产品线价格参考"
|
||||
subtitle="6条定制游线路 + 1个亲子游学营 + 秋季产品"
|
||||
:title="st('pricing.reference', '产品线价格参考')"
|
||||
:subtitle="st('pricing.reference', '6条定制游线路 + 1个亲子游学营 + 秋季产品', 'subtitle')"
|
||||
/>
|
||||
<div class="pricing-grid">
|
||||
<div
|
||||
@@ -71,8 +71,8 @@
|
||||
<section v-if="pricing" class="section section-inclusions reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="费用说明"
|
||||
subtitle="所有产品遵循同一套费用标准"
|
||||
:title="st('pricing.explanation', '费用说明')"
|
||||
:subtitle="st('pricing.explanation', '所有产品遵循同一套费用标准', 'subtitle')"
|
||||
/>
|
||||
<div class="inclusions-grid">
|
||||
<div class="inclusion-col included">
|
||||
@@ -127,6 +127,8 @@ import { useOrganizationSchema, useBreadcrumbSchema, usePricingSchema } from '~/
|
||||
const { data: pricing } = await usePublicApi('pricing')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const seo = useSEO('pricing')
|
||||
useScrollReveal()
|
||||
|
||||
+11
-10
@@ -17,7 +17,7 @@
|
||||
|
||||
<!-- 风景大图 -->
|
||||
<div class="scenic-banner">
|
||||
<img src="/images/seasons/autumn-hero.jpg" alt="秋季大兴安岭金色白桦林" loading="eager" />
|
||||
<img :src="'/images/seasons/autumn-hero.jpg'" alt="秋季大兴安岭金色白桦林" loading="eager" />
|
||||
</div>
|
||||
|
||||
<!-- 产品设计理念 -->
|
||||
@@ -26,7 +26,7 @@
|
||||
<!-- 秋季亮点 -->
|
||||
<section v-if="autumnData" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="秋季呼伦贝尔 · 不可错过的风景" subtitle="每年9月中旬-10月中旬" />
|
||||
<CommonSectionTitle :title="st('productsAutumn.highlights', '秋季呼伦贝尔 · 不可错过的风景')" :subtitle="st('productsAutumn.highlights', '每年9月中旬-10月中旬', 'subtitle')" />
|
||||
<div class="highlights-grid">
|
||||
<div v-for="h in autumnData.highlights" :key="h.title" class="highlight-card">
|
||||
<h3 class="highlight-title">{{ h.title }}</h3>
|
||||
@@ -39,7 +39,7 @@
|
||||
<!-- 南线产品卡片 -->
|
||||
<section class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="南线 · 呼伦贝尔+阿尔山" subtitle="草原秋色与火山天池" />
|
||||
<CommonSectionTitle :title="st('productsAutumn.southLine', '南线 · 呼伦贝尔+阿尔山')" :subtitle="st('productsAutumn.southLine', '草原秋色与火山天池', 'subtitle')" />
|
||||
<div class="products-grid">
|
||||
<AutumnProductCard v-for="p in southVersions" :key="p.id" :product="p" />
|
||||
</div>
|
||||
@@ -49,7 +49,7 @@
|
||||
<!-- 南线特色体验 -->
|
||||
<section v-if="autumnData" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="南线独有体验" subtitle="阿尔山专属" />
|
||||
<CommonSectionTitle :title="st('productsAutumn.southHighlights', '南线独有体验')" :subtitle="st('productsAutumn.southHighlights', '阿尔山专属', 'subtitle')" />
|
||||
<div class="highlights-grid highlights-grid--small">
|
||||
<div v-for="h in autumnData.southHighlights" :key="h.title" class="highlight-card">
|
||||
<h3 class="highlight-title">{{ h.title }}</h3>
|
||||
@@ -62,14 +62,14 @@
|
||||
<!-- 中间转化入口 -->
|
||||
<CommonSectionCTA
|
||||
title="不确定选南线还是北线?"
|
||||
description="联系定制师,根据您的假期和偏好推荐最适合的行程"
|
||||
description="联系定制师,根据您的假期和偏好推荐合适的行程"
|
||||
:links="[{ to: '/contact', text: '联系定制师' }]"
|
||||
/>
|
||||
|
||||
<!-- 北线产品卡片 -->
|
||||
<section class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="北线 · 呼伦贝尔+大兴安岭" subtitle="深入大兴安岭腹地赏秋" />
|
||||
<CommonSectionTitle :title="st('productsAutumn.northLine', '北线 · 呼伦贝尔+大兴安岭')" :subtitle="st('productsAutumn.northLine', '深入大兴安岭腹地赏秋', 'subtitle')" />
|
||||
<div class="products-grid">
|
||||
<AutumnProductCard v-for="p in northVersions" :key="p.id" :product="p" />
|
||||
</div>
|
||||
@@ -79,7 +79,7 @@
|
||||
<!-- 北线特色体验 -->
|
||||
<section v-if="autumnData" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="北线独有体验" subtitle="大兴安岭专属" />
|
||||
<CommonSectionTitle :title="st('productsAutumn.northHighlights', '北线独有体验')" :subtitle="st('productsAutumn.northHighlights', '大兴安岭专属', 'subtitle')" />
|
||||
<div class="highlights-grid highlights-grid--small">
|
||||
<div v-for="h in autumnData.northHighlights" :key="h.title" class="highlight-card">
|
||||
<h3 class="highlight-title">{{ h.title }}</h3>
|
||||
@@ -92,7 +92,7 @@
|
||||
<!-- 选择指南 -->
|
||||
<section v-if="autumnData?.selectionGuide" id="guide" class="section section--gray reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="不知道选哪个?" subtitle="按假期长度推荐" />
|
||||
<CommonSectionTitle :title="st('productsAutumn.selection', '不知道选哪个?')" :subtitle="st('productsAutumn.selection', '按假期长度推荐', 'subtitle')" />
|
||||
<div class="guide-items">
|
||||
<div v-for="item in autumnData.selectionGuide.byVacationLength" :key="item.condition" class="guide-item">
|
||||
<div class="guide-item-top">
|
||||
@@ -127,10 +127,11 @@
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: autumnData } = await usePublicApi('autumn-products')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const seo = useSEO('products-autumn', {
|
||||
title: '游牧的森林V3 - 呼伦贝尔秋季定制游 - 呼籁旅行',
|
||||
description: '呼籁旅行呼伦贝尔秋季定制游产品「游牧的森林」V3系列,南线呼伦贝尔+阿尔山、北线呼伦贝尔+大兴安岭,4-7天6个版本可选。独立成团不拼团,一家一单一车。',
|
||||
title: '游牧的森林V3 - 呼伦贝尔秋季定制游·4-8天7版本可选 - 呼籁旅行',
|
||||
description: '呼籁旅行呼伦贝尔秋季定制游产品「游牧的森林」V3系列,南线呼伦贝尔+阿尔山、北线呼伦贝尔+大兴安岭、南北8天7晚全景版,4-8天共7个版本可选。独立成团不拼团,一家一单一车。',
|
||||
h1: '游牧的森林 · 呼伦贝尔秋季定制游',
|
||||
})
|
||||
|
||||
|
||||
+8
-6
@@ -2,7 +2,7 @@
|
||||
<div class="page-products">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景"
|
||||
:subtitle="heroes?.products || '独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景'"
|
||||
:breadcrumbs="[{ text: '产品线', to: '/products' }, { text: '额吉的故乡' }]"
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<!-- 风景大图 -->
|
||||
<div class="scenic-banner">
|
||||
<img src="/images/seasons/summer-hero.jpg" alt="夏季呼伦贝尔莫日格勒河草原全景" loading="eager" />
|
||||
<img :src="'/images/seasons/summer-hero.jpg'" alt="夏季呼伦贝尔莫日格勒河草原全景" loading="eager" />
|
||||
</div>
|
||||
|
||||
<!-- 产品设计理念 -->
|
||||
@@ -26,7 +26,7 @@
|
||||
<!-- 产品卡片 -->
|
||||
<section v-if="products" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="额吉的故乡V9系列" subtitle="5-7天多版本行程" />
|
||||
<CommonSectionTitle :title="st('products.v9Series', '额吉的故乡V9系列')" :subtitle="st('products.v9Series', '5-7天多版本行程', 'subtitle')" />
|
||||
<div class="products-grid">
|
||||
<ProductsProductCard v-for="p in products.versions" :key="p.id" :product="p" />
|
||||
</div>
|
||||
@@ -35,14 +35,14 @@
|
||||
|
||||
<!-- 夏季风景 -->
|
||||
<div class="scenic-duo reveal">
|
||||
<img src="/images/seasons/summer-river.jpg" alt="夏季草原蒙古包莫日格勒河全景" loading="lazy" />
|
||||
<img src="/images/seasons/summer-car.jpg" alt="呼伦贝尔一单一车草原越野" loading="lazy" />
|
||||
<img :src="'/images/seasons/summer-river.jpg'" alt="夏季草原蒙古包莫日格勒河全景" loading="lazy" />
|
||||
<img :src="'/images/seasons/summer-car.jpg'" alt="呼伦贝尔一单一车草原越野" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<!-- 中间转化入口 -->
|
||||
<CommonSectionCTA
|
||||
title="不确定选哪个版本?"
|
||||
description="5个问题,1分钟,找到最适合你家庭的行程版本"
|
||||
description="5个问题,1分钟,找到适合你家庭的行程版本"
|
||||
:links="[{ to: '/selector', text: '使用选版工具' }, { to: '/contact', text: '联系定制师' }]"
|
||||
/>
|
||||
|
||||
@@ -92,7 +92,9 @@
|
||||
import { useProductsListSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: products } = await usePublicApi('products')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const { data: versions } = await usePublicApi('versions')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const seo = useSEO('products')
|
||||
useScrollReveal()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="page-winter">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景"
|
||||
:subtitle="heroes?.['products-winter'] || '独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景'"
|
||||
:breadcrumbs="[{ text: '产品线', to: '/products' }, { text: '嗨冰雪' }]"
|
||||
background-image="/images/seasons/winter-hero.jpg"
|
||||
/>
|
||||
@@ -22,7 +22,7 @@
|
||||
<!-- 产品卡片 -->
|
||||
<section v-if="winterData" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="嗨冰雪V3系列" subtitle="5-7天南北两线,专属私家团" />
|
||||
<CommonSectionTitle :title="st('productsWinter.versions', '嗨冰雪V3系列')" :subtitle="st('productsWinter.versions', '5-7天南北两线,专属私家团', 'subtitle')" />
|
||||
<div class="products-grid">
|
||||
<WinterProductCard v-for="p in winterData.versions" :key="p.id" :product="p" />
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
<!-- 共同亮点 -->
|
||||
<section v-if="winterData" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="冬季行程亮点" subtitle="每一条线路都会体验到" />
|
||||
<CommonSectionTitle :title="st('productsWinter.highlights', '冬季行程亮点')" :subtitle="st('productsWinter.highlights', '每一条线路都会体验到', 'subtitle')" />
|
||||
<div class="highlights-grid">
|
||||
<div v-for="(h, i) in winterData.highlights" :key="i" class="highlight-card">
|
||||
<h3 class="highlight-title">{{ h.title }}</h3>
|
||||
@@ -52,7 +52,7 @@
|
||||
<!-- 南线特色 -->
|
||||
<section v-if="winterData" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="南线特色 · 呼伦贝尔+阿尔山" subtitle="大雪原与童话小城的碰撞" />
|
||||
<CommonSectionTitle :title="st('productsWinter.south', '南线特色 · 呼伦贝尔+阿尔山')" :subtitle="st('productsWinter.south', '大雪原与童话小城的碰撞', 'subtitle')" />
|
||||
<div class="highlights-grid">
|
||||
<div v-for="(h, i) in winterData.southHighlights" :key="i" class="highlight-card">
|
||||
<h3 class="highlight-title">{{ h.title }}</h3>
|
||||
@@ -65,7 +65,7 @@
|
||||
<!-- 北线特色 -->
|
||||
<section v-if="winterData" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="北线特色 · 呼伦贝尔+大兴安岭" subtitle="边境风情与驯鹿部落的秘境" />
|
||||
<CommonSectionTitle :title="st('productsWinter.north', '北线特色 · 呼伦贝尔+大兴安岭')" :subtitle="st('productsWinter.north', '边境风情与驯鹿部落的秘境', 'subtitle')" />
|
||||
<div class="highlights-grid">
|
||||
<div v-for="(h, i) in winterData.northHighlights" :key="i" class="highlight-card">
|
||||
<h3 class="highlight-title">{{ h.title }}</h3>
|
||||
@@ -88,6 +88,8 @@
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: winterData } = await usePublicApi('winter-products')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const seo = useSEO('products-winter', {
|
||||
title: '嗨冰雪 - 呼伦贝尔冬季定制游 · 南北两线5-7天 - 呼籁旅行',
|
||||
|
||||
+5
-3
@@ -2,7 +2,7 @@
|
||||
<div class="page-qualifications">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1 || '资质荣誉'"
|
||||
subtitle="三重旅行社资质 · 1000万责任险 · 行业认证"
|
||||
:subtitle="heroes?.qualifications || '三重旅行社资质 · 1000万责任险 · 行业认证'"
|
||||
:breadcrumbs="[{ text: '资质荣誉' }]"
|
||||
background-image="/images/seasons/summer-hero.jpg"
|
||||
/>
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- 服务保障 -->
|
||||
<section v-if="about" class="section" aria-label="服务保障">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="服务保障" subtitle="三重旅行社资质 · 1000万责任险 · 正规电子合同 · 全程无购物" />
|
||||
<CommonSectionTitle :title="st('qualifications.safety', '服务保障')" :subtitle="st('qualifications.safety', '三重旅行社资质 · 1000万责任险 · 正规电子合同 · 全程无购物', 'subtitle')" />
|
||||
|
||||
<!-- 保障要点 -->
|
||||
<div class="guarantee-grid">
|
||||
@@ -80,7 +80,7 @@
|
||||
<!-- 荣誉认证 -->
|
||||
<section v-if="about" class="section section--green" aria-label="荣誉与认证">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="荣誉与认证" subtitle="行业机构和平台对呼籁服务质量的认可" />
|
||||
<CommonSectionTitle :title="st('qualifications.awards', '荣誉与认证')" :subtitle="st('qualifications.awards', '行业机构和平台对呼籁服务质量的认可', 'subtitle')" />
|
||||
<div class="awards-grid">
|
||||
<div v-for="award in about.awards" :key="award.title" class="award-card">
|
||||
<div class="award-card__year">{{ award.year }}</div>
|
||||
@@ -108,8 +108,10 @@
|
||||
import { useOrganizationSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: qualData } = await usePublicApi('qualifications')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
// qualifications key 用 safetyGuarantees,前端模板用 guarantees,做一层适配
|
||||
const about = computed(() => {
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
<div class="page-reviews">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="来自真实家庭的旅行评价"
|
||||
:subtitle="heroes?.reviews || '来自真实家庭的旅行评价'"
|
||||
:breadcrumbs="[{ text: '客户评价' }]"
|
||||
background-image="/images/summer-camp/camp-graduation.jpg"
|
||||
/>
|
||||
@@ -80,6 +80,7 @@ import { ref, computed } from 'vue'
|
||||
import { useBreadcrumbSchema, useAggregateRatingSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: reviews } = await usePublicApi('reviews')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
|
||||
const seo = useSEO('reviews')
|
||||
useScrollReveal()
|
||||
|
||||
+4
-3
@@ -2,7 +2,7 @@
|
||||
<div class="page-selector">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="5个问题 · 1分钟 · 找到最适合你的版本"
|
||||
:subtitle="heroes?.selector || '5个问题 · 1分钟 · 找到适合你的版本'"
|
||||
:breadcrumbs="[{ text: '产品线', to: '/products' }, { text: '选版工具' }]"
|
||||
background-image="/images/seasons/summer-hero.jpg"
|
||||
/>
|
||||
@@ -11,7 +11,7 @@
|
||||
<section class="page-tldr" aria-label="选版工具摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼伦贝尔行程选版工具:回答5个简单问题,1分钟内为你推荐最适合的产品版本。根据出行天数、人群、偏好智能匹配。</p>
|
||||
<p>呼伦贝尔行程选版工具:回答5个简单问题,1分钟内为你推荐合适的产品版本。根据出行天数、人群、偏好智能匹配。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -161,6 +161,7 @@
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: selectorData } = await usePublicApi('selector')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const { data: productsData } = await usePublicApi('products')
|
||||
|
||||
const seo = useSEO('selector')
|
||||
@@ -175,7 +176,7 @@ useHead({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Quiz',
|
||||
name: '呼伦贝尔行程版本选择工具',
|
||||
description: '5个问题帮你找到最适合的呼伦贝尔行程版本。根据出行天数、人群、孩子年龄、预算和偏好,从额吉的故乡V9系列6个版本中精准推荐。',
|
||||
description: '5个问题帮你找到合适的呼伦贝尔行程版本。根据出行天数、人群、孩子年龄、预算和偏好,从额吉的故乡V9系列6个版本中精准推荐。',
|
||||
url: 'https://1814.love/selector',
|
||||
about: {
|
||||
'@type': 'TouristTrip',
|
||||
|
||||
+50
-12
@@ -2,7 +2,7 @@
|
||||
<div class="page-stories">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="来自小红书的真实旅行分享"
|
||||
:subtitle="heroes?.stories || '来自小红书的真实旅行分享'"
|
||||
:breadcrumbs="[{ text: '客户故事' }]"
|
||||
background-image="/images/summer-camp/birch-group.jpg"
|
||||
/>
|
||||
@@ -26,16 +26,16 @@
|
||||
:key="story.id"
|
||||
v-bind="getCardProps(story)"
|
||||
class="story-card reveal"
|
||||
:class="{ 'story-card--no-image': !story.coverImage }"
|
||||
>
|
||||
<div class="story-card__visual">
|
||||
<!-- 有封面图:显示图片区 -->
|
||||
<div v-if="story.coverImage" class="story-card__visual">
|
||||
<img
|
||||
v-if="story.coverImage"
|
||||
:src="story.coverImage"
|
||||
:alt="story.title"
|
||||
class="story-card__img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="story-card__img-placeholder" />
|
||||
<!-- 小红书外链角标 -->
|
||||
<span v-if="story.sourceUrl" class="story-card__badge">
|
||||
<svg class="story-card__badge-icon" viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
@@ -43,14 +43,20 @@
|
||||
</svg>
|
||||
小红书
|
||||
</span>
|
||||
<!-- 原创故事标记 -->
|
||||
<span v-else-if="story.content" class="story-card__badge story-card__badge--original">
|
||||
原创故事
|
||||
</span>
|
||||
<!-- 家庭类型 -->
|
||||
<span class="story-card__family">{{ story.familyType }}</span>
|
||||
<span v-if="story.familyType" class="story-card__family">{{ story.familyType }}</span>
|
||||
</div>
|
||||
<div class="story-card__body">
|
||||
<!-- 无封面图时:家庭类型 + 来源标签在正文上方 -->
|
||||
<div v-if="!story.coverImage" class="story-card__meta-top">
|
||||
<span v-if="story.familyType" class="story-card__family story-card__family--inline">
|
||||
{{ story.familyType }}
|
||||
</span>
|
||||
<span v-if="story.sourceUrl" class="story-card__badge story-card__badge--inline">小红书</span>
|
||||
<span v-else-if="story.content" class="story-card__badge story-card__badge--inline story-card__badge--original">原创故事</span>
|
||||
</div>
|
||||
<h2 class="story-card__title">{{ story.title }}</h2>
|
||||
<p class="story-card__desc">{{ story.subtitle || story.summary }}</p>
|
||||
<div class="story-card__meta">
|
||||
@@ -89,6 +95,7 @@ import { resolveComponent } from 'vue'
|
||||
import { useOrganizationSchema, useBreadcrumbSchema, useStoriesListSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: storiesData } = await usePublicApi('stories')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
|
||||
@@ -103,8 +110,12 @@ useBreadcrumbSchema([
|
||||
|
||||
const stories = computed(() => storiesData.value?.stories || [])
|
||||
|
||||
// 过滤掉占位数据:卡片可见字段(title/subtitle/summary)包含 ⚠️ 视为未替换
|
||||
const readyStories = computed(() =>
|
||||
stories.value.filter(s => s.summary && !s.summary.includes('⚠️'))
|
||||
stories.value.filter(s => {
|
||||
const visible = [s.title, s.subtitle, s.summary].filter(Boolean).join(' ')
|
||||
return !visible.includes('⚠️')
|
||||
})
|
||||
)
|
||||
|
||||
// 占位内容未替换时,阻止搜索引擎索引空页面
|
||||
@@ -186,10 +197,37 @@ function getCardProps(story) {
|
||||
// 不固定高度,让图片保持原始比例实现瀑布流效果
|
||||
}
|
||||
|
||||
&__img-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: @color-primary-bg;
|
||||
// 无封面图时的卡片样式:加厚正文 padding,左侧加强调色带
|
||||
&--no-image {
|
||||
border-left: 4px solid @color-primary;
|
||||
background: linear-gradient(180deg, @color-primary-bg 0%, @color-bg-white 140px);
|
||||
|
||||
.story-card__body {
|
||||
padding: @space-lg @space-md @space-md;
|
||||
}
|
||||
|
||||
.story-card__title {
|
||||
font-size: @font-size-lg;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta-top {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
&__family--inline {
|
||||
position: static;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: @color-text-secondary;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
&__badge--inline {
|
||||
position: static;
|
||||
}
|
||||
|
||||
// 角标(小红书/原创)
|
||||
|
||||
+8
-6
@@ -2,7 +2,7 @@
|
||||
<div class="page-summer-camp">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="呼伦贝尔草原亲子游学营·6天5晚·每期7-9组家庭·5-12岁"
|
||||
:subtitle="heroes?.['summer-camp'] || '呼伦贝尔草原亲子游学营·6天5晚·每期7-9组家庭·5-12岁'"
|
||||
:breadcrumbs="[{ text: '小蒙马亲子游学营' }]"
|
||||
background-image="/images/summer-camp/river-banner.jpg"
|
||||
/>
|
||||
@@ -114,7 +114,7 @@
|
||||
<!-- 四大原则 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="行程设计四大原则" subtitle="每一处安排都经过精心考量" />
|
||||
<CommonSectionTitle :title="st('summerCamp.principles', '行程设计四大原则')" :subtitle="st('summerCamp.principles', '每一处安排都经过精心考量', 'subtitle')" />
|
||||
<div class="principles-grid">
|
||||
<div v-for="(p, i) in camp.principles" :key="i" class="principle-card">
|
||||
<div class="principle-index">{{ String(i + 1).padStart(2, '0') }}</div>
|
||||
@@ -131,7 +131,7 @@
|
||||
<!-- 行程概览 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="6天行程概览" subtitle="不赶路、不折腾,每一天都有记忆点" />
|
||||
<CommonSectionTitle :title="st('summerCamp.itinerary', '6天行程概览')" :subtitle="st('summerCamp.itinerary', '不赶路、不折腾,每一天都有记忆点', 'subtitle')" />
|
||||
<div class="itinerary-list">
|
||||
<div v-for="(day, i) in camp.itinerary" :key="i" class="itinerary-item">
|
||||
<div class="itinerary-day">D{{ day.day || i + 1 }}</div>
|
||||
@@ -148,7 +148,7 @@
|
||||
<!-- 核心体验 -->
|
||||
<section v-if="camp" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="行程核心体验" subtitle="草原写生·非遗研学·民俗体验,每一天都有收获" />
|
||||
<CommonSectionTitle :title="st('summerCamp.activities', '行程核心体验')" :subtitle="st('summerCamp.activities', '草原写生·非遗研学·民俗体验,每一天都有收获', 'subtitle')" />
|
||||
<div class="activities-grid">
|
||||
<div v-for="(activity, i) in camp.coreActivities" :key="i" class="activity-card">
|
||||
<div class="activity-index">{{ String(i + 1).padStart(2, '0') }}</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
<!-- 与V9的区别 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="小蒙马亲子游学营 vs 额吉的故乡" subtitle="两种产品,两种精彩" />
|
||||
<CommonSectionTitle :title="st('summerCamp.compare', '小蒙马亲子游学营 vs 额吉的故乡')" :subtitle="st('summerCamp.compare', '两种产品,两种精彩', 'subtitle')" />
|
||||
<div class="compare-cards">
|
||||
<div class="compare-card compare-card--camp">
|
||||
<h3>小蒙马亲子游学营</h3>
|
||||
@@ -198,7 +198,7 @@
|
||||
<!-- 家长常见问题(可折叠) -->
|
||||
<section class="section section--gray">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="家长常见问题" />
|
||||
<CommonSectionTitle :title="st('summerCamp.faq', '家长常见问题')" />
|
||||
<div class="camp-faq">
|
||||
<FaqItem
|
||||
v-for="(item, i) in campFAQ"
|
||||
@@ -228,6 +228,8 @@ import { useBreadcrumbSchema, useSummerCampSchema } from '~/composables/useJsonL
|
||||
|
||||
const { data: camp } = await usePublicApi('summer-camp')
|
||||
const { data: images } = await usePublicApi('images')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const seo = useSEO('summer-camp')
|
||||
useScrollReveal()
|
||||
|
||||
+14
-12
@@ -2,7 +2,7 @@
|
||||
<div class="page-winter-camp">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="呼伦贝尔雪原亲子冬令营·7天6晚·每期8组家庭"
|
||||
:subtitle="heroes?.['winter-camp'] || '呼伦贝尔雪原亲子冬令营·7天6晚·每期8组家庭'"
|
||||
:breadcrumbs="[{ text: '小蒙马冬季营' }]"
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<!-- 风景大图 -->
|
||||
<div class="scenic-banner">
|
||||
<img src="/images/seasons/winter-yurt.jpg" alt="冬季呼伦贝尔雪原蒙古包" loading="eager" />
|
||||
<img :src="'/images/seasons/winter-yurt.jpg'" alt="冬季呼伦贝尔雪原蒙古包" loading="eager" />
|
||||
</div>
|
||||
|
||||
<!-- 核心定位 -->
|
||||
@@ -50,7 +50,7 @@
|
||||
<!-- 为什么选呼伦贝尔 -->
|
||||
<section v-if="camp" class="section section--ice reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="为什么是呼伦贝尔?" subtitle="不是更冷,而是更纯粹" />
|
||||
<CommonSectionTitle :title="st('winterCamp.whyHulunbuir', '为什么是呼伦贝尔?')" :subtitle="st('winterCamp.whyHulunbuir', '不是更冷,而是更纯粹', 'subtitle')" />
|
||||
<div class="why-block">
|
||||
<p>{{ camp.whyHulunbuir }}</p>
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@
|
||||
<!-- 亲子营优势 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="亲子营的优势" subtitle="和独立小包团相比" />
|
||||
<CommonSectionTitle :title="st('winterCamp.advantages', '亲子营的优势')" :subtitle="st('winterCamp.advantages', '和独立小包团相比', 'subtitle')" />
|
||||
<div class="advantages-grid">
|
||||
<div v-for="(adv, i) in camp.campAdvantages" :key="i" class="advantage-card">
|
||||
<span class="advantage-check">✓</span>
|
||||
@@ -80,7 +80,7 @@
|
||||
<!-- 住宿保障 -->
|
||||
<section v-if="camp" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="住宿安排" subtitle="住得舒服,玩得才能惬意" />
|
||||
<CommonSectionTitle :title="st('winterCamp.hotels', '住宿安排')" :subtitle="st('winterCamp.hotels', '住得舒服,玩得才能惬意', 'subtitle')" />
|
||||
<div class="hotels-grid">
|
||||
<div v-for="(hotel, i) in camp.hotels" :key="i" class="hotel-card">
|
||||
<div class="hotel-star">{{ hotel.star }}</div>
|
||||
@@ -95,7 +95,7 @@
|
||||
<!-- 7天行程 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="7天行程概览" subtitle="不赶路、不折腾,每一天都有记忆点" />
|
||||
<CommonSectionTitle :title="st('winterCamp.itinerary', '7天行程概览')" :subtitle="st('winterCamp.itinerary', '不赶路、不折腾,每一天都有记忆点', 'subtitle')" />
|
||||
<div class="itinerary-list">
|
||||
<details v-for="day in camp.itinerary" :key="day.day" class="itinerary-item" :open="day.day <= 2">
|
||||
<summary class="itinerary-header">
|
||||
@@ -116,14 +116,14 @@
|
||||
|
||||
<!-- 冬季风景 -->
|
||||
<div class="scenic-duo reveal">
|
||||
<img src="/images/seasons/winter-yurt.jpg" alt="冬季雪原蒙古包日落" loading="lazy" />
|
||||
<img src="/images/seasons/winter-frost.jpg" alt="大兴安岭雾凇森林" loading="lazy" />
|
||||
<img :src="'/images/seasons/winter-yurt.jpg'" alt="冬季雪原蒙古包日落" loading="lazy" />
|
||||
<img :src="'/images/seasons/winter-frost.jpg'" alt="大兴安岭雾凇森林" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<!-- 摄影师 -->
|
||||
<section v-if="camp" class="section section--ice reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="随行摄影师" subtitle="不错过每一个自然的瞬间" />
|
||||
<CommonSectionTitle :title="st('winterCamp.photographer', '随行摄影师')" :subtitle="st('winterCamp.photographer', '不错过每一个自然的瞬间', 'subtitle')" />
|
||||
<div class="photographer-block">
|
||||
<h3>摄影师:{{ camp.photographer.name }}</h3>
|
||||
<p>{{ camp.photographer.description }}</p>
|
||||
@@ -135,7 +135,7 @@
|
||||
<!-- 穿衣指南 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="冬季穿衣指南" subtitle="穿对了,冷也是一种享受" />
|
||||
<CommonSectionTitle :title="st('winterCamp.clothing', '冬季穿衣指南')" :subtitle="st('winterCamp.clothing', '穿对了,冷也是一种享受', 'subtitle')" />
|
||||
<div class="clothing-grid">
|
||||
<div class="clothing-card">
|
||||
<h4>👕 上身</h4>
|
||||
@@ -160,7 +160,7 @@
|
||||
<!-- 随行服务 -->
|
||||
<section v-if="camp" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="随行服务配置" subtitle="比夏季更谨慎、更细致" />
|
||||
<CommonSectionTitle :title="st('winterCamp.serviceConfig', '随行服务配置')" :subtitle="st('winterCamp.serviceConfig', '比夏季更谨慎、更细致', 'subtitle')" />
|
||||
<div class="service-grid">
|
||||
<div v-for="(s, i) in camp.serviceConfig" :key="i" class="service-item">
|
||||
<span class="service-icon">👤</span>
|
||||
@@ -173,7 +173,7 @@
|
||||
<!-- 家长FAQ -->
|
||||
<section v-if="camp?.faq" class="section section--gray">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="家长常见问题" />
|
||||
<CommonSectionTitle :title="st('winterCamp.faq', '家长常见问题')" />
|
||||
<div class="camp-faq">
|
||||
<FaqItem
|
||||
v-for="(item, i) in camp.faq"
|
||||
@@ -212,6 +212,8 @@ import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: camp } = await usePublicApi('winter-camp')
|
||||
const { data: images } = await usePublicApi('images')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
const st = await useSectionTitles()
|
||||
|
||||
const seo = useSEO('winter-camp', {
|
||||
title: '小蒙马冬季亲子营 - 呼伦贝尔雪原冬令营·7天6晚·每期8组家庭 - 呼籁旅行',
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<!-- 小红书账号卡 -->
|
||||
<div class="sidebar-card sidebar-card--xhs">
|
||||
<div class="sidebar-card__account">
|
||||
<img src="/images/xiaohongshu/profile.jpg" alt="呼籁旅行" class="sidebar-card__avatar" />
|
||||
<img :src="'/images/xiaohongshu/profile.jpg'" alt="呼籁旅行" class="sidebar-card__avatar" />
|
||||
<div>
|
||||
<h3 class="sidebar-card__name">{{ wallData.account.name }}</h3>
|
||||
<span class="sidebar-card__badge">蓝V认证 · 旅行社</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="page-xiaohongshu">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1 || '小红书上的呼籁'"
|
||||
subtitle="来自真实客户的旅行笔记,点击查看原文"
|
||||
:subtitle="heroes?.xiaohongshu || '来自真实客户的旅行笔记,点击查看原文'"
|
||||
:breadcrumbs="[{ text: '小红书游记' }]"
|
||||
background-image="/images/seasons/summer-river.jpg"
|
||||
/>
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="account-card__inner">
|
||||
<div class="account-card__left">
|
||||
<div class="account-avatar">
|
||||
<img src="/images/xiaohongshu/profile.jpg" alt="呼籁旅行小红书头像" />
|
||||
<img :src="'/images/xiaohongshu/profile.jpg'" alt="呼籁旅行小红书头像" />
|
||||
<span class="account-avatar__badge">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
</span>
|
||||
@@ -164,6 +164,7 @@ import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
import { proxyImage } from '~/composables/useImageProxy'
|
||||
|
||||
const { data: wallData } = await usePublicApi('xiaohongshu-wall')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
|
||||
const seo = useSEO('xiaohongshu')
|
||||
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
<div class="page-youji">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1 || '客户游记'"
|
||||
subtitle="来自各平台的真实旅行故事,点击查看详情"
|
||||
:subtitle="heroes?.youji || '来自各平台的真实旅行故事,点击查看详情'"
|
||||
:breadcrumbs="[{ text: '客户游记' }]"
|
||||
background-image="/images/summer-camp/river-banner.jpg"
|
||||
/>
|
||||
@@ -139,6 +139,7 @@ import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
import { proxyImage } from '~/composables/useImageProxy'
|
||||
|
||||
const { data: youjiData } = await usePublicApi('youji')
|
||||
const { data: heroes } = await usePublicApi('heroes')
|
||||
|
||||
const seo = useSEO('youji')
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户