hulai-website/pages/customize.vue
刘涛 8832762e51 fix: 修复首页空白、小红书/游记封面图、定制页二维码
1. 首页大面积空白:useScrollReveal 添加 MutationObserver,
   确保异步子组件的 .reveal 元素也能触发入场动画

2. 小红书/游记封面图不显示:
   - 小红书 CDN 签名链接过期+防盗链,全部下载到本地
   - 新增 download-covers.mjs 脚本批量下载封面
   - 新增 useImageProxy composable 转换外部 URL
   - 页面 img 标签添加 referrerpolicy="no-referrer"
   - 管理端保存时自动下载外部封面图到本地
   - 种子数据更新为本地路径

3. 定制页微信号文字替换为二维码图片

4. prerender 路由添加 /xiaohongshu 和 /youji

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:37:58 +08:00

1000 行
24 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<div class="page-customize">
<LayoutPageHero
:title="seo.h1"
subtitle="填写出行需求,定制师2小时内回复,免费咨询"
:breadcrumbs="[{ text: '在线定制' }]"
background-image="/images/seasons/summer-hero.jpg"
/>
<!-- TL;DR -->
<section class="page-tldr" aria-label="在线定制摘要">
<div class="container">
<div class="page-tldr-inner">
<p>在线提交定制需求填写出行人数时间偏好定制师2小时内免费回复专属方案已服务10000+组家庭支持微信/电话/表单多渠道咨询</p>
</div>
</div>
</section>
<!-- 信任指标条 -->
<section v-if="customize" class="trust-bar">
<div class="container">
<ul class="trust-list">
<li v-for="stat in customize.trustStats" :key="stat.label" class="trust-item">
<span class="trust-value">{{ stat.value }}<em class="trust-unit">{{ stat.unit }}</em></span>
<span class="trust-label">{{ stat.label }}</span>
</li>
</ul>
</div>
</section>
<!-- 表单主区域 -->
<section class="section">
<div class="container">
<div class="customize-layout">
<!-- 表单主体 -->
<div class="form-wrap">
<!-- 提交成功状态 -->
<div v-if="submitted" class="success-card">
<div class="success-check">✓</div>
<h2 class="success-title">需求已收到!</h2>
<p class="success-desc">
定制师将在 <strong>2小时内</strong> 通过微信与您联系,<br>
请保持手机畅通。
</p>
<div class="success-wechat">
<span class="wechat-label">也可主动添加定制师微信</span>
<img src="/images/contact/qrcode-wechat.jpg" alt="定制师微信二维码" class="wechat-qrcode wechat-qrcode--success" />
</div>
<p class="success-tip">{{ customize.contact.tip }}</p>
<button class="btn-reset" @click="resetForm">重新填写</button>
</div>
<!-- 定制表单 -->
<form v-else class="customize-form" novalidate @submit.prevent="handleSubmit">
<!-- 1. 出行人数 -->
<fieldset class="form-section">
<legend class="section-legend">
<span class="legend-num">1</span>出行人数
</legend>
<div class="people-row">
<div class="form-field">
<label for="adults">成人</label>
<div class="number-ctrl">
<button type="button" aria-label="减少成人数" @click="decrement('adults', 1)"></button>
<span class="number-display" aria-live="polite">{{ form.adults }}</span>
<button type="button" aria-label="增加成人数" @click="increment('adults', 20)">+</button>
</div>
</div>
<div class="form-field">
<label for="children">儿童</label>
<div class="number-ctrl">
<button type="button" aria-label="减少儿童数" @click="decrement('children', 0)"></button>
<span class="number-display" aria-live="polite">{{ form.children }}</span>
<button type="button" aria-label="增加儿童数" @click="increment('children', 10)">+</button>
</div>
</div>
</div>
<!-- 儿童年龄 -->
<div v-if="form.children > 0" class="children-ages">
<p class="ages-hint">请填写每位儿童的年龄(岁)</p>
<div class="ages-row">
<div v-for="i in form.children" :key="i" class="age-field">
<label :for="`child-age-${i}`">孩子 {{ i }}</label>
<select :id="`child-age-${i}`" v-model="form.childAges[i - 1]">
<option value="">请选择</option>
<option v-for="age in 18" :key="age" :value="age">{{ age }} </option>
</select>
</div>
</div>
</div>
</fieldset>
<!-- 2. 出行计划 -->
<fieldset class="form-section">
<legend class="section-legend">
<span class="legend-num">2</span>出行计划
</legend>
<div class="two-col">
<div class="form-field">
<label for="departure-date">出行日期</label>
<input
id="departure-date"
v-model="form.date"
type="date"
:min="minDate"
class="input-date"
>
</div>
<div class="form-field">
<label for="duration">出行天数</label>
<select id="duration" v-model="form.duration">
<option value="">请选择</option>
<option v-for="d in customize.durations" :key="d.value" :value="d.value">
{{ d.label }}
</option>
</select>
</div>
</div>
</fieldset>
<!-- 3. 特殊需求 -->
<fieldset class="form-section">
<legend class="section-legend">
<span class="legend-num">3</span>特殊需求
<span class="legend-hint">可多选</span>
</legend>
<div class="checkbox-grid">
<label
v-for="item in customize.activities"
:key="item.value"
class="checkbox-item"
:class="{ 'is-checked': form.activities.includes(item.value) }"
>
<input
type="checkbox"
:value="item.value"
v-model="form.activities"
>
<span class="check-label">{{ item.label }}</span>
</label>
</div>
</fieldset>
<!-- 4. 预算范围 -->
<fieldset class="form-section">
<legend class="section-legend">
<span class="legend-num">4</span>预算范围
<span class="legend-hint">总费用参考</span>
</legend>
<div class="radio-grid">
<label
v-for="b in customize.budgets"
:key="b.value"
class="radio-item"
:class="{ 'is-checked': form.budget === b.value }"
>
<input type="radio" :value="b.value" v-model="form.budget">
<span>{{ b.label }}</span>
</label>
</div>
</fieldset>
<!-- 5. 联系方式 -->
<fieldset class="form-section">
<legend class="section-legend">
<span class="legend-num">5</span>联系方式
</legend>
<div class="contact-fields">
<div class="form-field" :class="{ 'has-error': errors.name }">
<label for="contact-name">
您的姓名 <em class="required">*</em>
</label>
<input
id="contact-name"
v-model.trim="form.name"
type="text"
placeholder="请输入姓名"
autocomplete="name"
>
<span v-if="errors.name" class="error-msg" role="alert">{{ errors.name }}</span>
</div>
<div class="form-field" :class="{ 'has-error': errors.phone }">
<label for="contact-phone">
手机号码 <em class="required">*</em>
</label>
<input
id="contact-phone"
v-model.trim="form.phone"
type="tel"
placeholder="请输入手机号"
autocomplete="tel"
>
<span v-if="errors.phone" class="error-msg" role="alert">{{ errors.phone }}</span>
</div>
<div class="form-field">
<label for="contact-wechat">微信号</label>
<input
id="contact-wechat"
v-model.trim="form.wechat"
type="text"
placeholder="与手机号相同可不填"
>
</div>
</div>
</fieldset>
<!-- 6. 补充说明 -->
<fieldset class="form-section">
<legend class="section-legend">
<span class="legend-num">6</span>补充说明
<span class="legend-hint">选填</span>
</legend>
<textarea
v-model="form.remarks"
rows="4"
placeholder="其他特殊需求、出行偏好、想去的地方……"
class="form-textarea"
/>
</fieldset>
<!-- 提交按钮 -->
<div class="form-submit">
<button type="submit" class="btn-submit">
提交定制需求
</button>
<p class="submit-hint">提交后定制师2小时内联系您免费咨询无任何套路</p>
</div>
</form>
</div>
<!-- 侧边辅助信息 -->
<aside class="customize-aside">
<div class="aside-card">
<h3 class="aside-title">定制服务流程</h3>
<ol class="process-list">
<li v-for="(step, i) in customize.process" :key="i" class="process-item">
<span class="process-num">{{ i + 1 }}</span>
<div class="process-text">
<strong>{{ step.title }}</strong>
<p>{{ step.desc }}</p>
</div>
</li>
</ol>
</div>
<div class="aside-card aside-wechat">
<h3 class="aside-title">不想填表</h3>
<p class="aside-desc">扫码添加定制师微信随时咨询</p>
<img src="/images/contact/qrcode-wechat.jpg" alt="定制师微信二维码" class="wechat-qrcode" />
<p class="wechat-tip">{{ customize.contact.tip }}</p>
</div>
</aside>
</div>
</div>
</section>
<!-- 内链闭环 -->
<CommonSectionCTA
title="想先了解产品?"
description="查看我们的行程版本,找到最适合您家庭的天数"
:links="[
{ to: '/products', text: '查看4-7天行程版本' },
{ to: '/faq', text: '查看常见问题解答' },
]"
bg="gray"
/>
</div>
</template>
<script setup>
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: customize } = await usePublicApi('customize')
const seo = useSEO('customize')
useBreadcrumbSchema([
{ text: '首页', to: '/' },
{ text: '在线定制' },
])
// 表单状态
const form = reactive({
adults: 2,
children: 0,
childAges: [],
date: '',
duration: '',
activities: [],
budget: '',
name: '',
phone: '',
wechat: '',
remarks: '',
})
const errors = reactive({ name: '', phone: '' })
const submitted = ref(false)
const submitting = ref(false)
const submitError = ref('')
// 最早可选出行日期:明天
const minDate = computed(() => {
const d = new Date()
d.setDate(d.getDate() + 1)
return d.toISOString().split('T')[0]
})
// 同步儿童年龄数组长度
watch(() => form.children, (newVal) => {
const current = form.childAges
form.childAges = Array.from({ length: newVal }, (_, i) => current[i] ?? '')
})
function increment(field, max) {
if (form[field] < max) form[field]++
}
function decrement(field, min = 1) {
if (form[field] > min) form[field]--
}
function validate() {
errors.name = form.name ? '' : '请填写您的姓名'
errors.phone = /^1[3-9]\d{9}$/.test(form.phone) ? '' : '请填写正确的手机号码'
return !errors.name && !errors.phone
}
async function handleSubmit() {
if (!validate()) return
if (submitting.value) return
submitting.value = true
submitError.value = ''
try {
await $fetch('/api/customize', {
method: 'POST',
body: {
name: form.name,
phone: form.phone,
wechat: form.wechat,
adults: form.adults,
children: form.children,
childAges: form.childAges,
date: form.date,
duration: form.duration,
activities: form.activities,
budget: form.budget,
remarks: form.remarks,
},
})
submitted.value = true
} catch (err) {
const msg = err?.data?.message || err?.message || '提交失败,请稍后重试'
submitError.value = msg
} finally {
submitting.value = false
}
}
function resetForm() {
Object.assign(form, {
adults: 2,
children: 0,
childAges: [],
date: '',
duration: '',
activities: [],
budget: '',
name: '',
phone: '',
wechat: '',
remarks: '',
})
errors.name = ''
errors.phone = ''
submitted.value = false
}
</script>
<style lang="less" scoped>
// 信任指标条
.trust-bar {
background: @color-bg-warm;
padding: @space-lg 0;
border-bottom: 1px solid @color-border;
}
.trust-list {
display: flex;
justify-content: center;
gap: @space-2xl;
list-style: none;
margin: 0;
padding: 0;
flex-wrap: wrap;
.respond-lg({
gap: @space-xl;
});
}
.trust-item {
display: flex;
flex-direction: column;
align-items: center;
gap: @space-xs;
}
.trust-value {
font-size: @font-size-2xl;
font-weight: 700;
color: @color-primary;
line-height: 1;
.trust-unit {
font-size: @font-size-md;
font-style: normal;
font-weight: 500;
margin-left: 2px;
}
}
.trust-label {
font-size: @font-size-sm;
color: @color-text-secondary;
}
// 整体布局
.customize-layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: @space-2xl;
align-items: start;
.respond-lg({
grid-template-columns: 1fr;
});
}
// 表单容器
.form-wrap {
background: @color-bg-white;
border-radius: @border-radius-lg;
border: 1px solid @color-border;
overflow: hidden;
}
// 成功状态卡片
.success-card {
padding: @space-2xl;
text-align: center;
.respond-lg({
padding: @space-xl @space-lg;
});
}
.success-check {
width: 64px;
height: 64px;
border-radius: 50%;
background: @color-primary;
color: #fff;
font-size: 28px;
line-height: 64px;
margin: 0 auto @space-lg;
}
.success-title {
font-size: @font-size-xl;
font-weight: 700;
color: @color-text-primary;
margin-bottom: @space-sm;
}
.success-desc {
font-size: @font-size-md;
color: @color-text-secondary;
line-height: 1.6;
margin-bottom: @space-xl;
}
.success-wechat {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: @space-xs;
padding: @space-md @space-xl;
background: @color-bg-warm;
border-radius: @border-radius-md;
margin-bottom: @space-sm;
}
.wechat-label {
font-size: @font-size-sm;
color: @color-text-secondary;
}
.wechat-id {
font-size: @font-size-lg;
font-weight: 700;
color: @color-primary;
letter-spacing: 0.5px;
}
.success-tip {
font-size: @font-size-sm;
color: @color-text-secondary;
margin-bottom: @space-xl;
}
.btn-reset {
display: inline-block;
padding: @space-sm @space-xl;
border: 1px solid @color-border;
border-radius: @border-radius-md;
background: transparent;
color: @color-text-secondary;
font-size: @font-size-sm;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @color-primary;
color: @color-primary;
}
}
// 表单整体
.customize-form {
padding: 0;
}
// 每个表单分区
.form-section {
border: none;
border-bottom: 1px solid @color-border;
margin: 0;
padding: @space-xl @space-2xl;
.respond-lg({
padding: @space-lg;
});
&:last-of-type {
border-bottom: none;
}
}
.section-legend {
display: flex;
align-items: center;
gap: @space-sm;
font-size: @font-size-md;
font-weight: 600;
color: @color-text-primary;
margin-bottom: @space-lg;
padding: 0;
float: none;
width: 100%;
}
.legend-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: @color-primary;
color: #fff;
font-size: @font-size-xs;
font-weight: 700;
flex-shrink: 0;
}
.legend-hint {
font-size: @font-size-sm;
font-weight: 400;
color: @color-text-secondary;
margin-left: auto;
}
// 出行人数
.people-row {
display: flex;
gap: @space-2xl;
.respond-lg({
gap: @space-xl;
});
}
.form-field {
display: flex;
flex-direction: column;
gap: @space-xs;
label {
font-size: @font-size-sm;
color: @color-text-secondary;
font-weight: 500;
}
&.has-error {
input,
select {
border-color: @color-error;
}
}
}
.number-ctrl {
display: flex;
align-items: center;
gap: 0;
border: 1px solid @color-border;
border-radius: @border-radius-md;
overflow: hidden;
width: fit-content;
button {
width: 40px;
height: 40px;
background: @color-bg-warm;
border: none;
font-size: @font-size-lg;
color: @color-text-primary;
cursor: pointer;
transition: background 0.15s;
line-height: 1;
&:hover {
background: @color-border;
}
}
}
.number-display {
display: inline-block;
min-width: 48px;
text-align: center;
font-size: @font-size-lg;
font-weight: 600;
color: @color-text-primary;
padding: 0 @space-sm;
line-height: 38px;
}
// 儿童年龄
.children-ages {
margin-top: @space-lg;
padding: @space-lg;
background: @color-bg-warm;
border-radius: @border-radius-md;
}
.ages-hint {
font-size: @font-size-sm;
color: @color-text-secondary;
margin-bottom: @space-md;
}
.ages-row {
display: flex;
flex-wrap: wrap;
gap: @space-md;
}
.age-field {
display: flex;
flex-direction: column;
gap: @space-xs;
label {
font-size: @font-size-xs;
color: @color-text-secondary;
}
select {
width: 100px;
}
}
// 双列布局(出行计划)
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: @space-lg;
.respond-lg({
grid-template-columns: 1fr;
});
}
// 通用输入样式
input[type='text'],
input[type='tel'],
input[type='date'],
select,
.form-textarea {
width: 100%;
padding: @space-sm @space-md;
border: 1px solid @color-border;
border-radius: @border-radius-md;
font-size: @font-size-md;
color: @color-text-primary;
background: @color-bg-white;
transition: border-color 0.15s;
outline: none;
font-family: inherit;
&:focus {
border-color: @color-primary;
box-shadow: 0 0 0 3px fade(@color-primary, 12%);
}
&::placeholder {
color: @color-text-placeholder;
}
}
.input-date {
cursor: pointer;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
// 错误提示
.error-msg {
font-size: @font-size-xs;
color: @color-error;
}
.required {
font-style: normal;
color: @color-error;
}
// 多选特殊需求
.checkbox-grid {
display: flex;
flex-wrap: wrap;
gap: @space-sm;
}
.checkbox-item {
display: flex;
align-items: center;
gap: @space-xs;
padding: @space-sm @space-md;
border: 1.5px solid @color-border;
border-radius: @border-radius-md;
cursor: pointer;
transition: all 0.15s;
user-select: none;
input[type='checkbox'] {
display: none;
}
.check-label {
font-size: @font-size-sm;
color: @color-text-primary;
}
&:hover {
border-color: @color-primary;
}
&.is-checked {
border-color: @color-primary;
background: fade(@color-primary, 8%);
.check-label {
color: @color-primary;
font-weight: 500;
}
}
}
// 单选预算
.radio-grid {
display: flex;
flex-wrap: wrap;
gap: @space-sm;
}
.radio-item {
display: flex;
align-items: center;
gap: @space-xs;
padding: @space-sm @space-md;
border: 1.5px solid @color-border;
border-radius: @border-radius-md;
cursor: pointer;
transition: all 0.15s;
user-select: none;
input[type='radio'] {
display: none;
}
span {
font-size: @font-size-sm;
color: @color-text-primary;
}
&:hover {
border-color: @color-primary;
}
&.is-checked {
border-color: @color-primary;
background: fade(@color-primary, 8%);
span {
color: @color-primary;
font-weight: 500;
}
}
}
// 联系方式字段组
.contact-fields {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: @space-lg;
.respond-lg({
grid-template-columns: 1fr;
});
}
// 提交区
.form-submit {
padding: @space-xl @space-2xl;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: @space-sm;
.respond-lg({
padding: @space-lg;
align-items: stretch;
});
}
.btn-submit {
display: inline-block;
padding: @space-md @space-2xl;
background: @color-primary;
color: #fff;
font-size: @font-size-md;
font-weight: 600;
border: none;
border-radius: @border-radius-md;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
letter-spacing: 0.5px;
&:hover {
background: @color-primary-dark;
}
&:active {
transform: scale(0.98);
}
.respond-lg({
width: 100%;
text-align: center;
padding: @space-md;
});
}
.submit-hint {
font-size: @font-size-xs;
color: @color-text-secondary;
}
// 侧边栏
.customize-aside {
display: flex;
flex-direction: column;
gap: @space-lg;
position: sticky;
top: 84px;
.respond-lg({
position: static;
});
}
.aside-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
padding: @space-xl;
}
.aside-title {
font-size: @font-size-md;
font-weight: 600;
color: @color-text-primary;
margin-bottom: @space-lg;
}
.aside-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
margin-bottom: @space-md;
}
// 流程步骤
.process-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: @space-md;
}
.process-item {
display: flex;
gap: @space-md;
align-items: flex-start;
}
.process-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: fade(@color-primary, 12%);
color: @color-primary;
font-size: @font-size-sm;
font-weight: 700;
flex-shrink: 0;
margin-top: 2px;
}
.process-text {
strong {
display: block;
font-size: @font-size-sm;
color: @color-text-primary;
margin-bottom: 2px;
}
p {
font-size: @font-size-xs;
color: @color-text-secondary;
line-height: 1.5;
margin: 0;
}
}
// 微信侧边卡片
.aside-wechat {
background: @color-bg-warm;
border-color: transparent;
}
.wechat-qrcode {
display: block;
width: 180px;
height: 180px;
object-fit: contain;
margin: @space-sm auto;
border-radius: @border-radius-md;
border: 1px solid @color-border;
background: @color-bg-white;
&--success {
width: 160px;
height: 160px;
}
}
.wechat-tip {
font-size: @font-size-xs;
color: @color-text-secondary;
text-align: center;
margin: 0;
}
</style>