Files
BigStickNewOfficialWebsite-V2/components/ConsultationModal.vue
T
2026-07-16 16:42:45 +08:00

486 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
const route = useRoute()
const { isOpen, context, form, closeConsultation } = useConsultation()
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()) {
nextErrors.contact = '请填写手机号或邮箱'
} 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 {
// 后续接入 CRM 或官网线索接口时,在此替换为真实请求。
await new Promise((resolve) => setTimeout(resolve, 650))
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"
placeholder="手机号或工作邮箱"
/>
<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>