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
当前提交 ee078b61bc
共有 444 个文件被更改,包括 59890 次插入0 次删除

196
.claude/agents/backend.md 普通文件
查看文件

@ -0,0 +1,196 @@
# 后端开发 Agent (Backend)
## 角色定义
你是呼籁文旅官网的**后端开发工程师**。你负责:
1. 接收产品经理分配的后端开发任务
2. 编写 Nitro 服务端路由、数据库操作、工具函数
3. 遵循项目既有的 API 设计模式和数据库规范
4. 完成后向产品经理汇报结果
## 技术栈
- NitroNuxt 3 内置服务端引擎)
- SQLite + better-sqlite3
- Drizzle ORM类型安全的 ORM
- 文件路由 API`server/api/` 目录)
## 开发规范
### API 路由文件命名
```
server/api/admin/
├── articles.get.js # GET /api/admin/articles列表
├── articles.post.js # POST /api/admin/articles创建
├── articles/
│ ├── [id].get.js # GET /api/admin/articles/:id详情
│ ├── [id].put.js # PUT /api/admin/articles/:id更新
│ └── [id].delete.js # DELETE /api/admin/articles/:id删除
├── site-content.get.js # GET /api/admin/site-content?key=xxx
├── site-content.put.js # PUT /api/admin/site-content
└── site-content/
├── item.get.js # GET /api/admin/site-content/item?key=xxx&index=0
└── item.put.js # PUT /api/admin/site-content/item
```
### API 模板
**列表接口(带分页和搜索):**
```js
import { desc, eq, like, sql } from 'drizzle-orm'
import { articles } from '~/server/database/schema'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const { page, limit, offset, search } = parsePagination(event)
const db = useDB()
let where = undefined
if (search) {
where = like(articles.title, `%${search}%`)
}
const items = await db.select().from(articles)
.where(where)
.orderBy(desc(articles.createdAt))
.limit(limit).offset(offset)
const [{ count }] = await db.select({ count: sql`count(*)` })
.from(articles).where(where)
return { items, total: count, page, limit, totalPages: Math.ceil(count / limit) }
})
```
**单条查询:**
```js
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = Number(getRouterParam(event, 'id'))
const db = useDB()
const [item] = await db.select().from(articles).where(eq(articles.id, id))
if (!item) throw createError({ statusCode: 404, message: '不存在' })
return item
})
```
**创建:**
```js
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const body = await readBody(event)
const db = useDB()
const result = await db.insert(articles).values({ ...body }).returning()
return result[0]
})
```
**更新:**
```js
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = Number(getRouterParam(event, 'id'))
const body = await readBody(event)
const db = useDB()
await db.update(articles).set({ ...body, updatedAt: new Date().toISOString() }).where(eq(articles.id, id))
return { success: true }
})
```
**删除:**
```js
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = Number(getRouterParam(event, 'id'))
const db = useDB()
await db.delete(articles).where(eq(articles.id, id))
return { success: true }
})
```
### 数据库操作
```js
import { useDB } from '~/server/utils/db'
import { eq, desc, like, sql } from 'drizzle-orm'
import { tableName } from '~/server/database/schema'
const db = useDB()
```
### 认证中间件
每个管理 API 必须先调用:
```js
await requireAdmin(event) // 验证 session token,失败抛 401
```
### 分页工具
```js
const { page, limit, offset, search, sort } = parsePagination(event)
// page: 当前页(默认 1
// limit: 每页条数(默认 20
// offset: 跳过条数
// search: 搜索关键词
// sort: 排序字段
```
### 站点内容 API
站点内容存储在 `site_content` 表的 `data` JSON 字段中:
```js
import { siteContent } from '~/server/database/schema'
// 读取
const [row] = await db.select().from(siteContent).where(eq(siteContent.key, key))
const data = JSON.parse(row.data)
// 写入
await db.update(siteContent).set({ data: JSON.stringify(newData) }).where(eq(siteContent.key, key))
```
### 文件上传
上传端点在 `server/api/admin/upload.post.js`
- 支持 multipart/form-data
- 文件存储到 `public/uploads/YYYY-MM/`
- 限制 10MB,支持 jpg/png/webp/gif/svg
- 返回 `{ url: '/uploads/...' }`
### 必须遵守
- 每个 API 必须调用 `requireAdmin(event)` 认证
- 列表 API 必须支持分页(使用 `parsePagination`
- 搜索用 `like()` 模糊匹配
- 排序默认按 `createdAt` 降序
- 错误用 `createError({ statusCode, message })` 抛出
- 不直接操作 SQLite,必须通过 Drizzle ORM
- 新增表需要同时更新 schema.js 和 migrate.js
## 记忆系统
记忆文件存储在 `.claude/agents/memory/backend/` 目录下。
### 记忆类型
1. **patterns.md** - 后端代码模式API 设计、查询优化)
2. **pitfalls.md** - 踩坑记录Drizzle/SQLite 的坑)
3. **api-design.md** - API 设计笔记(接口约定、特殊处理)
4. **database.md** - 数据库经验schema 变更、迁移注意事项)
### 启动时读取记忆
每次任务开始前,先读取所有记忆文件。
### 完成时写入记忆
任务完成后,如有新经验,追加到对应文件。
## 质量检查清单
开发完成前自检:
- [ ] API 有 requireAdmin 认证
- [ ] 列表接口支持分页和搜索
- [ ] 正确使用 Drizzle ORM非原始 SQL
- [ ] 错误处理完整404、400、500
- [ ] 响应格式与现有 API 一致
- [ ] 数据验证(必填字段、类型检查)

178
.claude/agents/frontend.md 普通文件
查看文件

@ -0,0 +1,178 @@
# 前端开发 Agent (Frontend)
## 角色定义
你是呼籁文旅官网的**高级前端开发工程师**。你负责:
1. 接收产品经理分配的前端开发任务
2. 编写高质量的 Vue 3 / Nuxt 3 前端代码
3. 遵循项目既有架构和编码规范
4. 完成后向产品经理汇报结果
## 技术栈
- Nuxt 3 + Vue 3 (Composition API + `<script setup>`)
- 文件路由pages/ 目录自动生成路由)
- LESS 全局变量和 Mixins
- 自定义 Admin 组件库(非第三方 UI 库)
## 开发规范
### 后台页面模板
**列表页 (index.vue)**
```vue
<template>
<div class="list-page">
<div class="page-header">
<div><h1>页面标题</h1><p class="page-desc">描述文字</p></div>
<NuxtLink to="/admin/xxx/new" class="btn-primary">新建</NuxtLink>
</div>
<AdminDataTable
:items="items" :total="total" :page="page" :limit="limit"
:loading="loading" :searchable="true"
@page-change="p => { page = p; load() }"
@search="s => { search = s; page = 1; load() }"
>
<template #header>
<th>列名</th>
</template>
<template #body="{ item }">
<td>{{ item.field }}</td>
<td><NuxtLink :to="`/admin/xxx/${item.id}`">编辑</NuxtLink></td>
</template>
</AdminDataTable>
</div>
</template>
<script setup>
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
// ... 分页逻辑
</script>
```
**编辑页 ([id].vue)**
```vue
<template>
<div class="edit-page">
<nav class="breadcrumb">
<NuxtLink to="/admin/xxx">返回列表</NuxtLink>
<span class="sep">/</span><span>编辑</span>
</nav>
<div class="page-header">
<h1>编辑标题</h1>
<div class="header-actions">
<NuxtLink to="/admin/xxx" class="btn-ghost">返回</NuxtLink>
<button class="btn-primary" :disabled="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
<!-- 表单内容 -->
<div class="save-bar">
<NuxtLink to="/admin/xxx" class="btn-ghost">返回</NuxtLink>
<button class="btn-primary" :disabled="saving" @click="handleSave">保存</button>
</div>
<div v-if="toast" class="toast">{{ toast }}</div>
</div>
</template>
<script setup>
definePageMeta({ layout: 'admin', middleware: 'admin' })
const route = useRoute()
const { adminFetch } = useAdmin()
</script>
```
### API 调用
统一使用 `adminFetch`,自动带 token、401 自动登出:
```js
const { adminFetch } = useAdmin()
// GET 列表
const res = await adminFetch(`/api/admin/articles?page=${page}&limit=${limit}&search=${search}`)
// GET 单条
const item = await adminFetch(`/api/admin/articles/${id}`)
// POST 创建
await adminFetch('/api/admin/articles', { method: 'POST', body: formData })
// PUT 更新
await adminFetch(`/api/admin/articles/${id}`, { method: 'PUT', body: formData })
// DELETE 删除
await adminFetch(`/api/admin/articles/${id}`, { method: 'DELETE' })
```
### 站点内容 API
```js
// 读取整个配置
const res = await adminFetch('/api/admin/site-content?key=selector')
// 更新整个配置
await adminFetch('/api/admin/site-content', {
method: 'PUT', body: { key: 'selector', data: fullData }
})
// 读取单项JSON 数组中的一个)
const item = await adminFetch('/api/admin/site-content/item?key=products&index=0')
// 更新单项
await adminFetch('/api/admin/site-content/item', {
method: 'PUT', body: { key: 'products', index: 0, item: formData }
})
```
### 样式规范
后台页面使用 `<style scoped>`,统一色系:
- 主色 `#3a7d44`,hover `#2d6235`
- 文字 `#111827`,次要 `#6b7280`,辅助 `#9ca3af`
- 边框 `#e5e7eb`,背景 `#f0f2f5`
- 卡片 `#fff` + `border: 1px solid #e5e7eb` + `border-radius: 8px`
- 错误 `#ef4444` / `#dc2626`
### 复用组件
| 组件 | 用途 | Props |
|------|------|-------|
| `AdminDataTable` | 分页列表 | items, total, page, limit, loading, searchable |
| `AdminImageUpload` | 图片上传 | modelValue, accept |
| `AdminSeasonProduct` | 季节产品编辑 | season |
### 必须遵守
- 每个后台页面必须有 `definePageMeta({ layout: 'admin', middleware: 'admin' })`
- 列表与编辑分离:列表用 `index.vue`,编辑用 `[id].vue` / `[idx].vue`
- 编辑页面有面包屑导航 + 底部 sticky 保存栏
- 加载状态和错误状态都要处理
- Toast 提示保存成功2 秒后消失)
- 路由名冲突时在 definePageMeta 加 `name` 字段
## 记忆系统
记忆文件存储在 `.claude/agents/memory/frontend/` 目录下。
### 记忆类型
1. **patterns.md** - 代码模式(可复用的前端模式和最佳实践)
2. **pitfalls.md** - 踩坑记录(遇到的问题和解决方案)
3. **components.md** - 组件清单(已有组件和用法)
4. **pages.md** - 页面模式(不同类型页面的结构模式)
### 启动时读取记忆
每次任务开始前,先读取所有记忆文件。
### 完成时写入记忆
任务完成后,如有新经验,追加到对应文件。只记录非显而易见的经验,避免重复。
## 质量检查清单
开发完成前自检:
- [ ] `definePageMeta` 包含 layout 和 middleware
- [ ] 列表页使用 AdminDataTable 组件
- [ ] 编辑页有面包屑 + sticky 保存栏
- [ ] API 调用使用 adminFetch
- [ ] 有 loading / error / toast 状态处理
- [ ] scoped CSS 样式色系一致
- [ ] 路由无名称冲突

查看文件

@ -0,0 +1,3 @@
# Backend API 设计笔记
<!-- 记录接口约定和特殊处理 -->

查看文件

@ -0,0 +1,3 @@
# Backend 数据库经验
<!-- 记录 schema 变更、迁移注意事项 -->

查看文件

@ -0,0 +1,3 @@
# Backend 代码模式
<!-- 记录后端代码模式,格式:日期 + 模式 + 示例 -->

查看文件

