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>
这个提交包含在:
当前提交
ee078b61bc
196
.claude/agents/backend.md
普通文件
196
.claude/agents/backend.md
普通文件
@ -0,0 +1,196 @@
|
|||||||
|
# 后端开发 Agent (Backend)
|
||||||
|
|
||||||
|
## 角色定义
|
||||||
|
|
||||||
|
你是呼籁文旅官网的**后端开发工程师**。你负责:
|
||||||
|
1. 接收产品经理分配的后端开发任务
|
||||||
|
2. 编写 Nitro 服务端路由、数据库操作、工具函数
|
||||||
|
3. 遵循项目既有的 API 设计模式和数据库规范
|
||||||
|
4. 完成后向产品经理汇报结果
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Nitro(Nuxt 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
普通文件
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 个前台数据 key(blog, 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 页面自带 NConfigProvider(layout: 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
普通文件
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
普通文件
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
普通文件
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
|
||||||
|
- Badge:4px
|
||||||
|
|
||||||
|
### 页面类型模式
|
||||||
|
|
||||||
|
**列表页:**
|
||||||
|
- 页面标题 + 描述 + 操作按钮(右上角)
|
||||||
|
- 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
|
||||||
46
.claude/api-field-mapping.md
普通文件
46
.claude/api-field-mapping.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
普通文件
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
|
||||||
|
|
||||||
|
请直接执行任务,完成后输出变更摘要。
|
||||||
18
.claude/commands/frontend.md
普通文件
18
.claude/commands/frontend.md
普通文件
@ -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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
|
||||||
202
CLAUDE.md
普通文件
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 自动登出。
|
||||||
|
|
||||||
|
### 后端 API(server/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
普通文件
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.json(SEO信息 — 偶尔优化)
|
||||||
|
|
||||||
|
```
|
||||||
|
后台需要的功能:
|
||||||
|
✅ 编辑每个页面的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 → 链接文字+地址
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、推荐的技术方案
|
||||||
|
|
||||||
|
### 方案 A:JSON文件 + 简易后台(推荐起步)
|
||||||
|
|
||||||
|
**适合阶段**:现在 → 日均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 2(1-2月后):简易后台 + 读写JSON + 一键generate
|
||||||
|
Phase 3(业务增长后):数据库 + API + CDN图片 + SSR渲染
|
||||||
|
```
|
||||||
582
README-HANDOFF.md
普通文件
582
README-HANDOFF.md
普通文件
@ -0,0 +1,582 @@
|
|||||||
|
# 呼籁旅行官网 - 后端对接 & 运营维护文档
|
||||||
|
|
||||||
|
> 域名:1814.love | 框架:Nuxt 3 SSG(静态站)| 语言:JavaScript + Vue 3 + Less
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目概览
|
||||||
|
|
||||||
|
这是一个**纯静态网站**,没有后端API、没有数据库。所有内容都存放在 `/data/*.json` 文件中,修改JSON即可更新网站内容。
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 框架 | Nuxt 3(Vue 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(照片)、PNG(Logo/图标)
|
||||||
|
- **尺寸**:宽度不超过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运行环境
|
||||||
274
assets/less/global.less
普通文件
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;DR(GEO/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
普通文件
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
普通文件
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;
|
||||||
119
components/about/BrandStory.vue
普通文件
119
components/about/BrandStory.vue
普通文件
@ -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>
|
||||||
194
components/about/CopyrightList.vue
普通文件
194
components/about/CopyrightList.vue
普通文件
@ -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>
|
||||||
195
components/about/MascotShowcase.vue
普通文件
195
components/about/MascotShowcase.vue
普通文件
@ -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>
|
||||||
104
components/about/SubsidiaryGrid.vue
普通文件
104
components/about/SubsidiaryGrid.vue
普通文件
@ -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>
|
||||||
179
components/about/TeamIntro.vue
普通文件
179
components/about/TeamIntro.vue
普通文件
@ -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>
|
||||||
298
components/admin/DataTable.vue
普通文件
298
components/admin/DataTable.vue
普通文件
@ -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>
|
||||||
225
components/admin/ImageUpload.vue
普通文件
225
components/admin/ImageUpload.vue
普通文件
@ -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="移除">✕</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>
|
||||||
110
components/admin/SeasonProduct.vue
普通文件
110
components/admin/SeasonProduct.vue
普通文件
@ -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}">▸</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}">▸</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]}">▸</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>
|
||||||
250
components/autumn/ProductCard.vue
普通文件
250
components/autumn/ProductCard.vue
普通文件
@ -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">✓</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">→</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>
|
||||||
160
components/common/SectionCTA.vue
普通文件
160
components/common/SectionCTA.vue
普通文件
@ -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>
|
||||||
83
components/common/TrustBadge.vue
普通文件
83
components/common/TrustBadge.vue
普通文件
@ -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>
|
||||||
43
components/faq/FaqCategory.vue
普通文件
43
components/faq/FaqCategory.vue
普通文件
@ -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
普通文件
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>
|
||||||
66
components/guides/GuideNav.vue
普通文件
66
components/guides/GuideNav.vue
普通文件
@ -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>
|
||||||
206
components/home/HeroBanner.vue
普通文件
206
components/home/HeroBanner.vue
普通文件
@ -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>
|
||||||
260
components/home/ProductPreview.vue
普通文件
260
components/home/ProductPreview.vue
普通文件
@ -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>
|
||||||
129
components/home/ReviewHighlights.vue
普通文件
129
components/home/ReviewHighlights.vue
普通文件
@ -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>
|
||||||
45
components/home/TrustBar.vue
普通文件
45
components/home/TrustBar.vue
普通文件
@ -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>
|
||||||
204
components/layout/AppFooter.vue
普通文件
204
components/layout/AppFooter.vue
普通文件
@ -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>
|
||||||
552
components/layout/AppHeader.vue
普通文件
552
components/layout/AppHeader.vue
普通文件
@ -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>
|
||||||
188
components/layout/MobileNav.vue
普通文件
188
components/layout/MobileNav.vue
普通文件
@ -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>
|
||||||
191
components/layout/PageHero.vue
普通文件
191
components/layout/PageHero.vue
普通文件
@ -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>
|
||||||
222
components/products/ProductCard.vue
普通文件
222
components/products/ProductCard.vue
普通文件
@ -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">✓</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>
|
||||||
225
components/reviews/ReviewItem.vue
普通文件
225
components/reviews/ReviewItem.vue
普通文件
@ -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">▾</span> 查看完整截图
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="screenshot-collapse"
|
||||||
|
@click="expanded = false"
|
||||||
|
aria-label="收起截图"
|
||||||
|
>
|
||||||
|
<span class="expand-icon expand-icon--up">▴</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>
|
||||||
120
components/reviews/ReviewList.vue
普通文件
120
components/reviews/ReviewList.vue
普通文件
@ -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>
|
||||||
105
components/reviews/ReviewSummary.vue
普通文件
105
components/reviews/ReviewSummary.vue
普通文件
@ -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>
|
||||||
250
components/winter/ProductCard.vue
普通文件
250
components/winter/ProductCard.vue
普通文件
@ -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">✓</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
普通文件
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
普通文件
625
composables/useJsonLd.js
普通文件
@ -0,0 +1,625 @@
|
|||||||
|
/**
|
||||||
|
* JSON-LD 结构化数据生成
|
||||||
|
* 用于搜索引擎和 AI 大模型采信
|
||||||
|
*
|
||||||
|
* 核心目标:让 AI(Kimi/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
普通文件
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,数据嵌入 HTML(SEO 友好)
|
||||||
|
* 客户端导航:浏览器直接调 /api/content/[key](Network 可见)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function usePublicApi(endpoint, options = {}) {
|
||||||
|
return useFetch(`/api/content/${endpoint}`, {
|
||||||
|
key: `public-${endpoint}`,
|
||||||
|
// 客户端导航时重新请求接口,不使用 payload 缓存
|
||||||
|
getCachedData: () => undefined,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
某些文件未显示,因为此 diff 中更改的文件太多 显示更多
正在加载...
x
在新工单中引用
屏蔽一个用户