fix: 后台编辑器字段全覆盖 + 新建 about/versions/pricing 专用编辑器

- tour 编辑器补全:selectionGuide 3维度、isMainProduct、timeline 正确结构
- camp 编辑器补全:name、campEssentials
- calendar 补全:quickRecommendations、travelAdvice、recommendedProducts
- guides 补全:穿搭章节 months[] 每月数据
- brand 补全:ctaButtons、conversionPath、icpUrl、eContract
- qualifications 补全:enterpriseMatrix
- youji 补全:notes[].tags
- gallery 补全:页面配置(intro/stats/categories)
- 新建 about.vue 专用编辑器(14个分区)
- 新建 versions.vue 专用编辑器(8个分区)
- 新建 pricing.vue 专用编辑器(5个分区)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
这个提交包含在:
Mimingguang 2026-03-25 09:06:50 +08:00
父节点 ee078b61bc
当前提交 810ae2724d
共有 11 个文件被更改,包括 1136 次插入19 次删除

查看文件

@ -0,0 +1 @@
{"sessionId":"c823a359-416f-43c5-8fad-22f6a5ddd8bc","pid":59167,"acquiredAt":1774346188338}

查看文件

@ -0,0 +1,423 @@
<template>
<div style="max-width: 1100px">
<n-space justify="space-between" align="center" style="margin-bottom: 20px">
<div>
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827">关于我们</h1>
<p style="margin: 0; font-size: 13px; color: #9ca3af">管理关于页面的所有内容品牌故事团队资质荣誉等</p>
</div>
<n-space>
<n-button tag="a" href="/about" target="_blank" secondary>查看前台 </n-button>
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
</n-space>
</n-space>
<n-spin :show="!loaded">
<n-collapse :default-expanded-names="['story']">
<!-- story -->
<n-collapse-item title="品牌故事" name="story">
<n-card>
<n-form label-placement="top">
<n-form-item label="标题">
<n-input v-model:value="data.story.title" />
</n-form-item>
<n-form-item label="段落">
<n-dynamic-input v-model:value="data.story.paragraphs" :min="1">
<template #default="{ value, index }">
<n-input
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:value="value"
@update:value="v => data.story.paragraphs[index] = v"
placeholder="段落内容"
style="width: 100%"
/>
</template>
</n-dynamic-input>
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- serviceJourney -->
<n-collapse-item title="服务旅程" name="serviceJourney">
<n-card>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="标题"><n-input v-model:value="data.serviceJourney.title" /></n-form-item></n-gi>
<n-gi><n-form-item label="副标题"><n-input v-model:value="data.serviceJourney.subtitle" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="服务时刻">
<div style="width: 100%">
<div v-for="(m, i) in data.serviceJourney.moments" :key="i" style="display: flex; gap: 8px; align-items: flex-start; margin-bottom: 8px">
<n-input-number v-model:value="m.step" placeholder="步骤" style="width: 80px" :min="1" />
<n-input v-model:value="m.name" placeholder="名称" style="width: 140px" />
<n-input v-model:value="m.detail" placeholder="详情" style="flex: 1" />
<n-button size="tiny" type="error" quaternary @click="data.serviceJourney.moments.splice(i, 1)"></n-button>
</div>
<n-button size="small" type="primary" dashed @click="data.serviceJourney.moments.push({ step: data.serviceJourney.moments.length + 1, name: '', detail: '' })">添加时刻</n-button>
</div>
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- team -->
<n-collapse-item title="团队" name="team">
<n-card>
<n-form label-placement="top">
<n-form-item label="团队介绍">
<n-input v-model:value="data.team.summary" type="textarea" :autosize="{ minRows: 3, maxRows: 8 }" />
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- heritage -->
<n-collapse-item title="成长纪事" name="heritage">
<n-card>
<n-form label-placement="top">
<n-form-item label="标题">
<n-input v-model:value="data.heritage.title" />
</n-form-item>
<n-form-item label="里程碑">
<div style="width: 100%">
<n-card v-for="(ms, i) in data.heritage.milestones" :key="i" size="small" style="margin-bottom: 12px">
<template #header>
<n-space align="center">
<n-input v-model:value="ms.year" placeholder="年份" style="width: 100px" />
<n-button size="tiny" type="error" quaternary @click="data.heritage.milestones.splice(i, 1)">删除此年</n-button>
</n-space>
</template>
<n-dynamic-input v-model:value="ms.paragraphs" :min="1">
<template #default="{ value, index }">
<n-input
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:value="value"
@update:value="v => ms.paragraphs[index] = v"
placeholder="段落内容"
style="width: 100%"
/>
</template>
</n-dynamic-input>
</n-card>
<n-button size="small" type="primary" dashed @click="data.heritage.milestones.push({ year: '', paragraphs: [''] })">添加年份</n-button>
</div>
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- culture -->
<n-collapse-item title="企业文化" name="culture">
<n-card>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="核心价值观"><n-input v-model:value="data.culture.core" /></n-form-item></n-gi>
<n-gi><n-form-item label="信念"><n-input v-model:value="data.culture.belief" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="价值观">
<div style="width: 100%">
<n-card v-for="(val, i) in data.culture.values" :key="i" size="small" style="margin-bottom: 12px">
<template #header>
<n-space align="center">
<n-input v-model:value="val.name" placeholder="名称" style="width: 120px" />
<n-button size="tiny" type="error" quaternary @click="data.culture.values.splice(i, 1)">删除</n-button>
</n-space>
</template>
<n-dynamic-input v-model:value="val.points" :min="1">
<template #default="{ value, index }">
<n-input
type="textarea"
:autosize="{ minRows: 1, maxRows: 3 }"
:value="value"
@update:value="v => val.points[index] = v"
placeholder="要点"
style="width: 100%"
/>
</template>
</n-dynamic-input>
</n-card>
<n-button size="small" type="primary" dashed @click="data.culture.values.push({ name: '', points: [''] })">添加价值观</n-button>
</div>
</n-form-item>
<n-form-item label="Slogan">
<div style="width: 100%">
<div v-for="(line, i) in data.culture.slogan.lines" :key="i" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px">
<n-input v-model:value="line.label" placeholder="标签" style="width: 100px" />
<n-input v-model:value="line.text" placeholder="文字" style="flex: 1" />
<n-button size="tiny" type="error" quaternary @click="data.culture.slogan.lines.splice(i, 1)"></n-button>
</div>
<n-button size="small" type="primary" dashed @click="data.culture.slogan.lines.push({ label: '', text: '' })">添加行</n-button>
</div>
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- xiaohongshu -->
<n-collapse-item title="小红书" name="xiaohongshu">
<n-card>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="账号"><n-input v-model:value="data.xiaohongshu.account" /></n-form-item></n-gi>
<n-gi><n-form-item label="认证类型"><n-input v-model:value="data.xiaohongshu.verifiedType" /></n-form-item></n-gi>
<n-gi><n-form-item label="粉丝数"><n-input v-model:value="data.xiaohongshu.followers" /></n-form-item></n-gi>
<n-gi><n-form-item label="点赞数"><n-input v-model:value="data.xiaohongshu.likes" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="一句话介绍">
<n-input v-model:value="data.xiaohongshu.tagline" />
</n-form-item>
<n-form-item label="描述">
<n-input v-model:value="data.xiaohongshu.description" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" />
</n-form-item>
<n-form-item label="标签">
<n-dynamic-input v-model:value="data.xiaohongshu.tags" :min="1" />
</n-form-item>
<n-form-item label="荣誉">
<n-dynamic-input v-model:value="data.xiaohongshu.awards" :min="0" />
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- subsidiaries -->
<n-collapse-item title="子公司" name="subsidiaries">
<n-card>
<div style="width: 100%">
<n-card v-for="(sub, i) in data.subsidiaries" :key="i" size="small" style="margin-bottom: 12px">
<template #header>
<n-space align="center">
<span style="font-size: 13px; color: #6b7280">{{ i + 1 }}.</span>
<n-button size="tiny" type="error" quaternary @click="data.subsidiaries.splice(i, 1)">删除</n-button>
</n-space>
</template>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="名称"><n-input v-model:value="sub.name" /></n-form-item></n-gi>
<n-gi><n-form-item label="成立时间"><n-input v-model:value="sub.established" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="角色">
<n-input v-model:value="sub.role" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" />
</n-form-item>
</n-form>
</n-card>
<n-button size="small" type="primary" dashed @click="data.subsidiaries.push({ name: '', role: '', established: '' })">添加子公司</n-button>
</div>
</n-card>
</n-collapse-item>
<!-- trademark -->
<n-collapse-item title="商标" name="trademark">
<n-card>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="商标名"><n-input v-model:value="data.trademark.name" /></n-form-item></n-gi>
<n-gi><n-form-item label="注册范围"><n-input v-model:value="data.trademark.scope" /></n-form-item></n-gi>
<n-gi :span="2"><n-form-item label="持有者"><n-input v-model:value="data.trademark.holder" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="描述">
<n-input v-model:value="data.trademark.description" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" />
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- copyrights -->
<n-collapse-item title="版权" name="copyrights">
<n-card>
<div style="width: 100%">
<n-card v-for="(c, i) in data.copyrights" :key="i" size="small" style="margin-bottom: 12px">
<template #header>
<n-space align="center">
<span style="font-size: 13px; color: #6b7280">{{ c.name || '新版权' }}</span>
<n-button size="tiny" type="error" quaternary @click="data.copyrights.splice(i, 1)">删除</n-button>
</n-space>
</template>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="作品名"><n-input v-model:value="c.name" /></n-form-item></n-gi>
<n-gi><n-form-item label="登记号"><n-input v-model:value="c.regNo" /></n-form-item></n-gi>
<n-gi><n-form-item label="类别"><n-input v-model:value="c.category" /></n-form-item></n-gi>
<n-gi><n-form-item label="登记日期"><n-input v-model:value="c.date" /></n-form-item></n-gi>
<n-gi :span="2"><n-form-item label="持有者"><n-input v-model:value="c.holder" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="描述">
<n-input v-model:value="c.description" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" />
</n-form-item>
</n-form>
</n-card>
<n-button size="small" type="primary" dashed @click="data.copyrights.push({ name: '', regNo: '', category: '', holder: '', date: '', description: '' })">添加版权</n-button>
</div>
</n-card>
</n-collapse-item>
<!-- founder -->
<n-collapse-item title="创始人" name="founder">
<n-card>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="姓名"><n-input v-model:value="data.founder.name" /></n-form-item></n-gi>
<n-gi><n-form-item label="头衔"><n-input v-model:value="data.founder.title" /></n-form-item></n-gi>
<n-gi><n-form-item label="深耕年限"><n-input v-model:value="data.founder.yearsInHulunbuir" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="背景">
<n-input v-model:value="data.founder.background" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" />
</n-form-item>
<n-form-item label="专长">
<n-dynamic-input v-model:value="data.founder.expertise" :min="1" />
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- guarantees -->
<n-collapse-item title="保障承诺" name="guarantees">
<n-card>
<div style="width: 100%">
<div v-for="(g, i) in data.guarantees" :key="i" style="margin-bottom: 12px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px">
<n-space justify="space-between" style="margin-bottom: 8px">
<span style="font-size: 13px; font-weight: 500; color: #374151">{{ i + 1 }}.</span>
<n-button size="tiny" type="error" quaternary @click="data.guarantees.splice(i, 1)">删除</n-button>
</n-space>
<n-form label-placement="top">
<n-form-item label="标题"><n-input v-model:value="g.title" /></n-form-item>
<n-form-item label="详情"><n-input v-model:value="g.detail" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" /></n-form-item>
</n-form>
</div>
<n-button size="small" type="primary" dashed @click="data.guarantees.push({ title: '', detail: '' })">添加保障</n-button>
</div>
</n-card>
</n-collapse-item>
<!-- licenses -->
<n-collapse-item title="许可证" name="licenses">
<n-card>
<div style="width: 100%">
<n-card v-for="(l, i) in data.licenses" :key="i" size="small" style="margin-bottom: 12px">
<template #header>
<n-space align="center">
<span style="font-size: 13px; color: #6b7280">{{ l.licenseNo || '新许可证' }}</span>
<n-button size="tiny" type="error" quaternary @click="data.licenses.splice(i, 1)">删除</n-button>
</n-space>
</template>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="许可证号"><n-input v-model:value="l.licenseNo" /></n-form-item></n-gi>
<n-gi><n-form-item label="持有者"><n-input v-model:value="l.holder" /></n-form-item></n-gi>
<n-gi><n-form-item label="发证机关"><n-input v-model:value="l.issuer" /></n-form-item></n-gi>
<n-gi><n-form-item label="有效期"><n-input v-model:value="l.validPeriod" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="备注">
<n-input v-model:value="l.note" />
</n-form-item>
</n-form>
</n-card>
<n-button size="small" type="primary" dashed @click="data.licenses.push({ licenseNo: '', holder: '', issuer: '', validPeriod: '', note: '' })">添加许可证</n-button>
</div>
</n-card>
</n-collapse-item>
<!-- insurance -->
<n-collapse-item title="保险" name="insurance">
<n-card>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="标题"><n-input v-model:value="data.insurance.title" /></n-form-item></n-gi>
<n-gi><n-form-item label="承保公司"><n-input v-model:value="data.insurance.insurer" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="描述">
<n-input v-model:value="data.insurance.description" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" />
</n-form-item>
<n-form-item label="保障范围">
<n-input v-model:value="data.insurance.policyScope" />
</n-form-item>
<n-form-item label="理赔联系方式">
<n-input v-model:value="data.insurance.claimContact" />
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- awards -->
<n-collapse-item title="荣誉奖项" name="awards">
<n-card>
<div style="width: 100%">
<n-card v-for="(a, i) in data.awards" :key="i" size="small" style="margin-bottom: 12px">
<template #header>
<n-space align="center">
<span style="font-size: 13px; color: #6b7280">{{ a.year }} {{ a.title || '新奖项' }}</span>
<n-button size="tiny" type="error" quaternary @click="data.awards.splice(i, 1)">删除</n-button>
</n-space>
</template>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="年份"><n-input v-model:value="a.year" /></n-form-item></n-gi>
<n-gi><n-form-item label="颁发机构"><n-input v-model:value="a.issuer" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="奖项名称">
<n-input v-model:value="a.title" />
</n-form-item>
<n-form-item label="详情">
<n-input v-model:value="a.detail" />
</n-form-item>
</n-form>
</n-card>
<n-button size="small" type="primary" dashed @click="data.awards.push({ year: '', title: '', detail: '', issuer: '' })">添加奖项</n-button>
</div>
</n-card>
</n-collapse-item>
</n-collapse>
</n-spin>
</div>
</template>
<script setup>
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NCollapse, NCollapseItem, NDynamicInput, NInputNumber } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
const data = ref({
story: { title: '', paragraphs: [] },
serviceJourney: { title: '', subtitle: '', moments: [] },
team: { summary: '' },
heritage: { title: '', milestones: [] },
culture: { core: '', belief: '', values: [], slogan: { lines: [] } },
xiaohongshu: { account: '', verified: true, verifiedType: '', followers: '', likes: '', tagline: '', description: '', tags: [], awards: [] },
subsidiaries: [],
trademark: { name: '', scope: '', holder: '', description: '' },
copyrights: [],
founder: { name: '', title: '', background: '', expertise: [], yearsInHulunbuir: '' },
guarantees: [],
licenses: [],
insurance: { title: '', description: '', insurer: '', policyScope: '', claimContact: '' },
awards: []
})
const loaded = ref(false)
const saving = ref(false)
async function load() {
const res = await adminFetch('/api/admin/site-content?key=about')
data.value = res.data
loaded.value = true
}
async function save() {
saving.value = true
try {
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'about', data: data.value } })
message.success('已保存')
} catch (e) {
message.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>

查看文件

@ -24,6 +24,8 @@
<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="功能 Slogan"><n-input v-model:value="data.slogan.functional" /></n-form-item></n-gi>
<n-gi><n-form-item label="ICP 主体"><n-input v-model:value="data.icpEntity" /></n-form-item></n-gi> <n-gi><n-form-item label="ICP 主体"><n-input v-model:value="data.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.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>
<n-gi><n-form-item label="电子合同说明"><n-input v-model:value="data.eContract" /></n-form-item></n-gi>
</n-grid> </n-grid>
</n-form> </n-form>
</n-card> </n-card>
@ -54,25 +56,73 @@
</n-space> </n-space>
</n-space> </n-space>
</n-card> </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;">
<div style="font-size: 13px; font-weight: 500; color: #555; margin-bottom: 6px;">{{ btnLabel }}</div>
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="按钮文字" :show-feedback="false"><n-input v-model:value="data.ctaButtons[btnKey].text" /></n-form-item></n-gi>
<n-gi><n-form-item label="链接地址" :show-feedback="false"><n-input v-model:value="data.ctaButtons[btnKey].to" /></n-form-item></n-gi>
</n-grid>
</div>
</n-form>
</n-card>
<n-card title="转化路径">
<template #header-extra>
<n-button size="small" type="primary" @click="addConversionStep">添加</n-button>
</template>
<n-space vertical :size="8">
<n-space v-for="(c, i) in data.conversionPath" :key="i" align="center">
<n-input-number v-model:value="c.step" placeholder="步骤" style="width: 80px" size="small" :min="1" />
<n-input v-model:value="c.label" placeholder="标签" style="width: 140px" />
<n-input v-model:value="c.to" placeholder="链接" style="width: 180px" />
<n-button size="tiny" type="error" quaternary @click="data.conversionPath.splice(i, 1)"></n-button>
</n-space>
</n-space>
</n-card>
</n-space> </n-space>
</n-spin> </n-spin>
</div> </div>
</template> </template>
<script setup> <script setup>
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi } from 'naive-ui' import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSpin, NGrid, NGi } from 'naive-ui'
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' }) definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin() const { adminFetch } = useAdmin()
const message = useMessage() const message = useMessage()
const data = ref({ slogan: {}, trustStats: [], differentiators: [] }) const data = ref({ slogan: {}, trustStats: [], differentiators: [], ctaButtons: { primary: { text: '', to: '' }, secondary: { text: '', to: '' }, pricing: { text: '', to: '' }, customize: { text: '', to: '' }, contact: { text: '', to: '' } }, conversionPath: [], icpUrl: '', eContract: '' })
const loaded = ref(false) const loaded = ref(false)
const saving = ref(false) const saving = ref(false)
const ctaLabels = {
primary: '主按钮 (primary)',
secondary: '次按钮 (secondary)',
pricing: '价格按钮 (pricing)',
customize: '定制按钮 (customize)',
contact: '联系按钮 (contact)'
}
function addConversionStep() {
const nextStep = data.value.conversionPath.length ? Math.max(...data.value.conversionPath.map(c => c.step || 0)) + 1 : 1
data.value.conversionPath.push({ step: nextStep, label: '', to: '' })
}
async function load() { async function load() {
const res = await adminFetch('/api/admin/site-content?key=brand') const res = await adminFetch('/api/admin/site-content?key=brand')
data.value = res.data const d = res.data
if (!d.ctaButtons) d.ctaButtons = {}
const defaultBtn = { text: '', to: '' }
;['primary', 'secondary', 'pricing', 'customize', 'contact'].forEach(k => {
if (!d.ctaButtons[k]) d.ctaButtons[k] = { ...defaultBtn }
})
if (!d.conversionPath) d.conversionPath = []
if (!d.icpUrl) d.icpUrl = ''
if (!d.eContract) d.eContract = ''
data.value = d
loaded.value = true loaded.value = true
} }