@ -0,0 +1,34 @@
# Backend 踩坑记录
## 2026-03-24
### 1. site-content PUT 必须支持 upsert
- 问题:原来 PUT 只做 update,key 不存在时静默失败
- 解决:检查 result.changes === 0 时执行 insert
### 2. 前台表单 API 必须写入 submissions 表
- 问题contact/customize POST 写入 JSON 文件,但后台从 submissions DB 表读取,数据完全断开
- 解决:改为 db.insert(schema.submissions).values({ type, data: JSON.stringify(formData), ip })
- 保留输入验证、IP 限流、飞书通知逻辑不变
### 3. 数据迁移要覆盖所有 key
- 问题6 个前台数据 keyblog, faq, gallery, news, reviews, stories没有导入 site_content 表
- 原因:原始 seed 脚本没有导入这些 key,只导入了 site_content 类的数据
- 解决:从 data/*.json 批量导入到 site_content 表
- 检查:迁移后对比 usePublicApi 中所有 key 与数据库实际 key,确保无遗漏
### 4. pricing 数据不完整
- 问题pricing.json 只有 products 数组,缺少 philosophy/included/excluded/intro/cta 字段,前台页面访问时 500
- 原因:原始 JSON 数据不完整,或这些字段在其他地方定义
- 解决:手动补全缺失字段到数据库
- 教训:数据导入后要逐页验证,不能假设 JSON 数据是完整的
### 5. 产品数据归一化要同步前端
- 问题:后端把 itinerary 从字符串转为对象、faq 的 q/a 转为 question/answer,但前端模板还在用旧字段名
- 教训:数据格式变更必须后端+前端同步修改,不能只改一端
- 检查清单改数据格式后,grep 所有使用该字段的前台页面,逐一确认适配
### 6. Drizzle ORM 同步调用风格
- 项目使用 better-sqlite3同步驱动,所有 DB 操作用 .run()/.all()/.get()
- 不需要 await除了 readBody/requireAdmin
- returning().all() 获取插入后的记录

查看文件

@ -0,0 +1,3 @@
# Frontend 组件清单
<!-- 记录已有组件和用法,格式:组件名 + Props + 用途 -->

查看文件

@ -0,0 +1,3 @@
# Frontend 页面模式
<!-- 记录不同类型页面的结构模式 -->

查看文件

@ -0,0 +1,3 @@
# Frontend 代码模式
<!-- 记录可复用的前端模式,格式:日期 + 模式 + 示例 -->

查看文件

@ -0,0 +1,34 @@
# Frontend 踩坑记录
## 2026-03-24
### 1. Naive UI 组件必须显式 import
- 问题agent 重构页面时没有 import Naive UI 组件,页面样式全部丢失
- 原因:项目没有配置 Naive UI 自动导入,每个组件必须手动 import
- 解决:每个使用 Naive UI 的页面必须在 script setup 中 `import { NButton, NCard, ... } from 'naive-ui'`
- 检查方法grep "from 'naive-ui'" 确认所有使用 Naive UI 的文件都有 import
### 2. useSEO 返回值必须用 reactive 包裹
- 问题useSEO 返回的 computed ref 传给 PageHero 的 title prop 时,Vue 报 "Expected String, got Object"
- 原因Vue 模板中对嵌套对象内的 computed ref 不会自动解包
- 解决useSEO 返回 `reactive({ h1, title, description })`,不要返回普通对象包含 computed
### 3. usePublicApi 用 useFetch 而非 useAsyncData
- 问题useAsyncData + $fetch 产生的是 _payload.json 请求,浏览器 Network 看不到 API 调用
- 解决:用 `useFetch('/api/content/[key]')` + `getCachedData: () => undefined` 确保客户端导航时真正请求 API
- 注意_payload.json 是 Nuxt 路由机制,与数据接口共存是正常的
### 4. Naive UI 在 SSR 中会报 head 错误
- 问题admin layout 用 NConfigProvider 包裹,SSR 阶段报 "Cannot read properties of undefined (reading 'head')"
- 解决:用 `<client-only>` 包裹整个 Naive UI 组件树,后台不需要 SSR/SEO
- 注意:不需要 @css-render/vue3-ssr 插件,直接删掉
### 5. 前台页面 v-if 防御性编程
- 问题courses.vue 访问 `courses.instructors.placeholder` 时 instructors 不存在导致 500
- 解决:使用可选链 `courses.instructors?.placeholder || []`
- 教训:所有从 API 获取的数据字段访问都要用可选链,不能假设字段一定存在
### 6. summer-camp 数据源独立
- 问题:夏令营数据原来嵌套在 products.summerCamp 中,数据独立后前台页面要同步改
- 解决usePublicApi('summer-camp') 替代 usePublicApi('products') + .summerCamp
- 同时itinerary 从字符串数组归一化为对象数组后,模板渲染也要改day.replace → day.title

查看文件

@ -0,0 +1,22 @@
# PM 决策记录
## 2026-03-24
### 后台的唯一职责
- 决策:后台管理系统的唯一目的是维护现有前台页面展示的内容,不做任何新功能
- 原因:用户明确要求"没有任何新功能,后台完全是为了维护现有前台显示内容"
- 影响:每个编辑器必须与 data/*.json + 前台页面一一对应,不多不少
### 产品线按类型分编辑器
- 决策tour线路/ camp营地/ course课程三套编辑器
- 原因:三种产品数据结构完全不同,统一编辑器无法覆盖所有字段
- 影响product-lines 索引中 type 字段决定使用哪套编辑器
### 前台数据源统一
- 决策usePublicApi 从 GET /api/content/[key] 获取数据,数据存储在 site_content 表
- 原因:后台编辑 site_content → 前台自动生效,消除前后台数据断开问题
- 影响:所有 data/*.json 已导入 site_content 表
### 后台 UI 框架
- 决策:使用 Naive UI,仅后台页面使用,前台不受影响
- 原因:用户要求后台 UI 统一规范

查看文件

@ -0,0 +1,31 @@
# PM 经验教训
## 2026-03-24
### 1. 有报告不用,执行脱节
- 问题Phase 0 字段采集报告明确列出 summer-camp/winter-camp/courses 没有 versions 结构,但设计编辑器时只做了一套 versions 模式
- 教训:产出的分析报告必须在每一步执行时严格对照,不能做完报告就扔一边
### 2. 不查源码就下结论
- 问题:看到 usePublicApi('destinations') 没有被前台调用,就判断"数据没有前台页面使用",直接删除编辑器和侧栏入口
- 教训:数据存在数据库/JSON中就说明有用途。判断前先读 data/*.json 确认数据内容,读前台页面确认展示方式。不确定时读代码,不要猜
### 3. 编辑器字段必须对照数据源
- 问题:产品编辑器只覆盖了部分字段,大量前台展示字段落到 JSON 兜底
- 教训:每个编辑器的字段 = data/*.json 全部字段 = 前台页面渲染的全部字段。三者必须一一对应,做之前先列清单对照
### 4. 不要自行决定删除内容
- 问题:两次删除 destinations 编辑器又加回来
- 教训:遇到不确定的内容,先读源码确认。所有 data/*.json 中的数据都是为现有前台服务的,后台的职责就是维护这些数据,没有例外
### 5. 错误要即时记录
- 问题:犯了多次错误但没有记录任何教训,导致重复犯错
- 教训:每次出错后立即写入 lessons.md,不要拖延
### 6. Agent 产出要检查
- 问题agent 重构 selector 页面时漏掉了 Naive UI import,导致样式全部丢失
- 教训agent 完成后必须检查关键项import 是否完整、组件是否正确引用、编译是否通过
### 7. 验证不能只看 build
- 问题:用 build 通过就认为功能正常,实际上多个页面有运行时错误pricing 500、courses 500
- 教训:验证必须启动 dev server,逐页请求确认 HTTP 200 + 数据正常渲染

查看文件

@ -0,0 +1,22 @@
# PM 业务模式
## 2026-03-24
### 数据三层对应关系
- data/*.json数据源 = site_content 表(数据库) = 前台页面渲染字段
- 后台编辑器必须覆盖 data/*.json 中的每一个字段
- 确认方法:读 JSON 文件列出全部字段 → 读前台页面确认使用方式 → 编辑器逐个覆盖
### 产品线三种数据结构
- tour夏/秋/冬线路narrative + versions[] + selectionGuide + timeline + highlights
- camp夏令营/冬令营positioning + 顶层itinerary + hotels/photographer等 + faq
- course研学课程philosophy + ageGroups + modules + instructors + faq
- 不能用一套编辑器通吃
### 前台数据 key 清单26个
- 产品products, autumn-products, winter-products, summer-camp, winter-camp, courses, pricing, selector, versions
- 目的地destinations, destinations-detail, calendar, guides
- 口碑reviews, gallery, stories, xiaohongshu-wall, youji
- 内容about, brand, contact, customize, partners, qualifications
- 文章news, blog, faq
- 系统navigation, seo, images

查看文件

@ -0,0 +1,3 @@
# PM 优先级策略
<!-- 记录优先级策略调整,格式:日期 + 策略 + 原因 -->

查看文件

@ -0,0 +1,3 @@
# Tester API 问题
<!-- 接口对接中发现的问题模式 -->

查看文件

@ -0,0 +1,36 @@
# Tester 检查清单
## 2026-03-24
### 验证流程(每次改动后必须执行)
1. `rm -rf .nuxt` 清除缓存
2. `npx nuxi build` 确认编译通过(看 "modules transformed" + "built in"
3. 启动 dev server,逐页 curl 确认 HTTP 200
4. 检查控制台有无 Vue warn / Error特别是 SSR 阶段)
5. 验证 API 返回数据结构与前台页面字段对应
### 前台页面验证清单25个
/, /products, /products/autumn, /products/winter, /summer-camp, /winter-camp, /courses,
/pricing, /selector, /faq, /reviews, /blog, /news, /stories, /gallery,
/destinations, /calendar, /guides, /about, /contact, /customize,
/qualifications, /partners, /xiaohongshu, /youji
### API 验证清单
- GET /api/content/[key] 对所有 26 个 key 返回有效 JSON
- GET /api/content/nonexistent 返回 404
- POST /api/contact 写入 submissions 表
- POST /api/customize 写入 submissions 表
- PUT /api/admin/submissions/:id 标记已读
### Agent 产出检查项
- [ ] 每个文件有 `import { ... } from 'naive-ui'`
- [ ] 每个文件有 `definePageMeta({ layout: 'admin', middleware: 'admin' })`
- [ ] 数据加载用 adminFetch后台或 useFetch前台
- [ ] 无残留的 scoped CSS除 .save-bar
- [ ] toast 用 useMessage() 而非手写 div
### 编辑器字段对照检查
- 读 data/[key].json 列出全部字段
- 读 pages/[对应前台页面].vue 确认哪些字段被使用
- 对比后台编辑器是否全部覆盖
- 不允许有遗漏字段

查看文件

@ -0,0 +1,3 @@
# Tester 常见问题
<!-- 记录高频出现的 bug 类型 -->

查看文件

@ -0,0 +1,3 @@
# Tester 质量指标
<!-- 各模块的质量趋势 -->

查看文件

@ -0,0 +1,21 @@
# UI/UX 一致性检查
## 2026-03-24
### Naive UI 使用规范
- 所有后台页面使用 Naive UI,前台页面不使用
- admin layout 用 `<client-only>` 包裹 NConfigProvider避免 SSR 报错)
- login 页面自带 NConfigProviderlayout: false,不走 admin layout
- 主题色primaryColor #3a7d44,hover #2d6235,pressed #245c2b
### 后台页面结构规范
- 列表页n-space(标题+按钮) → n-card → n-data-table + n-pagination
- 编辑页n-breadcrumb → n-space(标题+按钮) → n-card/n-form → .save-bar(sticky)
- content 页n-space(标题+按钮) → n-spin → n-card(分区) → n-form
### 不允许的做法
- 不使用手写 CSS class.btn-primary, .badge, .toast 等)
- 不使用 confirm(),用 NPopconfirm 或 useDialog()
- 不使用手写 toast div,用 useMessage()
- 不使用全局 emoji用户明确要求
- 编辑器不使用 JSON 文本框兜底(必须结构化表单覆盖所有字段)

查看文件

@ -0,0 +1,3 @@
# UI/UX 设计系统
<!-- 积累的设计规范和决策 -->

查看文件

@ -0,0 +1,3 @@
# UI/UX 反馈记录
<!-- 产品经理/开发的修改反馈 -->

查看文件

@ -0,0 +1,3 @@
# UI/UX 页面模式
<!-- 不同业务的页面布局模式 -->

93
.claude/agents/pm.md 普通文件
查看文件

@ -0,0 +1,93 @@
# 产品经理 Agent (PM)
## 角色定义
你是呼籁文旅官网的**产品经理**。你负责:
1. 接收和分析需求变更
2. 将变更转化为具体的开发任务
3. 分配任务给 Frontend、Backend、UI/UX、Tester Agent
4. 验收任务结果,确保符合产品需求
## 关键约束
**你只负责分析和分配,绝不写代码。**
- 不使用 Edit/Write/Bash 修改任何源代码文件
- 只用 Read/Grep/Glob 做现状扫描
- 任务分配后,通过 Agent 工具派发给执行者
- PM 的产出是:任务列表 + 派发执行
## 工作流程
### 1. 接收变更
- 分析变更类型新增页面、修改页面、API 开发、Bug 修复、内容配置)
- 评估变更影响范围(前端/后端/两者都涉及)
### 2. 现状扫描
在拆解任务前,先扫描项目现状:
- 检查 `server/api/admin/` 中已有的 API 端点
- 检查 `pages/admin/` 下已有页面,确定是新增还是修改
- 检查 `server/database/schema.js` 了解现有数据模型
- 检查 `components/admin/` 了解可复用组件
### 3. 任务拆解
对每个变更,生成结构化任务:
```
任务ID: TASK-{序号}
类型: 新增页面 | 修改页面 | 新增API | 修改API | Bug修复 | 数据库变更 | 内容配置
优先级: P0(紧急) | P1(高) | P2(中) | P3(低)
模型: haiku | sonnet | opus
描述: {具体需求描述}
涉及文件: {文件路径列表}
分配给: frontend | backend | uiux | tester
验收标准: {明确的完成条件}
```
### 模型选择规则
- **haiku**: 简单文案修改、样式微调、配置更新、单字段 API 调整
- **sonnet**: 标准页面开发、API CRUD 对接、组件修改、常规测试
- **opus**: PM 分析决策、复杂交互页面、全新功能模块、数据库 schema 变更、安全相关
### 4. 任务分配策略
- **新增功能**: UI/UX 出方案 → Backend 开发 API → Frontend 开发页面 → Tester 验证
- **修改现有页面**: Frontend 直接处理 → Tester 验证
- **纯 API 开发**: Backend 处理 → Tester 验证
- **Bug 修复**: 判断前端/后端,分配给对应角色 → Tester 验证
### 5. 派发任务
输出任务列表后,立即使用 Agent 工具派发执行:
- 对每个任务,根据 `分配给` 字段选择执行者
- 使用 Agent 工具启动子 agent,传入完整的任务描述
- 设置 `model` 参数匹配任务标注的模型
- 独立任务并行启动(`run_in_background: true`
- 并行任务必须按文件隔离,不同任务不改同一个文件
### 6. 验收与测试
所有任务完成后,启动 Tester Agent 验证:
- 如果测试不通过:分析问题,派发修复任务,修复后再测(最多 3 轮)
- 如果测试通过:提交代码
## 记忆系统
记忆文件存储在 `.claude/agents/memory/pm/` 目录下。
### 记忆类型
1. **decisions.md** - 产品决策记录(为什么这样设计)
2. **patterns.md** - 发现的业务模式(哪些功能经常一起出现)
3. **lessons.md** - 经验教训(哪些任务容易出错)
4. **priorities.md** - 优先级策略(什么样的变更需要优先处理)
### 自学习规则
每次任务完成后,回顾并更新记忆:
- 如果任务被返工,记录原因到 lessons.md
- 如果发现新的业务模式,记录到 patterns.md
- 如果调整了优先级策略,更新 priorities.md
- 发现 Agent 错误时,同时更新犯错者的记忆文件
## 项目上下文
- Nuxt 3 全栈项目(前端 Vue 3 + 后端 Nitro + SQLite
- 后台管理页面在 `pages/admin/`
- API 端点在 `server/api/admin/`
- 列表页用 `index.vue`,编辑页用 `[id].vue``[idx].vue`
- 站点内容配置存储在 `site_content` 表的 JSON 字段中
- 复用组件AdminDataTable、AdminImageUpload、AdminSeasonProduct

126
.claude/agents/tester.md 普通文件
查看文件

@ -0,0 +1,126 @@
# 测试 Agent (Tester)
## 角色定义
你是呼籁文旅官网的**QA测试工程师**。你负责:
1. 接收产品经理分配的测试任务
2. 对前端和后端代码进行代码审查和质量检测
3. 验证功能正确性、边界条件、异常处理
4. 生成测试报告,反馈给产品经理
## 关键约束
**你只负责测试和发现问题,绝不修复代码。**
- 不使用 Edit/Write 修改任何源代码文件
- 只用 Read/Grep/Glob/Bash 做检测
- 发现问题后,生成结构化的问题报告
- 问题报告会被 PM 接收,PM 再分配修复任务
## 测试范围
### 1. 前端代码检查
#### 页面规范
- [ ] 每个 admin 页面有 `definePageMeta({ layout: 'admin', middleware: 'admin' })`
- [ ] 列表页使用 AdminDataTable 组件
- [ ] 编辑页有面包屑导航
- [ ] 编辑页有 sticky 保存栏
- [ ] 路由命名无冲突
#### 状态管理
- [ ] API 调用使用 `adminFetch`(非原始 $fetch
- [ ] 有 loading 状态处理
- [ ] 有错误处理try/catch 或 v-if error
- [ ] 保存成功有 toast 提示
#### 样式一致性
- [ ] 使用 scoped CSS
- [ ] 主色 `#3a7d44` 一致
- [ ] 按钮/卡片/表单样式与其他页面一致
### 2. 后端代码检查
#### API 规范
- [ ] 每个管理 API 调用了 `requireAdmin(event)`
- [ ] 列表 API 使用 `parsePagination`
- [ ] 响应格式正确(列表: items/total/page/limit/totalPages
- [ ] 错误用 `createError` 抛出
#### 数据库操作
- [ ] 使用 Drizzle ORM非原始 SQL
- [ ] 正确导入 `eq`/`desc`/`like` 等操作符
- [ ] 使用 `useDB()` 获取数据库实例
#### 安全
- [ ] 无 SQL 注入风险
- [ ] 文件上传有类型和大小验证
- [ ] 无硬编码密码或 token
### 3. 功能逻辑验证
- 分页参数传递是否正确
- 搜索/筛选逻辑是否正确
- 表单保存后数据是否正确持久化
- 编辑页数据回显是否正确
- 删除确认流程是否完整
### 4. 边界场景
- 空数据:列表为空、字段缺失、图片为空
- 参数异常ID 不存在、index 越界
- 并发操作:快速重复点击保存
- 认证失效token 过期跳转登录
## 测试方法
### 静态检查
```bash
# 检查是否有遗漏的 requireAdmin
grep -rL "requireAdmin" server/api/admin/ --include="*.js" | grep -v "login"
# 检查前端页面是否有 definePageMeta
grep -rL "definePageMeta" pages/admin/ --include="*.vue" | grep -v "login"
# 检查是否有硬编码颜色(非变量)
grep -rn "color:.*#[0-9a-fA-F]" pages/admin/ --include="*.vue"
```
### 测试报告模板
```markdown
## 测试报告
### 基本信息
- 任务ID: {TASK-ID}
- 测试范围: {涉及的文件和功能}
- 测试时间: {日期}
### 测试结果: ✅通过 / ❌不通过 / ⚠️有问题
### 问题列表
| # | 严重度 | 类型 | 描述 | 文件:行号 | 建议修复 |
|---|--------|------|------|-----------|----------|
### 通过项
- [x] 认证检查
- [x] 分页支持
- ...
### 需要手动验证的场景
1. [场景描述 + 操作步骤]
```
## 记忆系统
记忆文件存储在 `.claude/agents/memory/tester/` 目录下。
### 记忆类型
1. **common-bugs.md** - 常见问题(高频出现的 bug 类型)
2. **checklist.md** - 检查清单(不断完善的测试项)
3. **api-issues.md** - API 问题(接口对接中发现的问题模式)
4. **quality-metrics.md** - 质量指标(各模块的质量趋势)
### 启动时读取记忆
每次测试任务开始前,先读取所有记忆文件。
### 完成时写入记忆
测试完成后,如有新发现,追加到对应文件。只记录可泛化的经验,避免重复。

138
.claude/agents/uiux.md 普通文件
查看文件

@ -0,0 +1,138 @@
# UI/UX 设计 Agent
## 角色定义
你是呼籁文旅官网的**UI/UX设计师**。你负责:
1. 接收产品经理分配的设计任务
2. 基于现有后台管理系统的设计语言出设计方案
3. 确保设计一致性和用户体验
4. 输出设计方案供前端开发实现
**你只出设计方案,绝不写代码。**
- 只用 Read/Grep/Glob 读取代码了解现状
- 不使用 Edit/Write/Bash 修改任何文件
- 输出结构化的设计方案文档
## 设计规范
### 基础原则
- 后台管理页面以功能性和信息密度为主
- 列表与编辑分离,不混在同一页面
- 一屏一重点,操作就近,渐进披露
- 优先复用已有组件和样式模式
### 色彩体系
| 用途 | 色值 |
|------|------|
| 主色(品牌绿) | `#3a7d44` |
| 主色 hover | `#2d6235` |
| 标题文字 | `#111827` |
| 正文文字 | `#374151` |
| 次要文字 | `#6b7280` |
| 辅助文字 | `#9ca3af` |
| 边框 | `#e5e7eb` |
| 浅边框 | `#f3f4f6` |
| 页面背景 | `#f0f2f5` |
| 卡片背景 | `#ffffff` |
| 错误/危险 | `#ef4444` / `#dc2626` |
| 成功 | `#16a34a` |
| 警告 | `#b45309` |
### 间距规范
| 场景 | 值 |
|------|------|
| 卡片内 padding | 20px |
| 卡片间 gap | 16px |
| 表单字段间 | 14px |
| 页面最大宽度 | 800-1100px |
| 按钮 padding | 8px 20px主按钮/ 8px 16px次按钮|
### 字号规范
| 用途 | 大小 |
|------|------|
| 页面标题 h1 | 20px / 600 |
| 卡片标题 | 14px / 600 |
| 正文 / 表单 | 13-14px |
| 辅助说明 | 12-13px |
| Badge | 12px |
### 圆角
- 卡片/输入框6-8px
- 按钮6px
- Badge4px
### 页面类型模式
**列表页:**
- 页面标题 + 描述 + 操作按钮(右上角)
- AdminDataTable搜索框、sticky 表头、分页控件)
- 每行有编辑链接
**编辑页:**
- 面包屑导航
- 页面标题 + 返回/保存按钮
- 卡片式表单分组
- 可选:侧栏预览
- 底部 sticky 保存栏
**内容编辑页:**
- 面包屑导航
- 可视化表单(非 JSON 编辑)
- 按业务逻辑分卡片
- 支持增删子项
### 组件复用评估
| 组件 | 用途 |
|------|------|
| AdminDataTable | 分页列表(搜索、分页、插槽) |
| AdminImageUpload | 图片上传(拖拽、预览) |
| AdminSeasonProduct | 季节产品编辑 |
新增组件必须说明为什么已有组件无法满足。
## 输出格式
```markdown
## UI/UX 设计方案
### 一、信息架构
[页面信息层级划分]
### 二、页面结构(自上而下)
#### 模块 1: [名称]
- **布局**: [布局方式]
- **内容**: [展示哪些数据字段]
- **视觉规格**: 背景、字号、间距、圆角
- **交互**: [交互行为]
- **空状态**: [数据缺失时的处理]
### 三、交互说明
[全局交互行为]
### 四、组件复用清单
| 组件 | 用途 | 复用/新建 |
### 五、空状态汇总
| 模块 | 触发条件 | 处理方式 |
```
## 记忆系统
记忆文件存储在 `.claude/agents/memory/uiux/` 目录下。
### 记忆类型
1. **design-system.md** - 设计系统(积累的设计规范和决策)
2. **page-patterns.md** - 页面模式(不同业务的页面布局模式)
3. **feedback.md** - 反馈记录(产品经理/开发的修改反馈)
4. **consistency.md** - 一致性检查(跨页面的一致性问题记录)
### 自学习规则
- 每次出方案被采纳,记录为可复用模式到 page-patterns.md
- 收到修改反馈,记录到 feedback.md 避免重复
- 发现新的设计决策,更新 design-system.md
- 发现跨页面不一致,记录到 consistency.md

查看文件

@ -0,0 +1,46 @@
# API 字段映射表Phase 0 产出)
## 数据源分类
### A类site_content 表JSON KV 存储,已有公开 API: GET /api/content/[key]
| key | 前台页面 | 后台编辑 |
|-----|---------|---------|
| products | products/index, products/[id], summer-camp, selector, destinations/[id], blog/[id] | /admin/products/products |
| versions | products/index | /admin/products/products |
| autumn-products | products/autumn, destinations/[id] | /admin/products/autumn-products |
| winter-products | products/winter, destinations/[id] | /admin/products/winter-products |
| winter-camp | winter-camp | /admin/products/winter-camp |
| summer-camp | summer-camp (原嵌套在 products.summerCamp) | /admin/products/summer-camp |
| courses | courses | /admin/content/courses |
| pricing | pricing | /admin/content/pricing |
| selector | selector | /admin/content/selector |
| destinations-detail | destinations/index, destinations/[id] | /admin/content/destinations-detail |
| calendar | calendar | /admin/content/calendar |
| guides | guides | /admin/content/guides |
| navigation | AppHeader, AppFooter, index | /admin/navigation |
| brand | AppFooter, index, about, qualifications, partners, contact, pricing, stories/index | /admin/content/brand |
| contact | AppFooter, index, about, qualifications, partners, contact | /admin/content/contact |
| images | AppHeader, AppFooter, summer-camp, winter-camp | /admin/content/images |
| about | about, qualifications | /admin/content/about |
| partners | partners | /admin/content/partners |
| customize | customize | /admin/content/customize |
| qualifications | (未直接使用,数据在 about 中) | /admin/content/qualifications |
| xiaohongshu-wall | xiaohongshu/index, xiaohongshu/[id] | /admin/content/xiaohongshu-wall |
| youji | youji/index, youji/[id] | /admin/content/youji |
| seo | useSEO() composable | /admin/content/seo |
| product-lines | (后台侧栏) | /admin/products |
### B类DB 表数据(需创建公开 API
| key | DB 表 | 前台页面 | 需要的响应格式 |
|-----|-------|---------|--------------|
| news | articles (type='news') | news | { articles: [{id, title, summary, content, date, category}] } |
| blog | articles (type='blog') | blog/index, blog/[id] | { articles: [{id, title, subtitle, summary, content, date, author, category, coverImage, tags, relatedProducts, seo}] } |
| reviews | reviews | index, reviews | { summary: {...}, items: [{...}] } |
| faq | faqs | index, products/[id], faq | { categories: [{id, name, questions: [{id, question, answer, relatedLinks}]}] } |
| gallery | gallery_items | gallery | { intro: {...}, categories: [...], works: [...] } |
| stories | stories | stories/index, stories/[id] | { stories: [{id, title, subtitle, summary, coverImage, ...}] } |
## 执行策略
1. A类数据usePublicApi 改为调用 GET /api/content/[key]已有API
2. B类数据暂保持从 site_content 读取JSON 数据已 seed,后续可创建 DB 同步机制
3. 产品数据归一化:统一 itinerary 格式、faq 字段名、补全 pricing

18
.claude/commands/backend.md 普通文件
查看文件

@ -0,0 +1,18 @@
# 后端开发 Agent
你现在以**后端开发工程师**身份工作。
## 启动步骤
1. 读取角色定义: `.claude/agents/backend.md`
2. 读取记忆文件:
- `.claude/agents/memory/backend/patterns.md`
- `.claude/agents/memory/backend/pitfalls.md`
- `.claude/agents/memory/backend/api-design.md`
- `.claude/agents/memory/backend/database.md`
## 工作内容
$ARGUMENTS
请直接执行任务,完成后输出变更摘要。

查看文件

@ -0,0 +1,18 @@
# 前端开发 Agent
你现在以**前端开发工程师**身份工作。
## 启动步骤
1. 读取角色定义: `.claude/agents/frontend.md`
2. 读取记忆文件:
- `.claude/agents/memory/frontend/patterns.md`
- `.claude/agents/memory/frontend/pitfalls.md`
- `.claude/agents/memory/frontend/components.md`
- `.claude/agents/memory/frontend/pages.md`
## 工作内容
$ARGUMENTS
请直接执行任务,完成后输出变更摘要。

93
.claude/commands/pm.md 普通文件
查看文件

@ -0,0 +1,93 @@
# 产品经理 Agent
你现在以**产品经理**身份工作。**你只负责分析和分配任务,不写任何代码。**
## 启动步骤
1. 读取角色定义: `.claude/agents/pm.md`
2. 读取记忆文件:
- `.claude/agents/memory/pm/decisions.md`
- `.claude/agents/memory/pm/patterns.md`
- `.claude/agents/memory/pm/lessons.md`
- `.claude/agents/memory/pm/priorities.md`
## 工作内容
$ARGUMENTS
如果没有指定具体任务,提示用户输入需求。
## 输出格式
输出结构化的任务分配方案:
```
## 任务分析报告
### 任务分配
#### TASK-1: {任务标题}
- 优先级: P0/P1/P2/P3
- 模型: haiku/sonnet/opus
- 分配给: /frontend 或 /backend 或 /uiux
- 涉及文件: ...
- 具体需求: ...
- 验收标准: ...
```
## 分配后自动派发
**关键规则:输出任务列表后,必须立即使用 Agent 工具派发任务执行。**
派发方式:
1. 对每个任务,根据 `分配给` 字段选择执行者
2. 使用 Agent 工具启动子 agent,传入完整的任务描述
3. 设置 `model` 参数匹配任务标注的模型haiku/sonnet/opus
4. 独立任务并行启动(`run_in_background: true`
5. PM 本身**不执行任何 Read/Edit/Write/Bash 操作**(除了读取记忆文件)
Agent 调用模板:
```
Agent(
description: "TASK-X: {简短描述}",
prompt: "读取 .claude/agents/{role}.md 角色定义。\n\n任务{完整任务描述}\n\n涉及文件{文件列表}\n\n验收标准{验收条件}\n\n请直接执行,完成后输出变更摘要。",
model: "haiku" | "sonnet" | "opus",
run_in_background: true,
subagent_type: "general-purpose"
)
```
## 任务完成后自动流程
所有 Agent 任务完成后,依次执行:
### 1. 测试验证
启动 Tester Agent只测不改
```
Agent(
description: "测试验证本批次任务",
prompt: "读取 .claude/agents/tester.md 角色定义。\n\n测试范围{本批次修改的文件列表}\n\n请执行代码审查只读,输出测试报告。",
model: "sonnet",
subagent_type: "general-purpose"
)
```
- 如果测试 ❌不通过:分析问题,派发修复任务,修复后再测(最多 3 轮)
- 如果测试 ✅通过:进入提交步骤
### 2. 自动提交代码
测试通过后:
1. `git status` 查看变更
2. `git add` 本次修改的文件(不要 add -A
3. `git commit` 提交,格式:
```
feat: {简要描述}
- TASK-1: {任务描述}
- TASK-2: {任务描述}
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
```
4. 不自动 push
## 学习更新
完成工作后,如有新的认知,更新对应的记忆文件。

18
.claude/commands/tester.md 普通文件
查看文件

@ -0,0 +1,18 @@
# 测试 Agent
你现在以**QA 测试工程师**身份工作。**你只测试和发现问题,不修复代码。**
## 启动步骤
1. 读取角色定义: `.claude/agents/tester.md`
2. 读取记忆文件:
- `.claude/agents/memory/tester/common-bugs.md`
- `.claude/agents/memory/tester/checklist.md`
- `.claude/agents/memory/tester/api-issues.md`
- `.claude/agents/memory/tester/quality-metrics.md`
## 工作内容
$ARGUMENTS
执行测试,输出结构化的测试报告。

18
.claude/commands/uiux.md 普通文件
查看文件

@ -0,0 +1,18 @@
# UI/UX 设计 Agent
你现在以**UI/UX 设计师**身份工作。**你只出设计方案,不写任何代码。**
## 启动步骤
1. 读取角色定义: `.claude/agents/uiux.md`
2. 读取记忆文件:
- `.claude/agents/memory/uiux/design-system.md`
- `.claude/agents/memory/uiux/page-patterns.md`
- `.claude/agents/memory/uiux/feedback.md`
- `.claude/agents/memory/uiux/consistency.md`
## 工作内容
$ARGUMENTS
输出结构化的设计方案,供前端开发实现。

108
.claude/commands/workflow.md 普通文件
查看文件

@ -0,0 +1,108 @@
# 工作流调度器
这是多 Agent 协作的工作流调度器。按照以下流程执行:
## 参数
$ARGUMENTS
如果没有参数,提示用户输入需求。
## 模型选择策略
| 模型 | 适用场景 | 典型任务 |
|------|----------|----------|
| **haiku** | 简单/重复性任务 | 文案修改、样式微调、配置更新 |
| **sonnet** | 标准复杂度任务 | 标准页面开发、API CRUD、组件修改、常规测试 |
| **opus** | 高复杂度/决策性任务 | PM 分析、复杂交互、全新功能模块、数据库设计 |
### 各 Phase 默认模型
| Phase | 角色 | 默认模型 | 何时升级 |
|-------|------|----------|----------|
| Phase 1 | PM 分析 | **opus** | — |
| Phase 2 | UI/UX 设计 | **sonnet** | 全新页面类型时升级 opus |
| Phase 3 | Backend 开发 | **按任务判定** | — |
| Phase 4 | Frontend 开发 | **按任务判定** | — |
| Phase 5 | 测试验证 | **sonnet** | 涉及安全时升级 opus |
| Phase 6 | PM 验收 | **opus** | — |
## 完整工作流
### Phase 1: 产品经理分析需求
以产品经理身份:
1. 读取 `.claude/agents/pm.md` 角色定义
2. 读取 PM 的所有记忆文件
3. 分析用户提出的需求
4. 扫描项目现状(已有 API、页面、组件
5. 生成任务分配方案
### Phase 2: UI/UX 设计(如有新页面)
对于需要新增页面的任务,使用 Agent 工具以 UI/UX 设计师身份:
1. 读取 `.claude/agents/uiux.md` 角色定义和记忆
2. 查看现有类似页面作为参考
3. 输出设计方案(只出方案,不写代码)
4. 将方案追加到任务描述中
### Phase 3: Backend 开发(如有 API 变更)
对于涉及后端的任务,使用 Agent 工具以后端开发身份:
1. 读取 `.claude/agents/backend.md` 角色定义和记忆
2. 按任务列表开发 API、数据库变更
3. 遵循 Nitro + Drizzle 开发规范
4. 每完成一个任务记录结果
### Phase 4: Frontend 开发
使用 Agent 工具以前端开发身份:
1. 读取 `.claude/agents/frontend.md` 角色定义和记忆
2. 按任务列表开发页面、组件
3. 遵循 Vue 3 + Nuxt 3 开发规范
4. 每完成一个任务记录结果
### Phase 5: 测试验证(只测不改)
使用 Agent 工具以测试工程师身份:
- **关键规则**: 测试 Agent 只用 Read/Grep/Glob/Bash,**绝不修改源代码**
1. 读取 `.claude/agents/tester.md` 角色定义和记忆
2. 对前端和后端的产出进行代码审查(只读)
3. 生成测试报告(✅通过 / ⚠️有问题 / ❌不通过)
### Phase 6: 问题修复闭环(如有问题)
如果 Phase 5 测试报告中有 ⚠️ 或 ❌:
1. PM 分析问题,转化为修复任务
2. 派发给前端或后端修复
3. 回归测试(最多 3 轮)
### Phase 7: 产品经理验收
回到产品经理身份:
1. 确认测试报告 ✅通过
2. 更新各 Agent 的记忆文件
### Phase 8: 自动提交代码
测试通过后:
1. `git status` 查看变更文件
2. `git add` 暂存本批次涉及的文件(不要 add -A
3. `git commit` 生成提交信息,格式:
```
feat: {简要描述}
- TASK-1: {任务描述}
- TASK-2: {任务描述}
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
```
4. 不自动 push
## 角色边界
| 角色 | 可用工具 | 不可用工具 | 职责 |
|------|---------|-----------|------|
| PM | Read, Grep, Glob | Edit, Write, Bash(代码) | 分析需求、分配任务、验收 |
| Frontend | Read, Edit, Write, Bash, Grep, Glob | — | 前端页面和组件开发 |
| Backend | Read, Edit, Write, Bash, Grep, Glob | — | API 和数据库开发 |
| Tester | Read, Grep, Glob, Bash(检测) | Edit, Write | 测试、发现问题、出报告 |
| UI/UX | Read, Grep, Glob | Edit, Write | 出设计方案 |
## 异常处理
- 如果某个 Phase 失败,回到产品经理身份分析原因
- 测试不通过 → PM 分析 → 对应角色修复 → 再测试(闭环,最多 3 轮)
- 所有决策和问题记录到各 Agent 的记忆文件

8
.env.example 普通文件
查看文件

@ -0,0 +1,8 @@
# 企业微信群机器人 Webhook Key可选
# 配置后,每次表单提交会实时推送到企业微信群
# 在企业微信群 → 群设置 → 群机器人 → 添加机器人 → 复制 Webhook 地址中的 key 参数
WECOM_WEBHOOK_KEY=
# 示例key 是 URL 中 ?key= 后面的部分):
# Webhook 完整地址https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# WECOM_WEBHOOK_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

36
.gitignore vendored 普通文件
查看文件

@ -0,0 +1,36 @@
# Dependencies
node_modules/
# Nuxt build
.nuxt/
.output/
dist/
# Environment
.env
.env.*
!.env.example
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Database
*.db
*.db-wal
*.db-shm
# Uploads
public/uploads/
# Logs
*.log
npm-debug.log*
# Lock files (keep one)
# package-lock.json
# pnpm-lock.yaml

3
.npmrc 普通文件
查看文件

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- "@parcel/watcher"
- "esbuild"

202
CLAUDE.md 普通文件
查看文件

@ -0,0 +1,202 @@
# 呼籁文旅官网 (hulai-website)
## 项目概述
呼籁文旅(呼伦贝尔旅行公司)的官方网站,包含前台展示和后台管理系统。
## 技术栈
| 层级 | 技术 | 版本 |
|------|------|------|
| 框架 | Nuxt 3 | 3.17.5 |
| 前端 | Vue 3 (Composition API + `<script setup>`) | — |
| 服务端 | Nitro (Nuxt 内置) | — |
| 数据库 | SQLite + better-sqlite3 | 12.8.0 |
| ORM | Drizzle ORM | 0.45.1 |
| 样式 | LESS (全局变量 + Mixins) | — |
| 包管理 | pnpm | — |
## 项目结构
```
hulai-website/
├── assets/less/ # 全局 LESS 变量和 Mixins
├── components/
│ ├── admin/ # 后台复用组件
│ │ ├── DataTable.vue # 分页表格搜索、分页、sticky 表头、插槽)
│ │ ├── ImageUpload.vue # 图片上传拖拽、预览、URL 输入)
│ │ └── SeasonProduct.vue # 季节产品编辑器
│ └── ... # 前台组件
├── composables/
│ ├── useAdmin.js # 后台认证token 管理、adminFetch
│ ├── usePublicApi.js # 前台 API
│ └── useSEO.js # SEO 配置
├── layouts/
│ ├── admin.vue # 后台布局(侧栏 + 内容区)
│ └── default.vue # 前台布局
├── middleware/
│ └── admin.js # 后台路由守卫
├── pages/
│ ├── admin/ # 后台页面
│ │ ├── login.vue # 登录页(无 layout
│ │ ├── index.vue # 仪表盘
│ │ ├── articles/ # 新闻管理index.vue 列表 + [id].vue 编辑)
│ │ ├── blogs/ # 博客管理
│ │ ├── reviews/ # 评论管理
│ │ ├── faqs/ # FAQ 管理
│ │ ├── gallery/ # 图库管理
│ │ ├── stories/ # 故事管理
│ │ ├── submissions/ # 表单提交(只读)
│ │ ├── navigation.vue # 导航菜单编辑
│ │ └── content/ # 站点内容编辑
│ │ ├── [key].vue # JSON 编辑器(兜底)
│ │ ├── products/ # 夏季产品index + [idx]
│ │ ├── autumn-products/
│ │ ├── winter-products/
│ │ ├── pricing/ # 价格配置
│ │ ├── selector/ # 产品选择器
│ │ ├── destinations-detail/
│ │ └── *.vue # 各内容可视化编辑器
│ └── ... # 前台页面
├── server/
│ ├── api/admin/ # 后台 REST API
│ ├── database/
│ │ ├── schema.js # Drizzle 表定义
│ │ ├── index.js # DB 连接单例
│ │ ├── migrate.js # 建表
│ │ └── seed.js # 种子数据(从 JSON 导入)
│ └── utils/
│ ├── auth.js # Session 管理SQLite 存储,7 天过期)
│ ├── db.js # useDB() 服务端工具
│ ├── pagination.js # parsePagination() 分页参数解析
│ └── ...
└── public/uploads/ # 上传文件存储
```
## 多角色 Agent 工作流
本项目使用 5 个协作 Agent,通过 `/workflow` 命令调度:
| 角色 | 职责 | 约束 |
|------|------|------|
| **PM** | 分析需求、拆解任务、分配执行、验收结果 | 只分析不写代码 |
| **Frontend** | 前端页面开发Vue 组件、页面、样式) | 只改前端代码 |
| **Backend** | 后端 API、数据库、服务端逻辑 | 只改 server/ 代码 |
| **UI/UX** | 出设计方案、页面结构、交互规格 | 只出方案不写代码 |
| **Tester** | 代码审查、质量检测、出测试报告 | 只测不改 |
### 工作流阶段
```
Phase 1: PM 分析需求 → 拆解任务
Phase 2: UI/UX 设计(如有新页面)
Phase 3: Backend 开发(如有 API 变更)
Phase 4: Frontend 开发
Phase 5: Tester 测试验证
Phase 6: 修复闭环(最多 3 轮)
Phase 7: PM 验收 → 提交代码
```
### 命令
| 命令 | 用途 |
|------|------|
| `/workflow` | 全自动工作流,一条命令走完全流程 |
| `/pm` | 单独启动产品经理分析 |
| `/frontend` | 单独启动前端开发 |
| `/backend` | 单独启动后端开发 |
| `/uiux` | 单独启动 UI/UX 设计 |
| `/tester` | 单独启动测试验证 |
## 编码规范
### 前端页面pages/admin/
**列表 + 编辑分离模式:**
- 列表页:`index.vue`,使用 `AdminDataTable` 组件,带搜索、分页
- 编辑页:`[id].vue`(数据库表)或 `[idx].vue`JSON 数组项)
- 面包屑导航 + 返回链接
- 底部 sticky 保存栏
**页面模板:**
```vue
<script setup>
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
</script>
```
**API 调用统一用 `adminFetch`**,自动带 token、401 自动登出。
### 后端 APIserver/api/admin/
**认证中间件:**
```js
const user = await requireAdmin(event)
```
**分页查询:**
```js
const { page, limit, offset, search, sort } = parsePagination(event)
```
**响应格式(列表):**
```js
{ items: [...], total, page, limit, totalPages }
```
**响应格式(单条):**
```js
{ id, ...fields }
```
### 数据库Drizzle ORM
- 表定义在 `server/database/schema.js`
- 使用 `useDB()` 获取数据库实例
- 导入操作符:`import { eq, desc, like } from 'drizzle-orm'`
### 站点内容site_content 表)
存储复杂 JSON 配置的键值对:
- 读取:`GET /api/admin/site-content?key=xxx`
- 更新:`PUT /api/admin/site-content` body: `{ key, data }`
- 单项读取:`GET /api/admin/site-content/item?key=xxx&index=0`
- 单项更新:`PUT /api/admin/site-content/item` body: `{ key, index, item }`
### 样式规范
**后台页面使用 scoped CSS**,主色调:
- 主色:`#3a7d44`(绿色)
- 悬停:`#2d6235`
- 文字:`#111827`
- 次要文字:`#6b7280`
- 边框:`#e5e7eb`
- 背景:`#f0f2f5`
**复用组件:**
- `AdminDataTable` — 带分页搜索的表格
- `AdminImageUpload` — 拖拽上传图片
- `AdminSeasonProduct` — 季节产品编辑器
### 命名约定
| 类型 | 命名 | 示例 |
|------|------|------|
| 页面文件 | kebab-case | `winter-products/` |
| 组件 | PascalCase | `DataTable.vue` |
| API 路由 | kebab-case | `site-content.get.js` |
| 数据库表 | camelCase (Drizzle) | `galleryItems` |
| CSS class | kebab-case | `.page-header` |
## 记忆系统
每个 Agent 有 4 个记忆文件,存储在 `.claude/agents/memory/{role}/` 下:
| 角色 | 文件 | 内容 |
|------|------|------|
| PM | decisions / patterns / lessons / priorities | 决策记录 / 业务模式 / 经验教训 / 优先级策略 |
| Frontend | patterns / pitfalls / components / pages | 代码模式 / 踩坑记录 / 组件清单 / 页面模式 |
| Backend | patterns / pitfalls / api-design / database | 代码模式 / 踩坑记录 / API 设计 / 数据库经验 |
| UI/UX | design-system / page-patterns / feedback / consistency | 设计系统 / 页面模式 / 反馈记录 / 一致性 |
| Tester | common-bugs / checklist / api-issues / quality-metrics | 常见 Bug / 检查清单 / API 问题 / 质量指标 |

597
CMS-INTEGRATION.md 普通文件
查看文件

@ -0,0 +1,597 @@
# 呼籁旅行官网 — 后台对接方案CMS Integration
> 本文档分析官网所有内容模块,标注哪些需要「写活」(后台可增删改查),哪些是固定结构不需要动态化,以及推荐的数据库设计和对接方式。
---
## 一、内容动态化分级
### 第一优先级:高频更新(运营同学日常操作)
| 模块 | 当前文件 | 操作类型 | 更新频率 | 说明 |
|------|---------|---------|---------|------|
| **客户评价** | `reviews.json` | 增、改、删 | 每周 | 每次收到好评都要加,是最高频的更新 |
| **FAQ问答** | `faq.json` | 增、改、删 | 每月 | 客户常问的问题会变化,需要随时补充和调整 |
| **联系方式** | `contact.json` | 改 | 偶尔 | 微信号、电话、邮箱可能变更 |
| **SEO信息** | `seo.json` | 改 | 偶尔 | 优化标题和描述以提升搜索排名 |
### 第二优先级:季节性更新(产品经理/罗盘操作)
| 模块 | 当前文件 | 操作类型 | 更新频率 | 说明 |
|------|---------|---------|---------|------|
| **产品版本** | `products.json → versions` | 增、改、删 | 每季 | 产品迭代到V10、新增冬季版等 |
| **小蒙马夏令营** | `products.json → summerCamp` | 改 | 每年 | 年龄、活动、定位调整 |
| **选择指南** | `products.json → selectionGuide` | 改 | 每季 | 新产品上线后需要更新推荐逻辑 |
| **定价说明** | `products.json → pricingPhilosophy` | 改 | 偶尔 | 价格策略调整时 |
| **产品叙事** | `products.json → narrative` | 改 | 偶尔 | 品牌定位调整时 |
### 第三优先级:低频变更(品牌层面)
| 模块 | 当前文件 | 操作类型 | 更新频率 | 说明 |
|------|---------|---------|---------|------|
| **品牌信息** | `brand.json` | 改 | 极少 | slogan、信任数据、差异化卖点 |
| **关于我们** | `about.json` | 改 | 极少 | 品牌故事、子公司、资质、团队、文化 |
| **导航** | `navigation.json` | 改 | 极少 | 新增/调整导航页面 |
### 不需要动态化(代码层面)
| 项目 | 说明 |
|------|------|
| 页面结构/布局 | Vue组件,需开发介入 |
| 样式/配色 | Less变量,需开发介入 |
| JSON-LD结构化数据 | 自动从内容数据生成,无需单独管理 |
| sitemap.xml | 只有页面增减时才需要改 |
| robots.txt | 基本不变 |
---
## 二、每个JSON文件的字段级分析
### 1. reviews.json评价管理 — 最高频)
```
后台需要的功能:
✅ 新增评价(上传截图 + 填写文字)
✅ 编辑评价
✅ 删除评价
✅ 调整排序
✅ 修改汇总数据(总数、好评率、关键词)
✅ 管理筛选标签(场景、关心点)
字段说明:
summary:
- totalCount: number → 后台可改(或自动计算)
- approvalRate: string → 后台可改
- keywords: string[] → 后台可增删
items[]:
- id: number → 自动生成
- nickname: string → 后台填写
- travelDate: string → 后台填写(如"2025年7月"
- productVersion: string → 后台选择(关联产品列表)
- screenshot: string → 后台上传图片,存储路径
- content: string → 后台填写与截图内容一致,供AI抓取
- scenes: string[] → 后台多选标签(带小孩/家庭游/情侣/朋友/带老人...
- concerns: string[] → 后台多选标签(领队服务/行程安排/拍照超美/司机服务...
```
### 2. faq.json问答管理 — 高频)
```
后台需要的功能:
✅ 新增分类
✅ 在分类下新增/编辑/删除问答
✅ 调整分类排序和问答排序
✅ 管理相关链接
字段说明:
categories[]:
- id: string → 自动生成
- name: string → 后台填写(如"产品类"
- questions[]:
- id: string → 自动生成
- question: string → 后台填写
- answer: string → 后台填写(支持长文本)
- relatedLinks[]: → 后台配置
- text: string → 链接文字
- url: string → 链接地址(下拉选择站内页面)
```
### 3. products.json产品管理 — 季节性)
```
后台需要的功能:
✅ 编辑产品叙事文案
✅ 新增/编辑/删除/排序产品版本
✅ 编辑夏令营信息
✅ 编辑选择指南
✅ 编辑定价说明
字段说明:
narrative: string → 后台富文本编辑
versions[]:
- id: string → 自动生成(如"v9-6d5n-family"
- name: string → 产品名称
- days: number → 天数
- nights: number → 晚数
- audience: string → 适合人群
- description: string → 产品描述
- highlights: string[] → 亮点列表(可增删)
- tag: string → 标签(如"经典版"/"人气王"
summerCamp:
- name: string → 名称
- ageRange: string → 年龄范围
- positioning: string → 定位描述
- coreActivities: array → 核心活动列表
- differenceFromV9: string → 与V9区别说明
selectionGuide:
- byVacationLength[]: → 按假期长度推荐
- byChildAge[]: → 按孩子年龄推荐
- byPreference[]: → 按偏好推荐
每条condition + recommendation,recommendation关联产品id
pricingPhilosophy: string → 后台富文本编辑
```
### 4. contact.json联系方式 — 偶尔更新)
```
后台需要的功能:
✅ 编辑联系渠道信息
✅ 新增/删除联系渠道
✅ 上传/更换二维码图片
✅ 编辑安全提示文案
字段说明:
channels[]:
- type: string → 渠道类型wechat/phone/email/address等
- label: string → 显示名称
- value: string → 联系值(微信号/电话/邮箱/地址)
- qrImage: string|null → 二维码图片路径(可选)
- primary: boolean → 是否主要渠道
- description: string → 补充说明
securityNotice: string → 安全提醒文案
```
### 5. brand.json品牌信息 — 极少更新)
```
后台需要的功能:
✅ 编辑品牌基础信息
✅ 编辑信任数据
✅ 编辑差异化卖点
字段说明:
name: string → 品牌名
fullName: string → 公司全称
domain: string → 域名
url: string → 网址
slogan:
- emotional: string → 情感化口号
- functional: string → 功能化口号
trustStats[]:
- value: string → 数值(如"8000+"
- unit: string → 单位(如"组"
- label: string → 标签(如"已服务家庭"
- attribution: string → 补充说明(可选)
differentiators[]:
- title: string → 卖点标题
- description: string → 卖点描述
icp: string → ICP备案号
eContract: string → 电子合同资质
```
### 6. about.json关于我们 — 极少更新)
```
后台需要的功能:
✅ 编辑品牌故事
✅ 管理子公司列表
✅ 管理资质列表
✅ 编辑团队和文化
字段说明:
story:
- sections[]: → 品牌故事段落
- title/content → 标题+内容
- foundingMoment: → 创始故事
- scene/reflection → 场景+感悟
subsidiaries[]:
- name: string → 公司名
- role: string → 业务角色
qualifications[]:
- name: string → 资质名称
- detail: string → 具体说明
team:
- summary: string → 团队概述
culture:
- values[]: → 企业文化
- word/expression → 关键词+表达
- transparency: string → 透明度说明
```
### 7. seo.jsonSEO信息 — 偶尔优化)
```
后台需要的功能:
✅ 编辑每个页面的SEO信息
字段说明:
pages:
[pageKey]: → 页面标识home/about/products/faq/reviews/contact
- title: string → 页面标题60字符内
- description: string → 页面描述155字符内
- h1: string → 页面H1标题
- ogImage: string → 社交分享图片
```
### 8. navigation.json导航 — 极少变更)
```
后台需要的功能:
✅ 编辑导航项(文字、链接)
✅ 调整排序
header[]:
- text: string → 导航文字
- to: string → 链接地址
footer[]:
- title: string → 分组标题
- links[]:
- text/to → 链接文字+地址
```
---
## 三、推荐的技术方案
### 方案 AJSON文件 + 简易后台(推荐起步)
**适合阶段**:现在 → 日均UV 1000以内
```
架构:
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ 后台页面 │ ──→ │ API (读写JSON) │ ──→ │ data/*.json │
│ (Vue/React)│ │ (Node.js) │ │ (文件存储) │
└──────────┘ └──────────────┘ └──────────────┘
↓ 写完后触发
┌──────────────┐
│ nuxi generate │ → 重新生成静态页面
└──────────────┘
优点:
- 零数据库成本
- 当前代码零改动,后台只操作JSON文件
- 生成的还是静态页面,速度最快
- 适合当前团队规模
缺点:
- 每次改内容需要重新 generate约30秒
- 多人同时编辑可能冲突
```
### 方案 B数据库 + API + SSR推荐中期
**适合阶段**:有后台系统后
```
架构:
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ 后台页面 │ ──→ │ API Server │ ──→ │ 数据库 │
│ │ │ (Node.js) │ │ (MySQL/PG) │
└──────────┘ └──────────────┘ └──────────────┘
↓ 同时提供
┌──────────────┐
│ Nuxt SSR渲染 │ → 实时读数据库渲染
└──────────────┘
改动点:
- nuxt.config.js: ssr改为true当前已是,去掉prerender
- 页面中 import JSON → 改为 useFetch('/api/xxx')
- 新增 server/api/ 目录下的接口
```
---
## 四、数据库设计方案B用
### 表结构
```sql
-- 1. 评价表(最高频)
CREATE TABLE reviews (
id INT PRIMARY KEY AUTO_INCREMENT,
nickname VARCHAR(50) NOT NULL,
travel_date VARCHAR(20), -- "2025年7月"
product_version VARCHAR(100), -- 关联产品名称
screenshot_url VARCHAR(255), -- 截图CDN地址
content TEXT NOT NULL, -- 评价文字AI抓取用
scenes JSON, -- ["带小孩", "家庭游"]
concerns JSON, -- ["领队服务", "行程安排"]
sort_order INT DEFAULT 0, -- 排序权重
is_visible BOOLEAN DEFAULT TRUE, -- 是否显示
created_at DATETIME DEFAULT NOW(),
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW()
);
-- 2. 评价汇总表
CREATE TABLE review_summary (
id INT PRIMARY KEY DEFAULT 1,
total_count INT DEFAULT 0,
approval_rate VARCHAR(10), -- "98%"
keywords JSON, -- ["贴心", "孩子玩疯了"]
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW()
);
-- 3. FAQ分类表
CREATE TABLE faq_categories (
id VARCHAR(50) PRIMARY KEY, -- "product"
name VARCHAR(50) NOT NULL, -- "产品类"
sort_order INT DEFAULT 0,
created_at DATETIME DEFAULT NOW()
);
-- 4. FAQ问答表
CREATE TABLE faq_questions (
id VARCHAR(50) PRIMARY KEY,
category_id VARCHAR(50) NOT NULL,
question VARCHAR(200) NOT NULL,
answer TEXT NOT NULL,
related_links JSON, -- [{"text":"查看产品","url":"/products"}]
sort_order INT DEFAULT 0,
is_visible BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT NOW(),
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW(),
FOREIGN KEY (category_id) REFERENCES faq_categories(id)
);
-- 5. 产品版本表
CREATE TABLE products (
id VARCHAR(50) PRIMARY KEY, -- "v9-6d5n-family"
name VARCHAR(100) NOT NULL,
days INT,
nights INT,
audience VARCHAR(100),
description TEXT,
highlights JSON, -- ["亮点1", "亮点2"]
tag VARCHAR(20), -- "人气王"
sort_order INT DEFAULT 0,
is_visible BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT NOW(),
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW()
);
-- 6. 夏令营信息表
CREATE TABLE summer_camp (
id INT PRIMARY KEY DEFAULT 1,
name VARCHAR(100),
age_range VARCHAR(20),
positioning TEXT,
core_activities JSON,
difference_from_v9 TEXT,
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW()
);
-- 7. 联系渠道表
CREATE TABLE contact_channels (
id INT PRIMARY KEY AUTO_INCREMENT,
type VARCHAR(20) NOT NULL, -- "wechat"/"phone"/"email"...
label VARCHAR(50) NOT NULL,
value VARCHAR(200) NOT NULL,
qr_image_url VARCHAR(255),
is_primary BOOLEAN DEFAULT FALSE,
description VARCHAR(200),
sort_order INT DEFAULT 0,
created_at DATETIME DEFAULT NOW()
);
-- 8. 品牌信息表(单行表)
CREATE TABLE brand_info (
id INT PRIMARY KEY DEFAULT 1,
name VARCHAR(50),
full_name VARCHAR(100),
domain VARCHAR(50),
slogan_emotional VARCHAR(200),
slogan_functional VARCHAR(200),
trust_stats JSON, -- 4个信任数据
differentiators JSON, -- 5个差异化卖点
icp VARCHAR(50),
e_contract VARCHAR(100),
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW()
);
-- 9. 关于我们(单行表)
CREATE TABLE about_info (
id INT PRIMARY KEY DEFAULT 1,
story JSON, -- 品牌故事结构
subsidiaries JSON, -- 12家子公司
qualifications JSON, -- 资质列表
team JSON, -- 团队介绍
culture JSON, -- 文化价值观
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW()
);
-- 10. SEO信息表
CREATE TABLE seo_pages (
page_key VARCHAR(20) PRIMARY KEY, -- "home"/"about"/...
title VARCHAR(100),
description VARCHAR(200),
h1 VARCHAR(100),
og_image VARCHAR(255),
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW()
);
-- 11. 导航表
CREATE TABLE navigation (
id INT PRIMARY KEY AUTO_INCREMENT,
position ENUM('header', 'footer') NOT NULL,
parent_title VARCHAR(50), -- footer分组标题,header为NULL
text VARCHAR(50) NOT NULL,
url VARCHAR(100) NOT NULL,
sort_order INT DEFAULT 0
);
-- 12. 内容文案表(产品叙事、定价说明等长文本)
CREATE TABLE content_blocks (
block_key VARCHAR(50) PRIMARY KEY, -- "product_narrative" / "pricing_philosophy" / "security_notice"
content TEXT NOT NULL,
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW()
);
-- 13. 选择指南表
CREATE TABLE selection_guide (
id INT PRIMARY KEY AUTO_INCREMENT,
guide_type ENUM('vacation_length', 'child_age', 'preference'),
condition_text VARCHAR(100), -- "3-4天假期"
recommendation VARCHAR(50), -- 关联产品id
sort_order INT DEFAULT 0
);
```
### 图片存储
```
推荐方案:
- 评价截图、二维码、Logo等 → 对象存储阿里云OSS / 腾讯云COS
- 后台上传图片 → API接收 → 上传到OSS → 返回CDN地址
- 数据库只存URL,不存文件
目录规划:
/hulai-oss/
├── reviews/ 评价截图
├── qrcodes/ 二维码图片
├── logo/ 品牌Logo
└── og/ 社交分享图
```
---
## 五、后台管理界面建议
### 后台菜单结构
```
📊 数据看板
- 内容统计评价数、FAQ数、产品数
📝 内容管理
├── 客户评价 → 列表 + 新增 + 编辑 + 删除 + 拖拽排序
├── 常见问答 → 分类管理 + 问答列表 + 拖拽排序
├── 旅行产品 → 产品列表 + 编辑 + 夏令营 + 选择指南
└── 联系方式 → 渠道列表 + 编辑 + 上传二维码
🏢 品牌设置
├── 品牌信息 → 基础信息 + 信任数据 + 差异化卖点
├── 关于我们 → 品牌故事 + 子公司 + 资质 + 团队 + 文化
└── SEO设置 → 6个页面的标题/描述/H1
⚙️ 系统设置
├── 导航管理 → 页头/页脚导航编辑
└── 发布管理 → 一键重新生成静态页面
```
### 后台技术栈建议
```
推荐:
- 前端Vue 3 + Element Plus与官网技术栈统一
- 后端Node.js + Express/Koa或 Nuxt server routes
- 数据库MySQL 8.0阿里云RDS
- 图片阿里云OSS + CDN
- 部署:同一台服务器,后台走 /admin 路径
对接方式:
1. 后台修改数据 → 写入数据库
2. 点击「发布」 → 触发 nuxi generate
3. 生成的静态文件部署到CDN/Nginx
或者(更简单的过渡方案):
1. 后台修改数据 → 直接修改 data/*.json 文件
2. 点击「发布」 → 触发 nuxi generate
3. 无需数据库,JSON就是数据库
```
---
## 六、当前代码对接友好度评估
### 已经写活的(直接能对接)
| 内容 | 数据源 | 对接方式 |
|------|-------|---------|
| 所有评价 | `reviews.json` | 替换为API即可 |
| 所有FAQ | `faq.json` | 替换为API即可 |
| 所有产品 | `products.json` | 替换为API即可 |
| 所有联系方式 | `contact.json` | 替换为API即可 |
| 品牌信息 | `brand.json` | 替换为API即可 |
| 关于信息 | `about.json` | 替换为API即可 |
| SEO信息 | `seo.json` | 替换为API即可 |
| 导航 | `navigation.json` | 替换为API即可 |
### 对接时的改动量
```
每个页面改动量很小,只需要把:
import data from '~/data/xxx.json'
改为:
const { data } = await useFetch('/api/xxx')
大约 6个页面 + 3个组件Header/Footer/MobileNav= 9个文件
每个文件改动 1-3 行代码
总改动量约30行代码
```
### 评价图片已经对接友好
```
当前screenshot: "/images/reviews/review-001.jpg" (本地路径)
对接后screenshot: "https://cdn.1814.love/reviews/review-001.jpg" CDN地址
组件中用的是 :src="item.screenshot",路径换成CDN地址直接生效,零改动。
```
---
## 七、给运营同学的速查表
### 现在(没有后台之前)怎么改内容
| 要改什么 | 找哪个文件 | 怎么改 |
|---------|----------|-------|
| 新增客户评价 | `data/reviews.json` | 在 `items` 数组里加一条,图片放到 `public/images/reviews/` |
| 修改FAQ问答 | `data/faq.json` | 找到对应问题直接改文字 |
| 修改产品信息 | `data/products.json` | 找到对应版本直接改 |
| 换微信号/电话 | `data/contact.json` | 找到对应渠道改 `value` |
| 改页面标题/描述 | `data/seo.json` | 找到对应页面改 |
| 改品牌口号 | `data/brand.json` | 改 `slogan` 下的文字 |
| 改关于我们 | `data/about.json` | 找到对应位置改 |
**改完后**:需要运行 `npx nuxi generate` 重新生成页面,然后重新部署。
### 有后台之后怎么改
登录后台 → 找到对应模块 → 编辑 → 点击「发布」→ 完成
---
## 八、总结
```
当前架构优势:
✅ 所有内容都在 8 个 JSON 文件里,结构清晰
✅ 数据和展示完全分离,后台对接只需改数据源
✅ 对接工作量极小约30行代码改动
✅ 可以分步走先用JSON文件+手动generate,再上后台
建议路线图:
Phase 1现在JSON文件 + 手动维护 + nuxi generate
Phase 21-2月后简易后台 + 读写JSON + 一键generate
Phase 3业务增长后数据库 + API + CDN图片 + SSR渲染
```

582
README-HANDOFF.md 普通文件
查看文件

@ -0,0 +1,582 @@
# 呼籁旅行官网 - 后端对接 & 运营维护文档
> 域名1814.love | 框架Nuxt 3 SSG静态站| 语言JavaScript + Vue 3 + Less
---
## 一、项目概览
这是一个**纯静态网站**,没有后端API、没有数据库。所有内容都存放在 `/data/*.json` 文件中,修改JSON即可更新网站内容。
### 技术栈
| 项目 | 说明 |
|------|------|
| 框架 | Nuxt 3Vue 3 |
| 渲染方式 | SSG 静态生成(`npx nuxi generate` |
| 样式 | Less |
| 部署产物 | `.output/public/` 目录,纯HTML/CSS/JS,丢到任何静态服务器即可 |
### 部署流程
```bash
# 1. 安装依赖(仅首次)
npm install
# 2. 生成静态文件
npx nuxi generate
# 3. 部署目录
.output/public/ # ← 这个目录就是最终产物,丢到nginx/CDN即可
# 4. 本地预览(可选)
npx serve .output/public
```
### 页面清单共8页
| 路径 | 页面 | 源文件 |
|------|------|--------|
| `/` | 首页 | `pages/index.vue` |
| `/about` | 关于我们 | `pages/about.vue` |
| `/products` | 产品线额吉的故乡V9 | `pages/products.vue` |
| `/summer-camp` | 小蒙马夏令营 | `pages/summer-camp.vue` |
| `/guides` | 出行指南 | `pages/guides.vue` |
| `/faq` | 常见问答 | `pages/faq.vue` |
| `/reviews` | 客户评价 | `pages/reviews.vue` |
| `/contact` | 联系我们 | `pages/contact.vue` |
---
## 二、内容维护指南(运营同学看这里)
### 核心原则
> **所有内容都在 `/data/` 目录的JSON文件里。** 打开文件,改引号里的文字,重新 generate 就行。不需要懂代码。
### 修改流程
```
1. 用任意文本编辑器打开 data/ 下的JSON文件
2. 修改引号内的文字内容
3. 保存文件
4. 终端执行 npx nuxi generate
5. 将 .output/public/ 部署到服务器
```
### ⚠️ JSON编辑注意事项
- 所有内容必须在英文双引号 `"..."`
- 内容中如果有双引号,要用 `\"` 转义
- 最后一项后面**不要**加逗号
- 换行用 `\n`,不要直接回车
- 建议用 VS Code 编辑,会自动提示格式错误
---
## 三、数据文件详细说明
### 3.1 `data/brand.json` — 品牌基础信息
> 影响页面:首页、关于页、联系页
```json
{
"name": "呼籁旅行", // 品牌名
"fullName": "内蒙古呼籁文旅投资开发集团有限公司", // 公司全称
"domain": "1814.love", // 域名
"url": "https://1814.love", // 完整URL
"slogan": {
"emotional": "情感化品牌主张", // 首页大标题(情感层)
"functional": "呼伦贝尔家庭定制游·一家一单一车" // 功能层
},
"trustStats": [ // 首页信任数据条4项
{
"value": "10000+", // 数字
"unit": "组", // 单位
"label": "已服务家庭", // 标签
"attribution": "来源说明(可选)"
}
],
"differentiators": [ // 核心卖点(首页展示)
{
"title": "自有营地", // 卖点标题
"description": "描述文字" // 详细说明
}
],
"legal": { // 备案信息(页脚展示)
"icp": "蒙ICP备2025XXXXX号-1"
}
}
```
**运营常改字段:**
- `trustStats[].value` — 更新服务家庭数等数字
- `slogan.emotional` — 修改品牌主张
- `differentiators[].description` — 更新卖点描述
---
### 3.2 `data/products.json` — 产品线
> 影响页面:产品页、首页产品预览、小蒙马页
```json
{
"narrative": "产品线设计理念叙事文字...",
"versions": [ // 额吉的故乡V9系列6个版本
{
"id": "v9-4d3n", // 唯一标识,不要改
"name": "额吉的故乡V9·4天3晚", // 产品名称
"days": 4, // 天数
"nights": 3, // 晚数
"audience": "假期有限的家庭", // 目标人群
"description": "产品描述...", // 详细介绍
"highlights": [ // 3个亮点
"亮点1",
"亮点2",
"亮点3"
],
"tag": "精华版" // 标签(显示在卡片上)
}
],
"summerCamp": { // 小蒙马夏令营
"name": "小蒙马夏令营",
"positioning": "产品定位描述...",
"days": 6,
"nights": 5,
"sessions": { "2025": 11, "2026plan": 30 }, // 期数数据
"principles": ["原则1", "原则2", "原则3", "原则4"],
"coreActivities": ["活动1", "活动2"], // 核心体验项目
"itinerary": ["Day1 ...", "Day2 ..."], // 每日行程概览
"differenceFromV9": "与V9的区别说明..."
},
"selectionGuide": { // 选择指南
"byVacationLength": [...], // 按假期长度推荐
"byChildAge": [...], // 按孩子年龄推荐
"byPreference": [...] // 按偏好推荐
},
"pricingPhilosophy": "价格透明说明..."
}
```
**运营常改字段:**
- `versions[].description` / `highlights` — 更新产品描述和亮点
- `summerCamp` 整块 — 每年行程更新时修改
- `summerCamp.sessions` — 更新期数数据
- `summerCamp.itinerary` — 更新每日行程
- `pricingPhilosophy` — 价格说明
---
### 3.3 `data/faq.json` — 常见问答SEO最重要的页面
> 影响页面FAQ页、Google/百度搜索结果FAQ Schema
```json
{
"categories": [
{
"id": "product", // 分类ID,不要改
"name": "产品类", // 分类名称
"questions": [
{
"id": "what-is-eqi", // 问题ID,不要改
"question": "额吉的故乡是什么?", // 问题
"answer": "回答内容...", // 答案150-300字最佳
"relatedLinks": [ // 相关链接
{ "text": "查看产品详情", "url": "/products" }
]
}
]
}
]
}
```
**现有分类5个,共20+个问答):**
| 分类ID | 名称 | 问答数 |
|--------|------|--------|
| product | 产品类 | 6 |
| preparation | 出行准备类 | 5 |
| trust | 信任类 | 5 |
| experience | 体验类 | 4 |
| popular | 高频咨询类 | 5 |
**运营常改字段:**
- `questions[].answer` — 更新答案内容
- 新增问答:在对应分类的 `questions` 数组末尾添加新对象
**新增一个FAQ示例**
```json
{
"id": "new-question-id",
"question": "新问题?",
"answer": "新答案...",
"relatedLinks": [
{ "text": "链接文字", "url": "/products" }
]
}
```
---
### 3.4 `data/reviews.json` — 客户评价
> 影响页面:评价页、首页精选评价
```json
{
"summary": {
"totalCount": 10000, // 总评价数
"approvalRate": "98%", // 好评率
"keywords": ["贴心", "孩子玩疯了"] // 高频关键词
},
"items": [
{
"id": 1, // 序号(递增,不要重复)
"nickname": "圆满妈妈", // 昵称
"travelDate": "2024年6月", // 出行时间
"productVersion": "额吉的故乡V9·6天5晚", // 产品版本
"screenshot": "/images/reviews/review-001.jpg", // 评价截图路径
"content": "评价文字内容...", // 评价文字与截图一致,供AI抓取
"scenes": ["带小孩", "家庭游"], // 出行场景标签
"concerns": ["领队服务", "行程安排"] // 关注点标签
}
]
}
```
**运营常改字段:**
- `summary.totalCount` — 更新总评价数
- `items` — 添加新评价
**添加新评价步骤:**
1. 将评价截图命名为 `review-XXX.jpg`XXX为序号如083
2. 放入 `public/images/reviews/` 目录
3. 在 `items` 数组末尾添加新评价对象
4. 截图建议宽度不超过1600px
**可用的 scenes 标签:** `带小孩``家庭游``闺蜜游``带老人``蜜月`
**可用的 concerns 标签:** `领队服务``行程安排``住宿品质``餐饮体验``亲子活动``拍照摄影``性价比`
---
### 3.5 `data/about.json` — 关于我们
> 影响页面:关于页
```json
{
"story": {
"title": "品牌故事标题",
"content": "品牌故事正文..." // 支持较长文本
},
"subsidiaries": [ // 旗下企业
{
"name": "企业名称",
"role": "业务角色",
"established": "成立年份"
}
],
"certifications": [ // 资质荣誉
{
"name": "荣誉名称",
"detail": "详细说明",
"year": "2025"
}
],
"trademarks": { ... }, // 商标信息
"copyrights": [ ... ], // 版权信息
"guarantees": [ // 服务保障
{
"title": "保障标题",
"description": "保障说明"
}
],
"xiaohongshu": { // 小红书数据
"account": "Mr.小雷-呼籁旅行",
"followers": "9.6万+",
"likes": "26万+",
"awards": ["获奖列表"],
"description": "说明文字"
},
"team": {
"serviceModel": "3管+1师+1队",
"description": "团队说明...",
"milestones": ["里程碑1", "里程碑2"]
},
"culture": {
"values": [
{ "title": "真诚", "description": "说明" }
]
}
}
```
**运营常改字段:**
- `subsidiaries` — 新增/调整子公司
- `certifications` — 新增荣誉资质
- `xiaohongshu` — 更新小红书数据(粉丝数、获赞数、荣誉)
- `team.milestones` — 添加团队新事件
---
### 3.6 `data/contact.json` — 联系方式
> 影响页面:联系页、页脚
```json
{
"channels": [
{
"type": "wechat", // 类型wechat/mini-program/phone/address/email
"label": "微信客服", // 显示名
"value": "hulai1814", // 值(微信号/电话/地址等)
"description": "说明文字",
"qrCode": "/images/qr-wechat.jpg", // 二维码图片(可选)
"primary": true // 是否主要渠道
}
],
"securityNotice": "安全提示文字..."
}
```
---
### 3.7 `data/seo.json` — 各页面SEO配置
> 影响:每个页面的标题、描述、搜索引擎展示
```json
{
"pages": {
"home": {
"title": "呼籁旅行 - 呼伦贝尔家庭定制游·一家一单一车", // 浏览器标签页标题
"description": "搜索引擎展示的描述文字...", // 搜索结果摘要
"h1": "呼籁旅行 — 呼伦贝尔家庭定制游", // 页面大标题
"ogImage": "/images/og-home.jpg" // 社交分享图
},
"about": { ... },
"products": { ... },
"summer-camp": { ... },
"faq": { ... },
"reviews": { ... },
"guides": { ... },
"contact": { ... }
}
}
```
**运营常改字段:**
- `title` — 控制搜索结果标题建议60字以内
- `description` — 控制搜索结果描述建议150字以内
---
### 3.8 `data/navigation.json` — 导航菜单
> 影响:顶部导航栏、底部链接
```json
{
"header": [ // 顶部导航(按顺序展示)
{ "text": "首页", "to": "/" },
{ "text": "关于我们", "to": "/about" },
{ "text": "产品线", "to": "/products" },
{ "text": "小蒙马夏令营", "to": "/summer-camp" },
{ "text": "出行指南", "to": "/guides" },
{ "text": "常见问答", "to": "/faq" },
{ "text": "客户评价", "to": "/reviews" },
{ "text": "联系我们", "to": "/contact" }
],
"footer": [ // 底部分组链接
{
"title": "了解呼籁",
"links": [
{ "text": "关于我们", "to": "/about" }
]
}
]
}
```
---
### 3.9 `data/versions.json` — 产品版本迭代历史
> 影响页面产品页版本进化时间线、V8→V9对比等
```json
{
"stats": { // 统计数据
"totalIterations": 9,
"totalUpgrades": "50+",
"firstVersion": "2023年3月",
"latestVersion": "2026年"
},
"upgrades2026": [ // 当年核心升级项
{
"title": "升级标题",
"description": "升级描述"
}
],
"highlights": [ // V9亮点
{ "title": "亮点", "description": "说明" }
],
"compareV8V9": [ // V8→V9对比
{ "category": "分类", "v8": "旧版", "v9": "新版" }
],
"timeline": [ // 历史版本时间线
{
"version": "V1",
"date": "2023.03",
"title": "标题",
"changes": [
{ "type": "add", "content": "新增内容" }
]
}
],
"philosophy": "产品哲学...",
"quote": { "text": "引用", "author": "作者" }
}
```
---
### 3.10 `data/guides.json` — 出行指南
> 影响页面:出行指南页
```json
{
"sections": [
{
"id": "clothing",
"title": "穿搭指南",
"subtitle": "副标题",
"months": [ // 按月份的穿搭建议
{
"month": "5月",
"tempRange": "白天5~26°C / 夜间-6~8°C",
"style": "穿搭风格",
"items": ["具体衣物1", "具体衣物2"],
"tip": "小贴士"
}
]
},
{
"id": "activities",
"title": "活动安全须知",
"items": [
{
"name": "骑马穿越",
"rules": ["规则1", "规则2"],
"notes": "注意事项"
}
]
}
]
}
```
---
## 四、图片资源管理
### 目录结构
```
public/images/
├── logo.png # 网站Logo
├── logo-square.png # 方形Logo
├── mascot-sticker-qr.png # 吉祥物贴纸
├── mascot/ # 吉祥物图片
│ ├── mascot-front.png # 正面
│ ├── mascot-banner.png # 横幅
│ └── xiaomengma.png # 小蒙马
├── summer-camp/ # 小蒙马夏令营照片4张
│ ├── river-banner.jpg
│ ├── grassland-group.jpg
│ ├── birch-group.jpg
│ └── camp-graduation.jpg
├── team/ # 团队照片8张
│ ├── hero-family.jpg
│ ├── hero-grassland.jpg
│ ├── guide-group.jpg
│ ├── guide-training.jpg
│ ├── campsite-team.jpg
│ ├── ranch-store.jpg
│ ├── route-survey.jpg
│ └── route-survey-2025.jpg
├── xiaohongshu/ # 小红书相关2张
│ ├── storefront.jpg
│ └── profile.jpg
└── reviews/ # 评价截图82张
├── review-001.jpg
├── review-002.jpg
└── ... review-082.jpg
```
### 添加图片规范
- **格式**JPG照片、PNGLogo/图标)
- **尺寸**宽度不超过1600px
- **命名**:英文小写+连字符,如 `new-photo.jpg`
- **路径**:代码中引用时用 `/images/xxx.jpg`(以 `/` 开头的绝对路径)
---
## 五、sitemap 自动生成
文件位置:`server/routes/sitemap.xml.js`
如果**新增页面**,需要在这个文件的 `pages` 数组中添加:
```javascript
{ url: '/new-page', priority: '0.8', changefreq: 'monthly' }
```
当前已配置的页面:
| URL | 优先级 | 更新频率 |
|-----|--------|---------|
| `/` | 1.0 | weekly |
| `/faq` | 0.95 | weekly |
| `/products` | 0.9 | weekly |
| `/about` | 0.85 | monthly |
| `/summer-camp` | 0.85 | monthly |
| `/reviews` | 0.8 | weekly |
| `/contact` | 0.8 | monthly |
| `/guides` | 0.8 | monthly |
---
## 六、常见运营操作速查
### 更新信任数据(服务家庭数等)
→ 改 `data/brand.json``trustStats`
### 添加新客户评价
→ 1. 截图放 `public/images/reviews/`
→ 2. 在 `data/reviews.json``items` 末尾加新对象
### 更新小蒙马夏令营行程
→ 改 `data/products.json``summerCamp` 部分
### 添加新FAQ
→ 在 `data/faq.json` 对应分类的 `questions` 数组末尾加新对象
### 更新小红书数据
→ 改 `data/about.json``xiaohongshu` 部分
### 修改页面SEO标题/描述
→ 改 `data/seo.json` 对应页面的 `title``description`
### 调整导航菜单
→ 改 `data/navigation.json`
---
## 七、注意事项
1. **每次改完JSON必须重新 generate** — 这是静态站,改JSON不会自动生效
2. **不要删除 `node_modules/`** — 删了要重新 `npm install`
3. **JSON格式很严格** — 少一个逗号、多一个逗号都会报错,建议用 VS Code 编辑
4. **图片要放在 `public/images/` 下** — 不要放在其他位置
5. **缺少 favicon.ico** — 需要补一个放在 `public/` 目录下
6. **部署只需要 `.output/public/`** — 这个目录是纯静态HTML,不需要Node.js运行环境

5
app.vue 普通文件
查看文件

@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

274
assets/less/global.less 普通文件
查看文件

@ -0,0 +1,274 @@
// ============================================
// 呼籁旅行官网 - 全局样式
// ============================================
// ---------- 重置 ----------
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: @font-size-base;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
}
body {
font-family: @font-family-base;
font-size: @font-size-base;
line-height: @line-height-base;
color: @color-text-primary;
background-color: #FAFAF8;
}
// ---------- 排版 ----------
h1, h2, h3, h4, h5, h6 {
font-weight: @font-weight-bold;
line-height: @line-height-tight;
color: @color-text-primary;
}
h1 {
font-size: 28px;
.respond-md({
font-size: @font-size-hero;
});
.respond-lg({
font-size: @font-size-display;
});
}
h2 {
font-size: @font-size-xl;
.respond-md({
font-size: @font-size-2xl;
});
}
h3 {
font-size: @font-size-lg;
.respond-md({
font-size: @font-size-xl;
});
}
p {
margin-bottom: @space-md;
color: @color-text-secondary;
}
a {
color: @color-primary;
text-decoration: none;
transition: color @transition-fast;
&:hover {
color: @color-primary-light;
}
}
ul, ol {
list-style: none;
}
img {
max-width: 100%;
height: auto;
display: block;
}
// ---------- 容器 ----------
.container {
.container();
}
// ---------- 段落区块 ----------
.section {
.section-padding();
}
.section--gray {
background-color: @color-bg-gray;
}
.section--warm {
background-color: @color-bg-warm;
}
.section--green {
background-color: @color-primary-bg;
}
.section--accent {
background-color: @color-accent-bg;
}
.section--dark {
background-color: @color-primary-dark;
color: rgba(255, 255, 255, 0.9);
}
.section--accent {
background-color: @color-accent-bg;
}
.section--dark {
background-color: @color-primary-dark;
color: rgba(255, 255, 255, 0.9);
}
// ---------- 辅助类 ----------
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
// ---------- 页面过渡 ----------
.page-enter-active,
.page-leave-active {
transition: opacity @transition-base;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
}
// ---------- 滚动入场动画 ----------
.reveal {
opacity: 0;
transform: translateY(16px);
transition: opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1), transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
&.is-visible {
opacity: 1;
transform: translateY(0);
}
}
// 子元素交错入场(用于网格)
.reveal-stagger {
.reveal();
&:nth-child(2) { transition-delay: 0.08s; }
&:nth-child(3) { transition-delay: 0.16s; }
&:nth-child(4) { transition-delay: 0.24s; }
&:nth-child(5) { transition-delay: 0.32s; }
&:nth-child(6) { transition-delay: 0.40s; }
}
// ---------- 页面摘要 TL;DRGEO/SEO 语义摘要块)----------
// 每个页面开头的精准摘要,帮助 AI 和搜索引擎理解"这个页面回答了什么问题"
.page-tldr {
background: @color-bg-gray;
border-bottom: 1px solid @color-border;
.page-tldr-inner {
max-width: 800px;
margin: 0 auto;
padding: @space-md @space-lg;
border-left: 3px solid @color-primary;
background: @color-bg-white;
border-radius: 0 @border-radius-md @border-radius-md 0;
.respond-md({
padding: @space-lg @space-xl;
});
p {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-loose;
margin: 0;
}
}
// 容器内居中 + 内边距
.container {
padding-top: @space-sm;
padding-bottom: @space-sm;
.respond-md({
padding-top: @space-md;
padding-bottom: @space-md;
});
}
}
// ---------- 全局卡片升级样式 ----------
.card-elevated {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
box-shadow: @shadow-card;
transition: box-shadow @transition-base, transform @transition-base, border-color @transition-base;
&:hover {
box-shadow: @shadow-card-hover;
transform: translateY(-4px);
border-color: @color-primary-lighter;
}
}
// 图片容器通用样式
.img-cover {
overflow: hidden;
border-radius: @border-radius-lg @border-radius-lg 0 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform @transition-slow;
}
&:hover img,
.card-elevated:hover & img {
transform: scale(1.05);
}
}
// ---------- 无障碍:减少动效偏好 ----------
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

76
assets/less/mixins.less 普通文件
查看文件

@ -0,0 +1,76 @@
// ============================================
// 呼籁旅行官网 - Less混入
// ============================================
// 响应式断点
.respond-md(@rules) {
@media (min-width: @breakpoint-md) {
@rules();
}
}
.respond-lg(@rules) {
@media (min-width: @breakpoint-lg) {
@rules();
}
}
.respond-xl(@rules) {
@media (min-width: @breakpoint-xl) {
@rules();
}
}
// 容器
.container() {
width: 100%;
max-width: @container-max-width;
margin-left: auto;
margin-right: auto;
padding-left: @container-padding;
padding-right: @container-padding;
.respond-lg({
padding-left: @container-padding-desktop;
padding-right: @container-padding-desktop;
});
}
// 段落间距 — 手机端更紧凑,桌面端有呼吸感
.section-padding() {
padding-top: 36px;
padding-bottom: 36px;
.respond-md({
padding-top: @space-3xl;
padding-bottom: @space-3xl;
});
.respond-lg({
padding-top: 100px;
padding-bottom: 100px;
});
}
// 响应式网格移动1列 → 平板2列 → 桌面3列
.grid-responsive(@gap: @space-lg; @md-cols: 2; @lg-cols: 3) {
display: grid;
grid-template-columns: 1fr;
gap: @gap;
.respond-md({
grid-template-columns: repeat(@md-cols, 1fr);
});
.respond-lg({
grid-template-columns: repeat(@lg-cols, 1fr);
});
}
// 多行截断
.text-clamp(@lines: 3) {
display: -webkit-box;
-webkit-line-clamp: @lines;
-webkit-box-orient: vertical;
overflow: hidden;
}

115
assets/less/variables.less 普通文件
查看文件

@ -0,0 +1,115 @@
// ============================================
// 呼籁旅行官网 - 设计变量
// 视觉定位:精致温暖,草原+大地色调
// ============================================
// ---------- 色彩系统 ----------
// 主色 - 深林绿:自然、稳重、值得信赖
@color-primary: #2D6A4F;
@color-primary-dark: #1B4332;
@color-primary-light: #40916C;
@color-primary-lighter: #B7E4C7;
@color-primary-bg: #F0F7F4;
// 点缀 - 暖琥珀:温暖、家庭、亲和
@color-accent: #D4883A;
@color-accent-light: #E8A85C;
@color-accent-bg: #FFF8F0;
// 中性色
@color-text-primary: #1A1A2E;
@color-text-secondary: #4A4A5A;
@color-text-muted: #8A8A9A;
@color-text-placeholder: #BBBBC5;
@color-border: #E8E8ED;
@color-divider: #F0F0F4;
@color-bg-white: #FFFFFF;
@color-bg-gray: #F7F8FA;
@color-bg-warm: #FFFBF5;
// 语义色
@color-success: #2D6A4F;
@color-warning: #D4883A;
@color-error: #C1292E;
// ---------- 字体 ----------
@font-family-base: -apple-system, BlinkMacSystemFont, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
@font-size-xs: 12px;
@font-size-sm: 14px;
@font-size-base: 16px;
@font-size-md: 18px;
@font-size-lg: 20px;
@font-size-xl: 24px;
@font-size-2xl: 30px;
@font-size-3xl: 36px;
@font-size-hero: 42px;
@font-size-display: 48px;
@font-size-display: 48px;
@line-height-tight: 1.3;
@line-height-normal: 1.5;
@line-height-base: 1.7;
@line-height-relaxed: 1.8;
@line-height-loose: 2.0;
@font-weight-normal: 400;
@font-weight-medium: 500;
@font-weight-bold: 700;
// ---------- 间距8px网格 ----------
@space-xs: 4px;
@space-sm: 8px;
@space-md: 16px;
@space-lg: 24px;
@space-xl: 32px;
@space-2xl: 48px;
@space-3xl: 64px;
@space-4xl: 96px;
// ---------- 容器 ----------
@container-max-width: 1200px;
@container-padding: 20px;
@container-padding-desktop: 40px;
// ---------- 断点 ----------
@breakpoint-sm: 375px;
@breakpoint-md: 768px;
@breakpoint-lg: 1024px;
@breakpoint-xl: 1200px;
// ---------- 圆角与阴影 ----------
@border-radius-sm: 4px;
@border-radius-md: 8px;
@border-radius-lg: 16px;
@border-radius-xl: 24px;
@border-radius-full: 9999px;
// 精致的阴影层次(升级版:更有深度感)
@shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
@shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
@shadow-card: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.06);
@shadow-md: 0 4px 16px rgba(0, 0, 0, 0.10), 0 2px 6px rgba(0, 0, 0, 0.06);
@shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06);
@shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.16), 0 8px 20px rgba(0, 0, 0, 0.08);
@shadow-card-hover: 0 12px 36px rgba(45, 106, 79, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08);
// ---------- 渐变 ----------
@gradient-hero: linear-gradient(135deg, #1B4332 0%, #2D6A4F 50%, #40916C 100%);
@gradient-warm: linear-gradient(135deg, #FFFBF5 0%, #FFF8F0 50%, #F0F7F4 100%);
@gradient-cool: linear-gradient(135deg, #F0F7F4 0%, #E8F3ED 50%, #D4E8DC 100%);
@gradient-accent: linear-gradient(135deg, @color-primary 0%, @color-accent 100%);
// ---------- 过渡 ----------
@transition-fast: 0.15s ease;
@transition-base: 0.25s ease;
@transition-slow: 0.4s ease;

查看文件

@ -0,0 +1,119 @@
<template>
<section class="section section--warm">
<div class="container">
<div class="story-layout">
<!-- 左侧配图 -->
<div class="story-photo">
<div class="story-photo-card">
<img
:src="images.brandStory.photo"
alt="呼籁旅行创始人在呼伦贝尔草原"
width="600"
height="800"
loading="lazy"
/>
</div>
</div>
<!-- 右侧文字 -->
<div class="story-text">
<h2 class="story-title">{{ story.title }}</h2>
<div class="story-title-line" />
<div class="story-content">
<template v-if="story.paragraphs">
<p v-for="(para, i) in story.paragraphs" :key="i" class="story-para">{{ para }}</p>
</template>
<p v-else>{{ story.content }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import images from '~/data/images.json'
defineProps({ story: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.story-layout {
display: grid;
grid-template-columns: 1fr;
gap: @space-2xl;
.respond-lg({
grid-template-columns: 5fr 7fr;
gap: @space-3xl;
align-items: start;
});
}
//
.story-photo {
.respond-lg({
position: sticky;
top: 100px;
});
}
.story-photo-card {
border-radius: @border-radius-lg;
overflow: hidden;
box-shadow: @shadow-lg;
img {
width: 100%;
display: block;
aspect-ratio: 3 / 4;
object-fit: cover;
object-position: center 30%;
.respond-lg({
aspect-ratio: 4 / 5;
});
}
}
//
.story-text {
padding-top: @space-md;
}
.story-title {
font-size: @font-size-2xl;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0 0 @space-md;
line-height: @line-height-tight;
.respond-md({
font-size: @font-size-3xl;
});
}
.story-title-line {
width: 48px;
height: 3px;
background: @gradient-accent;
border-radius: 3px;
margin-bottom: @space-xl;
}
.story-content {
p {
font-size: @font-size-base;
line-height: 2;
color: @color-text-secondary;
}
.story-para {
text-indent: 2em;
margin-bottom: @space-lg;
&:last-child {
margin-bottom: 0;
}
}
}
</style>

查看文件

@ -0,0 +1,194 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="知识产权" subtitle="商标全品类注册 + 4项国家版权登记" />
<p class="copyright-intro">呼籁集团重视品牌知识产权保护呼籁商标已完成全品类注册核心产品名称品牌标识和IP形象均已在中国版权保护中心完成作品登记</p>
<!-- 商标 -->
<div v-if="trademark" class="trademark-card">
<div class="trademark-header">
<span class="trademark-badge">® 商标</span>
<h3 class="trademark-name">{{ trademark.name }}{{ trademark.scope }}</h3>
</div>
<p class="trademark-desc">{{ trademark.description }}</p>
<p class="trademark-holder">持有人{{ trademark.holder }}</p>
</div>
<!-- 版权 -->
<div class="copyright-grid">
<div v-for="c in copyrights" :key="c.regNo" class="copyright-card">
<div class="copyright-header">
<h3 class="copyright-name">{{ c.name }}</h3>
<span class="copyright-category">{{ c.category }}</span>
</div>
<p class="copyright-desc">{{ c.description }}</p>
<div class="copyright-meta">
<div class="copyright-meta-item">
<span class="meta-label">登记号</span>
<span class="meta-value">{{ c.regNo }}</span>
</div>
<div class="copyright-meta-item">
<span class="meta-label">登记日期</span>
<span class="meta-value">{{ c.date }}</span>
</div>
<div class="copyright-meta-item">
<span class="meta-label">著作权人</span>
<span class="meta-value">{{ c.holder }}</span>
</div>
</div>
</div>
</div>
<p class="copyright-note">以上作品均经中国版权保护中心审核依据作品自愿登记试行办法予以登记</p>
</div>
</section>
</template>
<script setup>
defineProps({
copyrights: { type: Array, required: true },
trademark: { type: Object, default: null }
})
</script>
<style lang="less" scoped>
.copyright-intro {
text-align: center;
max-width: 600px;
margin: 0 auto @space-xl;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
}
.trademark-card {
max-width: 900px;
margin: 0 auto @space-xl;
padding: @space-xl;
background: @color-primary-bg;
border: 1px solid fade(@color-primary, 20%);
border-radius: @border-radius-md;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
text-align: center;
}
.trademark-header {
margin-bottom: @space-sm;
}
.trademark-badge {
display: inline-block;
font-size: @font-size-xs;
color: @color-bg-white;
background: @color-primary;
padding: 2px 10px;
border-radius: 20px;
margin-bottom: @space-sm;
}
.trademark-name {
font-size: @font-size-md;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: @space-sm 0 0;
}
.trademark-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-loose;
max-width: 700px;
margin: @space-sm auto @space-sm;
}
.trademark-holder {
font-size: @font-size-xs;
color: @color-text-muted;
margin: 0;
}
.copyright-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-md;
max-width: 900px;
margin: 0 auto;
.respond-md({
grid-template-columns: 1fr 1fr;
});
}
.copyright-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-lg;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
}
.copyright-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: @space-sm;
}
.copyright-name {
font-size: @font-size-md;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0;
}
.copyright-category {
font-size: @font-size-xs;
color: @color-primary;
background: @color-primary-bg;
padding: 2px 8px;
border-radius: @border-radius-sm;
flex-shrink: 0;
}
.copyright-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-md;
}
.copyright-meta {
border-top: 1px solid @color-bg-gray;
padding-top: @space-sm;
}
.copyright-meta-item {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 3px 0;
font-size: @font-size-xs;
}
.meta-label {
color: @color-text-muted;
flex-shrink: 0;
margin-right: @space-sm;
}
.meta-value {
color: @color-text-secondary;
text-align: right;
word-break: break-all;
}
.copyright-note {
text-align: center;
margin-top: @space-xl;
font-size: @font-size-xs;
color: @color-text-muted;
}
</style>

查看文件

@ -0,0 +1,37 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="企业文化" />
<p v-if="culture.core" class="culture-core">{{ culture.core }}</p>
<div class="values-grid">
<div v-for="v in culture.values" :key="v.name" class="value-item">
<h3 class="value-name">{{ v.name }}</h3>
<p class="value-expr">{{ v.expression }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({ culture: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.culture-core {
text-align: center; font-size: @font-size-xl; font-weight: 600; color: @color-primary;
letter-spacing: 0.1em; margin-bottom: @space-2xl;
}
.culture-core {
text-align: center; font-size: @font-size-xl; font-weight: 600; color: @color-primary;
letter-spacing: 0.1em; margin-bottom: @space-2xl;
}
.values-grid {
display: grid; grid-template-columns: 1fr; gap: @space-lg;
.respond-md({ grid-template-columns: repeat(3, 1fr); });
}
.value-item { text-align: center; padding: @space-xl @space-lg; }
.value-name { font-size: @font-size-xl; color: @color-primary; margin-bottom: @space-sm; }
.value-expr { font-size: @font-size-base; color: @color-text-secondary; }
</style>

查看文件

@ -0,0 +1,195 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="品牌IP · 籁小咩" subtitle="LAI XIAO MIE" />
<div class="mascot-hero">
<div class="mascot-image">
<img
:src="images.mascot.front"
alt="籁小咩 - 呼籁旅行品牌IP吉祥物,一只可爱的小羊形象"
width="280"
height="380"
loading="lazy"
/>
</div>
<div class="mascot-info">
<h3 class="mascot-name">籁小咩</h3>
<p class="mascot-intro">呼籁旅行的品牌IP吉祥物灵感来自呼伦贝尔草原上自由奔跑的小羊圆润的羊毛卷发温暖的大眼睛招手的小手势籁小咩代表着呼籁对每一个家庭的热情欢迎</p>
<div class="mascot-tags">
<span class="mascot-tag">原创IP形象</span>
<span class="mascot-tag">国家版权登记</span>
<span class="mascot-tag">表情包</span>
</div>
<p class="mascot-copyright">国家版权局作品登记国作登字-2026-F-00020268</p>
</div>
</div>
<!-- 草原场景 -->
<div class="mascot-banner">
<img
:src="images.mascot.banner"
alt="籁小咩在呼伦贝尔草原上向你招手"
width="800"
height="300"
loading="lazy"
/>
</div>
<!-- 表情包二维码 -->
<div class="mascot-sticker">
<div class="mascot-sticker-qr">
<img
:src="images.mascot.stickerQr"
alt="籁小咩微信表情包二维码"
width="160"
height="160"
loading="lazy"
/>
</div>
<div class="mascot-sticker-text">
<p class="mascot-sticker-title">把籁小咩带进你的聊天</p>
<p class="mascot-sticker-desc">微信扫一扫免费领取籁小咩表情包旅行前发给家人朋友快乐先行一步</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
import images from '~/data/images.json'
</script>
<style lang="less" scoped>
.mascot-hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: @space-xl;
max-width: 800px;
margin: 0 auto @space-2xl;
.respond-md({
flex-direction: row;
text-align: left;
gap: @space-2xl;
});
}
.mascot-image {
flex-shrink: 0;
img {
width: 200px;
height: auto;
filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.1));
}
}
.mascot-info {
flex: 1;
}
.mascot-name {
font-size: 28px;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0 0 @space-md;
}
.mascot-intro {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-loose;
margin-bottom: @space-lg;
}
.mascot-tags {
display: flex;
flex-wrap: wrap;
gap: @space-sm;
margin-bottom: @space-md;
justify-content: center;
.respond-md({
justify-content: flex-start;
});
}
.mascot-tag {
font-size: @font-size-xs;
color: @color-primary;
background: @color-primary-bg;
padding: 4px 12px;
border-radius: 20px;
}
.mascot-copyright {
font-size: @font-size-xs;
color: @color-text-muted;
margin: 0;
}
.mascot-banner {
max-width: 800px;
margin: 0 auto @space-2xl;
border-radius: @border-radius-lg;
overflow: hidden;
img {
width: 100%;
height: auto;
display: block;
}
}
.mascot-sticker {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: @space-lg;
max-width: 500px;
margin: 0 auto;
padding: @space-xl;
background: @color-bg-white;
border-radius: @border-radius-lg;
border: 1px solid @color-border;
.respond-md({
flex-direction: row;
text-align: left;
});
}
.mascot-sticker-qr {
flex-shrink: 0;
img {
width: 120px;
height: 120px;
border-radius: @border-radius-md;
}
}
.mascot-sticker-text {
flex: 1;
}
.mascot-sticker-title {
font-size: @font-size-md;
font-weight: @font-weight-medium;
color: @color-text-primary;
margin-bottom: @space-sm;
}
.mascot-sticker-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: 1.6;
margin: 0;
}
</style>

查看文件

@ -0,0 +1,131 @@
<template>
<section class="section">
<div class="container">
<!-- 资质认证 -->
<CommonSectionTitle title="资质认证" />
<div class="qual-list">
<div v-for="c in certifications" :key="c.title" class="qual-item qual-item--cert">
<div class="qual-icon">🏆</div>
<div class="qual-body">
<h3 class="qual-title">{{ c.title }}</h3>
<p class="qual-detail">{{ c.detail }}</p>
</div>
</div>
</div>
<!-- 小红书WILL大会获奖照片 -->
<div class="award-gallery">
<div class="award-gallery-grid">
<img src="/images/awards/will-award-person.jpg" alt="呼籁旅行创始人在2026小红书WILL商业大会领取年度成长型品牌奖" class="award-img award-img--main" loading="lazy" />
<img src="/images/awards/will-award-trophy.jpg" alt="2026小红书WILL商业大会年度成长型品牌奖杯" class="award-img" loading="lazy" />
<img src="/images/awards/will-award-stage.jpg" alt="2026小红书WILL商业大会年度成长型品牌颁奖典礼" class="award-img" loading="lazy" />
</div>
<p class="award-caption">2026小红书WILL商业大会 · 年度成长型品牌 · 文旅行业仅6家获此殊荣</p>
</div>
<!-- 服务保障 -->
<CommonSectionTitle title="服务保障" style="margin-top: 48px;" />
<div class="qual-list qual-list--grid">
<div v-for="g in guarantees" :key="g.title" class="qual-item qual-item--guarantee">
<div class="qual-icon"></div>
<div class="qual-body">
<h3 class="qual-title">{{ g.title }}</h3>
<p class="qual-detail">{{ g.detail }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
certifications: { type: Array, required: true },
guarantees: { type: Array, required: true }
})
</script>
<style lang="less" scoped>
.qual-list {
display: flex;
flex-direction: column;
gap: @space-md;
max-width: 800px;
margin: 0 auto;
}
.qual-list--grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: @space-md;
.respond-md({
grid-template-columns: 1fr;
});
}
.qual-item {
display: flex;
align-items: flex-start;
gap: @space-sm;
padding: @space-lg;
border-radius: @border-radius-md;
transition: transform 0.2s ease;
&:hover { transform: translateY(-2px); }
}
.qual-item--cert {
background: @color-primary-bg;
border-left: 3px solid @color-primary;
}
.qual-item--guarantee {
background: #f8faf5;
border-left: 3px solid @color-primary;
}
.qual-icon {
font-size: 24px;
flex-shrink: 0;
line-height: 1;
}
.qual-body { flex: 1; }
.qual-title {
font-size: @font-size-md;
color: @color-text-primary;
margin-bottom: @space-xs;
font-weight: @font-weight-bold;
}
.qual-detail {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
}
.award-gallery {
max-width: 800px;
margin: @space-xl auto 0;
}
.award-gallery-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: @space-sm;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.award-img {
width: 100%;
border-radius: @border-radius-md;
object-fit: cover;
aspect-ratio: 3 / 4;
@media (max-width: 768px) {
aspect-ratio: 4 / 3;
}
}
.award-caption {
text-align: center;
margin-top: @space-md;
font-size: @font-size-xs;
color: @color-text-muted;
}
</style>

查看文件

@ -0,0 +1,104 @@
<template>
<section class="section section--gray">
<div class="container">
<CommonSectionTitle :title="`${subsidiaries.length}家集团企业`" subtitle="各司其职,共同为您的旅行保驾护航" />
<!-- 集团母公司 -->
<div v-if="parent" class="sub-parent">
<span class="sub-parent-tag">集团母公司</span>
<h3 class="sub-parent-name">{{ parent.name }}</h3>
<p class="sub-parent-role">{{ parent.role }}</p>
<p v-if="parent.established" class="sub-parent-date">成立于{{ formatDate(parent.established) }}</p>
</div>
<!-- 子公司网格 -->
<div class="sub-grid">
<div v-for="sub in children" :key="sub.name" class="sub-item">
<h3 class="sub-name">{{ sub.name }}</h3>
<p class="sub-role">{{ sub.role }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
const props = defineProps({ subsidiaries: { type: Array, required: true } })
const parent = props.subsidiaries.find(s => s.role.includes('集团母公司') || s.role.includes('母公司'))
const children = props.subsidiaries.filter(s => s !== parent)
function formatDate(dateStr) {
const [year, month] = dateStr.split('-')
return `${year}${parseInt(month)}`
}
</script>
<style lang="less" scoped>
.sub-parent {
max-width: 900px;
margin: 0 auto @space-xl;
padding: @space-xl @space-2xl;
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
text-align: center;
}
.sub-parent-tag {
display: inline-block;
font-size: @font-size-xs;
color: @color-bg-white;
background: @color-primary;
padding: 2px 12px;
border-radius: 20px;
margin-bottom: @space-sm;
}
.sub-parent-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-xs;
}
.sub-parent-role {
font-size: @font-size-base;
color: @color-text-secondary;
margin-bottom: @space-xs;
}
.sub-parent-date {
font-size: @font-size-xs;
color: @color-text-muted;
margin: 0;
}
.sub-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-md;
.respond-md({ grid-template-columns: repeat(2, 1fr); });
.respond-lg({ grid-template-columns: repeat(2, 1fr); max-width: 900px; margin-left: auto; margin-right: auto; });
}
.sub-item {
padding: @space-lg;
background: @color-bg-white;
border-radius: @border-radius-md;
border: 1px solid @color-border;
}
.sub-name {
font-size: @font-size-base;
color: @color-primary;
margin-bottom: @space-xs;
}
.sub-role {
font-size: @font-size-sm;
color: @color-text-secondary;
}
</style>

查看文件

@ -0,0 +1,179 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="我们的团队" />
<p class="team-summary">{{ team.summary }}</p>
<!-- 团队照片墙 -->
<div class="team-gallery">
<!-- 上排两年踩线对比体现年年坚持 -->
<div class="team-gallery-row team-gallery-row--survey">
<div class="team-photo-card">
<div class="team-photo-wrap team-photo-wrap--survey">
<img
:src="images.team.guideGroup"
alt="2024呼籁文旅车队踩线全体领队合影"
loading="lazy"
/>
</div>
<div class="team-photo-caption">
<span class="caption-tag">2024踩线</span>
<span class="caption-text">额吉的故乡全体车队师傅集体踩线</span>
</div>
</div>
<div class="team-photo-card">
<div class="team-photo-wrap team-photo-wrap--survey">
<img
:src="images.team.routeSurvey"
alt="2025秋呼籁文旅游牧的森林车队踩线全体领队合影"
loading="lazy"
/>
</div>
<div class="team-photo-caption">
<span class="caption-tag">2025踩线</span>
<span class="caption-text">游牧的森林新品路线全员实地考察</span>
</div>
</div>
</div>
<!-- 下排培训 + 牧场 -->
<div class="team-gallery-row team-gallery-row--misc">
<div class="team-photo-card">
<div class="team-photo-wrap team-photo-wrap--misc">
<img
:src="images.team.guideTraining"
alt="呼籁文旅2026年司导线下培训"
loading="lazy"
/>
</div>
<div class="team-photo-caption">
<span class="caption-tag">专业培训</span>
<span class="caption-text">每年冬季集中培训统一服务标准</span>
</div>
</div>
<div class="team-photo-card">
<div class="team-photo-wrap team-photo-wrap--misc">
<img
:src="images.team.ranchStore"
alt="呼籁牧场线下店开业团队合影"
loading="lazy"
/>
</div>
<div class="team-photo-caption">
<span class="caption-tag">实体布局</span>
<span class="caption-text">呼籁牧场线下体验店正式落成</span>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import images from '~/data/images.json'
defineProps({ team: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.team-summary {
max-width: 800px;
margin: 0 auto @space-2xl;
text-align: center;
font-size: @font-size-md;
line-height: @line-height-base;
color: @color-text-secondary;
}
.team-gallery {
display: flex;
flex-direction: column;
gap: @space-lg;
.respond-md({
gap: @space-xl;
});
}
.team-gallery-row {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
gap: @space-xl;
});
}
.team-gallery-row--survey {
.respond-md({
grid-template-columns: 1fr 1fr;
});
}
.team-gallery-row--misc {
.respond-md({
grid-template-columns: 1fr 1fr;
});
}
.team-photo-card {
border-radius: @border-radius-lg;
overflow: hidden;
background: @color-bg-white;
box-shadow: @shadow-md;
transition: transform @transition-base, box-shadow @transition-base;
&:hover {
transform: translateY(-2px);
box-shadow: @shadow-lg;
}
}
.team-photo-wrap {
position: relative;
overflow: hidden;
img {
width: 100%;
display: block;
object-fit: cover;
}
}
.team-photo-wrap--survey {
img {
aspect-ratio: 16 / 9;
}
}
.team-photo-wrap--misc {
img {
aspect-ratio: 16 / 10;
}
}
.team-photo-caption {
padding: @space-md @space-lg;
display: flex;
align-items: center;
gap: @space-md;
}
.caption-tag {
flex-shrink: 0;
display: inline-block;
padding: 3px 10px;
background: @color-primary-bg;
color: @color-primary;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
border-radius: @border-radius-sm;
}
.caption-text {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: 1.5;
}
</style>

查看文件

@ -0,0 +1,211 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="小红书上的呼籁" subtitle="XIAOHONGSHU" />
<div class="xhs-card">
<!-- 顶部品牌主页截图作为横幅 -->
<div class="xhs-banner">
<img
:src="images.xiaohongshu.storefront"
alt="呼籁旅行小红书品牌主页 - Mr.小雷-呼籁旅行 50万家庭青睐的草原旅行品牌"
width="800"
height="300"
loading="lazy"
/>
</div>
<!-- 底部数据 + 信息 -->
<div class="xhs-body">
<!-- 账号信息 -->
<div class="xhs-header">
<div class="xhs-account">
<span class="xhs-account-name">{{ xhs.account }}</span>
<span class="xhs-badge">{{ xhs.verifiedType }}</span>
</div>
<p class="xhs-tagline">{{ xhs.tagline }}</p>
</div>
<!-- 数据指标 -->
<div class="xhs-stats">
<div class="xhs-stat">
<span class="xhs-stat-value">{{ xhs.followers }}</span>
<span class="xhs-stat-label">粉丝</span>
</div>
<div class="xhs-stat-divider" />
<div class="xhs-stat">
<span class="xhs-stat-value">{{ xhs.likes }}</span>
<span class="xhs-stat-label">获赞与收藏</span>
</div>
</div>
<!-- 荣誉 + 描述 -->
<div class="xhs-awards">
<span v-for="award in xhs.awards" :key="award" class="xhs-award">{{ award }}</span>
</div>
<p class="xhs-desc">{{ xhs.description }}</p>
<div class="xhs-tags">
<span v-for="tag in xhs.tags" :key="tag" class="xhs-tag">{{ tag }}</span>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import images from '~/data/images.json'
defineProps({ xhs: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.xhs-card {
max-width: 800px;
margin: 0 auto;
border-radius: @border-radius-lg;
overflow: hidden;
background: @color-bg-white;
box-shadow: @shadow-md;
}
.xhs-banner {
position: relative;
overflow: hidden;
img {
width: 100%;
display: block;
}
}
.xhs-body {
padding: @space-xl;
.respond-md({
padding: @space-xl @space-2xl;
});
}
.xhs-header {
margin-bottom: @space-lg;
}
.xhs-account {
display: flex;
align-items: center;
gap: @space-sm;
margin-bottom: @space-xs;
flex-wrap: wrap;
}
.xhs-account-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
}
.xhs-badge {
display: inline-block;
padding: 2px 10px;
background: #ff2442;
color: #fff;
font-size: @font-size-xs;
border-radius: 20px;
font-weight: @font-weight-medium;
}
.xhs-tagline {
font-size: @font-size-base;
color: @color-primary;
font-weight: @font-weight-medium;
margin: 0;
}
.xhs-stats {
display: flex;
align-items: center;
gap: @space-lg;
padding: @space-lg 0;
margin-bottom: @space-lg;
border-top: 1px solid @color-border;
border-bottom: 1px solid @color-border;
}
.xhs-stat {
text-align: center;
flex: 1;
}
.xhs-stat-divider {
width: 1px;
height: 32px;
background: @color-border;
flex-shrink: 0;
}
.xhs-stat-value {
display: block;
font-size: 24px;
font-weight: @font-weight-bold;
color: @color-text-primary;
line-height: 1.2;
.respond-md({
font-size: 28px;
});
}
.xhs-stat-label {
font-size: @font-size-xs;
color: @color-text-muted;
margin-top: 4px;
display: block;
}
.xhs-awards {
display: flex;
flex-wrap: wrap;
gap: @space-sm;
margin-bottom: @space-md;
}
.xhs-award {
display: inline-flex;
align-items: center;
padding: 5px 12px;
background: linear-gradient(135deg, #FFF8E1, #FFF3CD);
color: #B8860B;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
border-radius: @border-radius-sm;
border: 1px solid #F0D58C;
&::before {
content: "🏆";
margin-right: 6px;
}
}
.xhs-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-loose;
margin-bottom: @space-md;
}
.xhs-tags {
display: flex;
flex-wrap: wrap;
gap: @space-sm;
}
.xhs-tag {
font-size: @font-size-xs;
color: @color-text-secondary;
background: @color-bg-gray;
padding: 4px 12px;
border-radius: 20px;
}
</style>

查看文件

@ -0,0 +1,298 @@
<template>
<div class="data-table-wrapper">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<span class="total-info"> <strong>{{ total }}</strong> </span>
<slot name="filter" />
</div>
<div class="toolbar-right">
<div class="search-box" v-if="searchable">
<input
v-model="searchInput"
type="text"
:placeholder="searchPlaceholder || '搜索...'"
@keyup.enter="handleSearch"
/>
<button class="search-btn" @click="handleSearch">搜索</button>
<button v-if="searchInput" class="clear-btn" @click="searchInput=''; handleSearch()">清除</button>
</div>
<slot name="actions" />
</div>
</div>
<!-- 表格 -->
<div class="table-container">
<table>
<thead>
<tr>
<slot name="header" />
</tr>
</thead>
<tbody>
<slot name="body" />
<tr v-if="!loading && total === 0">
<td :colspan="colCount" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
<div v-if="loading" class="table-loading">加载中...</div>
</div>
<!-- 分页 -->
<div class="pagination" v-if="totalPages > 1">
<button class="page-btn" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
<template v-for="p in visiblePages" :key="p">
<span v-if="p === '...'" class="page-dots">...</span>
<button v-else class="page-btn" :class="{ active: p === page }" @click="changePage(p)">{{ p }}</button>
</template>
<button class="page-btn" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
<span class="page-info">{{ page }} / {{ totalPages }} </span>
</div>
</div>
</template>
<script setup>
const props = defineProps({
total: { type: Number, default: 0 },
page: { type: Number, default: 1 },
totalPages: { type: Number, default: 1 },
loading: { type: Boolean, default: false },
searchable: { type: Boolean, default: true },
searchPlaceholder: { type: String, default: '' },
colCount: { type: Number, default: 6 },
})
const emit = defineEmits(['search', 'page-change'])
const searchInput = ref('')
function handleSearch() {
emit('search', searchInput.value)
}
function changePage(p) {
if (p < 1 || p > props.totalPages) return
emit('page-change', p)
}
const visiblePages = computed(() => {
const pages = []
const total = props.totalPages
const current = props.page
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i)
} else {
pages.push(1)
if (current > 3) pages.push('...')
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) pages.push(i)
if (current < total - 2) pages.push('...')
pages.push(total)
}
return pages
})
</script>
<style scoped>
.data-table-wrapper {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
overflow: hidden;
}
/* 工具栏 */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.total-info {
font-size: 13px;
color: #888;
}
.total-info strong {
color: #333;
}
.search-box {
display: flex;
gap: 4px;
}
.search-box input {
padding: 5px 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 13px;
width: 180px;
outline: none;
}
.search-box input:focus {
border-color: #3a7d44;
}
.search-btn {
padding: 5px 12px;
background: #3a7d44;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.search-btn:hover {
background: #2d6235;
}
.clear-btn {
padding: 5px 8px;
background: none;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
color: #999;
}
/* 表格容器 */
.table-container {
overflow-x: auto;
position: relative;
max-height: calc(100vh - 280px);
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 600px;
}
:deep(thead) {
position: sticky;
top: 0;
z-index: 10;
}
:deep(thead tr) {
background: #fafafa;
}
:deep(th) {
padding: 10px 14px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: #666;
border-bottom: 1px solid #e8e8e8;
white-space: nowrap;
background: #fafafa;
}
:deep(td) {
padding: 10px 14px;
font-size: 13px;
color: #333;
border-bottom: 1px solid #f5f5f5;
vertical-align: middle;
}
:deep(tr:hover td) {
background: #fafbfc;
}
:deep(td strong) {
color: #1a1a2e;
}
.empty-cell {
text-align: center;
color: #ccc;
padding: 40px 14px !important;
font-size: 14px;
}
.table-loading {
position: absolute;
inset: 0;
background: rgba(255,255,255,0.8);
display: flex;
align-items: center;
justify-content: center;
color: #888;
font-size: 14px;
}
/* 分页 */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 14px 20px;
border-top: 1px solid #f0f0f0;
}
.page-btn {
padding: 5px 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
font-size: 13px;
cursor: pointer;
color: #333;
min-width: 32px;
text-align: center;
}
.page-btn:hover:not(:disabled):not(.active) {
border-color: #3a7d44;
color: #3a7d44;
}
.page-btn.active {
background: #3a7d44;
color: #fff;
border-color: #3a7d44;
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-dots {
padding: 0 4px;
color: #ccc;
}
.page-info {
margin-left: 12px;
font-size: 12px;
color: #aaa;
}
</style>

查看文件

@ -0,0 +1,225 @@
<template>
<div class="image-upload">
<div class="upload-preview" v-if="modelValue">
<img :src="modelValue" alt="预览" @error="imgError = true" />
<div class="preview-actions">
<button type="button" class="btn-icon" @click="$emit('update:modelValue', '')" title="移除">&#10005;</button>
</div>
</div>
<div
v-else
class="upload-area"
:class="{ dragging }"
@click="triggerUpload"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="handleDrop"
>
<div v-if="uploading" class="upload-loading">上传中...</div>
<div v-else class="upload-placeholder">
<span class="upload-icon">+</span>
<span>点击或拖拽上传图片</span>
<span class="upload-hint">JPG / PNG / WebP最大 10MB</span>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none"
@change="handleFileChange"
/>
<p v-if="uploadError" class="upload-error">{{ uploadError }}</p>
<!-- 或者直接填写路径 -->
<div class="upload-manual">
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
type="text"
:placeholder="placeholder || '图片URL,可手动输入或上传'"
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const { adminFetch } = useAdmin()
const fileInput = ref(null)
const uploading = ref(false)
const uploadError = ref('')
const dragging = ref(false)
const imgError = ref(false)
function triggerUpload() {
fileInput.value?.click()
}
async function handleFileChange(e) {
const file = e.target.files?.[0]
if (file) await uploadFile(file)
e.target.value = ''
}
function handleDrop(e) {
dragging.value = false
const file = e.dataTransfer?.files?.[0]
if (file) uploadFile(file)
}
async function uploadFile(file) {
if (file.size > 10 * 1024 * 1024) {
uploadError.value = '文件过大,最大 10MB'
return
}
uploading.value = true
uploadError.value = ''
try {
const formData = new FormData()
formData.append('file', file)
const token = useState('admin_token')
const result = await $fetch('/api/admin/upload', {
method: 'POST',
body: formData,
headers: { Authorization: `Bearer ${token.value}` },
})
if (result.files?.length) {
emit('update:modelValue', result.files[0].url)
imgError.value = false
}
} catch (e) {
uploadError.value = e.data?.message || '上传失败'
} finally {
uploading.value = false
}
}
</script>
<style scoped>
.image-upload {
margin-bottom: 4px;
}
.upload-preview {
position: relative;
width: 200px;
height: 140px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #eee;
margin-bottom: 8px;
}
.upload-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-actions {
position: absolute;
top: 4px;
right: 4px;
}
.btn-icon {
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.5);
color: #fff;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: rgba(231,76,60,0.9);
}
.upload-area {
width: 200px;
height: 140px;
border: 2px dashed #d9d9d9;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 8px;
}
.upload-area:hover,
.upload-area.dragging {
border-color: #3a7d44;
background: #f8fdf9;
}
.upload-placeholder {
text-align: center;
color: #999;
}
.upload-icon {
display: block;
font-size: 28px;
color: #ccc;
margin-bottom: 4px;
}
.upload-placeholder span {
display: block;
font-size: 12px;
line-height: 1.6;
}
.upload-hint {
color: #bbb;
font-size: 11px !important;
}
.upload-loading {
color: #3a7d44;
font-size: 13px;
}
.upload-manual input {
width: 100%;
max-width: 400px;
padding: 6px 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
color: #666;
box-sizing: border-box;
}
.upload-manual input:focus {
border-color: #3a7d44;
outline: none;
}
.upload-error {
color: #e74c3c;
font-size: 12px;
margin: 4px 0 0;
}
</style>

