feat: 官网代码全量更新 (2026-04-18)

这个提交包含在:
wx
2026-04-18 15:13:28 +08:00
父节点 fcac8f88db
当前提交 f91ecbb093
共修改 135 个文件,包含 10307 行新增和 3349 行删除
+39 -1
查看文件
@@ -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' })
+105 -19
查看文件
@@ -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;">&rsaquo;</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;"
>&rsaquo;</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>
+283
查看文件
@@ -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 {
+117
查看文件
@@ -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>
+10 -1
查看文件
@@ -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
}
+254
查看文件
@@ -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>