sync: 同步最新呼籁官网代码到仓库

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
刘涛
2026-03-22 21:57:35 +08:00
共同撰写人 Claude Sonnet 4.6
当前提交 a25f3a77ce
共修改 319 个文件,包含 46379 行新增和 0 行删除
+94
查看文件
@@ -0,0 +1,94 @@
/**
* POST /api/contact
* 联系表单提交接口(通用留言)
*
* 请求体:
* {
* name: string // 姓名(必填)
* phone: string // 手机号(必填)
* subject?: string // 咨询主题
* message: string // 留言内容(必填)
* }
*/
import { checkRateLimit } from '../utils/rateLimit'
import { appendRecord } from '../utils/storage'
import { notifyWecom } from '../utils/notify'
const PHONE_RE = /^1[3-9]\d{9}$/
export default defineEventHandler(async (event) => {
// ── 速率限制 ──────────────────────────────────────────
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
const { allowed, remaining, resetAt } = checkRateLimit(`contact:${ip}`)
setResponseHeader(event, 'X-RateLimit-Remaining', String(remaining))
if (!allowed) {
const waitMin = Math.ceil((resetAt - Date.now()) / 60000)
throw createError({
statusCode: 429,
message: `提交过于频繁,请 ${waitMin} 分钟后再试`,
})
}
// ── 读取请求体 ─────────────────────────────────────────
const body = await readBody(event)
// ── 字段校验 ──────────────────────────────────────────
const errors = {}
if (!body?.name?.trim()) {
errors.name = '请填写您的姓名'
}
if (!body?.phone || !PHONE_RE.test(body.phone.trim())) {
errors.phone = '请填写正确的手机号码'
}
if (!body?.message?.trim() || body.message.trim().length < 5) {
errors.message = '请填写留言内容(至少5个字)'
}
if (body?.message?.trim().length > 1000) {
errors.message = '留言内容不超过1000字'
}
if (Object.keys(errors).length > 0) {
throw createError({
statusCode: 422,
data: { errors },
message: '提交内容有误,请检查后重试',
})
}
// ── 构建记录 ──────────────────────────────────────────
const record = {
id: `CT${Date.now()}`,
createdAt: new Date().toISOString(),
name: body.name.trim(),
phone: body.phone.trim(),
subject: body.subject?.trim() ?? '',
message: body.message.trim(),
ip,
source: 'contact-form',
}
// ── 持久化 ────────────────────────────────────────────
await appendRecord('contact.json', record)
// ── 企业微信通知(配置 WECOM_WEBHOOK_KEY 后生效)───────
await notifyWecom('📩 新联系留言 - 呼籁旅行', {
'编号': record.id,
'姓名': record.name,
'手机': record.phone,
'咨询主题': record.subject || '(未填)',
'留言内容': record.message,
})
return {
success: true,
id: record.id,
message: '留言已收到,我们会尽快与您联系',
}
})
+115
查看文件
@@ -0,0 +1,115 @@
/**
* POST /api/customize
* 定制表单提交接口
*
* 请求体:
* {
* name: string // 姓名(必填)
* phone: string // 手机号(必填)
* wechat?: string // 微信号(选填)
* adults: number // 成人数(必填)
* children: number // 儿童数
* childAges: number[] // 儿童年龄列表
* date?: string // 出行日期 YYYY-MM-DD
* duration?: string // 出行天数
* activities: string[]// 特殊需求标签
* budget?: string // 预算范围
* remarks?: string // 补充说明
* }
*/
import { checkRateLimit } from '../utils/rateLimit'
import { appendRecord } from '../utils/storage'
import { notifyWecom } from '../utils/notify'
// 手机号正则
const PHONE_RE = /^1[3-9]\d{9}$/
export default defineEventHandler(async (event) => {
// ── 速率限制 ──────────────────────────────────────────
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
const { allowed, remaining, resetAt } = checkRateLimit(`customize:${ip}`)
setResponseHeader(event, 'X-RateLimit-Remaining', String(remaining))
if (!allowed) {
const waitMin = Math.ceil((resetAt - Date.now()) / 60000)
throw createError({
statusCode: 429,
message: `提交过于频繁,请 ${waitMin} 分钟后再试`,
})
}
// ── 读取请求体 ─────────────────────────────────────────
const body = await readBody(event)
// ── 字段校验 ──────────────────────────────────────────
const errors = {}
if (!body?.name?.trim()) {
errors.name = '请填写您的姓名'
}
if (!body?.phone || !PHONE_RE.test(body.phone.trim())) {
errors.phone = '请填写正确的手机号码(11位大陆手机号)'
}
if (!Number.isInteger(body?.adults) || body.adults < 1 || body.adults > 20) {
errors.adults = '成人人数不合法'
}
if (Object.keys(errors).length > 0) {
throw createError({
statusCode: 422,
data: { errors },
message: '提交内容有误,请检查后重试',
})
}
// ── 构建记录 ──────────────────────────────────────────
const record = {
id: `CZ${Date.now()}`,
createdAt: new Date().toISOString(),
name: body.name.trim(),
phone: body.phone.trim(),
wechat: body.wechat?.trim() ?? '',
adults: body.adults,
children: body.children ?? 0,
childAges: Array.isArray(body.childAges) ? body.childAges : [],
date: body.date ?? '',
duration: body.duration ?? '',
activities: Array.isArray(body.activities) ? body.activities : [],
budget: body.budget ?? '',
remarks: body.remarks?.trim() ?? '',
ip,
source: 'customize-form',
}
// ── 持久化 ────────────────────────────────────────────
await appendRecord('customize.json', record)
// ── 企业微信通知(配置 WECOM_WEBHOOK_KEY 后生效)───────
const childInfo = record.children > 0
? `成人 ${record.adults} 人、儿童 ${record.children} 人(${record.childAges.join('、')}岁)`
: `成人 ${record.adults} 人`
await notifyWecom('🌿 新定制需求 - 呼籁旅行', {
'编号': record.id,
'姓名': record.name,
'手机': record.phone,
'微信': record.wechat || '(同手机号)',
'出行人数': childInfo,
'出行日期': record.date || '未填写',
'出行天数': record.duration || '未选择',
'特殊需求': record.activities.length ? record.activities.join('、') : '无',
'预算范围': record.budget || '未选择',
'补充说明': record.remarks || '无',
})
// ── 返回 ──────────────────────────────────────────────
return {
success: true,
id: record.id,
message: '需求已收到,定制师将在2小时内联系您',
}
})
+179
查看文件
@@ -0,0 +1,179 @@
export default defineEventHandler((event) => {
const pages = [
{
url: '/', priority: '1.0', changefreq: 'weekly',
images: [{ loc: '/images/team/hero-grassland.jpg', title: '呼伦贝尔草原风光 - 呼籁旅行' }],
},
{
url: '/faq', priority: '0.95', changefreq: 'weekly',
images: [{ loc: '/images/team/hero-grassland.jpg', title: '呼伦贝尔草原 - 呼籁旅行常见问答' }],
},
{
url: '/products', priority: '0.9', changefreq: 'weekly',
images: [{ loc: '/images/mascot/xiaomengma.png', title: '小蒙马夏令营IP形象' }],
},
{
url: '/about', priority: '0.85', changefreq: 'monthly',
images: [
{ loc: '/images/team/hero-family.jpg', title: '呼伦贝尔家庭游合影' },
{ loc: '/images/team/guide-group.jpg', title: '呼籁旅行领队团队合影' },
{ loc: '/images/mascot/mascot-front.png', title: '呼籁旅行吉祥物' },
],
},
{
url: '/reviews', priority: '0.8', changefreq: 'weekly',
images: [
{ loc: '/images/reviews/review-001.jpg', title: '呼籁旅行客户好评截图' },
{ loc: '/images/reviews/review-002.jpg', title: '呼伦贝尔亲子游客户评价' },
],
},
{
url: '/contact', priority: '0.8', changefreq: 'monthly',
images: [{ loc: '/images/contact/qrcode-wechat.jpg', title: '呼籁旅行微信客服二维码' }],
},
{
url: '/pricing', priority: '0.9', changefreq: 'monthly',
images: [{ loc: '/images/mascot/mascot-front.png', title: '呼籁旅行 额吉的故乡V9系列定制游价格参考' }],
},
{
url: '/selector', priority: '0.85', changefreq: 'monthly',
images: [],
},
{
url: '/guides', priority: '0.8', changefreq: 'monthly',
images: [],
},
{
url: '/news', priority: '0.85', changefreq: 'weekly',
images: [],
},
{
url: '/calendar', priority: '0.85', changefreq: 'monthly',
images: [],
},
{
url: '/courses', priority: '0.85', changefreq: 'monthly',
images: [],
},
{
url: '/summer-camp', priority: '0.85', changefreq: 'monthly',
images: [
{ loc: '/images/summer-camp/river-banner.jpg', title: '小蒙马夏令营河畔活动' },
{ loc: '/images/summer-camp/grassland-group.jpg', title: '草原亲子夏令营合影' },
{ loc: '/images/summer-camp/birch-group.jpg', title: '白桦林研学活动' },
{ loc: '/images/summer-camp/camp-graduation.jpg', title: '夏令营结营仪式' },
],
},
]
// 目的地页面
pages.push({
url: '/destinations', priority: '0.9', changefreq: 'weekly',
images: [],
})
const destinationIds = [
'moriguole', 'eergunashire', 'heishantou', 'manzhouli',
'hulunhu', 'aershan', 'shiwei', 'genhe',
]
for (const id of destinationIds) {
pages.push({
url: `/destinations/${id}`, priority: '0.85', changefreq: 'monthly',
images: [],
})
}
// 博客攻略页面
const blogArticles = [
'hulunbuir-travel-guide-2026',
'hulunbuir-family-travel-guide',
'hulunbuir-best-month-to-visit',
'hulunbuir-self-drive-vs-chartered-bus',
'hulunbuir-travel-cost-breakdown',
'hulunbuir-travel-with-toddlers-4-6',
'hulunbuir-travel-tips-avoid-pitfalls',
'grassland-photography-outfit-guide',
]
pages.push({ url: '/blog', priority: '0.85', changefreq: 'weekly', images: [] })
for (const id of blogArticles) {
pages.push({ url: `/blog/${id}`, priority: '0.8', changefreq: 'monthly', images: [] })
}
// 旅拍作品展示页
pages.push({
url: '/gallery',
priority: '0.85',
changefreq: 'monthly',
images: [
{ loc: '/images/gallery/gallery-001.jpg', title: '莫日格勒河畔蒙古袍亲子旅拍' },
{ loc: '/images/gallery/gallery-002.jpg', title: '额尔古纳湿地情侣旅拍' },
{ loc: '/images/gallery/gallery-003.jpg', title: '白桦林间闺蜜写真' },
{ loc: '/images/gallery/gallery-004.jpg', title: '黑山头草原全家福' },
],
})
// 攻略指南详情页
pages.push({ url: '/guide-custom', priority: '0.8', changefreq: 'monthly', images: [] })
// 冬季营
pages.push({ url: '/winter-camp', priority: '0.85', changefreq: 'monthly', images: [] })
// 在线定制表单
pages.push({ url: '/customize', priority: '0.9', changefreq: 'monthly', images: [] })
// 信任度页面
pages.push({
url: '/stories', priority: '0.85', changefreq: 'weekly',
images: [{ loc: '/images/team/hero-family.jpg', title: '呼籁旅行真实客户故事 - 家庭呼伦贝尔旅行记录' }],
})
const storyIds = [
'story-3',
'story-4',
'story-5',
'story-6',
'story-7',
]
for (const id of storyIds) {
pages.push({ url: `/stories/${id}`, priority: '0.8', changefreq: 'monthly', images: [] })
}
// 产品独立页面(与 data/products.json versions 对齐)
const productVersions = [
{ id: 'v9-4d3n', name: '呼伦贝尔4天3晚精华版亲子游' },
{ id: 'v9-5d4n', name: '呼伦贝尔5天4晚经典版亲子游' },
{ id: 'v9-6d5n-family', name: '呼伦贝尔6天5晚亲子版亲子游' },
{ id: 'v9-6d5n-comfort', name: '呼伦贝尔6天5晚舒适版亲子游' },
{ id: 'v9-7d6n-family', name: '呼伦贝尔7天6晚亲子版亲子游' },
{ id: 'v9-7d6n-wild', name: '呼伦贝尔7天6晚旷野版亲子游' },
]
for (const v of productVersions) {
pages.push({
url: `/products/${v.id}`, priority: '0.85', changefreq: 'monthly',
images: [],
})
}
const today = new Date().toISOString().split('T')[0]
const base = 'https://1814.love'
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${pages
.map(
(p) => ` <url>
<loc>${base}${p.url}</loc>
<lastmod>${today}</lastmod>
<changefreq>${p.changefreq}</changefreq>
<priority>${p.priority}</priority>${p.images.map(img => `
<image:image>
<image:loc>${base}${img.loc}</image:loc>
<image:title>${img.title}</image:title>
</image:image>`).join('')}
</url>`
)
.join('\n')}
</urlset>`
setResponseHeader(event, 'content-type', 'application/xml')
return xml
})
+22
查看文件
@@ -0,0 +1,22 @@
[
{
"id": "CT1774007263178",
"createdAt": "2026-03-20T11:47:43.178Z",
"name": "李女士",
"phone": "13711112222",
"subject": "询问秋季团期",
"message": "请问9月底有哪些团期可以选择?",
"ip": "::1",
"source": "contact-form"
},
{
"id": "CT1774023293117",
"createdAt": "2026-03-20T16:14:53.117Z",
"name": "测试用户",
"phone": "13800138000",
"subject": "行程咨询",
"message": "请问七月份有空档吗,想带家人来",
"ip": "::1",
"source": "contact-form"
}
]
+87
查看文件
@@ -0,0 +1,87 @@
[
{
"id": "CZ1774007128348",
"createdAt": "2026-03-20T11:45:28.348Z",
"name": "测试用户",
"phone": "13800138000",
"wechat": "",
"adults": 2,
"children": 1,
"childAges": [
5
],
"date": "2026-07-01",
"duration": "5d4n",
"activities": [
"骑马",
"露营"
],
"budget": "1-2万",
"remarks": "这是测试提交",
"ip": "::1",
"source": "customize-form"
},
{
"id": "CZ1774007220100",
"createdAt": "2026-03-20T11:47:00.100Z",
"name": "王小明",
"phone": "13912345678",
"wechat": "wxid_test",
"adults": 2,
"children": 2,
"childAges": [
6,
9
],
"date": "2026-08-15",
"duration": "6d5n",
"activities": [
"骑马",
"露营",
"捕鱼"
],
"budget": "2-3万",
"remarks": "希望住蒙古包",
"ip": "::1",
"source": "customize-form"
},
{
"id": "CZ1774023293092",
"createdAt": "2026-03-20T16:14:53.092Z",
"name": "测试用户",
"phone": "13800138000",
"wechat": "",
"adults": 2,
"children": 1,
"childAges": [
5
],
"date": "2026-07-15",
"duration": "6d5n",
"activities": [],
"budget": "8000-12000",
"remarks": "",
"ip": "::1",
"source": "customize-form"
},
{
"id": "CZ1774057315225",
"createdAt": "2026-03-21T01:41:55.225Z",
"name": "测试用户",
"phone": "13800138000",
"wechat": "",
"adults": 2,
"children": 1,
"childAges": [],
"date": "",
"duration": "6天5晚",
"activities": [
"骑马",
"旅拍"
],
"budget": "品质优先",
"remarks": "",
"ip": "::1",
"source": "customize-form"
}
]
+40
查看文件
@@ -0,0 +1,40 @@
/**
* 企业微信群机器人 Webhook 通知
* 在 .env 中配置 WECOM_WEBHOOK_KEY 即可启用
* 不配置则静默跳过
*
* 文档:https://developer.work.weixin.qq.com/document/path/91770
*/
const WECOM_API = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send'
/**
* 发送企业微信群机器人 markdown 消息
* @param {string} title - 消息标题(用作 markdown 一级标题)
* @param {Record<string, string>} fields - { 字段名: 字段值 }
*/
export async function notifyWecom(title, fields) {
const key = process.env.WECOM_WEBHOOK_KEY
if (!key) return
const lines = Object.entries(fields)
.map(([k, v]) => `> **${k}**:${v}`)
.join('\n')
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
const content = `## ${title}\n${lines}\n> **提交时间**:${now}`
try {
await $fetch(`${WECOM_API}?key=${key}`, {
method: 'POST',
body: {
msgtype: 'markdown',
markdown: { content },
},
})
} catch (err) {
// 通知失败不影响主流程,静默忽略
console.error('[notify] 企业微信通知失败:', err?.message)
}
}
+42
查看文件
@@ -0,0 +1,42 @@
/**
* 简单内存速率限制
* 每个 IP 每小时最多允许 MAX_REQUESTS 次提交
*/
const WINDOW_MS = 60 * 60 * 1000 // 1 小时
const MAX_REQUESTS = 5
// Map<ip, { count: number, resetAt: number }>
const store = new Map()
// 每 2 小时清理一次过期记录,防止内存泄漏
setInterval(() => {
const now = Date.now()
for (const [ip, record] of store.entries()) {
if (now > record.resetAt) {
store.delete(ip)
}
}
}, 2 * 60 * 60 * 1000)
/**
* 检查并记录一次请求
* @param {string} ip
* @returns {{ allowed: boolean, remaining: number, resetAt: number }}
*/
export function checkRateLimit(ip) {
const now = Date.now()
const record = store.get(ip)
if (!record || now > record.resetAt) {
store.set(ip, { count: 1, resetAt: now + WINDOW_MS })
return { allowed: true, remaining: MAX_REQUESTS - 1, resetAt: now + WINDOW_MS }
}
if (record.count >= MAX_REQUESTS) {
return { allowed: false, remaining: 0, resetAt: record.resetAt }
}
record.count++
return { allowed: true, remaining: MAX_REQUESTS - record.count, resetAt: record.resetAt }
}
+56
查看文件
@@ -0,0 +1,56 @@
/**
* 文件存储工具
* 将表单数据追加写入 server/storage/ 目录下的 JSON 文件
*/
import { promises as fs } from 'fs'
import { resolve } from 'path'
const STORAGE_DIR = resolve(process.cwd(), 'server/storage')
/**
* 确保存储目录存在
*/
async function ensureDir() {
try {
await fs.mkdir(STORAGE_DIR, { recursive: true })
} catch {
// 目录已存在,忽略
}
}
/**
* 将记录追加到指定文件
* @param {string} filename - 不含路径,如 'customize.json'
* @param {object} record
*/
export async function appendRecord(filename, record) {
await ensureDir()
const filepath = resolve(STORAGE_DIR, filename)
let records = []
try {
const raw = await fs.readFile(filepath, 'utf-8')
records = JSON.parse(raw)
} catch {
// 文件不存在或解析失败,从空数组开始
}
records.push(record)
await fs.writeFile(filepath, JSON.stringify(records, null, 2), 'utf-8')
return record
}
/**
* 读取所有记录(管理员用)
* @param {string} filename
*/
export async function readRecords(filename) {
const filepath = resolve(STORAGE_DIR, filename)
try {
const raw = await fs.readFile(filepath, 'utf-8')
return JSON.parse(raw)
} catch {
return []
}
}