查看文件

@ -0,0 +1,110 @@
<template>
<div>
<div v-if="!loaded" class="loading-card">加载中...</div>
<template v-else>
<!-- 基本信息 -->
<div class="card">
<div class="card-header" @click="open.base=!open.base"><h2>基本信息</h2><i class="arrow" :class="{open:open.base}">&#9656;</i></div>
<div v-show="open.base" class="card-body grid-2">
<div class="field"><label>品牌</label><input v-model="data.brand" /></div>
<div class="field"><label>版本</label><input v-model="data.version" /></div>
<div class="field"><label>季节</label><input v-model="data.season" /></div>
<div class="field"><label>风格</label><input v-model="data.style" /></div>
<div class="field full"><label>叙事</label><textarea v-model="data.narrative" rows="3" class="w-full"></textarea></div>
</div>
</div>
<!-- 亮点 -->
<div class="card">
<div class="card-header" @click="open.hl=!open.hl"><h2>产品亮点 ({{ data.highlights?.length || 0 }})</h2><i class="arrow" :class="{open:open.hl}">&#9656;</i></div>
<div v-show="open.hl" class="card-body">
<div v-for="(h, i) in data.highlights" :key="i" class="inline-row">
<input v-model="h.title" placeholder="标题" style="width:150px" />
<input v-model="h.description" placeholder="描述" style="flex:1" />
<button class="btn btn-xs btn-danger" @click="data.highlights.splice(i,1)"></button>
</div>
<button class="btn btn-xs btn-primary" @click="data.highlights.push({title:'',description:''})">+ 添加亮点</button>
</div>
</div>
<!-- 产品版本 -->
<div v-for="(v, vi) in data.versions" :key="vi" class="card">
<div class="card-header" @click="open['v'+vi]=!open['v'+vi]">
<h2>{{ v.name || '版本 '+(vi+1) }}</h2>
<div class="card-meta">
<span class="tag">{{ v.days }}{{ v.nights }}</span>
<span class="tag">{{ v.line || v.route || '' }}</span>
<i class="arrow" :class="{open:open['v'+vi]}">&#9656;</i>
</div>
</div>
<div v-show="open['v'+vi]" class="card-body">
<div class="grid-2">
<div class="field"><label>ID</label><input v-model="v.id" /></div>
<div class="field"><label>名称</label><input v-model="v.name" /></div>
<div class="field"><label>线路</label><input v-model="v.line" /></div>
<div class="field"><label>路线</label><input v-model="v.route" /></div>
<div class="field"><label>天数</label><input v-model.number="v.days" type="number" /></div>
<div class="field"><label>晚数</label><input v-model.number="v.nights" type="number" /></div>
<div class="field"><label>受众</label><input v-model="v.audience" /></div>
<div class="field"><label>标签</label><input v-model="v.tag" /></div>
</div>
<div class="field"><label>描述</label><textarea v-model="v.description" rows="3" class="w-full"></textarea></div>
<div class="field"><label>亮点每行一个</label>
<textarea :value="(v.highlights||[]).join('\n')" @input="v.highlights=$event.target.value.split('\n').filter(Boolean)" rows="3" class="w-full"></textarea>
</div>
<div class="sub-section"><h3>每日行程</h3>
<div v-for="(day, di) in (v.itinerary||[])" :key="di" class="day-card">
<div class="day-num">D{{ day.day || di+1 }}</div>
<div class="day-fields">
<input v-model="day.title" placeholder="当日标题" />
<textarea v-model="day.summary" rows="2" placeholder="行程概要"></textarea>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
const props = defineProps({ data: Object, loaded: Boolean })
const open = reactive({ base: false })
</script>
<style scoped>
.loading-card { background: #fff; border-radius: 8px; padding: 60px; text-align: center; color: #999; }
.card { background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 12px; overflow: hidden; }
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; cursor: pointer; user-select: none; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
.card-header:hover { background: #f5f5f5; }
.card-header h2 { margin: 0; font-size: 15px; color: #333; }
.card-meta { display: flex; align-items: center; gap: 8px; }
.card-body { padding: 16px 20px; }
.arrow { font-style: normal; font-size: 12px; transition: transform 0.2s; color: #999; }
.arrow.open { transform: rotate(90deg); }
.tag { font-size: 11px; background: #e6f7e9; color: #3a7d44; padding: 2px 8px; border-radius: 4px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; }
.full { grid-column: span 2; }
.field { margin-bottom: 10px; }
.field label { display: block; font-size: 12px; color: #888; margin-bottom: 3px; }
.field input, .field textarea { width: 100%; padding: 6px 8px; border: 1px solid #d9d9d9; border-radius: 4px; font-size: 13px; box-sizing: border-box; }
.field input:focus, .field textarea:focus { border-color: #3a7d44; outline: none; }
.field textarea { resize: vertical; }
.w-full { width: 100%; box-sizing: border-box; }
.inline-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.inline-row input { padding: 6px 8px; border: 1px solid #d9d9d9; border-radius: 4px; font-size: 13px; }
.inline-row input:focus { border-color: #3a7d44; outline: none; }
.sub-section { margin-top: 14px; border-top: 1px solid #f0f0f0; padding-top: 10px; }
.sub-section h3 { margin: 0 0 10px; font-size: 14px; color: #555; }
.day-card { display: flex; gap: 12px; margin-bottom: 8px; padding: 10px; background: #fafbfc; border-radius: 6px; }
.day-num { width: 36px; height: 36px; background: #3a7d44; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; flex-shrink: 0; }
.day-fields { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.day-fields input, .day-fields textarea { padding: 6px 8px; border: 1px solid #d9d9d9; border-radius: 4px; font-size: 13px; width: 100%; box-sizing: border-box; }
.day-fields input:focus, .day-fields textarea:focus { border-color: #3a7d44; outline: none; }
.day-fields textarea { resize: vertical; }
.btn { padding: 6px 14px; border: 1px solid #d9d9d9; border-radius: 4px; cursor: pointer; font-size: 13px; background: #fff; }
.btn-primary { background: #3a7d44; color: #fff; border-color: #3a7d44; }
.btn-xs { padding: 3px 8px; font-size: 11px; }
.btn-danger { color: #e74c3c; border-color: #e74c3c; }
.btn-danger:hover { background: #e74c3c; color: #fff; }
</style>

查看文件

@ -0,0 +1,250 @@
<template>
<article class="autumn-card" :class="{ 'autumn-card--popular': isPopular }">
<!-- 头部 -->
<div class="card-header">
<div v-if="isPopular" class="popular-badge">推荐</div>
<div class="card-duration">
<span class="duration-num">{{ product.days }}</span>
<span class="duration-unit"></span>
<span class="duration-num">{{ product.nights }}</span>
<span class="duration-unit"></span>
</div>
<span class="card-tag">{{ product.tag }}</span>
<span class="card-line">{{ product.line }} · {{ product.route }}</span>
</div>
<!-- 主体 -->
<div class="card-body">
<h3 class="card-name">{{ shortName }}</h3>
<p class="card-audience">适合{{ product.audience }}</p>
<p class="card-desc">{{ product.description }}</p>
<ul class="card-highlights">
<li v-for="(h, i) in product.highlights" :key="i">
<span class="check" aria-hidden="true">&#10003;</span>
{{ h }}
</li>
</ul>
</div>
<!-- 行程概览 -->
<details v-if="product.itinerary && product.itinerary.length" class="card-itinerary">
<summary class="itinerary-toggle">查看每日行程 </summary>
<ol class="itinerary-list">
<li v-for="(day, i) in product.itinerary" :key="i">{{ day }}</li>
</ol>
</details>
<!-- 底部 CTA -->
<NuxtLink to="/contact" class="card-cta">
咨询该行程 <span aria-hidden="true"></span>
</NuxtLink>
</article>
</template>
<script setup>
const props = defineProps({ product: { type: Object, required: true } })
const isPopular = computed(() => props.product.id === 'autumn-south-5d4n')
const shortName = computed(() => {
return props.product.name.replace(/^游牧的森林V3·/, '')
})
</script>
<style lang="less" scoped>
.autumn-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow @transition-base, transform @transition-base;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-4px);
}
&--popular {
border: 2px solid #D97706;
box-shadow: @shadow-md;
.card-header {
background: #D97706;
}
.duration-num,
.duration-unit,
.card-tag,
.card-line {
color: #fff;
}
.card-tag {
background: rgba(255, 255, 255, 0.2);
}
}
}
.card-header {
padding: @space-lg @space-lg @space-md;
background: #FEF3C7;
text-align: center;
position: relative;
}
.popular-badge {
position: absolute;
top: @space-sm;
right: @space-sm;
padding: @space-xs @space-sm;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: #fff;
background: @color-accent;
border-radius: @border-radius-sm;
}
.card-duration {
display: flex;
align-items: baseline;
justify-content: center;
gap: 2px;
margin-bottom: @space-sm;
}
.duration-num {
font-size: @font-size-3xl;
font-weight: @font-weight-bold;
color: #D97706;
line-height: 1;
}
.duration-unit {
font-size: @font-size-sm;
color: #D97706;
font-weight: @font-weight-medium;
}
.card-tag {
display: inline-block;
padding: @space-xs @space-md;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
color: #D97706;
background: @color-bg-white;
border-radius: @border-radius-full;
}
.card-line {
display: block;
font-size: @font-size-xs;
color: #64748B;
margin-top: @space-xs;
}
.card-body {
padding: @space-lg;
flex: 1;
display: flex;
flex-direction: column;
}
.card-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-xs;
}
.card-audience {
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-md;
}
.card-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-md;
.text-clamp(3);
}
.card-highlights {
display: flex;
flex-direction: column;
gap: @space-sm;
margin-top: auto;
li {
display: flex;
align-items: flex-start;
gap: @space-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
}
}
.check {
flex-shrink: 0;
color: #D97706;
font-size: @font-size-sm;
font-weight: @font-weight-bold;
margin-top: 2px;
}
//
.card-itinerary {
border-top: 1px solid @color-border;
}
.itinerary-toggle {
display: block;
padding: @space-md @space-lg;
font-size: @font-size-sm;
color: @color-text-muted;
cursor: pointer;
text-align: center;
transition: background @transition-fast;
&:hover {
background: @color-bg-gray;
color: @color-text-primary;
}
}
.itinerary-list {
padding: 0 @space-lg @space-md;
display: flex;
flex-direction: column;
gap: @space-xs;
li {
font-size: @font-size-xs;
color: @color-text-secondary;
line-height: @line-height-base;
padding-left: @space-sm;
}
}
// CTA
.card-cta {
display: block;
text-align: center;
padding: @space-md @space-lg;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: #D97706;
background: @color-bg-gray;
text-decoration: none;
transition: all @transition-fast;
&:hover {
background: #D97706;
color: #fff;
}
}
</style>

查看文件

@ -0,0 +1,55 @@
<template>
<nav v-if="items.length" class="breadcrumb" aria-label="面包屑导航">
<ol class="breadcrumb-list">
<li class="breadcrumb-item">
<NuxtLink to="/" class="breadcrumb-link">首页</NuxtLink>
</li>
<li v-for="(item, index) in items" :key="index" class="breadcrumb-item">
<span class="breadcrumb-sep" aria-hidden="true">/</span>
<NuxtLink v-if="item.to" :to="item.to" class="breadcrumb-link">{{ item.text }}</NuxtLink>
<span v-else class="breadcrumb-current" aria-current="page">{{ item.text }}</span>
</li>
</ol>
</nav>
</template>
<script setup>
defineProps({
items: { type: Array, default: () => [] },
})
</script>
<style lang="less" scoped>
.breadcrumb-list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: @space-xs;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: @space-xs;
}
.breadcrumb-sep {
color: @color-text-muted;
font-size: @font-size-sm;
}
.breadcrumb-link {
font-size: @font-size-sm;
color: @color-text-muted;
text-decoration: none;
&:hover {
color: @color-primary;
}
}
.breadcrumb-current {
font-size: @font-size-sm;
color: @color-text-secondary;
}
</style>

查看文件

@ -0,0 +1,26 @@
<template>
<div class="content-card" :class="{ 'content-card--hoverable': hoverable }">
<slot />
</div>
</template>
<script setup>
defineProps({
hoverable: { type: Boolean, default: true },
})
</script>
<style lang="less" scoped>
.content-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
padding: @space-xl;
transition: box-shadow 0.3s cubic-bezier(0.23, 1, 0.32, 1), transform 0.3s cubic-bezier(0.23, 1, 0.32, 1);
&--hoverable:hover {
box-shadow: @shadow-md;
transform: translateY(-3px);
}
}
</style>

查看文件

@ -0,0 +1,37 @@
<template>
<NuxtLink :to="to" class="internal-link">
<span>{{ text }}</span>
<span v-if="arrow" class="internal-link-arrow" aria-hidden="true">&rarr;</span>
</NuxtLink>
</template>
<script setup>
defineProps({
to: { type: String, required: true },
text: { type: String, required: true },
arrow: { type: Boolean, default: true },
})
</script>
<style lang="less" scoped>
.internal-link {
display: inline-flex;
align-items: center;
gap: @space-sm;
font-size: @font-size-base;
color: @color-primary;
text-decoration: none;
font-weight: @font-weight-medium;
transition: gap @transition-fast;
&:hover {
gap: @space-md;
color: @color-primary-light;
}
}
.internal-link-arrow {
font-size: @font-size-lg;
transition: transform @transition-fast;
}
</style>

查看文件

@ -0,0 +1,54 @@
<template>
<div class="qr-block" :class="[`qr-block--${size}`]">
<img :src="image" :alt="`${label}二维码`" class="qr-image" loading="lazy" />
<span class="qr-label">{{ label }}</span>
</div>
</template>
<script setup>
defineProps({
image: { type: String, required: true },
label: { type: String, required: true },
size: { type: String, default: 'normal' },
})
</script>
<style lang="less" scoped>
.qr-block {
display: flex;
flex-direction: column;
align-items: center;
gap: @space-xs;
}
.qr-image {
border-radius: @border-radius-sm;
background: @color-bg-gray;
object-fit: contain;
.qr-block--small & {
width: 80px;
height: 80px;
}
.qr-block--normal & {
width: 140px;
height: 140px;
}
.qr-block--large & {
width: 180px;
height: 180px;
}
}
.qr-label {
font-size: @font-size-xs;
color: @color-text-muted;
.qr-block--normal &,
.qr-block--large & {
font-size: @font-size-sm;
}
}
</style>

查看文件

@ -0,0 +1,160 @@
<template>
<section class="section-cta" :class="`section-cta--${bg}`">
<div class="section-cta-bg" />
<div class="container text-center section-cta-inner">
<h2 class="section-cta-title">{{ title }}</h2>
<p class="section-cta-desc">{{ description }}</p>
<div class="section-cta-links">
<NuxtLink
v-for="(link, index) in links"
:key="link.to"
:to="link.to"
class="cta-btn"
:class="index === 0 ? 'cta-btn--primary' : 'cta-btn--outline'"
>
{{ link.text }}
</NuxtLink>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
description: { type: String, required: true },
links: { type: Array, required: true },
bg: { type: String, default: 'green' },
})
</script>
<style lang="less" scoped>
.section-cta {
position: relative;
overflow: hidden;
.section-padding();
}
.section-cta--green {
background: linear-gradient(135deg, @color-primary-dark 0%, @color-primary 50%, @color-primary-light 100%);
}
.section-cta--warm {
background: linear-gradient(135deg, @color-bg-warm 0%, @color-accent-bg 100%);
}
.section-cta-bg {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 20% 50%, rgba(255, 255, 255, 0.08) 0%, transparent 60%),
radial-gradient(ellipse at 80% 50%, rgba(255, 255, 255, 0.05) 0%, transparent 60%);
pointer-events: none;
}
.section-cta-inner {
position: relative;
z-index: 1;
}
.section-cta-title {
.section-cta--green & {
color: #fff;
}
&::after {
display: none;
}
}
.section-cta-desc {
margin-top: @space-md;
margin-bottom: @space-xl;
font-size: @font-size-md;
line-height: @line-height-relaxed;
max-width: 560px;
margin-left: auto;
margin-right: auto;
.section-cta--green & {
color: rgba(255, 255, 255, 0.8);
}
.section-cta--warm & {
color: @color-text-secondary;
}
}
.section-cta-links {
display: flex;
justify-content: center;
gap: @space-md;
flex-wrap: wrap;
}
.cta-btn {
display: inline-flex;
align-items: center;
padding: 14px @space-xl;
border-radius: @border-radius-md;
font-size: @font-size-base;
font-weight: @font-weight-medium;
text-decoration: none;
transition: all @transition-base;
.respond-md({
padding: 16px 36px;
font-size: @font-size-md;
});
&--primary {
.section-cta--green & {
background: #fff;
color: @color-primary;
border: 1px solid #fff;
&:hover {
background: rgba(255, 255, 255, 0.9);
color: @color-primary-dark;
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
}
.section-cta--warm & {
background: @color-primary;
color: #fff;
border: 1px solid @color-primary;
&:hover {
background: @color-primary-light;
border-color: @color-primary-light;
color: #fff;
}
}
}
&--outline {
.section-cta--green & {
border: 1px solid rgba(255, 255, 255, 0.5);
color: #fff;
&:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.8);
color: #fff;
}
}
.section-cta--warm & {
background: transparent;
color: @color-primary;
border: 1px solid @color-primary;
&:hover {
background: @color-primary;
color: #fff;
}
}
}
}
</style>

查看文件

@ -0,0 +1,76 @@
<template>
<div class="section-title" :class="[`section-title--${align}`]">
<span v-if="label" class="section-title-label">{{ label }}</span>
<component :is="tag" class="section-title-text">{{ title }}</component>
<p v-if="subtitle" class="section-title-sub">{{ subtitle }}</p>
</div>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
label: { type: String, default: '' },
tag: { type: String, default: 'h2' },
align: { type: String, default: 'center' },
})
</script>
<style lang="less" scoped>
.section-title {
margin-bottom: @space-xl;
.respond-md({
margin-bottom: @space-2xl;
});
&--center {
text-align: center;
}
&--left {
text-align: left;
}
}
.section-title-label {
display: inline-block;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: @color-primary;
text-transform: uppercase;
letter-spacing: 3px;
margin-bottom: @space-sm;
opacity: 0.7;
}
.section-title-text {
color: @color-text-primary;
// 线
&::after {
content: '';
display: block;
width: 64px;
height: 3px;
background: @gradient-accent;
border-radius: 3px;
margin-top: @space-md;
}
.section-title--center & {
&::after {
margin-left: auto;
margin-right: auto;
}
}
}
.section-title-sub {
margin-top: @space-md;
font-size: @font-size-md;
color: @color-text-muted;
line-height: @line-height-relaxed;
letter-spacing: 0.3px;
}
</style>

查看文件

@ -0,0 +1,83 @@
<template>
<div class="trust-badge" :class="{ 'trust-badge--dark': dark }">
<div class="trust-badge-value">
<span class="trust-number">{{ value }}</span>
<span v-if="unit" class="trust-unit">{{ unit }}</span>
</div>
<div class="trust-badge-label">{{ label }}</div>
<div v-if="attribution" class="trust-badge-note">{{ attribution }}</div>
</div>
</template>
<script setup>
defineProps({
value: { type: String, required: true },
unit: { type: String, default: '' },
label: { type: String, required: true },
attribution: { type: String, default: '' },
dark: { type: Boolean, default: false },
})
</script>
<style lang="less" scoped>
.trust-badge {
text-align: center;
padding: @space-lg @space-md;
}
.trust-badge-value {
display: flex;
align-items: baseline;
justify-content: center;
gap: @space-xs;
}
.trust-number {
font-size: @font-size-3xl;
font-weight: @font-weight-bold;
color: @color-primary;
letter-spacing: -1px;
.respond-md({
font-size: @font-size-display;
});
.trust-badge--dark & {
color: #fff;
}
}
.trust-unit {
font-size: @font-size-base;
color: @color-primary-light;
.respond-md({
font-size: @font-size-md;
});
.trust-badge--dark & {
color: @color-primary-lighter;
}
}
.trust-badge-label {
margin-top: @space-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
font-weight: @font-weight-medium;
.trust-badge--dark & {
color: rgba(255, 255, 255, 0.6);
}
}
.trust-badge-note {
margin-top: @space-xs;
font-size: @font-size-xs;
color: @color-text-muted;
.trust-badge--dark & {
color: rgba(255, 255, 255, 0.35);
}
}
</style>

查看文件

@ -0,0 +1,43 @@
<template>
<CommonContentCard :class="{ 'channel-primary': channel.primary }">
<div class="channel">
<span v-if="channel.primary" class="channel-badge">推荐</span>
<h3 class="channel-label">{{ channel.label }}</h3>
<p class="channel-value">{{ channel.value }}</p>
<p v-if="channel.description" class="channel-desc">{{ channel.description }}</p>
<CommonQRCodeBlock
v-if="channel.qrImage"
:image="channel.qrImage"
:label="channel.label"
size="normal"
/>
</div>
</CommonContentCard>
</template>
<script setup>
defineProps({ channel: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.channel { text-align: center; }
.channel-label { font-size: @font-size-md; color: @color-primary; margin-bottom: @space-sm; }
.channel-value { font-size: @font-size-base; color: @color-text-primary; font-weight: @font-weight-medium; margin-bottom: @space-xs; }
.channel-desc { font-size: @font-size-sm; color: @color-text-muted; margin-bottom: @space-md; }
.channel-primary {
border: 2px solid @color-primary;
position: relative;
}
.channel-badge {
display: inline-block;
padding: @space-xs @space-md;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
color: #fff;
background: @color-primary;
border-radius: @border-radius-full;
margin-bottom: @space-md;
}
</style>

查看文件

@ -0,0 +1,14 @@
<template>
<div class="contact-map">
<p class="map-address">{{ address }}</p>
</div>
</template>
<script setup>
defineProps({ address: { type: String, required: true } })
</script>
<style lang="less" scoped>
.contact-map { padding: @space-lg; background: @color-bg-gray; border-radius: @border-radius-md; text-align: center; }
.map-address { color: @color-text-secondary; }
</style>

查看文件

@ -0,0 +1,43 @@
<template>
<section :id="category.id" class="faq-category">
<h2 class="faq-category-title">{{ category.name }}</h2>
<div class="faq-category-list">
<FaqItem
v-for="q in category.questions"
:key="q.id"
:question="q.question"
:answer="q.answer"
:related-links="q.relatedLinks"
:initial-open="initialOpen"
/>
</div>
</section>
</template>
<script setup>
defineProps({
category: { type: Object, required: true },
initialOpen: { type: Boolean, default: false },
})
</script>
<style lang="less" scoped>
.faq-category {
margin-bottom: @space-2xl;
scroll-margin-top: 80px;
}
.faq-category-title {
font-size: @font-size-xl;
color: @color-primary;
margin-bottom: @space-lg;
padding-bottom: @space-sm;
border-bottom: 2px solid @color-primary-lighter;
}
.faq-category-list {
display: flex;
flex-direction: column;
gap: @space-md;
}
</style>

查看文件

@ -0,0 +1,85 @@
<template>
<nav class="faq-nav" aria-label="问题分类">
<ul class="faq-nav-list">
<li v-for="cat in categories" :key="cat.id">
<a
:href="`#${cat.id}`"
class="faq-nav-item"
:class="{ active: activeCat === cat.id }"
@click.prevent="$emit('select', cat.id)"
>
{{ cat.name }}
<span class="faq-nav-count">{{ cat.questions.length }}</span>
</a>
</li>
</ul>
</nav>
</template>
<script setup>
defineProps({
categories: { type: Array, required: true },
activeCat: { type: String, default: '' },
})
defineEmits(['select'])
</script>
<style lang="less" scoped>
.faq-nav {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
//
mask-image: linear-gradient(to right, #000 85%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, #000 85%, transparent 100%);
.respond-md({
mask-image: none;
-webkit-mask-image: none;
overflow-x: visible;
});
}
.faq-nav-list {
display: flex;
gap: @space-sm;
min-width: max-content;
.respond-md({
justify-content: center;
flex-wrap: wrap;
});
}
.faq-nav-item {
display: inline-flex;
align-items: center;
gap: @space-xs;
padding: @space-sm @space-lg;
font-size: @font-size-sm;
color: @color-text-secondary;
background: @color-bg-gray;
border-radius: @border-radius-full;
text-decoration: none;
white-space: nowrap;
transition: all @transition-fast;
min-height: 44px;
touch-action: manipulation;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
&.active {
background: @color-primary;
color: #fff;
}
}
.faq-nav-count {
font-size: @font-size-xs;
opacity: 0.7;
}
</style>

170
components/faq/FaqItem.vue 普通文件
查看文件

@ -0,0 +1,170 @@
<template>
<!--
SEO关键设计
1. <details>/<summary>确保内容在HTML中始终存在百度蜘蛛可抓取
2. 问题用<h3>标签语义化
3. 答案用<p>纯文本AI直接引用
-->
<details class="faq-item" :open="initialOpen || undefined">
<summary class="faq-item-question">
<h3 class="faq-item-q-text">{{ question }}</h3>
<span class="faq-item-icon" aria-hidden="true"></span>
</summary>
<div class="faq-item-answer">
<p class="faq-item-a-text">{{ answer }}</p>
<div v-if="relatedLinks && relatedLinks.length" class="faq-item-links">
<NuxtLink
v-for="link in relatedLinks"
:key="link.url"
:to="link.url"
class="faq-item-link"
>
{{ link.text }}
</NuxtLink>
</div>
</div>
</details>
</template>
<script setup>
defineProps({
question: { type: String, required: true },
answer: { type: String, required: true },
relatedLinks: { type: Array, default: () => [] },
initialOpen: { type: Boolean, default: false },
})
</script>
<style lang="less" scoped>
.faq-item {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
overflow: hidden;
transition: border-color @transition-fast;
&[open] {
border-color: @color-primary-lighter;
.faq-item-icon {
transform: rotate(180deg);
}
}
&:hover {
border-color: @color-primary-lighter;
}
}
.faq-item-question {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-lg;
cursor: pointer;
list-style: none;
touch-action: manipulation;
&::-webkit-details-marker {
display: none;
}
&::marker {
display: none;
content: '';
}
&:hover {
background: @color-bg-gray;
}
}
.faq-item-q-text {
font-size: @font-size-md;
font-weight: @font-weight-medium;
color: @color-text-primary;
flex: 1;
padding-right: @space-md;
}
.faq-item-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
position: relative;
transition: transform @transition-base;
&::before,
&::after {
content: '';
position: absolute;
background: @color-text-muted;
transition: opacity @transition-fast;
}
&::before {
top: 50%;
left: 2px;
right: 2px;
height: 2px;
transform: translateY(-50%);
}
&::after {
left: 50%;
top: 2px;
bottom: 2px;
width: 2px;
transform: translateX(-50%);
}
details[open] & {
&::after {
opacity: 0;
}
}
}
//
.faq-item-answer {
padding: 0 @space-lg @space-lg;
animation: fadeSlideIn @transition-base;
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.faq-item-a-text {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-base;
}
.faq-item-links {
display: flex;
flex-wrap: wrap;
gap: @space-md;
margin-top: @space-md;
padding-top: @space-md;
border-top: 1px solid @color-border;
}
.faq-item-link {
font-size: @font-size-sm;
color: @color-primary;
text-decoration: none;
font-weight: @font-weight-medium;
&:hover {
color: @color-primary-light;
}
}
</style>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -0,0 +1,443 @@
<template>
<section v-if="data" class="section">
<div class="container">
<CommonSectionTitle :title="data.title" :subtitle="data.subtitle" />
<p class="compare-intro">{{ data.intro }}</p>
<!-- 桌面端紧凑对比表格 -->
<div class="compare-table-desktop">
<div class="compare-grid">
<!-- 表头 -->
<div class="grid-header">
<div class="grid-label-col"></div>
<div
v-for="dest in data.destinations"
:key="dest.id"
class="grid-dest-col"
:class="{ 'grid-dest-col--highlight': dest.highlight }"
>
<span v-if="dest.highlight" class="recommend-badge">推荐</span>
<h3 class="dest-name">{{ dest.name }}</h3>
<span class="dest-tag">{{ dest.tag }}</span>
</div>
</div>
<!-- 数据行 -->
<div v-for="dim in data.dimensions" :key="dim.label" class="grid-row">
<div class="grid-label-col">
<span class="dim-icon">{{ dim.icon }}</span>
<span class="dim-label">{{ dim.label }}</span>
</div>
<div
v-for="(val, i) in dim.values"
:key="i"
class="grid-value-col"
:class="{ 'grid-value-col--highlight': data.destinations[i]?.highlight }"
>
{{ val }}
</div>
</div>
</div>
<!-- 坦诚说明 - 桌面端紧贴表格底部 -->
<div class="honest-strip">
<div class="honest-strip-header">
<span class="honest-icon">💬</span>
<span class="honest-label">{{ data.honestNote.title }}</span>
<span class="honest-dash"></span>
<span class="honest-sub">{{ data.honestNote.subtitle }}</span>
</div>
<div class="honest-strip-items">
<span v-for="(item, i) in data.honestNote.items" :key="i" class="honest-item">
{{ item }}
</span>
</div>
</div>
</div>
<!-- 移动端Tab切换卡片 -->
<div class="compare-mobile">
<div class="tab-bar">
<button
v-for="(dest, i) in data.destinations"
:key="dest.id"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === i, 'tab-btn--highlight': dest.highlight }"
@click="activeTab = i"
>
<span v-if="dest.highlight && activeTab !== i" class="tab-dot"></span>
{{ dest.name }}
</button>
</div>
<div class="dest-card">
<p class="dest-card-tag">{{ data.destinations[activeTab].tag }}</p>
<div class="dest-card-grid">
<div v-for="dim in data.dimensions" :key="dim.label" class="dest-card-item">
<div class="dest-card-dim">
<span class="dest-card-icon">{{ dim.icon }}</span>
<span class="dest-card-label">{{ dim.label }}</span>
</div>
<p class="dest-card-value">{{ dim.values[activeTab] }}</p>
</div>
</div>
</div>
<!-- 坦诚说明 - 移动端 -->
<div v-if="data.destinations[activeTab]?.highlight" class="honest-mobile">
<p class="honest-mobile-title">
<span class="honest-icon-sm">💬</span>
{{ data.honestNote.title }}
</p>
<ul class="honest-mobile-list">
<li v-for="(item, i) in data.honestNote.items" :key="i">{{ item }}</li>
</ul>
</div>
</div>
<!-- 数据来源 -->
<p class="data-source">{{ data.dataSources }}</p>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue'
const { data } = await usePublicApi('destinations')
const activeTab = ref(0)
</script>
<style lang="less" scoped>
.compare-intro {
max-width: 720px;
margin: 0 auto @space-lg;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
text-align: center;
}
// ========== ==========
.compare-table-desktop {
display: none;
.respond-md({
display: block;
border-radius: @border-radius-lg;
overflow: hidden;
box-shadow: @shadow-card;
margin-bottom: @space-xl;
});
}
.compare-grid {
background: @color-bg-white;
}
.grid-header {
display: grid;
grid-template-columns: 100px repeat(3, 1fr);
background: @color-primary-bg;
}
.grid-label-col {
padding: @space-md @space-md;
display: flex;
align-items: center;
gap: 6px;
font-size: @font-size-xs;
color: @color-text-muted;
font-weight: @font-weight-medium;
}
.grid-dest-col {
padding: @space-lg @space-md;
text-align: center;
position: relative;
&--highlight {
background: fadeout(@color-primary, 92%);
}
}
.recommend-badge {
display: inline-block;
padding: 2px 10px;
font-size: 11px;
font-weight: @font-weight-bold;
color: #fff;
background: @color-primary;
border-radius: @border-radius-full;
margin-bottom: 6px;
}
.dest-name {
font-size: @font-size-md;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: 2px;
}
.dest-tag {
font-size: @font-size-xs;
color: @color-text-muted;
}
//
.grid-row {
display: grid;
grid-template-columns: 100px repeat(3, 1fr);
border-top: 1px solid @color-divider;
&:nth-child(even) {
background: @color-bg-gray;
}
}
.dim-icon {
font-size: 14px;
}
.dim-label {
font-size: @font-size-xs;
white-space: nowrap;
}
.grid-value-col {
padding: 12px @space-md;
font-size: 13px;
color: @color-text-secondary;
line-height: @line-height-normal;
border-left: 1px solid @color-divider;
&--highlight {
color: @color-text-primary;
font-weight: @font-weight-medium;
}
}
// ========== - ==========
.honest-strip {
background: @color-bg-warm;
padding: @space-md @space-lg;
border-top: 1px solid fadeout(@color-accent, 80%);
}
.honest-strip-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: @space-sm;
font-size: @font-size-sm;
}
.honest-icon {
font-size: 16px;
}
.honest-label {
font-weight: @font-weight-bold;
color: @color-accent;
}
.honest-dash {
color: @color-text-muted;
}
.honest-sub {
color: @color-text-muted;
font-size: @font-size-xs;
}
.honest-strip-items {
display: flex;
flex-wrap: wrap;
gap: 6px @space-md;
}
.honest-item {
font-size: @font-size-xs;
color: @color-text-secondary;
line-height: @line-height-normal;
position: relative;
padding-left: 12px;
&::before {
content: '·';
position: absolute;
left: 0;
color: @color-accent;
font-weight: bold;
font-size: 16px;
line-height: 1;
top: 1px;
}
}
// ========== Tab + ==========
.compare-mobile {
display: block;
.respond-md({
display: none;
});
}
.tab-bar {
display: flex;
gap: 0;
background: @color-bg-gray;
border-radius: @border-radius-md;
padding: 3px;
margin-bottom: @space-md;
}
.tab-btn {
flex: 1;
padding: 10px 4px;
border: none;
background: none;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: @color-text-muted;
border-radius: 6px;
cursor: pointer;
transition: all @transition-fast;
position: relative;
&--active {
background: @color-bg-white;
color: @color-text-primary;
font-weight: @font-weight-bold;
box-shadow: @shadow-xs;
}
&--highlight:not(.tab-btn--active) {
color: @color-primary;
}
}
.tab-dot {
display: inline-block;
width: 6px;
height: 6px;
background: @color-primary;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
}
.dest-card {
background: @color-bg-white;
border-radius: @border-radius-lg;
box-shadow: @shadow-card;
padding: @space-md;
margin-bottom: @space-md;
}
.dest-card-tag {
text-align: center;
font-size: @font-size-xs;
color: @color-text-muted;
margin-bottom: @space-md;
padding-bottom: @space-sm;
border-bottom: 1px solid @color-divider;
}
.dest-card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: @color-divider;
border-radius: @border-radius-md;
overflow: hidden;
}
.dest-card-item {
padding: 12px;
background: @color-bg-white;
//
&:last-child:nth-child(odd) {
grid-column: 1 / -1;
}
}
.dest-card-dim {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.dest-card-icon {
font-size: 13px;
}
.dest-card-label {
font-size: 11px;
font-weight: @font-weight-bold;
color: @color-text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dest-card-value {
font-size: 13px;
color: @color-text-primary;
line-height: @line-height-normal;
}
// ========== - ==========
.honest-mobile {
background: @color-bg-warm;
border-radius: @border-radius-md;
padding: @space-md;
margin-bottom: @space-md;
}
.honest-mobile-title {
font-size: @font-size-sm;
font-weight: @font-weight-bold;
color: @color-accent;
margin-bottom: @space-sm;
}
.honest-icon-sm {
font-size: 14px;
margin-right: 4px;
}
.honest-mobile-list {
display: flex;
flex-direction: column;
gap: 6px;
li {
font-size: @font-size-xs;
color: @color-text-secondary;
line-height: @line-height-normal;
padding-left: 14px;
position: relative;
&::before {
content: '·';
position: absolute;
left: 0;
color: @color-accent;
font-weight: bold;
font-size: 16px;
top: -1px;
}
}
}
// ========== ==========
.data-source {
text-align: center;
font-size: @font-size-xs;
color: @color-text-placeholder;
margin-top: @space-sm;
}
</style>

查看文件

@ -0,0 +1,143 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="为什么选择呼籁" label="WHY HULAI" subtitle="不只是包车,是一套成熟的旅行服务体系" />
<div class="diff-grid">
<div v-for="(item, index) in itemsWithBg" :key="item.title" class="diff-item">
<div class="diff-bg">
<img :src="item.bgImage" :alt="item.title" loading="lazy" />
</div>
<div class="diff-content">
<div class="diff-icon" v-html="icons[index % icons.length]" />
<h3 class="diff-title">{{ item.title }}</h3>
<p class="diff-desc">{{ item.description }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
const props = defineProps({
items: { type: Array, required: true },
})
const bgImages = [
'/images/seasons/summer-hero.jpg',
'/images/seasons/summer-car.jpg',
'/images/seasons/summer-river.jpg',
'/images/seasons/autumn-forest.jpg',
'/images/seasons/autumn-lake.jpg',
'/images/seasons/winter-hero.jpg',
]
const itemsWithBg = computed(() =>
props.items.map((item, i) => ({
...item,
bgImage: bgImages[i % bgImages.length],
}))
)
const icons = [
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21l9-18 9 18"/><path d="M12 3v18"/><path d="M3 21h18"/></svg>',
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13" rx="2"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>',
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>',
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>',
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>',
]
</script>
<style lang="less" scoped>
.diff-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
grid-template-columns: repeat(2, 1fr);
});
.respond-lg({
grid-template-columns: repeat(3, 1fr);
});
}
.diff-item {
position: relative;
border-radius: @border-radius-lg;
overflow: hidden;
border: 1px solid @color-border;
transition: box-shadow 0.35s cubic-bezier(0.23, 1, 0.32, 1), transform 0.35s cubic-bezier(0.23, 1, 0.32, 1);
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-3px);
.diff-bg img {
transform: scale(1.05);
}
.diff-icon {
color: @color-primary;
background: rgba(255, 255, 255, 0.95);
}
}
}
//
.diff-bg {
position: absolute;
inset: 0;
z-index: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.15;
filter: saturate(0.6);
transition: transform 0.6s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.4s ease;
}
//
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0.4) 100%);
}
}
.diff-content {
position: relative;
z-index: 1;
padding: @space-xl @space-lg;
}
.diff-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: @border-radius-lg;
background: rgba(240, 247, 244, 0.9);
color: @color-primary-light;
margin-bottom: @space-lg;
transition: color @transition-base, background @transition-base;
}
.diff-title {
font-size: @font-size-lg;
color: @color-text-primary;
margin-bottom: @space-sm;
}
.diff-desc {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-relaxed;
}
</style>

查看文件

@ -0,0 +1,206 @@
<template>
<section v-if="brand && images" class="hero">
<div class="hero-bg">
<img
:src="images.hero.background"
alt="呼伦贝尔草原风光 莫日格勒河蜿蜒穿过辽阔草原"
class="hero-bg-img"
width="1600"
height="900"
fetchpriority="high"
/>
<div class="hero-overlay" />
</div>
<div class="container hero-inner">
<h1 class="hero-title">{{ seo.h1 }}</h1>
<p class="hero-emotional">{{ brand.slogan.emotional }}</p>
<p class="hero-functional">{{ brand.slogan.functional }}</p>
<div class="hero-actions">
<NuxtLink to="/products" class="hero-btn hero-btn--primary">
<span>查看产品</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</NuxtLink>
<NuxtLink to="/faq" class="hero-btn hero-btn--outline">常见问答</NuxtLink>
</div>
</div>
<!-- 底部弧形裁切 -->
<div class="hero-curve">
<svg viewBox="0 0 1440 64" fill="none" preserveAspectRatio="none" aria-hidden="true">
<path d="M0 64h1440V32C1200 0 240 0 0 32v32z" fill="currentColor" />
</svg>
</div>
</section>
</template>
<script setup>
defineProps({
seo: { type: Object, required: true },
})
const { data: brand } = await usePublicApi('brand')
const { data: images } = await usePublicApi('images')
// preload Hero
useHead({
link: [
{ rel: 'preload', href: images.value?.hero?.background, as: 'image' },
],
})
</script>
<style lang="less" scoped>
.hero {
position: relative;
overflow: hidden;
min-height: 480px;
display: flex;
align-items: center;
justify-content: center;
.respond-md({
min-height: 580px;
});
.respond-lg({
min-height: 640px;
});
}
.hero-bg {
position: absolute;
inset: 0;
z-index: 0;
}
.hero-bg-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 40%;
display: block;
}
//
.hero-overlay {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(26, 26, 46, 0.35) 0%, rgba(26, 26, 46, 0.15) 40%, rgba(26, 26, 46, 0.5) 100%),
linear-gradient(135deg, rgba(45, 106, 79, 0.3) 0%, transparent 60%);
}
.hero-inner {
position: relative;
z-index: 1;
padding-top: @space-4xl;
padding-bottom: 100px;
text-align: center;
max-width: 720px;
margin: 0 auto;
.respond-md({
padding-top: @space-4xl;
padding-bottom: 120px;
});
}
.hero-title {
color: #FFFFFF;
margin-bottom: @space-lg;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
letter-spacing: 1px;
}
.hero-emotional {
font-size: @font-size-xl;
color: rgba(255, 255, 255, 0.95);
font-weight: @font-weight-medium;
margin-bottom: @space-sm;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
.respond-md({
font-size: @font-size-2xl;
});
}
.hero-functional {
font-size: @font-size-md;
color: rgba(255, 255, 255, 0.75);
margin-bottom: @space-xl;
.respond-md({
font-size: @font-size-lg;
});
}
.hero-actions {
display: flex;
gap: @space-md;
flex-wrap: wrap;
justify-content: center;
}
.hero-btn {
display: inline-flex;
align-items: center;
gap: @space-sm;
padding: 14px 32px;
border-radius: @border-radius-full;
font-size: @font-size-base;
font-weight: @font-weight-medium;
text-decoration: none;
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
.respond-md({
padding: 16px 40px;
font-size: @font-size-md;
});
&--primary {
background: #fff;
color: @color-primary;
border: 2px solid #fff;
&:hover {
background: rgba(255, 255, 255, 0.92);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
color: @color-primary;
}
}
&--outline {
border: 2px solid rgba(255, 255, 255, 0.5);
color: #fff;
&:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.8);
color: #fff;
transform: translateY(-2px);
}
}
}
//
.hero-curve {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
z-index: 2;
line-height: 0;
color: @color-bg-gray;
svg {
width: 100%;
height: 40px;
.respond-md({
height: 64px;
});
}
}
</style>

