front/src/views/site/SiteHeader.vue
zzh 0910538e6b
All checks were successful
Build and Deploy Front / deploy (push) Successful in 8m13s
feat: 官网首页 liquid-glass 与布局优化,并改进批量邮件发信体验
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 13:42:29 +08:00

308 lines
11 KiB
Vue

<template>
<header id="header" class="header d-flex align-items-center fixed-top">
<div class="container-fluid container-xl d-flex align-items-center justify-content-between">
<a href="/" class="logo d-flex align-items-center" @click="$emit('home')">
<img src="/site/img/logo1.png" alt="IDE logo" width="40" height="40" />
<span class="brand-lockup">
<h1>IDE</h1>
<small class="brand-fullname">Immediate Delivery Express</small>
</span>
</a>
<button
type="button"
class="mobile-nav-toggle mobile-nav-show"
:aria-label="isZh ? '打开菜单' : 'Open menu'"
@click="$emit('open-nav')"
>
<i class="si si-list" aria-hidden="true"></i>
</button>
<button
type="button"
class="mobile-nav-toggle mobile-nav-hide d-none"
:aria-label="isZh ? '关闭菜单' : 'Close menu'"
@click="$emit('close-nav')"
>
<i class="si si-x" aria-hidden="true"></i>
</button>
<nav id="navbar" class="navbar">
<ul>
<li ref="dockRef" class="site-nav-dock">
<span class="site-nav-dock__glass" aria-hidden="true"></span>
<span ref="sliderRef" class="site-nav-dock__slider" aria-hidden="true"></span>
<a
v-for="item in items"
:key="item.id"
:href="item.href"
class="site-nav-dock__item"
:class="{ active: nav === item.id, 'is-active': nav === item.id }"
@click.prevent="$emit('nav', item.id)"
>
<span class="site-nav-dock__icon" aria-hidden="true" v-html="item.icon"></span>
<span class="site-nav-dock__label">{{ isZh ? item.zh : item.en }}</span>
</a>
</li>
<li class="site-nav-tools">
<span class="site-nav-tools__glass" aria-hidden="true"></span>
<a
v-if="!signedIn"
class="site-nav-login"
:href="loginUrl"
@pointerenter="$emit('warm-login')"
@focus="$emit('warm-login')"
@click="onLoginClick"
>{{ isZh ? '登录' : 'Log in' }}</a>
<a
v-else
class="site-nav-avatar"
:href="homeUrl"
:title="displayName"
>
<img :src="avatarUrl" :alt="displayName" width="32" height="32" />
</a>
<button
type="button"
id="language-toggle-btn"
class="language-toggle-link"
:aria-label="isZh ? 'Switch language' : '切换语言'"
@click="$emit('toggle-lang')"
>
<i class="si si-translate language-toggle-icon" aria-hidden="true"></i>
<span class="language-toggle__next">{{ isZh ? 'EN' : '中' }}</span>
</button>
</li>
</ul>
</nav>
</div>
</header>
</template>
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import {
peekCachedCurrentCustomer,
resolveCurrentCustomer,
} from '@/api/customer/resolve';
import { applyRbacUserToUserStore } from '@/utils/enter-system';
import { DEFAULT_AVATAR_URL, isValidAvatarUrl, resolveAvatarUrl } from '@/utils/avatar';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { goOfficialLogin, officialAppHomeUrl } from './lib/site-urls';
const props = defineProps<{
nav: string;
isZh: boolean;
loginUrl: string;
}>();
defineEmits<{
home: [];
nav: [id: string];
'toggle-lang': [];
'open-nav': [];
'close-nav': [];
'warm-login': [];
}>();
const router = useRouter();
const userStore = useUserStore();
const rbacUserStore = useRbacUserStore();
const signedIn = computed(() => rbacUserStore.isLoggedIn);
const homeUrl = officialAppHomeUrl();
function onLoginClick(e: MouseEvent) {
goOfficialLogin(router, undefined, e);
}
const avatarUrl = ref(DEFAULT_AVATAR_URL);
const displayName = computed(
() =>
userStore.name?.trim() ||
rbacUserStore.userInfo?.name?.trim() ||
rbacUserStore.userInfo?.account?.trim() ||
(props.isZh ? '进入系统' : 'Open console'),
);
function paintAvatar(raw?: string | null) {
avatarUrl.value = resolveAvatarUrl(
(isValidAvatarUrl(raw) ? raw : '') ||
userStore.avatar ||
rbacUserStore.userInfo?.avatar,
);
}
async function hydrateSession() {
if (!rbacUserStore.isLoggedIn) {
avatarUrl.value = DEFAULT_AVATAR_URL;
return;
}
try {
if (!rbacUserStore.userInfo?.id) {
const info = await rbacUserStore.ensureCurrentUser();
if (info) applyRbacUserToUserStore(info);
} else if (!(userStore.name || userStore.accountId) && rbacUserStore.userInfo) {
applyRbacUserToUserStore(rbacUserStore.userInfo);
}
paintAvatar(userStore.avatar || rbacUserStore.userInfo?.avatar);
if (!rbacUserStore.isCustomerPortal()) return;
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
const account = rbacUserStore.userInfo?.account?.trim() || '';
const cached = peekCachedCurrentCustomer({ mapId, account });
const fromCache = cached?.wechatAvatarUrl || cached?.wechatAvatar || '';
if (isValidAvatarUrl(fromCache)) paintAvatar(String(fromCache).trim());
const data = await resolveCurrentCustomer({ mapId, account });
const next = data?.wechatAvatarUrl || data?.wechatAvatar || '';
if (isValidAvatarUrl(next)) paintAvatar(String(next).trim());
} catch {
paintAvatar(userStore.avatar || rbacUserStore.userInfo?.avatar);
}
}
const items = [
{
id: 'home',
href: '/',
zh: '首页',
en: 'Home',
icon: '<svg viewBox="0 0 24 24" fill="none"><path d="M4.5 11.2 12 4.8l7.5 6.4V19a1.6 1.6 0 0 1-1.6 1.6h-3.4v-5.2h-5V20.6H6.1A1.6 1.6 0 0 1 4.5 19z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>',
},
{
id: 'routes',
href: '#routes',
zh: '线路',
en: 'Lanes',
icon: '<svg viewBox="0 0 24 24" fill="none"><path d="M6.2 7.2h7.4a3.2 3.2 0 0 1 0 6.4H10.4a3.2 3.2 0 0 0 0 6.4h7.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="6.2" cy="7.2" r="2.1" stroke="currentColor" stroke-width="1.8"/><circle cx="17.8" cy="19.9" r="2.1" stroke="currentColor" stroke-width="1.8"/></svg>',
},
{
id: 'services',
href: '#services',
zh: '服务',
en: 'Services',
icon: '<svg viewBox="0 0 24 24" fill="none"><path d="M4 14.5V12a8 8 0 0 1 16 0v2.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M4 14h2.8v5.2H4A1.6 1.6 0 0 1 2.4 17.6v-2A1.6 1.6 0 0 1 4 14z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M20 14h-2.8v5.2H20a1.6 1.6 0 0 0 1.6-1.6v-2A1.6 1.6 0 0 0 20 14z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>',
},
{
id: 'pricing',
href: '#pricing',
zh: '价格',
en: 'Price',
icon: '<svg viewBox="0 0 24 24" fill="none"><rect x="4.2" y="5.2" width="15.6" height="13.6" rx="2.2" stroke="currentColor" stroke-width="1.8"/><path d="M8 10.2h3.2M8 13.8h8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="15.4" cy="10.2" r="1.2" fill="currentColor"/></svg>',
},
{
id: 'about',
href: '#about',
zh: '关于',
en: 'About',
icon: '<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.2" stroke="currentColor" stroke-width="1.8"/><path d="M12 10.6V17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="7.6" r="1.05" fill="currentColor"/></svg>',
},
{
id: 'contact',
href: '#contact',
zh: '联系',
en: 'Contact',
icon: '<svg viewBox="0 0 24 24" fill="none"><path d="M5.2 6.2h13.6A1.8 1.8 0 0 1 20.6 8v7.2a1.8 1.8 0 0 1-1.8 1.8H9.4L4.8 20.4V8A1.8 1.8 0 0 1 6.6 6.2z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M8.4 10.4h7.2M8.4 13.4h4.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
},
];
const dockRef = ref<HTMLElement | null>(null);
const sliderRef = ref<HTMLElement | null>(null);
const SLIDER_INSET = 4;
const sliderPos = { x: 0, w: 0, visible: false };
let sliderReady = false;
let sliderRaf = 0;
let resizeObserver: ResizeObserver | null = null;
let mqNarrow: MediaQueryList | null = null;
let mqReduce: MediaQueryList | null = null;
function paintSlider(x: number, w: number, visible: boolean, animate: boolean) {
const el = sliderRef.value;
if (!el) return;
const reduce = !!mqReduce?.matches;
const canAnimate = animate && sliderReady && sliderPos.visible && !reduce && sliderPos.w > 0;
el.style.transition = canAnimate
? 'transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.18s ease'
: 'none';
el.style.width = `${w}px`;
el.style.transform = `translate3d(${x}px, 0, 0)`;
el.style.opacity = visible ? '1' : '0';
sliderPos.x = x;
sliderPos.w = w;
sliderPos.visible = visible;
if (!sliderReady && visible) {
requestAnimationFrame(() => {
sliderReady = true;
});
}
}
function updateSlider(animate: boolean) {
const dock = dockRef.value;
const active = dock?.querySelector<HTMLElement>('.site-nav-dock__item.is-active');
if (!dock || !active || mqNarrow?.matches) {
paintSlider(sliderPos.x, sliderPos.w, false, false);
return;
}
const x = active.offsetLeft + SLIDER_INSET;
const w = Math.max(active.offsetWidth - SLIDER_INSET * 2, 0);
if (
animate &&
Math.abs(sliderPos.x - x) < 0.5 &&
Math.abs(sliderPos.w - w) < 0.5 &&
sliderPos.visible
) {
return;
}
paintSlider(x, w, true, animate);
}
function syncSlider(animate: boolean) {
if (sliderRaf) cancelAnimationFrame(sliderRaf);
sliderRaf = requestAnimationFrame(() => {
sliderRaf = 0;
updateSlider(animate);
});
}
function onViewportResize() {
sliderReady = false;
syncSlider(false);
}
onMounted(() => {
void hydrateSession();
mqNarrow = window.matchMedia('(max-width: 1279px)');
mqReduce = window.matchMedia('(prefers-reduced-motion: reduce)');
void nextTick(() => syncSlider(false));
if (typeof ResizeObserver !== 'undefined' && dockRef.value) {
resizeObserver = new ResizeObserver(onViewportResize);
resizeObserver.observe(dockRef.value);
}
window.addEventListener('resize', onViewportResize, { passive: true });
});
onBeforeUnmount(() => {
if (sliderRaf) cancelAnimationFrame(sliderRaf);
resizeObserver?.disconnect();
window.removeEventListener('resize', onViewportResize);
});
watch(
() => rbacUserStore.isLoggedIn,
() => {
void hydrateSession();
},
);
watch(
() => [props.nav, props.isZh] as const,
async () => {
await nextTick();
syncSlider(true);
},
);
</script>