front/src/views/site/index.vue
zzh 92cf28b73b
All checks were successful
Build and Deploy Front / deploy (push) Successful in 9m42s
feat: 官网接入 GA4,埋首页点击与 SPA 浏览
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:41:45 +08:00

557 lines
16 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.

<template>
<div
ref="glassSceneEl"
class="official-site lg-scene"
:class="{
'is-routes-open': panel === 'routes',
'is-services-open': panel === 'services',
'is-topic-open': panel === 'pricing' || panel === 'about' || panel === 'contact',
'is-glass-ready': glass.ready.value,
'is-glass-fallback': glass.fallback.value,
}"
@click.capture="onOfficialLoginCapture"
>
<!--
lg-content 会被 liquid-glass 在 resize 时改写 left/right/宽高;
波浪放在固定视口 __paint 层,避免缩窗后右侧留白。
-->
<div
ref="glassContentEl"
class="ai-shell-bg lg-content"
data-layout="overlay"
aria-hidden="true"
>
<div class="ai-shell-bg__paint">
<GradientWaves
class="ai-shell-bg__waves"
horizon-color="#023e8a"
wave-color="#0077b6"
crest-color="#caf0f8"
:speed="0.34"
:amplitude="3.3"
:wave-scale="0.72"
:wave-ratio="0.82"
:swell="42"
:turbulence="26"
:tilt="1.08"
:zoom="0.92"
:height="4.8"
:fog-depth="22"
detail="low"
:brightness="1.08"
:opacity="1.0"
:mouse-interaction="true"
:parallax-strength="0.4"
:grain="false"
:fps="30"
:render-scale="0.72"
:max-dpr="1.25"
/>
<div class="ai-shell-bg__veil" aria-hidden="true"></div>
</div>
</div>
<SiteHeader
:nav="nav"
:is-zh="isZh"
:login-url="loginUrl"
@home="onHome"
@nav="onNav"
@toggle-lang="toggleLang"
@open-nav="openMobileNav"
@close-nav="closeMobileNav"
@warm-login="warmupOfficialLogin"
/>
<div id="official-site-mount">
<SiteAdvisorShell
v-model="draft"
:is-zh="isZh"
:chatting="chatting"
:history-open="historyOpen"
:busy="busy"
:login-url="loginUrl"
:show-new-chat-tip="showNewChatTip"
:messages="messages"
:typing="typing"
:threads="threads"
:active-thread-id="activeThreadId"
:routes-pending="routesPending"
@submit="onSubmit"
@intent="onIntent"
@human="onNav('contact')"
@wechat="openWecom"
@prefill="draft = $event"
@new-chat="onHome"
@toggle-history="historyOpen = !historyOpen"
@close-history="historyOpen = false"
@warm-login="warmupOfficialLogin"
@open-routes="openRoutes"
@open-panel="openPanel"
@switch-thread="onSwitchThread"
@choice="onChoice"
/>
</div>
<Transition name="site-fade">
<button
v-if="panel"
type="button"
class="site-panel-backdrop"
aria-hidden="true"
tabindex="-1"
@click="closePanel"
></button>
</Transition>
<Transition name="site-sheet">
<SiteRoutesPanel
v-if="panel === 'routes'"
key="routes"
:is-zh="isZh"
:dest-id="routesDest"
@close="closePanel"
@ask="askAdvisor"
/>
<SiteServicesPanel
v-else-if="panel === 'services'"
key="services"
:is-zh="isZh"
@close="closePanel"
@ask="askAdvisor"
/>
<SiteTopicPanel
v-else-if="panel === 'pricing' || panel === 'about' || panel === 'contact'"
:key="panel"
:topic="panel"
:is-zh="isZh"
:wecom-url="WECOM_KF_URL"
@close="closePanel"
@ask="askAdvisor"
@wechat="onTopicWechat"
@open-panel="openPanel"
/>
</Transition>
<SiteWecomModal :open="wecomOpen" :is-zh="isZh" :url="WECOM_KF_URL" @close="wecomOpen = false" />
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import SiteHeader from './SiteHeader.vue';
import SiteAdvisorShell from './SiteAdvisorShell.vue';
import SiteRoutesPanel from './SiteRoutesPanel.vue';
import SiteServicesPanel from './SiteServicesPanel.vue';
import SiteTopicPanel from './SiteTopicPanel.vue';
import SiteWecomModal from './SiteWecomModal.vue';
import GradientWaves from './GradientWaves.vue';
import {
goOfficialLoginFromHref,
officialLoginUrl,
warmupOfficialLogin,
} from './lib/site-urls';
import { shouldOpenWecomDirect, WECOM_KF_URL } from './lib/site-wecom';
import { provideSiteLiquidGlass } from './lib/site-liquid-glass';
import { useSiteAdvisor } from './useSiteAdvisor';
import { trackSiteEvent } from '@/utils/ga';
import './styles/vendor.css';
import './styles/icons.css';
import './styles/main.css';
import './styles/ai-advisor.css';
const glass = provideSiteLiquidGlass();
const glassSceneEl = ref<HTMLElement | null>(null);
const glassContentEl = ref<HTMLElement | null>(null);
type PanelId = 'routes' | 'services' | 'pricing' | 'about' | 'contact' | null;
const NAV_PANELS: PanelId[] = ['routes', 'services', 'pricing', 'about', 'contact'];
const TITLE = 'IDE - 跨境物流,全球即递';
const DESCRIPTION =
'IDE 面向中小卖家的跨境物流。全球数字化一站式跨境物流服务商。注册登记后即可询价,一口价不砍,专属顾问跟到签收。';
const router = useRouter();
const lang = ref<'zh' | 'en'>('zh');
const nav = ref('home');
const panel = ref<PanelId>(null);
const routesDest = ref('all');
const draft = ref('');
const historyOpen = ref(false);
const wecomOpen = ref(false);
const isZh = computed(() => lang.value === 'zh');
const loginUrl = computed(() => officialLoginUrl());
/** 顾问卡片等其它「去登录」链接统一走 SPA 翻页 */
function onOfficialLoginCapture(e: MouseEvent) {
const target = e.target as Element | null;
const anchor = target?.closest?.('a[href]') as HTMLAnchorElement | null;
if (!anchor) return;
const href = anchor.getAttribute('href') || '';
if (!href.includes('from=official-site')) return;
trackSiteEvent('home_login_click', {
link_role: anchor.classList.contains('site-nav-login')
? 'nav_login'
: anchor.classList.contains('ai-welcome-cta-link')
? 'hero_cta'
: 'other',
});
if (anchor.classList.contains('site-nav-login')) return;
goOfficialLoginFromHref(router, href, e);
}
function focusComposer() {
const input = document.querySelector('.ai-composer-input') as HTMLTextAreaElement | null;
input?.focus();
}
const advisor = useSiteAdvisor({
lang,
openServices: () => openPanel('services'),
focusComposer,
});
const {
messages,
threads,
activeThreadId,
busy,
typing,
chatting,
showNewChatTip,
routesPending,
} = advisor;
let previousTitle = '';
function readLang(): 'zh' | 'en' {
try {
const saved = localStorage.getItem('ide-language');
if (saved === 'zh' || saved === 'en') return saved;
} catch {
/* ignore */
}
return 'zh';
}
function ensureMeta(name: string, content: string) {
let node = document.querySelector(`meta[name="${name}"]`);
if (!node) {
node = document.createElement('meta');
node.setAttribute('name', name);
document.head.appendChild(node);
}
node.setAttribute('content', content);
}
function ensureOg(property: string, content: string) {
let node = document.querySelector(`meta[property="${property}"]`);
if (!node) {
node = document.createElement('meta');
node.setAttribute('property', property);
document.head.appendChild(node);
}
node.setAttribute('content', content);
}
function applyMeta() {
document.title = TITLE;
ensureMeta('description', DESCRIPTION);
ensureMeta(
'keywords',
'IDE, 跨境物流, 中小卖家, 9610, 疑难单, 出口退税, 欧美专线, 东南亚专线',
);
const origin = window.location.origin;
ensureOg('og:title', TITLE);
ensureOg('og:description', DESCRIPTION);
ensureOg('og:image', `${origin}/site/img/og-cover.png`);
ensureOg('og:url', `${origin}/`);
ensureOg('og:type', 'website');
}
function onMobileNavBackdrop(e: MouseEvent) {
const navEl = document.getElementById('navbar');
if (!navEl || e.target !== navEl) return;
closeMobileNav();
}
function openMobileNav() {
document.body.classList.add('mobile-nav-active');
document.querySelectorAll('.mobile-nav-show').forEach((n) => n.classList.add('d-none'));
document.querySelectorAll('.mobile-nav-hide').forEach((n) => n.classList.remove('d-none'));
document.getElementById('navbar')?.addEventListener('click', onMobileNavBackdrop);
}
function closeMobileNav() {
document.body.classList.remove('mobile-nav-active');
document.querySelectorAll('.mobile-nav-show').forEach((n) => n.classList.remove('d-none'));
document.querySelectorAll('.mobile-nav-hide').forEach((n) => n.classList.add('d-none'));
document.getElementById('navbar')?.removeEventListener('click', onMobileNavBackdrop);
}
function syncHash(id: string) {
const next = id && id !== 'home' ? `#${id}` : location.pathname + location.search;
if (id === 'home') {
if (location.hash) history.replaceState(null, '', location.pathname + location.search);
return;
}
if (location.hash !== next) history.replaceState(null, '', next);
}
function openPanel(id: Exclude<PanelId, null>) {
closeMobileNav();
historyOpen.value = false;
if (id !== 'routes') routesDest.value = 'all';
panel.value = id;
nav.value = id;
syncHash(id);
trackSiteEvent('home_panel_open', { panel_id: id });
}
function openRoutes(dest?: string) {
const destId = dest || 'all';
advisor.clearRoutesPending();
routesDest.value = destId;
trackSiteEvent('home_dest_click', { dest_id: destId });
openPanel('routes');
}
function onIntent(intentId: string) {
trackSiteEvent('home_intent_click', { intent_id: intentId });
advisor.askIntent(intentId);
}
function closePanel(id?: string) {
if (id && panel.value && panel.value !== id) return;
if (!panel.value) return;
panel.value = null;
nav.value = 'home';
syncHash('home');
}
function onHome() {
closeMobileNav();
closePanel();
historyOpen.value = false;
nav.value = 'home';
syncHash('home');
trackSiteEvent('home_nav', { nav_id: 'home' });
advisor.startNewChat();
}
function onNav(id: string) {
closeMobileNav();
if (id === 'home') {
onHome();
return;
}
trackSiteEvent('home_nav', { nav_id: id });
if (NAV_PANELS.includes(id as PanelId)) {
openPanel(id as Exclude<PanelId, null>);
return;
}
closePanel();
nav.value = id;
syncHash(id);
}
function askAdvisor(payload: { text: string; intentId: string }) {
const fromRoutes = panel.value === 'routes';
closePanel();
draft.value = '';
trackSiteEvent('home_ask', {
intent_id: payload.intentId,
from_routes: fromRoutes,
});
advisor.ask(payload.text, { intentId: payload.intentId, fromRoutes });
}
function onSubmit() {
const text = draft.value.trim();
if (!text) return;
draft.value = '';
trackSiteEvent('home_ask', { intent_id: 'free_text' });
advisor.ask(text);
}
function onChoice(payload: { id: string; label: string; flowId: string }) {
draft.value = '';
trackSiteEvent('home_choice', {
choice_id: payload.id,
flow_id: payload.flowId,
});
advisor.ask(payload.label, { kind: 'choice', flowId: payload.flowId, choice: payload.id });
}
function onSwitchThread(id: string) {
advisor.switchThread(id);
historyOpen.value = false;
}
function openWecom() {
trackSiteEvent('home_wecom_click', { entry: 'advisor' });
if (shouldOpenWecomDirect()) {
window.location.href = WECOM_KF_URL;
return;
}
wecomOpen.value = true;
}
function onTopicWechat() {
trackSiteEvent('home_wecom_click', { entry: 'topic' });
if (shouldOpenWecomDirect()) {
window.location.href = WECOM_KF_URL;
return;
}
wecomOpen.value = true;
}
function toggleLang() {
const next = lang.value === 'zh' ? 'en' : 'zh';
lang.value = next;
localStorage.setItem('ide-language', next);
trackSiteEvent('home_lang_toggle', { lang: next });
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (document.body.classList.contains('mobile-nav-active')) {
closeMobileNav();
return;
}
closePanel();
historyOpen.value = false;
wecomOpen.value = false;
}
}
function syncHeaderOffset() {
const header = document.getElementById('header');
const height = header ? Math.round(header.getBoundingClientRect().height) : 72;
document.documentElement.style.setProperty('--ai-header-h', `${height}px`);
}
function syncVisualViewport() {
const vv = window.visualViewport;
const height = Math.round(vv?.height ?? window.innerHeight);
const offsetTop = Math.round(vv?.offsetTop ?? 0);
document.documentElement.style.setProperty('--ai-vv-height', `${height}px`);
document.documentElement.style.setProperty('--ai-vv-offset-top', `${offsetTop}px`);
}
watch(panel, (id) => {
document.body.classList.toggle('site-panel-routes', id === 'routes');
document.body.classList.toggle('site-panel-services', id === 'services');
});
onMounted(() => {
previousTitle = document.title;
lang.value = readLang();
applyMeta();
document.documentElement.classList.add('command-home');
document.body.classList.add('command-home');
window.addEventListener('keydown', onKeydown);
window.addEventListener('resize', syncHeaderOffset);
window.addEventListener('resize', syncVisualViewport);
window.visualViewport?.addEventListener('resize', syncVisualViewport);
window.visualViewport?.addEventListener('scroll', syncVisualViewport);
syncHeaderOffset();
syncVisualViewport();
const hash = (window.location.hash || '').replace('#', '');
if (hash === '9610' || hash === 'services') openPanel('services');
else if (NAV_PANELS.includes(hash as PanelId)) openPanel(hash as Exclude<PanelId, null>);
else if (hash) nav.value = hash;
void nextTick(() => {
if (glassSceneEl.value && glassContentEl.value) {
glass.init(glassSceneEl.value, glassContentEl.value);
}
});
});
onBeforeUnmount(() => {
glass.dispose();
window.removeEventListener('keydown', onKeydown);
window.removeEventListener('resize', syncHeaderOffset);
window.removeEventListener('resize', syncVisualViewport);
window.visualViewport?.removeEventListener('resize', syncVisualViewport);
window.visualViewport?.removeEventListener('scroll', syncVisualViewport);
document.body.classList.remove('site-panel-routes', 'site-panel-services');
document.documentElement.classList.remove('command-home', 'app-boot-site');
closeMobileNav();
if (previousTitle) document.title = previousTitle;
});
</script>
<style scoped>
.official-site {
position: relative;
min-height: 100vh;
isolation: isolate;
}
</style>
<style>
body.command-home #app {
background: transparent;
min-height: 100%;
}
.site-panel-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 995;
border: none;
background: color-mix(in srgb, var(--ai-bg, #f0f3f4) 55%, rgba(18, 24, 32, 0.18));
backdrop-filter: blur(6px) saturate(140%);
-webkit-backdrop-filter: blur(6px) saturate(140%);
cursor: pointer;
}
.site-fade-enter-active,
.site-fade-leave-active {
transition: opacity 0.22s ease;
}
.site-fade-enter-from,
.site-fade-leave-to {
opacity: 0;
}
.site-sheet-enter-active,
.site-sheet-leave-active {
transition:
opacity 0.24s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.site-sheet-leave-active {
pointer-events: none;
}
.site-sheet-enter-from {
opacity: 0;
transform: translateX(-50%) translateY(-8px);
}
.site-sheet-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-4px);
}
@media (max-width: 780px) {
.site-sheet-enter-from {
transform: translateY(12px);
}
.site-sheet-leave-to {
transform: translateY(8px);
}
}
@media (prefers-reduced-motion: reduce) {
.site-fade-enter-active,
.site-fade-leave-active,
.site-sheet-enter-active,
.site-sheet-leave-active {
transition-duration: 0.01ms;
}
}
</style>