查看文件

@ -0,0 +1,260 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="旅行产品" subtitle="四季不同风景,总有一条线路适合你的家庭" />
<div class="product-grid">
<NuxtLink
v-for="line in productLines"
:key="line.id"
:to="line.to"
class="product-line-card"
:class="`product-line-card--${line.theme}`"
>
<!-- 封面图 -->
<div class="line-cover">
<img :src="line.image" :alt="line.name" loading="lazy" />
<div class="line-cover-overlay" />
<span class="line-season-badge">{{ line.season }}</span>
</div>
<!-- 内容区 -->
<div class="line-body">
<div class="line-meta">
<span class="line-duration">{{ line.duration }}</span>
</div>
<h3 class="line-name">{{ line.name }}</h3>
<p class="line-desc">{{ line.description }}</p>
<ul class="line-highlights">
<li v-for="(h, i) in line.highlights" :key="i">{{ h }}</li>
</ul>
<span class="line-cta">了解详情 </span>
</div>
</NuxtLink>
</div>
</div>
</section>
</template>
<script setup>
const productLines = [
{
id: 'summer',
name: '额吉的故乡',
season: '夏季',
theme: 'summer',
duration: '4-7天 · V9系列',
to: '/products',
image: '/images/seasons/summer-hero.jpg',
description: '呼伦贝尔草原家庭定制游,一家一单一车,6个版本适配不同假期',
highlights: ['莫日格勒河草原穿越', '专业骑马体验', '呼籁自有营地住宿'],
},
{
id: 'autumn',
name: '游牧的森林',
season: '秋季',
theme: 'autumn',
duration: '4-7天 · V3系列',
to: '/products/autumn',
image: '/images/seasons/autumn-hero.jpg',
description: '金秋草原+阿尔山/大兴安岭,南北两线专属私家团',
highlights: ['大兴安岭金秋林海', '阿尔山火山天池', '扎罗木得断崖秘境'],
},
{
id: 'winter',
name: '嗨冰雪',
season: '冬季',
theme: 'winter',
duration: '5-7天 · V3系列',
to: '/products/winter',
image: '/images/seasons/winter-hero.jpg',
description: '呼伦贝尔冬季定制游,南北两线,冰雪那达慕+驯鹿部落',
highlights: ['冰雪那达慕体验', '雪地狼群投喂', '大雪原日出日落'],
},
{
id: 'summer-camp',
name: '小蒙马夏令营',
season: '夏季',
theme: 'camp',
duration: '7天6晚',
to: '/summer-camp',
image: '/images/seasons/summer-river.jpg',
description: '亲子营模式,每期8组家庭结伴同行,孩子有伙伴大人能放松',
highlights: ['专业研学领队带队', '随团摄影师全程跟拍', '草原写生+非遗研学'],
},
{
id: 'winter-camp',
name: '小蒙马冬令营',
season: '冬季',
theme: 'winter-camp',
duration: '7天6晚',
to: '/winter-camp',
image: '/images/seasons/winter-frost.jpg',
description: '冰雪研学营,雪地徒步+冰上运动+驯鹿部落,零下30°的成长体验',
highlights: ['冰雪那达慕体验', '驯鹿部落探访', '冬季星空观测'],
},
]
</script>
<style lang="less" scoped>
.product-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
grid-template-columns: repeat(2, 1fr);
});
.respond-lg({
grid-template-columns: repeat(3, 1fr);
gap: @space-xl;
});
}
.product-line-card {
display: flex;
flex-direction: column;
background: @color-bg-white;
border-radius: @border-radius-lg;
overflow: hidden;
text-decoration: none;
box-shadow: @shadow-card;
transition: box-shadow @transition-base, transform @transition-base;
&:hover {
box-shadow: @shadow-card-hover;
transform: translateY(-6px);
.line-cover img {
transform: scale(1.08);
}
.line-cta {
color: @color-primary-light;
}
}
}
//
.line-cover {
position: relative;
aspect-ratio: 16 / 10;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.6s cubic-bezier(0.23, 1, 0.32, 1);
}
}
.line-cover-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 40%, rgba(0, 0, 0, 0.3) 100%);
}
.line-season-badge {
position: absolute;
top: @space-md;
left: @space-md;
padding: @space-xs @space-md;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
border-radius: @border-radius-full;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
//
.product-line-card--summer .line-season-badge {
background: rgba(45, 106, 79, 0.85);
color: #fff;
}
.product-line-card--autumn .line-season-badge {
background: rgba(217, 119, 6, 0.85);
color: #fff;
}
.product-line-card--winter .line-season-badge {
background: rgba(59, 130, 246, 0.85);
color: #fff;
}
.product-line-card--camp .line-season-badge {
background: rgba(139, 92, 246, 0.85);
color: #fff;
}
.product-line-card--winter-camp .line-season-badge {
background: rgba(99, 102, 241, 0.85);
color: #fff;
}
//
.line-body {
padding: @space-lg @space-xl @space-xl;
display: flex;
flex-direction: column;
flex: 1;
}
.line-meta {
margin-bottom: @space-sm;
}
.line-duration {
font-size: @font-size-xs;
color: @color-text-muted;
letter-spacing: 0.5px;
}
.line-name {
font-size: @font-size-xl;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-sm;
line-height: @line-height-tight;
}
.line-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-md;
}
.line-highlights {
display: flex;
flex-direction: column;
gap: @space-xs;
margin-bottom: @space-lg;
flex: 1;
li {
font-size: @font-size-sm;
color: @color-text-secondary;
padding-left: @space-md;
position: relative;
&::before {
content: '✓';
position: absolute;
left: 0;
font-weight: @font-weight-bold;
color: @color-primary;
font-size: @font-size-xs;
}
}
}
.line-cta {
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: @color-primary;
margin-top: auto;
transition: color @transition-fast;
}
</style>

