feat: 后台管理系统完整重构 + 前后台数据联通

- 后台全面引入 Naive UI 组件库,统一 UI 规范
- 前台 usePublicApi 切换到 API 调用(GET /api/content/[key])
- 前后台数据源统一(site_content 表)
- 产品线统一管理(tour/camp/course 三套编辑器)
- 产品数据归一化(itinerary/faq/pricing 格式统一)
- 表单提交 API 改写入 submissions 表
- 新增 submissions 标记已读 API
- site-content PUT 支持 upsert
- 修复前台 bug(stories 路由冲突、占位符警告、selector breadcrumb)
- 消除 PageHero 类型警告(useSEO reactive 修复)
- 25 个前台页面全部 HTTP 200,展示效果不变

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
这个提交包含在:
Mimingguang
2026-03-24 17:48:02 +08:00
共同撰写人 Claude Opus 4.6
当前提交 ee078b61bc
共修改 444 个文件,包含 59890 行新增和 0 行删除
+67
查看文件
@@ -0,0 +1,67 @@
<template>
<div class="activity-card">
<h4 class="activity-name">
<span class="activity-icon">{{ activity.icon }}</span>
{{ activity.name }}
</h4>
<ul class="activity-points">
<li v-for="(point, i) in activity.points" :key="i" class="activity-point">
{{ point }}
</li>
</ul>
</div>
</template>
<script setup>
defineProps({
activity: { type: Object, required: true },
})
</script>
<style lang="less" scoped>
.activity-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-lg;
}
.activity-name {
display: flex;
align-items: center;
gap: @space-sm;
font-size: @font-size-md;
font-weight: @font-weight-bold;
color: @color-primary;
margin: 0 0 @space-md;
}
.activity-icon {
font-size: @font-size-lg;
}
.activity-points {
display: flex;
flex-direction: column;
gap: @space-sm;
padding: 0;
margin: 0;
list-style: none;
}
.activity-point {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
padding-left: @space-md;
position: relative;
&::before {
content: '·';
position: absolute;
left: 0;
color: @color-primary;
font-weight: @font-weight-bold;
}
}
</style>
+53
查看文件
@@ -0,0 +1,53 @@
<template>
<div class="guide-checklist">
<div
v-for="(item, i) in items"
:key="i"
class="checklist-item"
:class="{ 'checklist-item--important': item.important }"
>
<span class="checklist-mark">{{ item.important ? '☑' : '☐' }}</span>
<span class="checklist-text">{{ item.text }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
items: { type: Array, required: true },
})
</script>
<style lang="less" scoped>
.guide-checklist {
display: flex;
flex-direction: column;
gap: @space-sm;
}
.checklist-item {
display: flex;
gap: @space-sm;
padding: @space-sm @space-md;
border-left: 2px solid @color-border;
line-height: @line-height-base;
&--important {
border-left-color: @color-primary;
}
}
.checklist-mark {
flex-shrink: 0;
color: @color-primary;
}
.checklist-text {
font-size: @font-size-sm;
color: @color-text-secondary;
.checklist-item--important & {
color: @color-text-primary;
}
}
</style>
+107
查看文件
@@ -0,0 +1,107 @@
<template>
<div class="clothing-card" :class="{ 'clothing-card--highlight': month.highlight }">
<div class="clothing-header">
<h4 class="clothing-month">{{ month.name }}</h4>
<span v-if="month.highlight" class="clothing-tag">大多数家庭选这个月</span>
</div>
<p class="clothing-desc">{{ month.desc }}</p>
<div class="clothing-temps">
<span class="temp-badge"><strong>白天</strong> {{ month.tempDay }}</span>
<span class="temp-badge"><strong>夜间</strong> {{ month.tempNight }}</span>
</div>
<div class="clothing-wear">
<p class="wear-line"><span class="wear-label">大人</span>{{ month.adultWear }}</p>
<p class="wear-line"><span class="wear-label">小朋友</span>{{ month.kidsWear }}</p>
</div>
<p v-if="month.note" class="clothing-note">{{ month.note }}</p>
</div>
</template>
<script setup>
defineProps({
month: { type: Object, required: true },
})
</script>
<style lang="less" scoped>
.clothing-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-lg;
&--highlight {
border-color: @color-primary-lighter;
box-shadow: 0 2px 12px rgba(45, 106, 79, 0.08);
}
}
.clothing-header {
display: flex;
align-items: center;
gap: @space-sm;
margin-bottom: @space-xs;
}
.clothing-month {
font-size: @font-size-base;
font-weight: @font-weight-bold;
color: @color-primary;
margin: 0;
}
.clothing-tag {
font-size: @font-size-xs;
color: @color-accent;
font-weight: @font-weight-medium;
}
.clothing-desc {
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-sm;
}
.clothing-temps {
display: flex;
gap: @space-lg;
margin-bottom: @space-md;
font-size: @font-size-sm;
color: @color-text-secondary;
strong {
color: @color-primary;
}
}
.clothing-wear {
display: flex;
flex-direction: column;
gap: @space-xs;
}
.wear-line {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin: 0;
}
.wear-label {
display: inline-block;
font-weight: @font-weight-medium;
color: @color-primary;
margin-right: @space-sm;
min-width: 48px;
}
.clothing-note {
margin-top: @space-md;
padding: @space-sm @space-md;
background: @color-bg-warm;
border-radius: @border-radius-sm;
font-size: @font-size-sm;
color: @color-accent;
line-height: @line-height-base;
}
</style>
+66
查看文件
@@ -0,0 +1,66 @@
<template>
<nav class="guide-nav">
<button
v-for="section in sections"
:key="section.id"
class="guide-nav-btn"
:class="{ active: active === section.id }"
@click="$emit('select', section.id)"
>
<span class="guide-nav-icon">{{ section.icon }}</span>
<span class="guide-nav-text">{{ section.title }}</span>
</button>
</nav>
</template>
<script setup>
defineProps({
sections: { type: Array, required: true },
active: { type: String, default: '' },
})
defineEmits(['select'])
</script>
<style lang="less" scoped>
.guide-nav {
display: flex;
flex-wrap: wrap;
gap: @space-sm;
margin-bottom: @space-2xl;
.respond-md({
justify-content: center;
gap: @space-md;
});
}
.guide-nav-btn {
display: flex;
align-items: center;
gap: 6px;
padding: @space-sm @space-md;
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-full;
cursor: pointer;
font-size: @font-size-sm;
color: @color-text-secondary;
transition: all @transition-fast;
white-space: nowrap;
&:hover {
border-color: @color-primary-light;
color: @color-primary;
}
&.active {
background: @color-primary;
border-color: @color-primary;
color: #fff;
}
}
.guide-nav-icon {
font-size: @font-size-base;
}
</style>
+246
查看文件
@@ -0,0 +1,246 @@
<template>
<div class="toddler-section">
<!-- 推荐项目 -->
<div class="toddler-group">
<h3 class="group-title group-title--green">{{ data.recommended.title }}</h3>
<p class="group-desc">{{ data.recommended.desc }}</p>
<div class="toddler-items">
<div v-for="item in data.recommended.items" :key="item.name" class="toddler-item toddler-item--green">
<span class="toddler-item-icon">{{ item.icon }}</span>
<div>
<strong class="toddler-item-name">{{ item.name }}</strong>
<p class="toddler-item-desc">{{ item.desc }}</p>
</div>
</div>
</div>
</div>
<!-- 视情况选择 -->
<div class="toddler-group">
<h3 class="group-title group-title--amber">{{ data.conditional.title }}</h3>
<p class="group-desc">{{ data.conditional.desc }}</p>
<div class="toddler-items">
<div v-for="item in data.conditional.items" :key="item.name" class="toddler-item toddler-item--amber">
<span class="toddler-item-icon">{{ item.icon }}</span>
<div>
<strong class="toddler-item-name">{{ item.name }}</strong>
<p class="toddler-item-desc">{{ item.desc }}</p>
</div>
</div>
</div>
</div>
<!-- 不建议参与 -->
<div class="toddler-group">
<h3 class="group-title group-title--muted">{{ data.notRecommended.title }}</h3>
<p class="group-desc">{{ data.notRecommended.desc }}</p>
<div class="toddler-not-items">
<div v-for="item in data.notRecommended.items" :key="item.name" class="toddler-not-item">
<strong>{{ item.name }}</strong> — {{ item.reason }}
</div>
</div>
</div>
<!-- 装备 -->
<div class="toddler-group">
<h3 class="group-title group-title--green">宝宝出行装备</h3>
<div class="toddler-equip">
<div v-for="eq in data.equipment" :key="eq.name" class="equip-item" :class="{ 'equip-item--provided': eq.provided }">
<strong class="equip-name">{{ eq.name }}</strong>
<span v-if="eq.provided" class="equip-badge">呼籁提供</span>
<p class="equip-desc">{{ eq.desc }}</p>
</div>
</div>
</div>
<!-- 用餐 -->
<div class="toddler-group">
<h3 class="group-title group-title--green">宝宝用餐小贴士</h3>
<ul class="toddler-dining">
<li v-for="(tip, i) in data.dining" :key="i">{{ tip }}</li>
</ul>
</div>
<!-- 温馨提醒 -->
<div class="toddler-notes">
<div v-for="note in data.notes" :key="note.title" class="toddler-note">
<strong>{{ note.title }}</strong>
<p>{{ note.content }}</p>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
data: { type: Object, required: true },
})
</script>
<style lang="less" scoped>
.toddler-section {
display: flex;
flex-direction: column;
gap: @space-2xl;
}
.toddler-group {
// group container
}
.group-title {
font-size: @font-size-lg;
margin: 0 0 @space-sm;
&--green { color: @color-primary; }
&--amber { color: @color-accent; }
&--muted { color: @color-text-muted; }
}
.group-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
margin-bottom: @space-lg;
}
.toddler-items {
display: flex;
flex-direction: column;
gap: @space-md;
}
.toddler-item {
display: flex;
gap: @space-md;
padding: @space-md @space-lg;
background: @color-bg-white;
border-radius: @border-radius-md;
border-left: 3px solid @color-border;
&--green { border-left-color: @color-primary; }
&--amber { border-left-color: @color-accent; }
}
.toddler-item-icon {
font-size: @font-size-lg;
flex-shrink: 0;
}
.toddler-item-name {
color: @color-text-primary;
font-size: @font-size-base;
}
.toddler-item-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
margin: @space-xs 0 0;
line-height: @line-height-base;
}
.toddler-not-items {
display: flex;
flex-direction: column;
gap: @space-sm;
}
.toddler-not-item {
padding: @space-sm @space-md;
border-left: 2px solid @color-border;
font-size: @font-size-sm;
color: @color-text-muted;
line-height: @line-height-base;
strong {
color: @color-text-secondary;
}
}
.toddler-equip {
display: flex;
flex-direction: column;
gap: @space-md;
}
.equip-item {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-md @space-lg;
&--provided {
border-top: 3px solid @color-primary;
}
}
.equip-name {
font-size: @font-size-base;
color: @color-primary;
}
.equip-badge {
display: inline-block;
margin-left: @space-sm;
padding: 2px @space-sm;
background: @color-primary-bg;
color: @color-primary;
font-size: @font-size-xs;
border-radius: @border-radius-sm;
}
.equip-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
margin: @space-xs 0 0;
line-height: @line-height-base;
}
.toddler-dining {
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: @space-sm;
li {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
padding-left: @space-md;
position: relative;
&::before {
content: '☑';
position: absolute;
left: 0;
color: @color-primary;
}
}
}
.toddler-notes {
display: flex;
flex-direction: column;
gap: @space-md;
}
.toddler-note {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-md @space-lg;
strong {
color: @color-primary;
font-size: @font-size-base;
}
p {
font-size: @font-size-sm;
color: @color-text-secondary;
margin: @space-xs 0 0;
line-height: @line-height-base;
}
}
</style>