front/src/views/site/SiteHeader.vue
zzh 9922d20141
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m0s
feat: 默认页面背景联动加载页,官网入口与线路面板体验优化
页面背景默认改为默认灰并同步启动屏;顶栏/登录头可回官网;线路卡片与话题面板顾问入口改为图标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:08:57 +08:00

317 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>
<i class="mobile-nav-toggle mobile-nav-show si si-list" aria-hidden="true" @click="$emit('open-nav')"></i>
<i class="mobile-nav-toggle mobile-nav-hide d-none si si-x" aria-hidden="true" @click="$emit('close-nav')"></i>
<nav id="navbar" class="navbar">
<ul>
<li ref="dockRef" class="site-nav-dock">
<span class="site-nav-dock__glass" aria-hidden="true"></span>
<span
class="site-nav-dock__slider"
:class="{ 'is-ready': sliderReady }"
:style="sliderStyle"
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')"
>{{ 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 { 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 { 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 userStore = useUserStore();
const rbacUserStore = useRbacUserStore();
const signedIn = computed(() => rbacUserStore.isLoggedIn);
const homeUrl = officialAppHomeUrl();
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 sliderReady = ref(false);
const sliderStyle = ref<Record<string, string>>({
left: '0px',
width: '0px',
opacity: '0',
});
const sliderPos = { x: 0, w: 0 };
let settleTimer: ReturnType<typeof setTimeout> | null = null;
let sliderAnimating = false;
let resizeObserver: ResizeObserver | null = null;
const SLIDER_INSET = 4;
const LIQUID_STRETCH_MS = 120;
function applySlider(x: number, w: number, opacity = '1') {
sliderPos.x = x;
sliderPos.w = w;
sliderStyle.value = {
left: `${x}px`,
width: `${w}px`,
opacity,
};
}
function readLiveSlider() {
const slider = dockRef.value?.querySelector<HTMLElement>('.site-nav-dock__slider');
if (!slider) return { ...sliderPos };
const cs = getComputedStyle(slider);
return {
x: Number.parseFloat(cs.left) || sliderPos.x,
w: Number.parseFloat(cs.width) || sliderPos.w,
};
}
function clearSettleTimer() {
if (settleTimer) {
clearTimeout(settleTimer);
settleTimer = null;
}
}
function updateSlider(animate: boolean) {
const dock = dockRef.value;
const active = dock?.querySelector<HTMLElement>('.site-nav-dock__item.is-active');
if (!dock || !active || window.matchMedia('(max-width: 1279px)').matches) {
sliderStyle.value = { ...sliderStyle.value, opacity: '0' };
sliderAnimating = false;
return;
}
const x = active.offsetLeft + SLIDER_INSET;
const w = Math.max(active.offsetWidth - SLIDER_INSET * 2, 0);
clearSettleTimer();
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!animate || !sliderReady.value || sliderPos.w <= 0 || reduceMotion) {
sliderReady.value = false;
applySlider(x, w);
requestAnimationFrame(() => {
sliderReady.value = true;
});
sliderAnimating = false;
return;
}
const from = readLiveSlider();
if (Math.abs(from.x - x) < 1 && Math.abs(from.w - w) < 1) {
applySlider(x, w);
sliderAnimating = false;
return;
}
const coverLeft = Math.min(from.x, x);
const coverWidth = Math.max(from.x + from.w, x + w) - coverLeft;
sliderAnimating = true;
applySlider(coverLeft, coverWidth);
settleTimer = setTimeout(() => {
applySlider(x, w);
sliderAnimating = false;
settleTimer = null;
}, LIQUID_STRETCH_MS);
}
async function syncSlider(animate: boolean) {
await nextTick();
updateSlider(animate);
}
function onViewportResize() {
if (sliderAnimating) return;
updateSlider(false);
}
onMounted(() => {
void hydrateSession();
void syncSlider(false);
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(onViewportResize);
if (dockRef.value) resizeObserver.observe(dockRef.value);
}
window.addEventListener('resize', onViewportResize);
});
onBeforeUnmount(() => {
clearSettleTimer();
resizeObserver?.disconnect();
window.removeEventListener('resize', onViewportResize);
});
watch(
() => rbacUserStore.isLoggedIn,
() => {
void hydrateSession();
},
);
watch(
() => [props.nav, props.isZh] as const,
() => {
void syncSlider(true);
},
);
</script>