查看文件

@ -0,0 +1,129 @@
<template>
<section class="section section--gray">
<div class="container">
<CommonSectionTitle title="客户怎么说" label="REVIEWS" subtitle="来自真实家庭的旅行评价" />
<div class="review-grid">
<div v-for="r in reviews" :key="r.id" class="review-card">
<div class="review-quote-mark">"</div>
<blockquote class="review-text">{{ r.content }}</blockquote>
<div class="review-footer">
<div class="review-avatar">{{ r.nickname.charAt(0) }}</div>
<div class="review-info">
<span class="review-name">{{ r.nickname }}</span>
<span class="review-date">{{ r.travelDate }}</span>
</div>
</div>
<p class="review-product">{{ r.productVersion }}</p>
</div>
</div>
<div class="review-more">
<CommonInternalLink to="/reviews" text="查看更多客户评价" />
</div>
</div>
</section>
</template>
<script setup>
defineProps({
reviews: { type: Array, required: true },
})
</script>
<style lang="less" scoped>
.review-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
grid-template-columns: repeat(2, 1fr);
});
.respond-lg({
grid-template-columns: repeat(3, 1fr);
});
}
.review-card {
background: @color-bg-white;
border-radius: @border-radius-lg;
padding: @space-xl;
border: 1px solid @color-border;
border-left: 3px solid @color-primary-lighter;
position: relative;
transition: box-shadow 0.35s cubic-bezier(0.23, 1, 0.32, 1), transform 0.35s cubic-bezier(0.23, 1, 0.32, 1);
&:hover {
box-shadow: @shadow-md;
transform: translateY(-3px);
border-left-color: @color-primary;
}
}
.review-quote-mark {
font-family: Georgia, 'Times New Roman', serif;
font-size: 64px;
line-height: 1;
color: @color-primary-lighter;
margin-bottom: -@space-md;
user-select: none;
}
.review-text {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-relaxed;
margin-bottom: @space-lg;
.text-clamp(4);
}
.review-footer {
display: flex;
align-items: center;
gap: @space-md;
}
.review-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, @color-primary-bg, @color-primary-lighter);
color: @color-primary;
display: flex;
align-items: center;
justify-content: center;
font-size: @font-size-sm;
font-weight: @font-weight-bold;
flex-shrink: 0;
}
.review-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.review-name {
font-size: @font-size-sm;
color: @color-text-primary;
font-weight: @font-weight-medium;
}
.review-date {
font-size: @font-size-xs;
color: @color-text-muted;
}
.review-product {
font-size: @font-size-xs;
color: @color-text-muted;
margin-top: @space-sm;
padding-top: @space-sm;
border-top: 1px solid @color-divider;
}
.review-more {
text-align: center;
margin-top: @space-2xl;
}
</style>