查看文件

@ -29,6 +29,55 @@
</n-form-item> </n-form-item>
</n-card> </n-card>
<n-card title="快速推荐" style="margin-bottom: 20px;">
<template #header-extra>
<n-button size="small" type="primary" @click="data.meta.quickRecommendations.push({ label: '', months: '', reason: '' })">添加</n-button>
</template>
<n-space vertical :size="10">
<n-space v-for="(r, ri) in data.meta.quickRecommendations" :key="ri" align="center">
<n-input v-model:value="r.label" placeholder="标签" style="width: 120px" />
<n-input v-model:value="r.months" placeholder="月份" style="width: 140px" />
<n-input v-model:value="r.reason" placeholder="原因" style="width: 360px" />
<n-button size="tiny" type="error" quaternary @click="data.meta.quickRecommendations.splice(ri, 1)"></n-button>
</n-space>
</n-space>
</n-card>
<n-card title="出行建议" style="margin-bottom: 20px;">
<template #header-extra>
<n-button size="small" type="primary" @click="data.meta.travelAdvice.push({ period: '', level: '', levelText: '', description: '' })">添加</n-button>
</template>
<n-space vertical :size="10">
<n-card v-for="(a, ai) in data.meta.travelAdvice" :key="ai" size="small" style="background: #fafafa;">
<n-space align="center" justify="end" style="margin-bottom: 6px;">
<n-button size="tiny" type="error" quaternary @click="data.meta.travelAdvice.splice(ai, 1)">删除</n-button>
</n-space>
<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="a.period" size="small" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="推荐等级标识" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="a.level" size="small" placeholder="如 best_value / peak / not_recommended" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="等级文字" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="a.levelText" size="small" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="描述" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="a.description" size="small" />
</n-form-item>
</n-gi>
</n-grid>
</n-card>
</n-space>
</n-card>
<n-grid :cols="3" :x-gap="16" :y-gap="16" responsive="screen" :collapsed-rows="999"> <n-grid :cols="3" :x-gap="16" :y-gap="16" responsive="screen" :collapsed-rows="999">
<n-gi v-for="m in data.months" :key="m.month"> <n-gi v-for="m in data.months" :key="m.month">
<n-card <n-card
@ -64,6 +113,15 @@
@input="m.highlights = $event.split('\n').filter(Boolean)" @input="m.highlights = $event.split('\n').filter(Boolean)"
/> />
</n-form-item> </n-form-item>
<n-form-item label="推荐产品(每行一个)" :show-feedback="false" style="margin-bottom: 8px;">
<n-input
type="textarea"
:rows="2"
size="small"
:value="(m.recommendedProducts||[]).join('\n')"
@input="m.recommendedProducts = $event.split('\n').filter(Boolean)"
/>
</n-form-item>
<n-space align="center" style="margin-top: 4px;"> <n-space align="center" style="margin-top: 4px;">
<n-tag <n-tag
:type="m.isPeakSeason ? 'success' : 'default'" :type="m.isPeakSeason ? 'success' : 'default'"
@ -89,11 +147,24 @@ import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' }) definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin() const { adminFetch } = useAdmin()
const message = useMessage() const message = useMessage()
const data = ref({ meta: {}, months: [] }) const data = ref({ meta: { quickRecommendations: [], travelAdvice: [] }, months: [] })
const loaded = ref(false) const loaded = ref(false)
const saving = ref(false) const saving = ref(false)
async function load() { const res = await adminFetch('/api/admin/site-content?key=calendar'); data.value = res.data; loaded.value = true } async function load() {
const res = await adminFetch('/api/admin/site-content?key=calendar')
const d = res.data
if (!d.meta) d.meta = {}
if (!d.meta.quickRecommendations) d.meta.quickRecommendations = []
if (!d.meta.travelAdvice) d.meta.travelAdvice = []
if (d.months) {
d.months.forEach(m => {
if (!m.recommendedProducts) m.recommendedProducts = []
})
}
data.value = d
loaded.value = true
}
async function save() { async function save() {
saving.value = true saving.value = true
try { try {

查看文件

@ -98,6 +98,45 @@
</template> </template>
</n-space> </n-space>
</div> </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>
</div> </div>
</n-card> </n-card>
</template> </template>
@ -105,7 +144,7 @@
</template> </template>
<script setup> <script setup>
import { NButton, NCard, NSpace, NInput, NFormItem, NSpin, NGrid, NGi, NTag } from 'naive-ui' import { NButton, NCard, NSpace, NInput, NFormItem, NSpin, NGrid, NGi, NTag, NCollapse, NCollapseItem } from 'naive-ui'
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' }) definePageMeta({ layout: 'admin', middleware: 'admin' })

查看文件

@ -0,0 +1,162 @@
<template>
<div style="max-width: 1100px">
<n-space justify="space-between" align="center" style="margin-bottom: 20px">
<div>
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827">价格配置</h1>
<p style="margin: 0; font-size: 13px; color: #9ca3af">管理产品价格费用说明价格理念等内容</p>
</div>
<n-space>
<n-button tag="a" href="/pricing" target="_blank" secondary>查看前台 </n-button>
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
</n-space>
</n-space>
<n-spin :show="!loaded">
<n-collapse :default-expanded-names="['intro']">
<!-- intro -->
<n-collapse-item title="价格说明" name="intro">
<n-card>
<n-form label-placement="top">
<n-form-item label="简介">
<n-input v-model:value="data.intro" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" />
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- products -->
<n-collapse-item title="产品列表" name="products">
<n-card>
<div style="width: 100%">
<n-card v-for="(p, i) in data.products" :key="i" size="small" style="margin-bottom: 12px">
<template #header>
<n-space align="center">
<span style="font-size: 13px; font-weight: 500; color: #374151">{{ p.name || '新产品' }}</span>
<span style="font-size: 12px; color: #3a7d44">{{ p.priceLabel }}</span>
<n-button size="tiny" type="error" quaternary @click="data.products.splice(i, 1)">删除</n-button>
</n-space>
</template>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="产品ID"><n-input v-model:value="p.id" /></n-form-item></n-gi>
<n-gi><n-form-item label="产品名称"><n-input v-model:value="p.name" /></n-form-item></n-gi>
<n-gi><n-form-item label="价格标签"><n-input v-model:value="p.priceLabel" /></n-form-item></n-gi>
<n-gi><n-form-item label="链接"><n-input v-model:value="p.url" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="价格说明">
<n-input v-model:value="p.priceNote" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" />
</n-form-item>
<n-grid :cols="3" :x-gap="12">
<n-gi><n-form-item label="标签"><n-input v-model:value="p.tag" /></n-form-item></n-gi>
<n-gi><n-form-item label="目标人群"><n-input v-model:value="p.audience" /></n-form-item></n-gi>
<n-gi>
<n-form-item label="天/晚">
<n-space>
<n-input-number v-model:value="p.days" placeholder="天" :min="0" style="width: 80px" />
<n-input-number v-model:value="p.nights" placeholder="晚" :min="0" style="width: 80px" />
</n-space>
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="亮点">
<n-dynamic-input v-model:value="p.highlights" :min="0" />
</n-form-item>
</n-form>
</n-card>
<n-button size="small" type="primary" dashed @click="data.products.push({ id: '', name: '', priceLabel: '', priceNote: '', highlights: [], url: '', tag: '', audience: '', days: null, nights: null })">添加产品</n-button>
</div>
</n-card>
</n-collapse-item>
<!-- included / excluded -->
<n-collapse-item title="费用包含/不包含" name="fees">
<n-card>
<n-form label-placement="top">
<n-form-item label="费用包含">
<n-dynamic-input v-model:value="data.included" :min="1" />
</n-form-item>
<n-form-item label="费用不包含">
<n-dynamic-input v-model:value="data.excluded" :min="1" />
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- philosophy -->
<n-collapse-item title="价格理念" name="philosophy">
<n-card>
<n-form label-placement="top">
<n-form-item label="标题">
<n-input v-model:value="data.philosophy.title" />
</n-form-item>
<n-form-item label="内容">
<n-input v-model:value="data.philosophy.content" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" />
</n-form-item>
<n-form-item label="承诺">
<n-input v-model:value="data.philosophy.promise" />
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- cta -->
<n-collapse-item title="行动号召" name="cta">
<n-card>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="标题"><n-input v-model:value="data.cta.title" /></n-form-item></n-gi>
<n-gi><n-form-item label="按钮文字"><n-input v-model:value="data.cta.button" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="描述">
<n-input v-model:value="data.cta.description" />
</n-form-item>
<n-form-item label="跳转链接">
<n-input v-model:value="data.cta.to" />
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
</n-collapse>
</n-spin>
</div>
</template>
<script setup>
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NCollapse, NCollapseItem, NDynamicInput, NInputNumber } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
const data = ref({
intro: '',
products: [],
included: [],
excluded: [],
philosophy: { title: '', content: '', promise: '' },
cta: { title: '', description: '', to: '', button: '' }
})
const loaded = ref(false)
const saving = ref(false)
async function load() {
const res = await adminFetch('/api/admin/site-content?key=pricing')
data.value = res.data
loaded.value = true
}
async function save() {
saving.value = true
try {
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'pricing', data: data.value } })
message.success('已保存')
} catch (e) {
message.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>

查看文件

@ -59,6 +59,49 @@
<n-button dashed block @click="addTo('awards', { year: '', title: '', detail: '', issuer: '' })">+ 添加荣誉奖项</n-button> <n-button dashed block @click="addTo('awards', { year: '', title: '', detail: '', issuer: '' })">+ 添加荣誉奖项</n-button>
</n-card> </n-card>
<!-- 企业矩阵 -->
<n-card title="企业矩阵" style="margin-bottom: 16px;">
<n-grid :cols="2" :x-gap="12" :y-gap="12">
<n-gi>
<n-form-item label="企业总数">
<n-input-number v-model:value="data.enterpriseMatrix.totalCount" style="width:100%" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="描述">
<n-input v-model:value="data.enterpriseMatrix.description" />
</n-form-item>
</n-gi>
</n-grid>
<div style="margin-top:12px; font-weight:500; margin-bottom:8px;">企业列表</div>
<div v-for="(c, i) in data.enterpriseMatrix.companies || []" :key="i" style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 10px; margin-bottom: 8px;">
<n-grid :cols="4" :x-gap="8">
<n-gi>
<n-form-item label="名称" :show-feedback="false">
<n-input v-model:value="c.name" size="small" placeholder="企业名称" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="标签" :show-feedback="false">
<n-input v-model:value="c.tag" size="small" placeholder="如:核心旅行社" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="成立时间" :show-feedback="false">
<n-input v-model:value="c.established" size="small" placeholder="如2023" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="角色" :show-feedback="false">
<n-input v-model:value="c.role" size="small" placeholder="职能描述" />
</n-form-item>
</n-gi>
</n-grid>
<n-button size="tiny" type="error" quaternary style="margin-top:4px;" @click="data.enterpriseMatrix.companies.splice(i, 1)">删除</n-button>
</div>
<n-button dashed block @click="data.enterpriseMatrix.companies.push({ name: '', tag: '', established: '', role: '' })">添加企业</n-button>
</n-card>
<!-- 保险信息 --> <!-- 保险信息 -->
<n-card v-if="data.insurance" title="保险信息" style="margin-bottom: 16px;"> <n-card v-if="data.insurance" title="保险信息" style="margin-bottom: 16px;">
<n-grid :cols="2" :x-gap="12" :y-gap="12"> <n-grid :cols="2" :x-gap="12" :y-gap="12">
@ -95,7 +138,7 @@
</template> </template>
<script setup> <script setup>
import { NButton, NCard, NSpace, NInput, NFormItem, NSpin, NGrid, NGi } from 'naive-ui' import { NButton, NCard, NSpace, NInput, NInputNumber, NFormItem, NSpin, NGrid, NGi } from 'naive-ui'
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' }) definePageMeta({ layout: 'admin', middleware: 'admin' })
@ -108,6 +151,12 @@ const saving = ref(false)
async function load() { async function load() {
const res = await adminFetch('/api/admin/site-content?key=qualifications') const res = await adminFetch('/api/admin/site-content?key=qualifications')
data.value = res.data data.value = res.data
if (!data.value.enterpriseMatrix) {
data.value.enterpriseMatrix = { totalCount: 0, description: '', companies: [] }
}
if (!data.value.enterpriseMatrix.companies) {
data.value.enterpriseMatrix.companies = []
}
loaded.value = true loaded.value = true
} }

查看文件

@ -0,0 +1,205 @@
<template>
<div style="max-width: 1100px">
<n-space justify="space-between" align="center" style="margin-bottom: 20px">
<div>
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827">版本迭代</h1>
<p style="margin: 0; font-size: 13px; color: #9ca3af">管理产品版本迭代历史升级亮点对比表等内容</p>
</div>
<n-space>
<n-button tag="a" href="/versions" target="_blank" secondary>查看前台 </n-button>
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
</n-space>
</n-space>
<n-spin :show="!loaded">
<n-collapse :default-expanded-names="['stats']">
<!-- stats -->
<n-collapse-item title="统计数据" name="stats">
<n-card>
<n-form label-placement="top">
<n-grid :cols="3" :x-gap="12">
<n-gi><n-form-item label="迭代次数"><n-input-number v-model:value="data.stats.iterations" :min="0" style="width: 100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="年数"><n-input-number v-model:value="data.stats.years" :min="0" style="width: 100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="接待人次"><n-input v-model:value="data.stats.guests" /></n-form-item></n-gi>
</n-grid>
</n-form>
</n-card>
</n-collapse-item>
<!-- upgrades2026 -->
<n-collapse-item title="2026升级亮点" name="upgrades2026">
<n-card>
<div style="width: 100%">
<n-card v-for="(u, i) in data.upgrades2026" :key="i" size="small" style="margin-bottom: 12px">
<template #header>
<n-space align="center">
<span style="font-size: 13px; color: #6b7280">{{ u.tag || '新亮点' }} - {{ u.name }}</span>
<n-button size="tiny" type="error" quaternary @click="data.upgrades2026.splice(i, 1)">删除</n-button>
</n-space>
</template>
<n-form label-placement="top">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="标签"><n-input v-model:value="u.tag" /></n-form-item></n-gi>
<n-gi><n-form-item label="名称"><n-input v-model:value="u.name" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="描述">
<n-input v-model:value="u.description" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" />
</n-form-item>
<n-form-item label="升级原因">
<n-input v-model:value="u.reason" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" />
</n-form-item>
</n-form>
</n-card>
<n-button size="small" type="primary" dashed @click="data.upgrades2026.push({ tag: '', name: '', description: '', reason: '' })">添加亮点</n-button>
</div>
</n-card>
</n-collapse-item>
<!-- highlights -->
<n-collapse-item title="产品亮点" name="highlights">
<n-card>
<div style="width: 100%">
<div v-for="(h, i) in data.highlights" :key="i" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px">
<n-input v-model:value="h.label" placeholder="标签" style="width: 100px" />
<n-input v-model:value="h.text" placeholder="描述" style="flex: 1" />
<n-button size="tiny" type="error" quaternary @click="data.highlights.splice(i, 1)"></n-button>
</div>
<n-button size="small" type="primary" dashed @click="data.highlights.push({ label: '', text: '' })">添加亮点</n-button>
</div>
</n-card>
</n-collapse-item>
<!-- compareV8V9 -->
<n-collapse-item title="版本对比表" name="compareV8V9">
<n-card>
<n-form label-placement="top">
<n-form-item label="表头">
<n-space>
<n-input v-for="(h, i) in data.compareV8V9.headers" :key="i" v-model:value="data.compareV8V9.headers[i]" :placeholder="'表头' + (i + 1)" style="width: 280px" />
</n-space>
</n-form-item>
<n-form-item label="对比行">
<div style="width: 100%">
<div v-for="(row, i) in data.compareV8V9.rows" :key="i" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px">
<n-input v-model:value="row[0]" :placeholder="data.compareV8V9.headers[0]" style="flex: 1" />
<span style="color: #9ca3af"></span>
<n-input v-model:value="row[1]" :placeholder="data.compareV8V9.headers[1]" style="flex: 1" />
<n-button size="tiny" type="error" quaternary @click="data.compareV8V9.rows.splice(i, 1)"></n-button>
</div>
<n-button size="small" type="primary" dashed @click="data.compareV8V9.rows.push(['', ''])">添加行</n-button>
</div>
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
<!-- timeline -->
<n-collapse-item title="版本时间线" name="timeline">
<n-card>
<div style="width: 100%">
<n-card v-for="(t, i) in data.timeline" :key="i" size="small" style="margin-bottom: 12px">
<template #header>
<n-space align="center">
<span style="font-size: 13px; font-weight: 600; color: #3a7d44">{{ t.version }}</span>
<span style="font-size: 12px; color: #9ca3af">{{ t.date }}</span>
<n-button size="tiny" type="error" quaternary @click="data.timeline.splice(i, 1)">删除</n-button>
</n-space>
</template>
<n-form label-placement="top">
<n-grid :cols="3" :x-gap="12">
<n-gi><n-form-item label="版本号"><n-input v-model:value="t.version" /></n-form-item></n-gi>
<n-gi><n-form-item label="日期"><n-input v-model:value="t.date" /></n-form-item></n-gi>
<n-gi><n-form-item label="标题"><n-input v-model:value="t.title" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="变更项">
<div style="width: 100%">
<div v-for="(c, ci) in t.changes" :key="ci" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px">
<n-input v-model:value="c.type" placeholder="类型(add/update/remove)" style="width: 160px" />
<n-input v-model:value="c.text" placeholder="变更内容" style="flex: 1" />
<n-button size="tiny" type="error" quaternary @click="t.changes.splice(ci, 1)"></n-button>
</div>
<n-button size="small" dashed @click="t.changes.push({ type: 'add', text: '' })">添加变更</n-button>
</div>
</n-form-item>
<n-form-item label="迭代原因">
<n-input v-model:value="t.reason" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" />
</n-form-item>
</n-form>
</n-card>
<n-button size="small" type="primary" dashed @click="data.timeline.push({ version: '', date: '', title: '', changes: [{ type: 'add', text: '' }], reason: '' })">添加版本</n-button>
</div>
</n-card>
</n-collapse-item>
<!-- philosophy -->
<n-collapse-item title="迭代理念" name="philosophy">
<n-card>
<div style="width: 100%">
<div v-for="(p, i) in data.philosophy" :key="i" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px">
<n-input v-model:value="p.label" placeholder="标签" style="width: 120px" />
<n-input v-model:value="p.text" placeholder="描述" style="flex: 1" />
<n-button size="tiny" type="error" quaternary @click="data.philosophy.splice(i, 1)"></n-button>
</div>
<n-button size="small" type="primary" dashed @click="data.philosophy.push({ label: '', text: '' })">添加理念</n-button>
</div>
</n-card>
</n-collapse-item>
<!-- quote -->
<n-collapse-item title="引用语" name="quote">
<n-card>
<n-form label-placement="top">
<n-form-item label="引用文字">
<n-input v-model:value="data.quote.text" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" />
</n-form-item>
<n-form-item label="作者">
<n-input v-model:value="data.quote.author" />
</n-form-item>
</n-form>
</n-card>
</n-collapse-item>
</n-collapse>
</n-spin>
</div>
</template>
<script setup>
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NCollapse, NCollapseItem, NDynamicInput, NInputNumber } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
const data = ref({
stats: { iterations: 0, years: 0, guests: '' },
upgrades2026: [],
highlights: [],
compareV8V9: { headers: ['', ''], rows: [] },
timeline: [],
philosophy: [],
quote: { text: '', author: '' }
})
const loaded = ref(false)
const saving = ref(false)
async function load() {
const res = await adminFetch('/api/admin/site-content?key=versions')
data.value = res.data
loaded.value = true
}
async function save() {
saving.value = true
try {
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'versions', data: data.value } })
message.success('已保存')
} catch (e) {
message.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>

查看文件

@ -68,6 +68,9 @@
<n-form-item label="摘要" :show-feedback="false" style="margin-bottom: 6px;"> <n-form-item label="摘要" :show-feedback="false" style="margin-bottom: 6px;">
<n-input v-model:value="n.summary" placeholder="摘要" size="small" /> <n-input v-model:value="n.summary" placeholder="摘要" size="small" />
</n-form-item> </n-form-item>
<n-form-item label="标签(逗号分隔)" :show-feedback="false" style="margin-bottom: 6px;">
<n-input :value="(n.tags||[]).join(', ')" @update:value="v => n.tags = v.split(',').map(s=>s.trim()).filter(Boolean)" placeholder="呼伦贝尔, 亲子游, 草原" size="small" />
</n-form-item>
<n-form-item label="版式" :show-feedback="false" style="margin-bottom: 6px;"> <n-form-item label="版式" :show-feedback="false" style="margin-bottom: 6px;">
<n-select <n-select
v-model:value="n.ratio" v-model:value="n.ratio"

查看文件

@ -11,6 +11,41 @@
</n-space> </n-space>
</n-space> </n-space>
<!-- 页面配置 -->
<n-card title="页面配置" style="margin-bottom: 20px">
<template #header-extra>
<n-button type="primary" size="small" :loading="configSaving" @click="saveConfig">保存配置</n-button>
</template>
<n-spin :show="!configLoaded">
<template v-if="configLoaded">
<!-- 介绍描述 -->
<n-form-item label="旅拍简介" style="margin-bottom: 12px">
<n-input v-model:value="config.intro.description" type="textarea" :rows="3" placeholder="旅拍服务简介文字" />
</n-form-item>
<!-- 数据亮点 -->
<div style="font-weight: 500; margin-bottom: 8px; color: #374151">数据亮点stats</div>
<div v-for="(s, i) in config.intro.stats" :key="i" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px;">
<n-input v-model:value="s.value" placeholder="数值,如 200+" style="width: 100px" size="small" />
<n-input v-model:value="s.label" placeholder="标签,如 件蒙古服饰" style="width: 130px" size="small" />
<n-input v-model:value="s.note" placeholder="说明文字" style="flex: 1" size="small" />
<n-button size="tiny" type="error" quaternary @click="config.intro.stats.splice(i, 1)"></n-button>
</div>
<n-button dashed size="small" style="margin-bottom: 16px" @click="config.intro.stats.push({ value: '', label: '', note: '' })">+ 添加数据项</n-button>
<!-- 分类 -->
<div style="font-weight: 500; margin-bottom: 8px; color: #374151">分类筛选categories</div>
<div v-for="(cat, i) in config.categories" :key="i" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px;">
<n-input v-model:value="cat.key" placeholder="key,如 family" style="width: 120px" size="small" />
<n-input v-model:value="cat.label" placeholder="显示名称,如 亲子家庭" style="width: 130px" size="small" />
<n-button size="tiny" type="error" quaternary :disabled="cat.key === 'all'" @click="config.categories.splice(i, 1)"></n-button>
</div>
<n-button dashed size="small" @click="config.categories.push({ key: '', label: '' })">+ 添加分类</n-button>
</template>
</n-spin>
</n-card>
<!-- 照片列表 -->
<n-card> <n-card>
<n-space style="margin-bottom: 16px"> <n-space style="margin-bottom: 16px">
<n-input v-model:value="search" placeholder="搜索标题、分类..." clearable style="width: 260px" @keyup.enter="loadData"> <n-input v-model:value="search" placeholder="搜索标题、分类..." clearable style="width: 260px" @keyup.enter="loadData">
@ -42,7 +77,7 @@
<script setup> <script setup>
import { h } from 'vue' import { h } from 'vue'
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NImage, NPopconfirm } from 'naive-ui' import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NImage, NPopconfirm, NSpin, NFormItem } from 'naive-ui'
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' }) definePageMeta({ layout: 'admin', middleware: 'admin' })
@ -56,6 +91,46 @@ const totalPages = ref(1)
const loading = ref(false) const loading = ref(false)
const search = ref('') const search = ref('')
// site_content gallery key
const config = ref({ intro: { description: '', stats: [] }, categories: [] })
const configLoaded = ref(false)
const configSaving = ref(false)
async function loadConfig() {
try {
const res = await adminFetch('/api/admin/site-content?key=gallery')
const d = res.data || {}
config.value = {
intro: {
description: d.intro?.description || '',
stats: d.intro?.stats || [],
},
categories: d.categories || [],
}
} catch {
message.error('页面配置加载失败')
} finally {
configLoaded.value = true
}
}
async function saveConfig() {
configSaving.value = true
try {
// works
const res = await adminFetch('/api/admin/site-content?key=gallery')
const full = res.data || {}
full.intro = { ...(full.intro || {}), description: config.value.intro.description, stats: config.value.intro.stats }
full.categories = config.value.categories
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'gallery', data: full } })
message.success('页面配置已保存')
} catch {
message.error('保存失败')
} finally {
configSaving.value = false
}
}
const columns = [ const columns = [
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) }, { title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
{ {
@ -131,5 +206,5 @@ async function handleDelete(item) {
} catch { message.error('删除失败') } } catch { message.error('删除失败') }
} }
onMounted(loadData) onMounted(() => { loadData(); loadConfig() })
</script> </script>

