All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m0s
页面背景默认改为默认灰并同步启动屏;顶栏/登录头可回官网;线路卡片与话题面板顾问入口改为图标。 Co-authored-by: Cursor <cursoragent@cursor.com>
317 lines
11 KiB
Vue
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>
|