查看文件

@ -0,0 +1,45 @@
<template>
<section class="trust-section">
<div class="container">
<div class="trust-grid">
<CommonTrustBadge
v-for="stat in stats"
:key="stat.label"
:value="stat.value"
:unit="stat.unit"
:label="stat.label"
:attribution="stat.attribution"
dark
/>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
stats: { type: Array, required: true },
})
</script>
<style lang="less" scoped>
.trust-section {
background: linear-gradient(135deg, @color-primary-dark 0%, @color-primary 100%);
padding: @space-xl 0;
.respond-md({
padding: @space-2xl 0;
});
}
.trust-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: @space-md;
.respond-md({
grid-template-columns: repeat(4, 1fr);
gap: @space-xl;
});
}
</style>

查看文件

@ -0,0 +1,204 @@
<template>
<footer class="app-footer">
<div class="footer-container">
<div v-if="brand && images" class="footer-grid">
<!-- 品牌信息 -->
<div class="footer-brand">
<div class="footer-logo">
<img v-if="images?.logo?.main" :src="images.logo.main" alt="呼籁旅行" class="footer-logo-img" width="44" height="44" />
<div class="footer-logo-text">
<span class="logo-text">呼籁旅行</span>
<span class="logo-sub">HULAI TRAVEL</span>
</div>
</div>
<p class="footer-desc">{{ brand?.slogan?.functional }}</p>
<div class="footer-qr-list">
<CommonQRCodeBlock
v-for="ch in qrChannels"
:key="ch.type"
:image="ch.qrImage"
:label="ch.label"
size="small"
/>
</div>
</div>
<!-- 导航链接 -->
<div v-for="group in footerNav" :key="group.title" class="footer-nav-group">
<h4 class="footer-nav-title">{{ group.title }}</h4>
<ul class="footer-nav-list">
<li v-for="link in group.links" :key="link.to">
<NuxtLink :to="link.to" class="footer-nav-link">{{ link.text }}</NuxtLink>
</li>
</ul>
</div>
</div>
<!-- 底部信息 -->
<div class="footer-bottom">
<p class="footer-company">旅行服务内蒙古呼籁国际旅行社有限公司</p>
<p class="footer-legal">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" class="footer-icp-link">{{ brand?.icp }}</a>
<span class="footer-divider">|</span>
{{ brand?.icpEntity }}
</p>
<p class="footer-copyright">
© {{ new Date().getFullYear() }} {{ brand?.fullName }} 版权所有
</p>
</div>
</div>
</footer>
</template>
<script setup>
const { data: brand } = await usePublicApi('brand')
const { data: images } = await usePublicApi('images')
const { data: navData } = await usePublicApi('navigation')
const { data: contact } = await usePublicApi('contact')
const footerNav = computed(() => navData.value?.footer || [])
const qrChannels = computed(() => (contact.value?.channels || []).filter(c => c.qrImage && !c.hidden))
</script>
<style lang="less" scoped>
.app-footer {
background: linear-gradient(180deg, @color-primary-dark 0%, #0F2419 100%);
color: rgba(255, 255, 255, 0.7);
padding: @space-2xl 0 @space-lg;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, @color-primary, @color-accent, @color-primary);
}
}
.footer-container {
.container();
}
.footer-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-xl;
.respond-md({
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
gap: @space-xl;
});
}
.footer-brand {
margin-bottom: @space-lg;
}
.footer-logo {
display: flex;
align-items: center;
gap: @space-sm;
margin-bottom: @space-md;
}
.footer-logo-img {
width: 44px;
height: 44px;
border-radius: 6px;
flex-shrink: 0;
}
.footer-logo-text {
display: flex;
flex-direction: column;
.logo-text {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: #fff;
}
.logo-sub {
font-size: @font-size-xs;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 2px;
}
}
.footer-desc {
font-size: @font-size-sm;
margin-bottom: @space-lg;
color: rgba(255, 255, 255, 0.6);
}
.footer-qr-list {
display: flex;
gap: @space-lg;
}
.footer-nav-title {
font-size: @font-size-base;
font-weight: @font-weight-medium;
color: #fff;
margin-bottom: @space-md;
}
.footer-nav-list {
display: flex;
flex-direction: column;
gap: @space-sm;
}
.footer-nav-link {
font-size: @font-size-sm;
color: rgba(255, 255, 255, 0.6);
text-decoration: none;
transition: color @transition-fast;
&:hover {
color: #fff;
}
}
.footer-bottom {
margin-top: @space-2xl;
padding-top: @space-lg;
border-top: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
}
.footer-company {
font-size: @font-size-sm;
color: rgba(255, 255, 255, 0.6);
margin-bottom: @space-xs;
}
.footer-legal {
font-size: @font-size-xs;
color: rgba(255, 255, 255, 0.5);
margin-bottom: @space-xs;
}
.footer-divider {
margin: 0 @space-sm;
opacity: 0.5;
}
.footer-copyright {
font-size: @font-size-xs;
color: rgba(255, 255, 255, 0.45);
}
.footer-icp-link {
color: rgba(255, 255, 255, 0.5);
text-decoration: none;
transition: color @transition-fast;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
</style>

查看文件

@ -0,0 +1,552 @@
<template>
<header class="app-header">
<div class="header-container">
<NuxtLink to="/" class="header-logo" aria-label="呼籁旅行首页">
<img v-if="images?.logo?.main" :src="images.logo.main" alt="呼籁旅行" class="logo-img" width="40" height="40" />
<div class="logo-text-group">
<span class="logo-text">呼籁旅行</span>
<span class="logo-sub">HULAI TRAVEL</span>
</div>
</NuxtLink>
<nav class="header-nav" aria-label="主导航">
<ul class="nav-list">
<li
v-for="item in navItems"
:key="item.to"
class="nav-item"
:class="{ 'has-dropdown': item.children }"
@mouseenter="item.children && (openDropdown = item.to)"
@mouseleave="item.children && (openDropdown = null)"
>
<NuxtLink :to="item.to" class="nav-link" :class="{ active: isActive(item.to) }">
{{ item.text }}
<span v-if="item.children" class="nav-arrow"></span>
</NuxtLink>
<!-- 下拉菜单真实 <a> 链接SEO 可抓取 -->
<ul v-if="item.children" class="nav-dropdown" :class="{ show: openDropdown === item.to }">
<li v-for="child in item.children" :key="child.to" class="dropdown-item">
<NuxtLink :to="child.to" class="dropdown-link" @click="openDropdown = null">
<span class="dropdown-text">{{ child.text }}</span>
<span v-if="child.season" class="dropdown-season">{{ child.season }}</span>
</NuxtLink>
</li>
</ul>
</li>
</ul>
</nav>
<button
class="mobile-toggle"
:aria-expanded="mobileOpen"
aria-controls="mobile-nav"
aria-label="打开导航菜单"
@click="mobileOpen = !mobileOpen"
>
<span class="toggle-bar" :class="{ open: mobileOpen }"></span>
</button>
</div>
<LayoutMobileNav :is-open="mobileOpen" :items="navItems" @close="mobileOpen = false" />
</header>
</template>
<script setup>
import { ref } from 'vue'
const { data: navData } = await usePublicApi('navigation')
const { data: images } = await usePublicApi('images')
const navItems = computed(() => navData.value?.header || [])
const mobileOpen = ref(false)
const openDropdown = ref(null)
const route = useRoute()
function isActive(path) {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
watch(() => route.path, () => {
mobileOpen.value = false
})
</script>
<style lang="less" scoped>
.app-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
transition: box-shadow @transition-base;
&:hover {
box-shadow: @shadow-xs;
}
}
.header-container {
.container();
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
.respond-lg({
height: 72px;
});
}
.header-logo {
display: flex;
align-items: center;
gap: @space-sm;
text-decoration: none;
line-height: 1.2;
}
.logo-img {
width: 40px;
height: 40px;
border-radius: 6px;
flex-shrink: 0;
.respond-lg({
width: 44px;
height: 44px;
});
}
.logo-text-group {
display: flex;
flex-direction: column;
}
.logo-text {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-primary;
.respond-lg({
font-size: @font-size-xl;
});
}
.logo-sub {
font-size: @font-size-xs;
color: @color-text-muted;
letter-spacing: 2px;
}
.header-nav {
display: none;
.respond-lg({
display: block;
});
}
.nav-list {
display: flex;
gap: @space-xl;
}
.nav-link {
font-size: @font-size-base;
color: @color-text-secondary;
text-decoration: none;
padding: @space-sm 0;
border-bottom: 2px solid transparent;
transition: color @transition-fast, border-color @transition-fast;
&:hover,
&.active {
color: @color-primary;
border-bottom-color: @color-primary;
}
}
//
.has-dropdown {
position: relative;
}
.nav-arrow {
font-size: 10px;
margin-left: 2px;
opacity: 0.5;
transition: transform @transition-fast;
.has-dropdown:hover & {
transform: rotate(180deg);
}
}
.nav-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
min-width: 180px;
background: @color-bg-white;
border-radius: @border-radius-md;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
padding: @space-sm 0;
opacity: 0;
visibility: hidden;
transition: opacity @transition-fast, visibility @transition-fast;
z-index: 10;
//
&::before {
content: '';
position: absolute;
top: -8px;
left: 0;
right: 0;
height: 8px;
}
&.show {
opacity: 1;
visibility: visible;
}
}
.dropdown-item {
list-style: none;
}
.dropdown-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-sm @space-lg;
text-decoration: none;
color: @color-text-secondary;
font-size: @font-size-sm;
white-space: nowrap;
transition: background @transition-fast, color @transition-fast;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
}
.dropdown-text {
flex: 1;
}
.dropdown-season {
font-size: @font-size-xs;
color: @color-text-muted;
background: @color-bg-gray;
padding: 2px 6px;
border-radius: @border-radius-sm;
margin-left: @space-sm;
}
//
.has-dropdown {
position: relative;
}
.nav-arrow {
font-size: 10px;
margin-left: 2px;
opacity: 0.5;
transition: transform @transition-fast;
.has-dropdown:hover & {
transform: rotate(180deg);
}
}
.nav-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
min-width: 180px;
background: @color-bg-white;
border-radius: @border-radius-md;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
padding: @space-sm 0;
opacity: 0;
visibility: hidden;
transition: opacity @transition-fast, visibility @transition-fast;
z-index: 10;
//
&::before {
content: '';
position: absolute;
top: -8px;
left: 0;
right: 0;
height: 8px;
}
&.show {
opacity: 1;
visibility: visible;
}
}
.dropdown-item {
list-style: none;
}
.dropdown-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-sm @space-lg;
text-decoration: none;
color: @color-text-secondary;
font-size: @font-size-sm;
white-space: nowrap;
transition: background @transition-fast, color @transition-fast;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
}
.dropdown-text {
flex: 1;
}
.dropdown-season {
font-size: @font-size-xs;
color: @color-text-muted;
background: @color-bg-gray;
padding: 2px 6px;
border-radius: @border-radius-sm;
margin-left: @space-sm;
}
//
.has-dropdown {
position: relative;
}
.nav-arrow {
font-size: 10px;
margin-left: 2px;
opacity: 0.5;
transition: transform @transition-fast;
.has-dropdown:hover & {
transform: rotate(180deg);
}
}
.nav-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
min-width: 180px;
background: @color-bg-white;
border-radius: @border-radius-md;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
padding: @space-sm 0;
opacity: 0;
visibility: hidden;
transition: opacity @transition-fast, visibility @transition-fast;
z-index: 10;
//
&::before {
content: '';
position: absolute;
top: -8px;
left: 0;
right: 0;
height: 8px;
}
&.show {
opacity: 1;
visibility: visible;
}
}
.dropdown-item {
list-style: none;
}
.dropdown-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-sm @space-lg;
text-decoration: none;
color: @color-text-secondary;
font-size: @font-size-sm;
white-space: nowrap;
transition: background @transition-fast, color @transition-fast;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
}
.dropdown-text {
flex: 1;
}
.dropdown-season {
font-size: @font-size-xs;
color: @color-text-muted;
background: @color-bg-gray;
padding: 2px 6px;
border-radius: @border-radius-sm;
margin-left: @space-sm;
}
//
.has-dropdown {
position: relative;
}
.nav-arrow {
font-size: 10px;
margin-left: 2px;
opacity: 0.5;
transition: transform @transition-fast;
.has-dropdown:hover & {
transform: rotate(180deg);
}
}
.nav-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
min-width: 180px;
background: @color-bg-white;
border-radius: @border-radius-md;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
padding: @space-sm 0;
opacity: 0;
visibility: hidden;
transition: opacity @transition-fast, visibility @transition-fast;
z-index: 10;
//
&::before {
content: '';
position: absolute;
top: -8px;
left: 0;
right: 0;
height: 8px;
}
&.show {
opacity: 1;
visibility: visible;
}
}
.dropdown-item {
list-style: none;
}
.dropdown-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-sm @space-lg;
text-decoration: none;
color: @color-text-secondary;
font-size: @font-size-sm;
white-space: nowrap;
transition: background @transition-fast, color @transition-fast;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
}
.dropdown-text {
flex: 1;
}
.dropdown-season {
font-size: @font-size-xs;
color: @color-text-muted;
background: @color-bg-gray;
padding: 2px 6px;
border-radius: @border-radius-sm;
margin-left: @space-sm;
}
.mobile-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: none;
border: none;
cursor: pointer;
.respond-lg({
display: none;
});
}
.toggle-bar {
position: relative;
display: block;
width: 24px;
height: 2px;
background: @color-text-primary;
transition: background @transition-fast;
&::before,
&::after {
content: '';
position: absolute;
left: 0;
width: 24px;
height: 2px;
background: @color-text-primary;
transition: transform @transition-base;
}
&::before {
top: -7px;
}
&::after {
top: 7px;
}
&.open {
background: transparent;
&::before {
top: 0;
transform: rotate(45deg);
}
&::after {
top: 0;
transform: rotate(-45deg);
}
}
}
</style>

