Files
BigStickNewOfficialWebsite-V2/components/ConsultationModal.vue
T

500 lines
13 KiB
Vue
Raw Normal View History

2026-07-16 16:42:45 +08:00
<script setup>
const route = useRoute()
const { isOpen, context, form, closeConsultation } = useConsultation()
2026-07-21 13:29:53 +08:00
const config = useRuntimeConfig()
const apiBase = config.public.officialApiBase || '/api'
2026-07-16 16:42:45 +08:00
const formElement = ref(null)
const nameInput = ref(null)
const errors = ref({})
const status = ref('idle')
const submitError = ref('')
const industries = ['企业服务', '教育培训', '本地生活', '医疗健康', '电商零售', '其他行业']
const needs = ['品牌曝光', '精准获客转化', '全域营销', 'GEO 诊断', '内容与媒体分发']
function validate() {
const nextErrors = {}
if (!form.value.name.trim()) nextErrors.name = '请填写您的姓名'
if (!form.value.company.trim()) nextErrors.company = '请填写公司名称'
if (!form.value.contact.trim()) {
2026-07-21 11:09:17 +08:00
nextErrors.contact = '请填写手机号'
2026-07-16 16:42:45 +08:00
} else if (
!/^(1\d{10}|[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}|[\d\s()+-]{6,})$/.test(form.value.contact.trim())
) {
nextErrors.contact = '联系方式格式不正确'
}
if (!form.value.consent) nextErrors.consent = '请确认允许工作人员与您联系'
errors.value = nextErrors
return Object.keys(nextErrors).length === 0
}
async function submitForm() {
submitError.value = ''
if (!validate()) return
status.value = 'submitting'
try {
2026-07-21 13:29:53 +08:00
await $fetch('/public/leads', {
baseURL: apiBase,
method: 'POST',
body: {
name: form.value.name.trim(),
company: form.value.company.trim(),
contact: form.value.contact.trim(),
industry: form.value.industry || context.value.industry || '',
need: form.value.need || '',
requirement: form.value.message.trim(),
sourcePage: route.fullPath,
sourceCta: context.value.source || context.value.title
}
})
2026-07-16 16:42:45 +08:00
status.value = 'success'
} catch {
status.value = 'error'
submitError.value = '暂时无法提交,请稍后重试或通过页脚联系方式咨询。'
}
}
function resetAndClose() {
status.value = 'idle'
errors.value = {}
submitError.value = ''
closeConsultation()
}
function onKeydown(event) {
if (event.key === 'Escape' && isOpen.value) resetAndClose()
}
watch(isOpen, async (open) => {
if (!import.meta.client) return
document.body.style.overflow = open ? 'hidden' : ''
if (open) {
status.value = 'idle'
errors.value = {}
await nextTick()
nameInput.value?.focus()
}
})
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<Transition name="consultation">
<div v-if="isOpen" class="consultation-mask" @mousedown.self="resetAndClose">
<section
class="consultation-panel"
:class="{ 'is-interior': route.path !== '/' }"
role="dialog"
aria-modal="true"
:aria-labelledby="
status === 'success' ? 'consultation-success-title' : 'consultation-title'
"
>
<button
class="consultation-close"
type="button"
aria-label="关闭咨询弹窗"
@click="resetAndClose"
>
<AppIcon name="close" />
</button>
<div v-if="status === 'success'" class="consultation-success">
<div class="success-mark" aria-hidden="true"><AppIcon name="check" /></div>
<p class="modal-kicker">提交成功</p>
<h2 id="consultation-success-title">需求已经记录</h2>
<p>我们会根据您填写的行业与目标匹配顾问请保持联系方式畅通</p>
<button class="modal-primary" type="button" @click="resetAndClose">完成</button>
</div>
<template v-else>
<header class="consultation-heading">
<p class="modal-kicker">1 1 增长沟通</p>
<h2 id="consultation-title">{{ context.title }}</h2>
<p>留下基本信息我们会结合您的业务阶段提供针对性建议</p>
</header>
<form
ref="formElement"
class="consultation-form"
novalidate
@submit.prevent="submitForm"
>
<div class="form-grid">
<label>
<span>姓名 <b>*</b></span>
<input
ref="nameInput"
v-model="form.name"
type="text"
autocomplete="name"
placeholder="怎么称呼您"
/>
<small v-if="errors.name">{{ errors.name }}</small>
</label>
<label>
<span>公司名称 <b>*</b></span>
<input
v-model="form.company"
type="text"
autocomplete="organization"
placeholder="请输入公司名称"
/>
<small v-if="errors.company">{{ errors.company }}</small>
</label>
</div>
<label>
<span>联系方式 <b>*</b></span>
<input
v-model="form.contact"
type="text"
autocomplete="tel"
2026-07-21 11:09:17 +08:00
placeholder="手机号"
2026-07-16 16:42:45 +08:00
/>
<small v-if="errors.contact">{{ errors.contact }}</small>
</label>
<div class="form-grid">
<label>
<span>所属行业</span>
<select v-model="form.industry">
<option value="">请选择</option>
<option v-for="industry in industries" :key="industry" :value="industry">
{{ industry }}
</option>
</select>
</label>
<label>
<span>核心需求</span>
<select v-model="form.need">
<option value="">请选择</option>
<option v-for="need in needs" :key="need" :value="need">{{ need }}</option>
</select>
</label>
</div>
<label>
<span>当前问题与目标</span>
<textarea
v-model="form.message"
rows="3"
placeholder="例如:希望提升品牌在主流 AI 平台的推荐率"
></textarea>
</label>
<label class="consent-row">
<input v-model="form.consent" type="checkbox" />
<span>我同意工作人员根据以上信息与我联系并妥善使用本次咨询信息</span>
</label>
<small v-if="errors.consent" class="consent-error">{{ errors.consent }}</small>
<p v-if="submitError" class="submit-error">{{ submitError }}</p>
<p class="source-note">咨询来源{{ context.source }}</p>
<button class="modal-primary" type="submit" :disabled="status === 'submitting'">
<span v-if="status === 'submitting'" class="submit-dots" aria-hidden="true"
><i></i><i></i><i></i
></span>
<template v-else>提交咨询 <AppIcon name="arrow-right" /></template>
</button>
</form>
</template>
</section>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.consultation-mask {
position: fixed;
z-index: 80;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 35, 62, 0.48);
backdrop-filter: blur(12px);
}
.consultation-panel {
position: relative;
width: min(620px, 100%);
max-height: min(820px, calc(100dvh - 48px));
overflow-y: auto;
padding: 40px;
border: 1px solid rgba(255, 255, 255, 0.74);
border-radius: 20px;
background:
radial-gradient(circle at 88% 8%, rgba(78, 158, 255, 0.16), transparent 31%),
rgba(255, 255, 255, 0.98);
box-shadow:
0 30px 80px rgba(28, 83, 155, 0.24),
inset 0 1px 0 #fff;
}
.consultation-panel.is-interior {
border-radius: 8px;
}
.consultation-close {
position: absolute;
top: 18px;
right: 18px;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 6px;
color: #4f647d;
background: #edf5ff;
}
.is-interior .consultation-close {
border: 1px solid rgba(72, 139, 219, 0.12);
border-radius: 5px;
}
.consultation-close :deep(.app-icon) {
width: 18px;
height: 18px;
}
.consultation-heading {
padding-right: 38px;
}
.modal-kicker {
margin: 0 0 8px;
color: #207bf2;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.16em;
}
.consultation-heading h2,
.consultation-success h2 {
margin: 0;
color: #24364d;
font-size: clamp(25px, 4vw, 34px);
letter-spacing: -0.035em;
line-height: 1.25;
}
.consultation-heading > p:last-child,
.consultation-success > p {
max-width: 32em;
margin: 12px 0 0;
color: #758399;
font-size: 14px;
line-height: 1.75;
}
.consultation-form {
display: grid;
gap: 17px;
margin-top: 28px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.consultation-form label {
display: grid;
gap: 7px;
}
.consultation-form label > span {
color: #41536a;
font-size: 14px;
font-weight: 600;
}
.consultation-form label > span b {
color: #2a80ef;
}
.consultation-form input:not([type='checkbox']),
.consultation-form select,
.consultation-form textarea {
width: 100%;
border: 1px solid #dbe7f5;
border-radius: 8px;
outline: 0;
background: rgba(247, 251, 255, 0.82);
color: #2f4056;
font: inherit;
font-size: 14px;
transition:
border-color 0.2s,
box-shadow 0.2s,
background 0.2s;
}
.consultation-form input:not([type='checkbox']),
.consultation-form select {
height: 46px;
padding: 0 13px;
}
.consultation-form textarea {
resize: vertical;
min-height: 90px;
padding: 12px 13px;
line-height: 1.6;
}
.consultation-form input:focus,
.consultation-form select:focus,
.consultation-form textarea:focus {
border-color: #66a8fa;
background: #fff;
box-shadow: 0 0 0 4px rgba(43, 128, 239, 0.1);
}
.consultation-form small,
.consent-error,
.submit-error {
color: #d54b55;
font-size: 12px;
}
.consent-row {
grid-template-columns: 18px 1fr;
align-items: start;
gap: 9px !important;
cursor: pointer;
}
.consent-row input {
width: 16px;
height: 16px;
margin: 2px 0 0;
accent-color: #247df0;
}
.consent-row span {
color: #5f6e81 !important;
font-size: 13px !important;
font-weight: 400 !important;
line-height: 1.55;
}
.source-note {
margin: -4px 0 0;
color: #718095;
font-size: 12px;
}
.modal-primary {
width: 100%;
height: 50px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 7px;
color: #fff;
background: linear-gradient(100deg, #453cff, #00a7ef);
box-shadow: 0 12px 24px rgba(35, 111, 231, 0.2);
transition:
transform 0.2s,
box-shadow 0.2s,
opacity 0.2s;
}
.modal-primary:hover {
transform: translateY(-2px);
box-shadow: 0 16px 30px rgba(35, 111, 231, 0.28);
}
.modal-primary:active {
transform: translateY(1px) scale(0.99);
}
.modal-primary:disabled {
cursor: wait;
opacity: 0.72;
}
.submit-dots {
display: flex;
gap: 5px;
}
.submit-dots i {
width: 5px;
height: 5px;
border-radius: 50%;
background: #fff;
animation: submit-dot 0.8s infinite alternate;
}
.submit-dots i:nth-child(2) {
animation-delay: 0.15s;
}
.submit-dots i:nth-child(3) {
animation-delay: 0.3s;
}
.consultation-success {
min-height: 360px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.consultation-success .modal-primary {
max-width: 220px;
margin-top: 28px;
}
.success-mark {
width: 68px;
height: 68px;
display: grid;
place-items: center;
margin-bottom: 20px;
border-radius: 22px;
color: #fff;
background: linear-gradient(145deg, #2a86f5, #65c7f4);
box-shadow: 0 16px 30px rgba(45, 131, 239, 0.25);
font-size: 30px;
}
.is-interior .success-mark {
border-radius: 8px;
}
.consultation-enter-active,
.consultation-leave-active {
transition: opacity 0.25s ease;
}
.consultation-enter-active .consultation-panel,
.consultation-leave-active .consultation-panel {
transition:
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.22s;
}
.consultation-enter-from,
.consultation-leave-to {
opacity: 0;
}
.consultation-enter-from .consultation-panel,
.consultation-leave-to .consultation-panel {
opacity: 0;
transform: translateY(18px) scale(0.98);
}
@keyframes submit-dot {
to {
transform: translateY(-4px);
opacity: 0.45;
}
}
@media (max-width: 640px) {
.consultation-mask {
align-items: end;
padding: 0;
}
.consultation-panel {
width: 100%;
max-height: calc(100dvh - 24px);
padding: 32px 20px 24px;
border-radius: 22px 22px 0 0;
}
.consultation-panel.is-interior {
border-radius: 8px 8px 0 0;
}
.form-grid {
grid-template-columns: 1fr;
}
.consultation-close {
top: 13px;
right: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.submit-dots i {
animation: none;
}
}
</style>