All checks were successful
Build and Deploy Front / deploy (push) Successful in 8m13s
Co-authored-by: Cursor <cursoragent@cursor.com>
308 lines
11 KiB
Vue
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>
|