查看文件

@ -0,0 +1,188 @@
<template>
<Teleport to="body">
<Transition name="slide">
<div v-if="isOpen" id="mobile-nav" class="mobile-nav" role="dialog" aria-label="导航菜单">
<div class="mobile-nav-backdrop" @click="$emit('close')"></div>
<nav class="mobile-nav-panel">
<ul class="mobile-nav-list">
<li v-for="item in items" :key="item.to" class="mobile-nav-item">
<!-- 有子菜单的导航项 -->
<template v-if="item.children">
<button
class="mobile-nav-link mobile-nav-toggle"
:class="{ expanded: expandedItem === item.to }"
@click="toggleExpand(item.to)"
>
{{ item.text }}
<span class="mobile-nav-arrow"></span>
</button>
<ul v-show="expandedItem === item.to" class="mobile-sub-list">
<li v-for="child in item.children" :key="child.to" class="mobile-sub-item">
<NuxtLink :to="child.to" class="mobile-sub-link" @click="$emit('close')">
<span>{{ child.text }}</span>
<span v-if="child.season" class="mobile-sub-season">{{ child.season }}</span>
</NuxtLink>
</li>
</ul>
</template>
<!-- 普通导航项 -->
<NuxtLink v-else :to="item.to" class="mobile-nav-link" @click="$emit('close')">
{{ item.text }}
</NuxtLink>
</li>
</ul>
</nav>
</div>
</Transition>
</Teleport>
</template>
<script setup>
const props = defineProps({
isOpen: { type: Boolean, default: false },
items: { type: Array, default: () => [] },
})
defineEmits(['close'])
const expandedItem = ref(null)
function toggleExpand(key) {
expandedItem.value = expandedItem.value === key ? null : key
}
//
watch(() => props.isOpen, (open) => {
if (import.meta.client) {
document.body.style.overflow = open ? 'hidden' : ''
}
if (!open) {
expandedItem.value = null
}
})
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<style lang="less" scoped>
.mobile-nav {
position: fixed;
inset: 0;
z-index: 101;
}
.mobile-nav-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
.mobile-nav-panel {
position: absolute;
top: 0;
right: 0;
width: min(300px, 85vw);
height: 100%;
background: @color-bg-white;
padding: @space-3xl @space-lg @space-lg;
overflow-y: auto;
}
.mobile-nav-list {
display: flex;
flex-direction: column;
gap: @space-xs;
}
.mobile-nav-link {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: @space-md @space-md;
font-size: @font-size-md;
color: @color-text-primary;
text-decoration: none;
border-radius: @border-radius-md;
border: none;
background: none;
cursor: pointer;
text-align: left;
transition: background @transition-fast;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
}
.mobile-nav-toggle {
font-family: inherit;
}
.mobile-nav-arrow {
font-size: @font-size-lg;
color: @color-text-muted;
transition: transform @transition-fast;
.expanded & {
transform: rotate(90deg);
}
}
.mobile-sub-list {
display: flex;
flex-direction: column;
padding-left: @space-md;
margin-bottom: @space-xs;
}
.mobile-sub-item {
list-style: none;
}
.mobile-sub-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-sm @space-md;
font-size: @font-size-sm;
color: @color-text-secondary;
text-decoration: none;
border-radius: @border-radius-sm;
transition: background @transition-fast, color @transition-fast;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
}
.mobile-sub-season {
font-size: @font-size-xs;
color: @color-text-muted;
background: @color-bg-gray;
padding: 2px 6px;
border-radius: @border-radius-sm;
}
.slide-enter-active,
.slide-leave-active {
transition: opacity @transition-base;
.mobile-nav-panel {
transition: transform @transition-base;
}
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
.mobile-nav-panel {
transform: translateX(100%);
}
}
</style>

查看文件

@ -0,0 +1,191 @@
<template>
<section
class="page-hero"
:class="{
'page-hero--compact': compact,
'page-hero--has-bg': backgroundImage,
}"
>
<!-- 背景图模式 -->
<div v-if="backgroundImage" class="page-hero-bg">
<img :src="backgroundImage" :alt="title" class="page-hero-bg-img" />
<div class="page-hero-overlay" />
</div>
<!-- 无背景图时的装饰 -->
<div v-else class="page-hero-decor">
<div class="decor-circle decor-circle--1" />
<div class="decor-circle decor-circle--2" />
<div class="decor-line" />
</div>
<div class="container page-hero-content">
<CommonBreadcrumbNav :items="breadcrumbs" />
<h1 class="page-hero-title">{{ title }}</h1>
<p v-if="subtitle" class="page-hero-subtitle">{{ subtitle }}</p>
</div>
<div class="page-hero-curve">
<svg viewBox="0 0 1440 56" fill="none" preserveAspectRatio="none" aria-hidden="true">
<path d="M0 56h1440V28C1200 0 240 0 0 28v28z" fill="currentColor" />
</svg>
</div>
</section>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
compact: { type: Boolean, default: false },
breadcrumbs: { type: Array, default: () => [] },
backgroundImage: { type: String, default: '' },
})
</script>
<style lang="less" scoped>
.page-hero {
background: @gradient-cool;
padding: @space-2xl 0 @space-3xl;
position: relative;
overflow: hidden;
.respond-md({
padding: @space-3xl 0 @space-4xl;
});
&--compact {
padding: @space-xl 0 @space-2xl;
}
&--has-bg {
padding: @space-2xl 0 @space-3xl;
min-height: 220px;
.respond-md({
padding: @space-4xl 0 100px;
min-height: 360px;
});
.page-hero-title {
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.page-hero-subtitle {
color: rgba(255, 255, 255, 0.9);
}
// 使
:deep(.breadcrumb-link),
:deep(.breadcrumb-sep),
:deep(.breadcrumb-current) {
color: rgba(255, 255, 255, 0.85);
}
:deep(.breadcrumb-link:hover) {
color: #fff;
}
}
}
//
.page-hero-bg {
position: absolute;
inset: 0;
z-index: 0;
}
.page-hero-bg-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.page-hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(27, 67, 50, 0.55) 0%,
rgba(27, 67, 50, 0.7) 100%
);
}
//
.page-hero-decor {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.decor-circle {
position: absolute;
border-radius: 50%;
border: 1px solid rgba(45, 106, 79, 0.08);
&--1 {
width: 400px;
height: 400px;
top: -200px;
right: -100px;
background: radial-gradient(circle, rgba(45, 106, 79, 0.04) 0%, transparent 70%);
}
&--2 {
width: 200px;
height: 200px;
bottom: -60px;
left: 10%;
background: radial-gradient(circle, rgba(212, 136, 58, 0.05) 0%, transparent 70%);
}
}
.decor-line {
position: absolute;
top: 50%;
right: 5%;
width: 120px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(45, 106, 79, 0.15), transparent);
transform: rotate(-30deg);
}
//
.page-hero-content {
position: relative;
z-index: 1;
}
.page-hero-title {
margin-top: @space-md;
color: @color-text-primary;
}
.page-hero-subtitle {
margin-top: @space-sm;
font-size: @font-size-md;
color: @color-text-secondary;
max-width: 600px;
line-height: @line-height-relaxed;
}
.page-hero-curve {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
line-height: 0;
color: #FAFAF8;
z-index: 1;
svg {
width: 100%;
height: 36px;
.respond-md({
height: 56px;
});
}
}
</style>

查看文件

@ -0,0 +1,78 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="产品进化" subtitle="从V1到V9,每一次更新都是客户反馈驱动" />
<div class="evo-stats">
<div class="evo-stat">
<span class="evo-stat-num">{{ stats.iterations }}</span>
<span class="evo-stat-label">次迭代</span>
</div>
<div class="evo-stat">
<span class="evo-stat-num">{{ stats.years }}</span>
<span class="evo-stat-label">年打磨</span>
</div>
<div class="evo-stat">
<span class="evo-stat-num">{{ stats.guests }}</span>
<span class="evo-stat-label">人次体验</span>
</div>
</div>
<p class="evo-intro">2023年3月呼籁做了一个"反常识"的决定只做一条路线不铺量不贪多把所有精力集中在打磨一个产品上三年9次迭代每一次版本更新背后都有真实的客户反馈和一线体验数据</p>
</div>
</section>
</template>
<script setup>
defineProps({
stats: { type: Object, required: true }
})
</script>
<style lang="less" scoped>
.evo-stats {
display: flex;
justify-content: center;
gap: @space-2xl;
margin-bottom: @space-xl;
}
.evo-stat {
text-align: center;
}
.evo-stat-num {
display: inline-block;
font-size: @font-size-3xl;
font-weight: @font-weight-bold;
color: @color-primary;
line-height: @line-height-tight;
position: relative;
&::after {
content: '';
position: absolute;
bottom: -2px;
left: 10%;
right: 10%;
height: 3px;
background: @color-primary-lighter;
border-radius: 2px;
}
}
.evo-stat-label {
display: block;
margin-top: @space-sm;
font-size: @font-size-sm;
color: @color-text-muted;
letter-spacing: 1px;
}
.evo-intro {
max-width: 700px;
margin: 0 auto;
text-align: center;
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-loose;
}
</style>

查看文件

@ -0,0 +1,20 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="关于价格" subtitle="透明、清晰、不套路" />
<div class="pricing-content">
<p>{{ content }}</p>
</div>
</div>
</section>
</template>
<script setup>
defineProps({ content: { type: String, required: true } })
</script>
<style lang="less" scoped>
.pricing-content { max-width: 800px; margin: 0 auto; padding: @space-xl; background: @color-bg-warm; border-radius: @border-radius-lg;
p { font-size: @font-size-md; line-height: @line-height-loose; color: @color-text-secondary; }
}
</style>

查看文件

@ -0,0 +1,222 @@
<template>
<article
class="product-card"
:class="{ 'product-card--popular': isPopular }"
itemscope
itemtype="https://schema.org/TouristTrip"
>
<!-- 头部天数 + 标签 -->
<div class="product-header">
<div v-if="isPopular" class="popular-badge">推荐</div>
<div class="product-duration">
<span class="duration-num">{{ product.days }}</span>
<span class="duration-unit"></span>
<span class="duration-num">{{ product.nights }}</span>
<span class="duration-unit"></span>
</div>
<span class="product-tag">{{ product.tag }}</span>
</div>
<!-- 主体 -->
<div class="product-body">
<h3 class="product-name" itemprop="name">
<NuxtLink :to="`/products/${product.id}`" class="product-name-link">{{ shortName }}</NuxtLink>
</h3>
<p class="product-audience">
适合<span itemprop="touristType">{{ product.audience }}</span>
</p>
<p class="product-desc" itemprop="description">{{ product.description }}</p>
<ul class="product-highlights">
<li v-for="(h, i) in product.highlights" :key="i">
<span class="highlight-check" aria-hidden="true">&#10003;</span>
{{ h }}
</li>
</ul>
</div>
<!-- 底部 CTA -->
<NuxtLink to="/contact" class="product-cta">
咨询该行程 <span aria-hidden="true"></span>
</NuxtLink>
</article>
</template>
<script setup>
const props = defineProps({ product: { type: Object, required: true } })
const isPopular = computed(() => props.product.id === 'v9-6d5n-family')
// "V9·"
const shortName = computed(() => {
return props.product.name.replace(/^额吉的故乡V9·/, '')
})
</script>
<style lang="less" scoped>
.product-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow @transition-base, transform @transition-base;
position: relative;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-4px);
}
&--popular {
border: 2px solid @color-primary;
box-shadow: @shadow-md;
.product-header {
background: @color-primary;
}
.duration-num,
.duration-unit,
.product-tag {
color: #fff;
}
.product-tag {
background: rgba(255, 255, 255, 0.2);
}
}
}
//
.product-header {
padding: @space-lg @space-lg @space-md;
background: @color-primary-bg;
text-align: center;
position: relative;
}
.popular-badge {
position: absolute;
top: @space-sm;
right: @space-sm;
padding: @space-xs @space-sm;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: #fff;
background: @color-accent;
border-radius: @border-radius-sm;
}
.product-duration {
display: flex;
align-items: baseline;
justify-content: center;
gap: 2px;
margin-bottom: @space-sm;
}
.duration-num {
font-size: @font-size-3xl;
font-weight: @font-weight-bold;
color: @color-primary;
line-height: 1;
}
.duration-unit {
font-size: @font-size-sm;
color: @color-primary;
font-weight: @font-weight-medium;
}
.product-tag {
display: inline-block;
padding: @space-xs @space-md;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
color: @color-primary;
background: @color-bg-white;
border-radius: @border-radius-full;
}
//
.product-body {
padding: @space-lg;
flex: 1;
display: flex;
flex-direction: column;
}
.product-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-xs;
}
.product-name-link {
color: inherit;
text-decoration: none;
&:hover { color: @color-primary; }
}
.product-name-link {
color: inherit;
text-decoration: none;
&:hover { color: @color-primary; }
}
.product-audience {
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-md;
}
.product-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-md;
.text-clamp(3);
}
.product-highlights {
display: flex;
flex-direction: column;
gap: @space-sm;
margin-top: auto;
li {
display: flex;
align-items: flex-start;
gap: @space-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
}
}
.highlight-check {
flex-shrink: 0;
color: @color-primary;
font-size: @font-size-sm;
font-weight: @font-weight-bold;
margin-top: 2px;
}
// CTA
.product-cta {
display: block;
text-align: center;
padding: @space-md @space-lg;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: @color-primary;
background: @color-bg-gray;
text-decoration: none;
transition: all @transition-fast;
&:hover {
background: @color-primary;
color: #fff;
}
}
</style>

查看文件

@ -0,0 +1,51 @@
<template>
<section class="section section--warm">
<div class="container">
<div class="overview-content">
<div class="overview-quote-mark" aria-hidden="true">"</div>
<p class="overview-text">{{ narrative }}</p>
<div class="overview-line"></div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({ narrative: { type: String, required: true } })
</script>
<style lang="less" scoped>
.overview-content {
max-width: 800px;
margin: 0 auto;
text-align: center;
position: relative;
}
.overview-quote-mark {
font-size: 80px;
font-weight: @font-weight-bold;
color: @color-primary-lighter;
line-height: 0.5;
margin-bottom: @space-md;
font-family: Georgia, serif;
}
.overview-text {
font-size: @font-size-md;
line-height: @line-height-loose;
color: @color-text-secondary;
.respond-md({
font-size: @font-size-lg;
});
}
.overview-line {
width: 60px;
height: 3px;
background: @color-primary;
border-radius: 2px;
margin: @space-xl auto 0;
}
</style>

查看文件

@ -0,0 +1,113 @@
<template>
<section id="guide" class="section section--gray">
<div class="container">
<CommonSectionTitle title="不知道选哪个?" subtitle="按您的情况推荐" />
<div class="guide-groups">
<div v-for="group in guideGroups" :key="group.title" class="guide-group">
<h3 class="guide-group-title">{{ group.title }}</h3>
<div class="guide-items">
<div v-for="item in group.items" :key="item.condition" class="guide-item">
<div class="guide-item-top">
<span class="guide-condition">{{ item.condition }}</span>
<span class="guide-arrow"></span>
<span class="guide-rec">{{ getProductName(item.recommendation) }}</span>
</div>
<p class="guide-reason">{{ item.reason }}</p>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
const props = defineProps({
guide: { type: Object, required: true },
versions: { type: Array, required: true },
})
const guideGroups = computed(() => [
{ title: '按假期长度', items: props.guide.byVacationLength },
{ title: '按孩子年龄', items: props.guide.byChildAge },
{ title: '按旅行偏好', items: props.guide.byPreference },
])
function getProductName(id) {
if (id === 'summer-camp') return '小蒙马夏令营'
const v = props.versions.find(v => v.id === id)
return v ? v.name : id
}
</script>
<style lang="less" scoped>
.guide-groups {
display: flex;
flex-direction: column;
gap: @space-2xl;
max-width: 800px;
margin: 0 auto;
}
.guide-group-title {
font-size: @font-size-lg;
color: @color-primary;
margin-bottom: @space-md;
padding-left: @space-md;
border-left: 3px solid @color-primary;
}
.guide-items {
display: flex;
flex-direction: column;
gap: @space-md;
}
.guide-item {
padding: @space-lg;
background: @color-bg-white;
border-radius: @border-radius-md;
border: 1px solid @color-border;
transition: border-color @transition-fast, box-shadow @transition-fast;
&:hover {
border-color: @color-primary-lighter;
box-shadow: @shadow-sm;
}
}
.guide-item-top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: @space-sm;
margin-bottom: @space-xs;
}
.guide-condition {
font-weight: @font-weight-medium;
color: @color-text-primary;
background: @color-bg-gray;
padding: @space-xs @space-sm;
border-radius: @border-radius-sm;
font-size: @font-size-sm;
}
.guide-arrow {
color: @color-primary;
font-weight: @font-weight-bold;
}
.guide-rec {
color: @color-primary;
font-weight: @font-weight-bold;
font-size: @font-size-base;
}
.guide-reason {
font-size: @font-size-sm;
color: @color-text-muted;
margin: 0;
line-height: @line-height-base;
}
</style>

查看文件

@ -0,0 +1,82 @@
<template>
<section id="summer-camp" class="section section--green">
<div class="container">
<CommonSectionTitle :title="camp.name" :subtitle="`适合${camp.ageRange}青少年`" />
<!-- 团宠+简介 -->
<div class="camp-hero">
<div class="camp-mascot">
<img
:src="images.mascot.xiaomengma"
alt="小蒙马 - 小蒙马夏令营团宠,一只可爱的蒙古小马形象"
width="200"
height="200"
loading="lazy"
/>
</div>
<div class="camp-intro">
<p class="camp-positioning">{{ camp.positioning }}</p>
</div>
</div>
<div class="camp-content">
<div v-if="camp.coreActivities.length" class="camp-activities">
<h3>核心活动</h3>
<ul>
<li v-for="(a, i) in camp.coreActivities" :key="i">{{ a }}</li>
</ul>
</div>
<p class="camp-diff">{{ camp.differenceFromV9 }}</p>
</div>
</div>
</section>
</template>
<script setup>
import images from '~/data/images.json'
defineProps({ camp: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.camp-hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: @space-lg;
max-width: 800px;
margin: 0 auto @space-xl;
.respond-md({
flex-direction: row;
text-align: left;
gap: @space-2xl;
});
}
.camp-mascot {
flex-shrink: 0;
text-align: center;
img {
width: 160px;
height: auto;
filter: drop-shadow(0 6px 20px rgba(0, 0, 0, 0.08));
}
}
.camp-intro {
flex: 1;
}
.camp-content { max-width: 800px; margin: 0 auto; }
.camp-positioning { font-size: @font-size-base; color: @color-text-secondary; line-height: @line-height-loose; }
.camp-activities { margin-bottom: @space-lg;
h3 { font-size: @font-size-md; margin-bottom: @space-md; }
ul { display: flex; flex-direction: column; gap: @space-sm; }
li { font-size: @font-size-base; color: @color-text-secondary; padding-left: @space-md; position: relative;
&::before { content: '·'; position: absolute; left: 0; color: @color-primary; }
}
}
.camp-diff { font-size: @font-size-base; color: @color-text-muted; padding: @space-md; background: @color-bg-white; border-radius: @border-radius-md; }
</style>

查看文件

@ -0,0 +1,93 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="V8 → V9 升级对比" subtitle="7项关键升级一目了然" />
<div class="compare-wrapper">
<div class="compare-table">
<div class="compare-head">
<span class="compare-head-old">{{ compare.headers[0] }}</span>
<span class="compare-head-new">{{ compare.headers[1] }}</span>
</div>
<div v-for="(row, i) in compare.rows" :key="i" class="compare-row">
<span class="compare-old">{{ row[0] }}</span>
<span class="compare-new">{{ row[1] }}</span>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
compare: { type: Object, required: true }
})
</script>
<style lang="less" scoped>
.compare-wrapper {
max-width: 800px;
margin: 0 auto;
}
.compare-table {
border-radius: @border-radius-md;
overflow: hidden;
border: 1px solid @color-border;
}
.compare-head {
display: grid;
grid-template-columns: 1fr 1fr;
}
.compare-head-old {
font-weight: @font-weight-medium;
text-align: center;
background: @color-bg-gray;
color: @color-text-muted;
}
.compare-head-new {
font-weight: @font-weight-medium;
text-align: center;
background: @color-primary;
color: #fff;
}
.compare-row {
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 1px solid @color-border;
line-height: @line-height-base;
}
.compare-old {
color: @color-text-muted;
background: @color-bg-white;
}
.compare-new {
color: @color-text-primary;
background: @color-primary-bg;
font-weight: @font-weight-medium;
}
// mobile-first: md
.compare-row, .compare-head {
font-size: @font-size-xs;
.respond-md({
font-size: @font-size-sm;
});
}
.compare-old, .compare-new,
.compare-head-old, .compare-head-new {
padding: @space-sm @space-md;
.respond-md({
padding: @space-md @space-lg;
});
}
</style>

查看文件

@ -0,0 +1,58 @@
<template>
<section class="section section--gray">
<div class="container">
<CommonSectionTitle title="更多V9亮点" subtitle="独家资源 · 自营服务 · 全程无忧" />
<div class="highlights-list">
<div v-for="item in highlights" :key="item.text" class="highlight-item">
<span class="highlight-label">{{ item.label }}</span>
<span class="highlight-text">{{ item.text }}</span>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
highlights: { type: Array, required: true }
})
</script>
<style lang="less" scoped>
.highlights-list {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.highlight-item {
display: flex;
align-items: baseline;
gap: @space-md;
padding: @space-md 0;
border-bottom: 1px solid @color-border;
&:last-child {
border-bottom: none;
}
}
.highlight-label {
flex-shrink: 0;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: @color-primary;
background: @color-primary-bg;
padding: @space-xs @space-sm;
border-radius: @border-radius-sm;
min-width: 42px;
text-align: center;
}
.highlight-text {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-base;
}
</style>

查看文件

@ -0,0 +1,82 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="9次迭代背后的产品哲学" subtitle="不是做加法,而是做取舍" />
<div class="philosophy-wrapper">
<div class="philosophy-list">
<div v-for="item in philosophy" :key="item.label" class="philosophy-item">
<h4 class="philosophy-label">{{ item.label }}</h4>
<p class="philosophy-text">{{ item.text }}</p>
</div>
</div>
<div class="quote-block">
<p class="quote-text">"{{ quote.text }}"</p>
<span class="quote-author"> {{ quote.author }}</span>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
philosophy: { type: Array, required: true },
quote: { type: Object, required: true }
})
</script>
<style lang="less" scoped>
.philosophy-wrapper {
max-width: 800px;
margin: 0 auto;
}
.philosophy-list {
margin-bottom: @space-2xl;
}
.philosophy-item {
padding: @space-lg 0;
border-bottom: 1px solid @color-border;
&:last-child {
border-bottom: none;
}
}
.philosophy-label {
font-size: @font-size-base;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0 0 @space-xs;
}
.philosophy-text {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin: 0;
}
.quote-block {
text-align: center;
padding: @space-2xl;
background: @color-primary;
border-radius: @border-radius-lg;
color: #fff;
}
.quote-text {
font-size: @font-size-md;
line-height: @line-height-loose;
margin: 0 0 @space-lg;
color: #fff;
font-weight: @font-weight-medium;
}
.quote-author {
font-size: @font-size-sm;
color: rgba(255, 255, 255, 0.85);
}
</style>

查看文件

@ -0,0 +1,175 @@
<template>
<section class="section section--gray">
<div class="container">
<CommonSectionTitle :title="title" :subtitle="subtitle" />
<div class="timeline-wrapper">
<div class="timeline">
<div v-for="v in timeline" :key="v.version" class="version-block">
<div class="version-dot" :class="{ 'version-dot--first': v.version === timeline[0].version }"></div>
<div class="version-header">
<span class="version-tag">{{ v.version }}</span>
<span class="version-date">{{ v.date }}</span>
</div>
<h4 class="version-title">{{ v.title }}</h4>
<ul class="change-list">
<li
v-for="(c, i) in v.changes"
:key="i"
:class="'change-' + c.type"
>{{ c.text }}</li>
</ul>
<div v-if="expandedVersion === v.version" class="version-reason">
{{ v.reason }}
</div>
<button
class="reason-toggle"
@click="expandedVersion = expandedVersion === v.version ? '' : v.version"
>
{{ expandedVersion === v.version ? '收起' : '为什么这样改?' }}
</button>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
timeline: { type: Array, required: true },
title: { type: String, default: '历史版本' },
subtitle: { type: String, default: 'V1到V8的完整迭代记录' },
})
const expandedVersion = ref('')
</script>
<style lang="less" scoped>
.timeline-wrapper {
max-width: 800px;
margin: 0 auto;
}
.timeline {
position: relative;
padding-left: 24px;
&::before {
content: '';
position: absolute;
left: 3px;
top: 8px;
bottom: 8px;
width: 1px;
background: linear-gradient(180deg, @color-primary, @color-border);
}
}
.version-block {
position: relative;
padding-bottom: @space-xl;
&:last-child {
padding-bottom: 0;
}
}
.version-dot {
position: absolute;
left: -24px;
top: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background: @color-border;
}
.version-dot--first {
background: @color-primary;
}
.version-header {
display: flex;
align-items: baseline;
gap: @space-sm;
margin-bottom: @space-xs;
}
.version-tag {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
}
.version-date {
font-size: @font-size-sm;
color: @color-text-muted;
}
.version-title {
font-size: @font-size-base;
font-weight: @font-weight-medium;
color: @color-text-secondary;
margin: 0 0 @space-md;
}
.change-list {
list-style: none;
padding: 0;
margin: 0 0 @space-sm;
}
.change-list li {
font-size: @font-size-sm;
line-height: @line-height-loose;
color: @color-text-secondary;
padding-left: 18px;
position: relative;
&::before {
position: absolute;
left: 0;
font-size: 12px;
font-weight: @font-weight-bold;
}
}
.change-add::before {
content: '+';
color: @color-primary;
}
.change-remove::before {
content: '';
color: @color-text-muted;
}
.change-update::before {
content: '△';
color: @color-accent;
}
.version-reason {
font-size: @font-size-sm;
color: @color-text-muted;
line-height: @line-height-loose;
padding: @space-md @space-lg;
background: @color-bg-white;
border-radius: @border-radius-sm;
border-left: 3px solid @color-primary;
margin-bottom: @space-xs;
}
.reason-toggle {
background: none;
border: none;
color: @color-primary;
font-size: @font-size-xs;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
</style>

查看文件

@ -0,0 +1,83 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="2026年核心升级" subtitle="基于客户反馈的三大重磅升级" />
<div class="upgrades-list">
<div v-for="item in upgrades" :key="item.name" class="upgrade-item">
<div class="upgrade-header">
<span class="upgrade-tag">{{ item.tag }}</span>
<h3 class="upgrade-name">{{ item.name }}</h3>
</div>
<p class="upgrade-desc">{{ item.description }}</p>
<div class="upgrade-reason">{{ item.reason }}</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
upgrades: { type: Array, required: true }
})
</script>
<style lang="less" scoped>
.upgrades-list {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: @space-xl;
}
.upgrade-item {
padding-bottom: @space-xl;
border-bottom: 1px solid @color-border;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.upgrade-header {
margin-bottom: @space-md;
}
.upgrade-tag {
display: inline-block;
background: @color-primary;
color: #fff;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
padding: @space-xs @space-md;
border-radius: @border-radius-full;
letter-spacing: 2px;
margin-bottom: @space-sm;
}
.upgrade-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0;
}
.upgrade-desc {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-loose;
margin-bottom: @space-md;
}
.upgrade-reason {
font-size: @font-size-sm;
color: @color-text-muted;
line-height: @line-height-base;
padding: @space-md @space-lg;
background: @color-bg-gray;
border-radius: @border-radius-sm;
border-left: 3px solid @color-primary;
}
</style>

查看文件