查看文件

@ -78,11 +78,12 @@
<n-gi><n-form-item label="名称"><n-input v-model:value="ver.name" /></n-form-item></n-gi> <n-gi><n-form-item label="名称"><n-input v-model:value="ver.name" /></n-form-item></n-gi>
<n-gi><n-form-item label="标签"><n-input v-model:value="ver.tag" /></n-form-item></n-gi> <n-gi><n-form-item label="标签"><n-input v-model:value="ver.tag" /></n-form-item></n-gi>
</n-grid> </n-grid>
<n-grid :cols="4" :x-gap="12"> <n-grid :cols="5" :x-gap="12">
<n-gi><n-form-item label="天数"><n-input-number v-model:value="ver.days" :min="1" style="width:100%" /></n-form-item></n-gi> <n-gi><n-form-item label="天数"><n-input-number v-model:value="ver.days" :min="1" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="晚数"><n-input-number v-model:value="ver.nights" :min="0" style="width:100%" /></n-form-item></n-gi> <n-gi><n-form-item label="晚数"><n-input-number v-model:value="ver.nights" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="线路"><n-input v-model:value="ver.line" placeholder="南线/北线" /></n-form-item></n-gi> <n-gi><n-form-item label="线路"><n-input v-model:value="ver.line" placeholder="南线/北线" /></n-form-item></n-gi>
<n-gi><n-form-item label="受众"><n-input v-model:value="ver.audience" /></n-form-item></n-gi> <n-gi><n-form-item label="受众"><n-input v-model:value="ver.audience" /></n-form-item></n-gi>
<n-gi><n-form-item label="主推产品"><n-switch v-model:value="ver.isMainProduct" /></n-form-item></n-gi>
</n-grid> </n-grid>
<n-form-item label="路线"><n-input v-model:value="ver.route" /></n-form-item> <n-form-item label="路线"><n-input v-model:value="ver.route" /></n-form-item>
<n-form-item label="描述"><n-input v-model:value="ver.description" type="textarea" :rows="3" /></n-form-item> <n-form-item label="描述"><n-input v-model:value="ver.description" type="textarea" :rows="3" /></n-form-item>
@ -134,15 +135,46 @@
<n-button size="tiny" type="error" quaternary @click="data.selectionGuide.byVacationLength.splice(i,1)"></n-button> <n-button size="tiny" type="error" quaternary @click="data.selectionGuide.byVacationLength.splice(i,1)"></n-button>
</div> </div>
</n-card> </n-card>
<n-card title="版本迭代时间线" style="margin-top:16px"> <n-card title="按孩子年龄推荐" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="addToArray(data, 'timeline', {year:'',title:'',description:''})">添加</n-button></template> <template #header-extra><n-button size="small" @click="ensureArray(data, 'selectionGuide.byChildAge'); data.selectionGuide.byChildAge.push({condition:'',recommendation:'',reason:''})">添加</n-button></template>
<div v-for="(t, i) in data.timeline || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;"> <div v-for="(g, i) in data.selectionGuide?.byChildAge || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
<n-input v-model:value="t.year" placeholder="年份" style="width:80px" /> <n-input v-model:value="g.condition" placeholder="条件" style="width:120px" />
<n-input v-model:value="t.title" placeholder="标题" style="width:160px" /> <n-input v-model:value="g.recommendation" placeholder="推荐产品ID" style="width:160px" />
<n-input v-model:value="t.description" placeholder="描述" style="flex:1" /> <n-input v-model:value="g.reason" placeholder="原因" style="flex:1" />
<n-button size="tiny" type="error" quaternary @click="data.timeline.splice(i,1)"></n-button> <n-button size="tiny" type="error" quaternary @click="data.selectionGuide.byChildAge.splice(i,1)"></n-button>
</div> </div>
</n-card> </n-card>
<n-card title="按偏好推荐" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="ensureArray(data, 'selectionGuide.byPreference'); data.selectionGuide.byPreference.push({condition:'',recommendation:'',reason:''})">添加</n-button></template>
<div v-for="(g, i) in data.selectionGuide?.byPreference || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
<n-input v-model:value="g.condition" placeholder="条件" style="width:120px" />
<n-input v-model:value="g.recommendation" placeholder="推荐产品ID" style="width:160px" />
<n-input v-model:value="g.reason" placeholder="原因" style="flex:1" />
<n-button size="tiny" type="error" quaternary @click="data.selectionGuide.byPreference.splice(i,1)"></n-button>
</div>
</n-card>
<n-card title="版本迭代时间线" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="addToArray(data, 'timeline', {version:'',date:'',reason:'',changes:[]})">添加</n-button></template>
<n-collapse>
<n-collapse-item v-for="(t, i) in data.timeline || []" :key="i" :name="i">
<template #header><strong>{{ t.version || '版本 '+(i+1) }}</strong> <span style="color:#9ca3af;font-size:12px;margin-left:8px">{{ t.date }}</span></template>
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.timeline.splice(i,1)">删除</n-button></template>
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="版本号"><n-input v-model:value="t.version" /></n-form-item></n-gi>
<n-gi><n-form-item label="日期"><n-input v-model:value="t.date" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="更新原因"><n-input v-model:value="t.reason" /></n-form-item>
<n-form-item label="变更列表">
<div v-for="(c, ci) in t.changes || []" :key="ci" style="display:flex;gap:8px;margin-bottom:6px;">
<n-input v-model:value="c.type" placeholder="类型" style="width:100px" />
<n-input v-model:value="c.text" placeholder="变更内容" style="flex:1" />
<n-button size="tiny" type="error" quaternary @click="t.changes.splice(ci,1)"></n-button>
</div>
<n-button size="tiny" dashed @click="if(!t.changes) t.changes=[]; t.changes.push({type:'',text:''})">添加变更</n-button>
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-card>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="faq" tab="常见问答"> <n-tab-pane name="faq" tab="常见问答">
@ -155,6 +187,7 @@
<n-tab-pane name="basic" tab="基本信息"> <n-tab-pane name="basic" tab="基本信息">
<n-card> <n-card>
<n-form label-placement="top"> <n-form label-placement="top">
<n-form-item label="营地名称"><n-input v-model:value="data.name" /></n-form-item>
<n-form-item label="营地定位"><n-input v-model:value="data.positioning" type="textarea" :rows="3" /></n-form-item> <n-form-item label="营地定位"><n-input v-model:value="data.positioning" type="textarea" :rows="3" /></n-form-item>
<n-grid :cols="4" :x-gap="12"> <n-grid :cols="4" :x-gap="12">
<n-gi><n-form-item label="天数"><n-input-number v-model:value="data.days" :min="1" style="width:100%" /></n-form-item></n-gi> <n-gi><n-form-item label="天数"><n-input-number v-model:value="data.days" :min="1" style="width:100%" /></n-form-item></n-gi>
@ -244,6 +277,12 @@
<n-input v-model:value="data.serviceConfig[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.serviceConfig.splice(i,1)"></n-button> <n-input v-model:value="data.serviceConfig[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.serviceConfig.splice(i,1)"></n-button>
</div> </div>
</n-card> </n-card>
<n-card v-if="data.campEssentials !== undefined" title="营地必备清单" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="data.campEssentials.push('')">添加</n-button></template>
<div v-for="(_, i) in data.campEssentials" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
<n-input v-model:value="data.campEssentials[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.campEssentials.splice(i,1)"></n-button>
</div>
</n-card>
<n-card v-if="data.coreActivities !== undefined" title="核心体验" style="margin-top:16px"> <n-card v-if="data.coreActivities !== undefined" title="核心体验" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="data.coreActivities.push('')">添加</n-button></template> <template #header-extra><n-button size="small" @click="data.coreActivities.push('')">添加</n-button></template>
<div v-for="(_, i) in data.coreActivities" :key="i" style="margin-bottom:8px;display:flex;gap:8px;"> <div v-for="(_, i) in data.coreActivities" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
@ -359,7 +398,7 @@ import { h } from 'vue'
import { import {
NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NForm, NFormItem, NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NForm, NFormItem,
NInput, NInputNumber, NBreadcrumb, NBreadcrumbItem, NInput, NInputNumber, NBreadcrumb, NBreadcrumbItem,
NTabs, NTabPane, NCollapse, NCollapseItem, NDynamicInput, NEmpty, NTabs, NTabPane, NCollapse, NCollapseItem, NDynamicInput, NEmpty, NSwitch,
} from 'naive-ui' } from 'naive-ui'
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
@ -462,7 +501,7 @@ function normalizeVersion(v) {
return { return {
id: v.id || '', name: v.name || '', tag: v.tag || '', id: v.id || '', name: v.name || '', tag: v.tag || '',
days: v.days || 0, nights: v.nights || 0, line: v.line || '', route: v.route || '', days: v.days || 0, nights: v.nights || 0, line: v.line || '', route: v.route || '',
audience: v.audience || '', description: v.description || '', audience: v.audience || '', description: v.description || '', isMainProduct: v.isMainProduct || false,
highlights: Array.isArray(v.highlights) ? v.highlights : [], highlights: Array.isArray(v.highlights) ? v.highlights : [],
itinerary: normalizeItinerary(v.itinerary || []), itinerary: normalizeItinerary(v.itinerary || []),
pricing: { pricing: {