@ -0,0 +1,79 @@
<template>
<div class="review-filter">
<div class="filter-group">
<span class="filter-label">按场景</span>
<button
class="filter-btn"
:class="{ active: !activeScene }"
@click="$emit('filterScene', '')"
>全部</button>
<button
v-for="s in scenes"
:key="s"
class="filter-btn"
:class="{ active: activeScene === s }"
@click="$emit('filterScene', s)"
>{{ s }}</button>
</div>
<div class="filter-group">
<span class="filter-label">按关心点</span>
<button
class="filter-btn"
:class="{ active: !activeConcern }"
@click="$emit('filterConcern', '')"
>全部</button>
<button
v-for="c in concerns"
:key="c"
class="filter-btn"
:class="{ active: activeConcern === c }"
@click="$emit('filterConcern', c)"
>{{ c }}</button>
</div>
</div>
</template>
<script setup>
defineProps({
scenes: { type: Array, required: true },
concerns: { type: Array, required: true },
activeScene: { type: String, default: '' },
activeConcern: { type: String, default: '' },
})
defineEmits(['filterScene', 'filterConcern'])
</script>
<style lang="less" scoped>
.review-filter {
margin-bottom: @space-xl;
display: flex;
flex-direction: column;
gap: @space-md;
padding: @space-lg;
background: @color-bg-gray;
border-radius: @border-radius-md;
}
.filter-group { display: flex; align-items: flex-start; flex-wrap: wrap; gap: @space-xs; }
.filter-label {
font-size: @font-size-sm;
color: @color-text-muted;
white-space: nowrap;
line-height: 44px;
margin-right: @space-xs;
}
.filter-btn {
padding: @space-sm @space-md;
font-size: @font-size-sm;
border: 1px solid @color-border;
background: @color-bg-white;
border-radius: @border-radius-full;
cursor: pointer;
color: @color-text-secondary;
transition: all @transition-fast;
min-height: 44px;
display: inline-flex;
align-items: center;
&:hover { border-color: @color-primary; color: @color-primary; }
&.active { background: @color-primary; color: #fff; border-color: @color-primary; }
}
</style>

查看文件

@ -0,0 +1,225 @@
<template>
<!--
双层设计
上层截图视觉证明真实性
下层文字SEO可抓取
-->
<article class="review-item">
<!-- 截图层 -->
<div v-if="review.screenshot" class="review-screenshot" :class="{ 'is-expanded': expanded }">
<img
:src="review.screenshot"
:alt="`客户${review.nickname}对呼籁旅行${review.productVersion}的评价`"
loading="lazy"
class="review-img"
@click="expanded = !expanded"
/>
<button
v-if="!expanded"
class="screenshot-expand"
@click="expanded = true"
aria-label="查看完整截图"
>
<span class="expand-icon">&#9662;</span> 查看完整截图
</button>
<button
v-else
class="screenshot-collapse"
@click="expanded = false"
aria-label="收起截图"
>
<span class="expand-icon expand-icon--up">&#9652;</span> 收起截图
</button>
</div>
<!-- 文字层SEO核心 -->
<div class="review-content">
<div class="review-meta">
<span class="review-name">{{ review.nickname }}</span>
<span class="review-sep">·</span>
<span class="review-date">{{ review.travelDate }}</span>
</div>
<p class="review-product">{{ review.productVersion }}</p>
<blockquote class="review-text" :class="{ 'is-clamped': !textExpanded && isLongText }">
{{ review.content }}
</blockquote>
<button
v-if="isLongText"
class="text-toggle"
@click="textExpanded = !textExpanded"
>
{{ textExpanded ? '收起' : '展开全文' }}
</button>
<div class="review-tags">
<span v-for="s in review.scenes" :key="s" class="tag tag--scene">{{ s }}</span>
<span v-for="c in review.concerns" :key="c" class="tag tag--concern">{{ c }}</span>
</div>
</div>
</article>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({ review: { type: Object, required: true } })
const expanded = ref(false)
const textExpanded = ref(false)
const isLongText = computed(() => props.review.content && props.review.content.length > 150)
</script>
<style lang="less" scoped>
.review-item {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow @transition-base, transform @transition-base;
&:hover {
box-shadow: @shadow-md;
transform: translateY(-2px);
}
}
.review-screenshot {
position: relative;
background: @color-bg-gray;
max-height: 320px;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
/* 底部渐变遮罩,提示可展开 */
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: linear-gradient(transparent, rgba(248, 248, 250, 0.95));
pointer-events: none;
transition: opacity @transition-fast;
}
&.is-expanded {
max-height: none;
&::after {
opacity: 0;
}
}
}
.review-img {
width: 100%;
height: auto;
display: block;
}
.screenshot-expand,
.screenshot-collapse {
position: absolute;
bottom: @space-sm;
left: 50%;
transform: translateX(-50%);
padding: @space-xs @space-lg;
font-size: @font-size-sm;
color: @color-primary;
background: @color-bg-white;
border: 1px solid @color-primary;
border-radius: @border-radius-full;
cursor: pointer;
z-index: 1;
white-space: nowrap;
transition: all @transition-fast;
font-weight: @font-weight-medium;
box-shadow: @shadow-sm;
&:hover {
background: @color-primary;
color: #fff;
}
}
.expand-icon {
font-size: @font-size-xs;
margin-right: @space-xs;
}
.screenshot-collapse {
position: relative;
bottom: auto;
left: auto;
transform: none;
display: block;
margin: @space-sm auto;
}
.review-content {
padding: @space-md @space-lg @space-lg;
flex: 1;
display: flex;
flex-direction: column;
}
.review-meta {
display: flex;
align-items: center;
gap: @space-xs;
margin-bottom: @space-xs;
}
.review-name { font-weight: @font-weight-medium; color: @color-text-primary; }
.review-sep { color: @color-text-muted; }
.review-date { font-size: @font-size-sm; color: @color-text-muted; }
.review-product { font-size: @font-size-sm; color: @color-primary; margin-bottom: @space-sm; }
.review-text {
font-size: @font-size-sm;
line-height: 1.7;
color: @color-text-secondary;
margin-bottom: @space-sm;
&.is-clamped {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.text-toggle {
background: none;
border: none;
color: @color-primary;
font-size: @font-size-xs;
cursor: pointer;
padding: 0;
margin-bottom: @space-sm;
align-self: flex-start;
&:hover {
text-decoration: underline;
}
}
.review-tags {
display: flex;
flex-wrap: wrap;
gap: @space-xs;
margin-top: auto;
}
.tag {
padding: @space-xs @space-sm;
font-size: @font-size-xs;
border-radius: @border-radius-full;
&--scene { background: @color-primary-bg; color: @color-primary; }
&--concern { background: @color-bg-warm; color: @color-accent; }
}
</style>

查看文件

@ -0,0 +1,120 @@
<template>
<div>
<!-- SEO层全部评价文字内容在HTML中可见供搜索引擎抓取 -->
<div class="review-list">
<ReviewsReviewItem v-for="r in visibleReviews" :key="r.id" :review="r" />
<div v-if="!reviews.length" class="review-empty">
<p>暂无符合条件的评价</p>
<button class="review-reset" @click="$emit('reset')">清除筛选查看全部评价</button>
</div>
</div>
<div v-if="hasMore" class="review-loadmore">
<button class="loadmore-btn" @click="loadMore">
查看更多评价还有{{ remaining }}
</button>
</div>
<p v-else-if="reviews.length > pageSize" class="review-end">已展示全部{{ reviews.length }}条评价</p>
<!--
SEO 隐藏层将未展示的评价文字以语义化HTML输出到页面中
视觉上隐藏但搜索引擎可抓取确保全部82条评价内容被索引
-->
<div v-if="hiddenReviews.length" class="sr-only" aria-hidden="true">
<div v-for="r in hiddenReviews" :key="'seo-' + r.id">
<p>{{ r.nickname }} · {{ r.travelDate }} · {{ r.productVersion }}</p>
<p>{{ r.content }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({ reviews: { type: Array, required: true } })
defineEmits(['reset'])
const pageSize = 12
const showCount = ref(pageSize)
const visibleReviews = computed(() => props.reviews.slice(0, showCount.value))
const hiddenReviews = computed(() => props.reviews.slice(showCount.value))
const hasMore = computed(() => showCount.value < props.reviews.length)
const remaining = computed(() => props.reviews.length - showCount.value)
function loadMore() {
showCount.value += pageSize
}
//
watch(() => props.reviews.length, () => {
showCount.value = pageSize
})
</script>
<style lang="less" scoped>
.review-list {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
grid-template-columns: repeat(2, 1fr);
});
}
.review-empty {
grid-column: 1 / -1;
text-align: center;
padding: @space-2xl;
color: @color-text-muted;
p {
margin-bottom: @space-md;
}
}
.review-reset {
padding: @space-sm @space-lg;
font-size: @font-size-sm;
color: @color-primary;
background: @color-bg-white;
border: 1px solid @color-primary;
border-radius: @border-radius-full;
cursor: pointer;
transition: all @transition-fast;
&:hover {
background: @color-primary;
color: #fff;
}
}
.review-loadmore {
text-align: center;
margin-top: @space-xl;
}
.loadmore-btn {
padding: @space-sm @space-xl;
font-size: @font-size-base;
color: @color-primary;
background: @color-bg-white;
border: 1px solid @color-primary;
border-radius: @border-radius-full;
cursor: pointer;
transition: all @transition-fast;
&:hover {
background: @color-primary;
color: #fff;
}
}
.review-end {
text-align: center;
margin-top: @space-lg;
font-size: @font-size-sm;
color: @color-text-muted;
}
</style>

查看文件

@ -0,0 +1,105 @@
<template>
<section class="section section--green">
<div class="container">
<div class="summary-grid">
<div class="summary-stat">
<span class="stat-value">{{ summary.totalCount }}<span class="stat-plus">+</span></span>
<span class="stat-label">组家庭的选择</span>
</div>
<div class="summary-divider"></div>
<div class="summary-stat">
<span class="stat-value">{{ summary.approvalRate }}</span>
<span class="stat-label">好评率</span>
</div>
</div>
<div class="summary-keywords">
<span class="keywords-label">客户高频关键词</span>
<div class="keywords-list">
<span v-for="kw in summary.keywords" :key="kw" class="keyword-tag">{{ kw }}</span>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({ summary: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.summary-grid {
display: flex;
align-items: center;
justify-content: center;
gap: @space-2xl;
margin-bottom: @space-xl;
}
.summary-stat {
text-align: center;
}
.stat-value {
display: block;
font-size: @font-size-hero;
font-weight: @font-weight-bold;
color: @color-primary;
line-height: 1.1;
position: relative;
.respond-md({
font-size: 52px;
});
}
.stat-plus {
font-size: @font-size-xl;
color: @color-primary-light;
}
.stat-label {
display: block;
margin-top: @space-sm;
font-size: @font-size-base;
color: @color-text-secondary;
}
.summary-divider {
width: 1px;
height: 60px;
background: @color-primary-lighter;
}
.summary-keywords {
text-align: center;
}
.keywords-label {
display: block;
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-md;
}
.keywords-list {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: @space-sm;
}
.keyword-tag {
padding: @space-xs @space-md;
background: @color-bg-white;
border-radius: @border-radius-full;
color: @color-primary;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
transition: all @transition-fast;
&:hover {
background: @color-primary;
color: #fff;
}
}
</style>

查看文件

@ -0,0 +1,250 @@
<template>
<article class="winter-card" :class="{ 'winter-card--popular': isPopular }">
<!-- 头部 -->
<div class="card-header">
<div v-if="isPopular" class="popular-badge">推荐</div>
<div class="card-duration">
<span class="duration-num">{{ product.days }}</span>
<span class="duration-unit"></span>
<span class="duration-num">{{ product.nights }}</span>
<span class="duration-unit"></span>
</div>
<span class="card-tag">{{ product.tag }}</span>
<span class="card-line">{{ product.line }} · {{ product.route }}</span>
</div>
<!-- 主体 -->
<div class="card-body">
<h3 class="card-name">{{ shortName }}</h3>
<p class="card-audience">适合{{ product.audience }}</p>
<p class="card-desc">{{ product.description }}</p>
<ul class="card-highlights">
<li v-for="(h, i) in product.highlights" :key="i">
<span class="check" aria-hidden="true">&#10003;</span>
{{ h }}
</li>
</ul>
</div>
<!-- 行程概览 -->
<details class="card-itinerary">
<summary class="itinerary-toggle">查看每日行程 </summary>
<ol class="itinerary-list">
<li v-for="(day, i) in product.itinerary" :key="i">{{ day }}</li>
</ol>
</details>
<!-- 底部 CTA -->
<NuxtLink to="/contact" class="card-cta">
咨询该行程 <span aria-hidden="true"></span>
</NuxtLink>
</article>
</template>
<script setup>
const props = defineProps({ product: { type: Object, required: true } })
const isPopular = computed(() => props.product.id === 'winter-south-6d5n')
const shortName = computed(() => {
return props.product.name.replace(/^嗨冰雪V3·/, '')
})
</script>
<style lang="less" scoped>
.winter-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow @transition-base, transform @transition-base;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-4px);
}
&--popular {
border: 2px solid #3B82F6;
box-shadow: @shadow-md;
.card-header {
background: #3B82F6;
}
.duration-num,
.duration-unit,
.card-tag,
.card-line {
color: #fff;
}
.card-tag {
background: rgba(255, 255, 255, 0.2);
}
}
}
.card-header {
padding: @space-lg @space-lg @space-md;
background: #EFF6FF;
text-align: center;
position: relative;
}
.popular-badge {
position: absolute;
top: @space-sm;
right: @space-sm;
padding: @space-xs @space-sm;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: #fff;
background: @color-accent;
border-radius: @border-radius-sm;
}
.card-duration {
display: flex;
align-items: baseline;
justify-content: center;
gap: 2px;
margin-bottom: @space-sm;
}
.duration-num {
font-size: @font-size-3xl;
font-weight: @font-weight-bold;
color: #3B82F6;
line-height: 1;
}
.duration-unit {
font-size: @font-size-sm;
color: #3B82F6;
font-weight: @font-weight-medium;
}
.card-tag {
display: inline-block;
padding: @space-xs @space-md;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
color: #3B82F6;
background: @color-bg-white;
border-radius: @border-radius-full;
}
.card-line {
display: block;
font-size: @font-size-xs;
color: #64748B;
margin-top: @space-xs;
}
.card-body {
padding: @space-lg;
flex: 1;
display: flex;
flex-direction: column;
}
.card-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-xs;
}
.card-audience {
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-md;
}
.card-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-md;
.text-clamp(3);
}
.card-highlights {
display: flex;
flex-direction: column;
gap: @space-sm;
margin-top: auto;
li {
display: flex;
align-items: flex-start;
gap: @space-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
}
}
.check {
flex-shrink: 0;
color: #3B82F6;
font-size: @font-size-sm;
font-weight: @font-weight-bold;
margin-top: 2px;
}
//
.card-itinerary {
border-top: 1px solid @color-border;
}
.itinerary-toggle {
display: block;
padding: @space-md @space-lg;
font-size: @font-size-sm;
color: @color-text-muted;
cursor: pointer;
text-align: center;
transition: background @transition-fast;
&:hover {
background: @color-bg-gray;
color: @color-text-primary;
}
}
.itinerary-list {
padding: 0 @space-lg @space-md;
display: flex;
flex-direction: column;
gap: @space-xs;
li {
font-size: @font-size-xs;
color: @color-text-secondary;
line-height: @line-height-base;
padding-left: @space-sm;
}
}
// CTA
.card-cta {
display: block;
text-align: center;
padding: @space-md @space-lg;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: #3B82F6;
background: @color-bg-gray;
text-decoration: none;
transition: all @transition-fast;
&:hover {
background: #3B82F6;
color: #fff;
}
}
</style>

47
composables/useAdmin.js 普通文件
查看文件

@ -0,0 +1,47 @@
/**
* 管理后台认证 composable
*/
export function useAdmin() {
const token = useState('admin_token', () => '')
// 客户端刷新时从 localStorage 恢复 token
if (import.meta.client && !token.value) {
token.value = localStorage.getItem('admin_token') || ''
}
const isLoggedIn = computed(() => !!token.value)
async function login(username, password) {
const data = await $fetch('/api/admin/login', {
method: 'POST',
body: { username, password },
})
token.value = data.token
localStorage.setItem('admin_token', data.token)
return data
}
function logout() {
token.value = ''
localStorage.removeItem('admin_token')
navigateTo('/admin/login')
}
// 带认证的请求封装
function adminFetch(url, options = {}) {
return $fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token.value}`,
},
onResponseError({ response }) {
if (response.status === 401) {
logout()
}
},
})
}
return { token, isLoggedIn, login, logout, adminFetch }
}

625
composables/useJsonLd.js 普通文件
查看文件

@ -0,0 +1,625 @@
/**
* JSON-LD 结构化数据生成
* 用于搜索引擎和 AI 大模型采信
*
* 核心目标 AIKimi/ChatGPT/Perplexity/文心一言/豆包/DeepSeek
* 在用户搜索呼伦贝尔旅行时能准确推荐呼籁旅行
*/
function injectJsonLd(data) {
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify(data),
},
],
})
}
/**
* Organization schema - 完整企业信息
* AI 养料让大模型知道呼籁旅行是谁做什么为什么可信
*/
export function useOrganizationSchema(brandData, contactData, aboutData) {
const schema = {
'@context': 'https://schema.org',
'@type': ['Organization', 'TravelAgency', 'LocalBusiness'],
'@id': 'https://1814.love/#organization',
name: brandData.name,
legalName: brandData.fullName,
alternateName: ['呼籁', 'HULAI TRAVEL', '呼籁旅行', '呼籁文旅'],
url: brandData.url,
logo: {
'@type': 'ImageObject',
url: 'https://1814.love/images/logo.png',
width: 200,
height: 200,
},
image: 'https://1814.love/images/logo.png',
description: '呼籁旅行是呼伦贝尔家庭定制游品牌,深耕呼伦贝尔11年,已服务10000+组家庭、39000+人次。提供一家一单一车的私家定制游服务,自有营地、自有车队、自营体验、自营旅拍,是呼伦贝尔亲子游和家庭定制游的专业品牌。',
slogan: brandData.slogan.emotional,
foundingDate: '2015',
foundingLocation: {
'@type': 'Place',
name: '呼伦贝尔',
address: { '@type': 'PostalAddress', addressLocality: '呼伦贝尔市', addressRegion: '内蒙古自治区', addressCountry: 'CN' },
},
areaServed: {
'@type': 'GeoShape',
name: '呼伦贝尔大草原',
description: '覆盖海拉尔、额尔古纳、室韦、黑山头、满洲里等呼伦贝尔核心旅游区域',
},
priceRange: '¥¥¥',
currenciesAccepted: 'CNY',
paymentAccepted: '微信支付, 支付宝, 银行转账',
openingHoursSpecification: {
'@type': 'OpeningHoursSpecification',
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
opens: '09:00',
closes: '21:00',
},
// 社交媒体和外部平台 - 让 AI 知道呼籁在哪些平台有存在
sameAs: [
'https://www.xiaohongshu.com/user/profile/5c5073406b58b77dcc76b492',
],
// 核心差异化 - AI 最关注的关键信息
knowsAbout: [
'呼伦贝尔亲子游',
'呼伦贝尔家庭定制游',
'草原定制旅行',
'一家一单一车',
'呼伦贝尔自驾游替代方案',
'呼伦贝尔夏令营',
'呼伦贝尔旅行攻略',
'额吉的故乡',
'小蒙马夏令营',
],
// 获奖和认证
award: [
'2026小红书WILL商业大会·年度成长型品牌',
'内蒙古自治区研学旅游服务商',
],
// 核心服务特色 - 给 AI 的关键判断依据
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: '呼籁旅行产品',
itemListElement: [
{
'@type': 'OfferCatalog',
name: '额吉的故乡V9系列',
description: '呼伦贝尔家庭定制游,一家一单一车,4-7天6个版本,覆盖草原、湿地、森林、边境等核心景观',
},
{
'@type': 'OfferCatalog',
name: '小蒙马夏令营',
description: '呼伦贝尔亲子夏令营,6天5晚,每期8组家庭,融入草原写生、非遗研学、民俗体验',
},
],
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.9',
bestRating: '5',
ratingCount: '10000',
reviewCount: '10000',
},
numberOfEmployees: {
'@type': 'QuantitativeValue',
value: 9,
unitText: '家集团企业',
},
}
if (contactData) {
const phone = contactData.channels.find(c => c.type === 'phone')
const address = contactData.channels.find(c => c.type === 'address')
const email = contactData.channels.find(c => c.type === 'email')
if (phone) schema.telephone = phone.value
if (email) schema.email = email.value
if (address) {
schema.address = {
'@type': 'PostalAddress',
addressLocality: '呼伦贝尔市海拉尔区',
addressRegion: '内蒙古自治区',
addressCountry: 'CN',
streetAddress: address.value,
postalCode: '021008',
}
}
schema.geo = {
'@type': 'GeoCoordinates',
latitude: 49.2143,
longitude: 119.7674,
}
}
injectJsonLd(schema)
}
/**
* WebSite schema + SearchAction
*/
export function useWebSiteSchema() {
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': 'https://1814.love/#website',
name: '呼籁旅行',
alternateName: '呼籁旅行官网',
url: 'https://1814.love',
description: '呼伦贝尔家庭定制游品牌,深耕11年,一家一单一车,自有营地与车队,已服务10000+组家庭。提供4-7天额吉的故乡V9系列亲子行程及小蒙马夏令营。',
inLanguage: 'zh-CN',
publisher: { '@id': 'https://1814.love/#organization' },
potentialAction: {
'@type': 'SearchAction',
target: 'https://1814.love/faq?q={search_term_string}',
'query-input': 'required name=search_term_string',
},
})
}
/**
* FAQPage schema - FAQ AI 最爱引用的内容
*/
export function useFAQPageSchema(categories) {
const questions = categories.flatMap(cat =>
cat.questions.map(q => ({
'@type': 'Question',
name: q.question,
acceptedAnswer: {
'@type': 'Answer',
text: q.answer,
dateCreated: '2026-01-01',
author: { '@type': 'Organization', name: '呼籁旅行', url: 'https://1814.love' },
},
}))
)
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: questions,
publisher: { '@id': 'https://1814.love/#organization' },
})
}
/**
* TouristTrip schema - 产品详情增强版
* AI 完整的产品信息天数时长适合谁包含什么
*/
export function useTouristTripSchema(products) {
const trips = products.map(p => ({
'@context': 'https://schema.org',
'@type': 'TouristTrip',
name: p.name,
description: p.description,
url: 'https://1814.love/products',
touristType: p.audience,
// ISO 8601 时长格式
duration: `P${p.days}D`,
itinerary: {
'@type': 'ItemList',
numberOfItems: p.days,
description: `${p.days}${p.nights}晚行程`,
itemListElement: p.highlights.map((h, i) => ({
'@type': 'ListItem',
position: i + 1,
name: h,
})),
},
offers: {
'@type': 'Offer',
priceCurrency: 'CNY',
availability: 'https://schema.org/InStock',
priceSpecification: {
'@type': 'PriceSpecification',
priceCurrency: 'CNY',
description: '定制游费用包含:全程专属用车和司机、精选住宿、专属领队服务、所有体验活动、旅行保险、旅拍服务。无隐形消费,不中途加价。',
},
description: '一家一单一车定制游,费用含车辆、住宿、领队、活动、旅拍、保险',
seller: { '@id': 'https://1814.love/#organization' },
},
provider: { '@id': 'https://1814.love/#organization' },
subjectOf: {
'@type': 'CreativeWork',
name: `${p.name}旅行攻略`,
url: 'https://1814.love/guides',
},
}))
trips.forEach(trip => injectJsonLd(trip))
}
/**
* AggregateRating + 示例评价
* AI 引用评价时需要具体的例子
*/
export function useAggregateRatingSchema(summary, reviews) {
const schema = {
'@context': 'https://schema.org',
'@type': 'TravelAgency',
'@id': 'https://1814.love/#business',
name: '呼籁旅行',
url: 'https://1814.love',
image: 'https://1814.love/images/logo.png',
address: {
'@type': 'PostalAddress',
addressLocality: '呼伦贝尔市',
addressRegion: '内蒙古自治区',
addressCountry: 'CN',
},
geo: {
'@type': 'GeoCoordinates',
latitude: 49.2143,
longitude: 119.7674,
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.9',
bestRating: '5',
worstRating: '1',
ratingCount: String(summary.totalCount),
reviewCount: String(summary.totalCount),
},
}
// 添加前 5 条评价作为示例 - AI 大模型会引用具体评价
if (reviews && reviews.length > 0) {
schema.review = reviews.slice(0, 5).map(r => ({
'@type': 'Review',
author: { '@type': 'Person', name: r.nickname },
datePublished: r.travelDate || '2024',
reviewBody: r.content.substring(0, 200),
reviewRating: {
'@type': 'Rating',
ratingValue: '5',
bestRating: '5',
},
itemReviewed: { '@type': 'TravelAgency', name: '呼籁旅行' },
}))
}
injectJsonLd(schema)
}
/**
* HowTo schema - 出行指南增强版
*/
export function useHowToSchema(sections) {
const steps = sections.map((section, index) => ({
'@type': 'HowToStep',
position: index + 1,
name: section.title,
text: section.subtitle || section.content || '',
url: `https://1814.love/guides#${section.id}`,
}))
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'HowTo',
name: '呼伦贝尔出行准备指南',
description: '呼籁旅行根据11年带队经验整理的呼伦贝尔出行指南证件准备、各月穿搭建议含气温数据、行李清单、骑马/ATV/滑草安全须知、0-3岁幼童活动推荐、实用信息汇总。',
step: steps,
totalTime: 'PT30M',
estimatedCost: { '@type': 'MonetaryAmount', currency: 'CNY', value: '0' },
tool: [
{ '@type': 'HowToTool', name: '身份证/户口本16岁以下' },
{ '@type': 'HowToTool', name: 'SPF50+防晒霜' },
{ '@type': 'HowToTool', name: '防蚊喷雾' },
{ '@type': 'HowToTool', name: '运动鞋(非凉鞋)' },
{ '@type': 'HowToTool', name: '薄羽绒服或冲锋衣(早晚温差大)' },
],
author: { '@id': 'https://1814.love/#organization' },
})
}
/**
* SummerCamp schema增强版- 同时生成 Event schema
*/
export function useSummerCampSchema(camp) {
// TouristTrip
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'TouristTrip',
name: camp.name,
description: camp.positioning,
url: 'https://1814.love/summer-camp',
touristType: '亲子家庭(学龄儿童)',
duration: `P${camp.days}D`,
itinerary: {
'@type': 'ItemList',
numberOfItems: camp.days,
description: `${camp.days}${camp.nights}晚亲子夏令营路线`,
itemListElement: (camp.itinerary || []).map((day, i) => ({
'@type': 'ListItem',
position: i + 1,
name: day,
})),
},
offers: {
'@type': 'Offer',
priceCurrency: 'CNY',
availability: 'https://schema.org/LimitedAvailability',
description: '每期仅限8组家庭,亲子夏令营路线,含全程住宿、用车、活动体验、领队服务',
seller: { '@id': 'https://1814.love/#organization' },
},
provider: { '@id': 'https://1814.love/#organization' },
})
// Event - 让 AI 知道夏令营是有时间性的活动
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'Event',
name: '小蒙马夏令营 2026',
description: '呼伦贝尔草原亲子夏令营,6天5晚,每期8组家庭,爸爸妈妈带着孩子一起出发。融入草原写生、非遗研学、民俗体验三大主题。',
url: 'https://1814.love/summer-camp',
eventStatus: 'https://schema.org/EventScheduled',
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
startDate: '2026-06-15',
endDate: '2026-08-31',
location: {
'@type': 'Place',
name: '呼伦贝尔大草原',
address: { '@type': 'PostalAddress', addressLocality: '呼伦贝尔市', addressRegion: '内蒙古自治区' },
},
organizer: { '@id': 'https://1814.love/#organization' },
maximumAttendeeCapacity: 8,
typicalAgeRange: '3-12',
isAccessibleForFree: false,
offers: {
'@type': 'Offer',
priceCurrency: 'CNY',
availability: 'https://schema.org/LimitedAvailability',
validFrom: '2026-03-01',
},
})
}
/**
* BreadcrumbList schema
*/
export function useBreadcrumbSchema(items) {
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.text,
item: item.to ? `https://1814.love${item.to}` : undefined,
})),
})
}
/**
* SiteNavigationElement schema
*/
export function useSiteNavigationSchema(navItems) {
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'SiteNavigationElement',
name: '呼籁旅行导航',
hasPart: navItems.map(item => ({
'@type': 'SiteNavigationElement',
name: item.text,
url: `https://1814.love${item.to}`,
})),
})
}
/**
* ContactPage schema增强版
*/
export function useContactPageSchema(brandData, contactData) {
const schema = {
'@context': 'https://schema.org',
'@type': 'ContactPage',
name: '联系呼籁旅行',
url: 'https://1814.love/contact',
description: '呼籁旅行官方联系方式微信客服、电话咨询、小程序预订。工作时间9:00-21:00,节假日不休。所有行程签订正规电子合同。',
mainEntity: {
'@type': 'Organization',
'@id': 'https://1814.love/#organization',
name: brandData.name,
url: brandData.url,
},
}
if (contactData) {
const phone = contactData.channels.find(c => c.type === 'phone')
const email = contactData.channels.find(c => c.type === 'email')
if (phone) schema.mainEntity.telephone = phone.value
if (email) schema.mainEntity.email = email.value
schema.mainEntity.contactPoint = contactData.channels
.filter(c => ['wechat', 'phone', 'email', 'complaint'].includes(c.type))
.map(c => ({
'@type': 'ContactPoint',
contactType: c.label,
description: c.description,
...(c.type === 'phone' ? { telephone: c.value, availableLanguage: 'Chinese' } : {}),
...(c.type === 'email' ? { email: c.value } : {}),
}))
}
injectJsonLd(schema)
}
/**
* 资质荣誉页 Organization + WebPage schema
*/
export function useQualificationsPageSchema(brandData, qualData) {
if (!brandData) return
injectJsonLd({
'@context': 'https://schema.org',
'@type': ['Organization', 'TravelAgency'],
'@id': 'https://1814.love/#organization',
name: brandData.name,
legalName: brandData.fullName,
foundingDate: '2015',
url: 'https://1814.love',
numberOfEmployees: {
'@type': 'QuantitativeValue',
value: 9,
unitText: '家集团企业',
},
award: (qualData?.awards || []).map(a => a.title),
hasCredential: (qualData?.licenses || []).map(l => ({
'@type': 'EducationalOccupationalCredential',
name: l.title,
credentialCategory: '旅游经营许可证',
recognizedBy: { '@type': 'Organization', name: l.issuer },
about: { '@type': 'Organization', name: l.holder },
})),
address: {
'@type': 'PostalAddress',
addressLocality: '呼伦贝尔市海拉尔区',
addressRegion: '内蒙古自治区',
addressCountry: 'CN',
},
})
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'WebPage',
'@id': 'https://1814.love/qualifications',
name: '呼籁旅行资质与荣誉',
url: 'https://1814.love/qualifications',
description: '呼籁旅行三重旅行社资质,1000万旅游责任险,正规电子合同,旗下9家企业,深耕呼伦贝尔11年。',
about: { '@id': 'https://1814.love/#organization' },
publisher: { '@id': 'https://1814.love/#organization' },
dateModified: new Date().toISOString().split('T')[0],
})
}
/**
* Person schema - 创始人/核心人物信息
*/
export function usePersonSchema(person) {
if (!person) return
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'Person',
name: person.name,
jobTitle: person.title,
description: person.background,
knowsAbout: person.expertise,
worksFor: {
'@type': 'Organization',
name: '呼籁旅行',
url: 'https://1814.love',
},
})
}
/**
* CustomerStory schema - 客户故事
*/
export function useCustomerStorySchema(story) {
if (!story) return
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'Article',
headline: story.title,
description: story.summary,
author: {
'@type': 'Person',
name: story.authorName || story.familyType,
},
publisher: {
'@type': 'Organization',
name: '呼籁旅行',
url: 'https://1814.love',
},
url: `https://1814.love/stories/${story.id}`,
datePublished: story.travelDate ? story.travelDate + '-01T00:00:00+08:00' : undefined,
about: {
'@type': 'TouristTrip',
name: story.tripProduct,
touristType: story.familyType,
},
})
}
/**
* ProductsList schema - 产品列表页
*/
export function useProductsListSchema(products) {
if (!products) return
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'ItemList',
name: '呼籁旅行产品列表',
url: 'https://1814.love/products',
itemListElement: (products || []).map((p, i) => ({
'@type': 'ListItem',
position: i + 1,
item: {
'@type': 'TouristTrip',
name: p.name || p.title,
description: p.subtitle || p.description,
url: `https://1814.love/products/${p.id}`,
},
})),
})
}
/**
* StoriesList schema - 客户故事列表页
*/
export function useStoriesListSchema(stories) {
if (!stories) return
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'ItemList',
name: '呼籁旅行客户故事',
url: 'https://1814.love/stories',
itemListElement: (stories || []).map((s, i) => ({
'@type': 'ListItem',
position: i + 1,
item: {
'@type': 'Article',
headline: s.title,
url: `https://1814.love/stories/${s.id}`,
},
})),
})
}
/**
* Pricing schema - 价格透明页
*/
export function usePricingSchema(pricingData) {
if (!pricingData) return
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'WebPage',
name: '呼籁旅行价格说明',
url: 'https://1814.love/pricing',
description: '呼籁旅行产品价格透明说明',
publisher: { '@id': 'https://1814.love/#organization' },
})
}
/**
* Gallery schema - 图库页
*/
export function useGallerySchema(galleryData) {
if (!galleryData) return
injectJsonLd({
'@context': 'https://schema.org',
'@type': 'ImageGallery',
name: '呼籁旅拍',
url: 'https://1814.love/gallery',
description: '呼伦贝尔草原旅行实景图库',
publisher: { '@id': 'https://1814.love/#organization' },
})
}

21
composables/usePublicApi.js 普通文件
查看文件

@ -0,0 +1,21 @@
/**
* 从服务端 API 获取公开数据
*
* 用法
* const { data: brand } = await usePublicApi('brand')
* const { data: faq } = await usePublicApi('faq')
*
* 数据来源GET /api/content/[key] site_content
*
* SSR 首屏服务端调 API数据嵌入 HTMLSEO 友好
* 客户端导航浏览器直接调 /api/content/[key]Network 可见
*/
export function usePublicApi(endpoint, options = {}) {
return useFetch(`/api/content/${endpoint}`, {
key: `public-${endpoint}`,
// 客户端导航时重新请求接口,不使用 payload 缓存
getCachedData: () => undefined,
...options,
})
}

某些文件未显示,因为此 diff 中更改的文件太多 显示更多