front/src/components/top-nav/index.vue
zzh abfca7a21a feat: add H5 customer center, marketing admin, and employee profile UX
Ship customer portal and marketing pages, user edit drawer with employee archive, IDE employee numbers, channel config refactor, and related nginx/route updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:44:02 +08:00

1132 lines
32 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 id="top-nav" class="top-nav">
<div class="top-nav-header">
<!-- Logo 区域 -->
<div class="top-nav-logo">
<img :src="navLogo" :alt="t('brand.name')" />
<div class="top-nav-logo__brand">
<span class="top-nav-logo__name">{{ t('brand.name') }}</span>
<span class="top-nav-logo__full">{{ t('brand.fullName') }}</span>
</div>
</div>
<!-- 顶部水平菜单 -->
<div class="top-nav-menu" ref="topMenuCon" @wheel="onMenuWheel">
<div
v-show="indicatorVisible"
class="top-nav-menu-indicator"
:class="{ 'is-ready': indicatorReady }"
:style="indicatorStyle"
/>
<template v-if="topNavEnable">
<template v-for="item in visibleTopMenus" :key="menuKey(item)">
<a-trigger
v-if="hasChildren(item) && menuDataReady"
trigger="hover"
position="bl"
:popup-offset="0"
:mouse-enter-delay="20"
:mouse-leave-delay="160"
>
<span
:ref="(el) => setMenuItemRef(menuKey(item), el)"
class="top-nav-menu-item"
:class="{ active: isTopMenuActive(item) }"
:data-menu-key="menuKey(item)"
@click="(e: MouseEvent) => onTopMenuClick(e, item)"
>
{{ formatMenuTitle(item) }}
</span>
<template #content>
<div class="nav-submenu-panel">
<div class="nav-submenu-panel__bridge" aria-hidden="true" />
<div class="nav-submenu-panel__title">
{{ formatMenuTitle(item) }}
</div>
<div class="child-window">
<div
v-for="(row, rowIndex) in getMenuRows(filterPage(item.children))"
:key="rowIndex"
class="child-window-item"
>
<div
v-for="(menuItem, childItem) in row"
:key="childKey(menuItem, childItem)"
class="child-menu-item"
@pointerdown.prevent="gotoFromSubmenu(menuItem)"
>
<span class="child-menu-item__icon">
<component
:is="getSubMenuIcon(menuItem, item, menuNameToLocaleKeyMap)"
/>
</span>
<span class="child-menu-item__text">
{{ formatMenuTitle(menuItem) }}
</span>
</div>
</div>
</div>
</div>
</template>
</a-trigger>
<span
v-else
:ref="(el) => setMenuItemRef(menuKey(item), el)"
class="top-nav-menu-item"
:class="{ active: isTopMenuActive(item) }"
:data-menu-key="menuKey(item)"
@click="(e: MouseEvent) => onTopMenuClick(e, item)"
>
{{ formatMenuTitle(item) }}
</span>
</template>
</template>
</div>
<!-- 右侧操作区 -->
<div class="top-nav-right">
<div class="top-nav-right-item">
<a class="print-connection-trigger" @click="e => e.preventDefault()">
<icon-printer class="print-connection-icon" />
<a-switch
size="small"
:model-value="printConnectionState.enabled"
:loading="printConnectionState.connecting"
@change="onTogglePrintConnection"
/>
<span
class="print-connection-state"
:class="{ 'print-connection-state--enabled': printConnectionState.enabled }"
>
{{ printConnectionState.enabled ? '已连接' : '未连接' }}
</span>
</a>
</div>
<!-- 联系客服 -->
<div class="top-nav-right-item">
<ContactService variant="navbar" />
</div>
<!-- 余额(仅客户门户显示) -->
<div v-if="showBalanceNav" class="top-nav-right-item">
<a-dropdown trigger="hover" placement="bottomRight">
<div class="balance-dropdown-trigger">
<span class="balance-label">{{ t('nav.balance.label') }}:</span>
<span class="balance-value">{{ balanceDisplay }}</span>
</div>
<template #content>
<a-doption @click="() => onBalanceMenuClick({ key: 'recharge' })">
{{ t('nav.balance.recharge') }}
</a-doption>
<a-doption @click="() => onBalanceMenuClick({ key: 'rechargeRecord' })">
{{ t('nav.balance.rechargeRecord') }}
</a-doption>
<a-doption @click="() => onBalanceMenuClick({ key: 'accountFlow' })">
{{ t('nav.balance.accountFlow') }}
</a-doption>
</template>
</a-dropdown>
</div>
<!-- 语言 -->
<div class="top-nav-right-item">
<SelectLang />
</div>
<!-- 消息 -->
<div class="top-nav-right-item">
<RightTopMessage />
</div>
<!-- 用户 -->
<div class="top-nav-right-item user-menu-item">
<RightTopUser />
</div>
</div>
</div>
<!-- Tab 导航栏 -->
<div v-if="tabNavEnable" class="top-nav-tabs">
<TabBar />
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted, onUnmounted, watch, nextTick, type ComponentPublicInstance } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAppStore } from '@/store';
import { useTabBarStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import TabBar from '@/components/tab-bar/index.vue';
import RightTopUser from './RightTopUser.vue';
import RightTopMessage from './RightTopMessage.vue';
import SelectLang from './SelectLang.vue';
import ContactService from '@/components/ContactService/index.vue';
import { IconPrinter } from '@arco-design/web-vue/es/icon';
import { Message } from '@arco-design/web-vue';
import { openWindow, regexUrl } from '@/utils';
import { MenuService, AuthService } from '@/api/rbac';
import type { MenuTreeDto } from '@/api/rbac';
import navLogo from '@/assets/500x500.png';
import {
initHiprintClientState,
setHiprintClientEnabled,
useHiprintClientState,
} from '@/utils/hiprint/hiprintClientState';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { getSubMenuIcon } from '@/utils/menu-icon';
import { MENU_NAME_TO_LOCALE_KEY, translateMenuTitle } from '@/utils/menu-i18n';
import { canAccessCustomerPortalRoute, isAdminPortalOnlyPath, isCustomerPortalOnlyPath } from '@/utils/login-portal';
import { resolveCurrentCustomer } from '@/api/customer/resolve';
import { CUSTOMER_BALANCE_CHANGED_EVENT } from '@/api/customer/accountFlow';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const tabBarStore = useTabBarStore();
const rbacUserStore = useRbacUserStore();
const topMenuCon = ref<HTMLElement | null>(null);
const topNavEnable = computed(() => true); // 启用顶部导航
const tabNavEnable = computed(() => appStore.tabBar);
const printConnectionState = useHiprintClientState();
const menuItemEls = new Map<string, HTMLElement>();
const indicatorReady = ref(false);
const indicatorVisible = ref(false);
const indicatorStyle = ref<Record<string, string>>({
width: '0px',
transform: 'translate3d(0,0,0)',
});
function menuKey(item: any): string {
return String(item?.path || item?.route || item?.id || item?.name || '');
}
function childKey(item: any, fallback: number | string): string {
return String(item?.path || item?.route || item?.id || fallback);
}
function setMenuItemRef(key: string, el: Element | ComponentPublicInstance | null) {
if (!key) return;
if (!el) {
menuItemEls.delete(key);
return;
}
const node = (el as ComponentPublicInstance).$el
? ((el as ComponentPublicInstance).$el as HTMLElement)
: (el as HTMLElement);
if (node instanceof HTMLElement) {
menuItemEls.set(key, node);
}
}
function updateActiveIndicator() {
const wrap = topMenuCon.value;
const key = belongTopMenu.value;
if (!wrap || !key) {
indicatorVisible.value = false;
return;
}
const el = menuItemEls.get(key);
if (!el) {
indicatorVisible.value = false;
return;
}
const wrapRect = wrap.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const left = elRect.left - wrapRect.left + wrap.scrollLeft;
indicatorStyle.value = {
width: `${elRect.width}px`,
transform: `translate3d(${left}px, 0, 0)`,
};
indicatorVisible.value = true;
// 下一帧再开 transition,避免首次定位也「滑过来」
requestAnimationFrame(() => {
indicatorReady.value = true;
});
// active 项滚入可视区
if (elRect.left < wrapRect.left + 8) {
wrap.scrollBy({ left: elRect.left - wrapRect.left - 24, behavior: 'smooth' });
} else if (elRect.right > wrapRect.right - 8) {
wrap.scrollBy({ left: elRect.right - wrapRect.right + 24, behavior: 'smooth' });
}
}
function onMenuWheel(e: WheelEvent) {
const wrap = topMenuCon.value;
if (!wrap) return;
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
const maxScroll = wrap.scrollWidth - wrap.clientWidth;
if (maxScroll <= 0) return;
e.preventDefault();
wrap.scrollLeft += e.deltaY;
}
async function onTopMenuClick(e: MouseEvent, item: any) {
e.preventDefault();
const children = filterPage(item.children);
if (children.length > 0) {
// 有二级:点击一级直接进第一个可见子页(比空点更快)
goto(children[0]);
return;
}
goto(item);
}
async function onTogglePrintConnection(value: string | number | boolean) {
try {
await setHiprintClientEnabled(!!value);
} catch (e: any) {
Message.warning(`连接失败:${e?.message || '请先启动 hiprint 客户端'}`);
}
}
// 菜单数据 - 全量菜单树和用户菜单权限
const allMenuTreeData = ref<MenuTreeDto[]>([]);
const menuLoading = ref(false);
const menuDataReady = ref(false);
// 使用 store 中的用户菜单权限(computed,自动响应式)
const userMenuTreeData = computed(() => rbacUserStore.menus || []);
// 根据用户菜单权限过滤菜单树(按菜单 Id + 权限码双重匹配,避免子菜单丢失)
function filterMenuByUserPermission(allMenus: MenuTreeDto[], userMenus: MenuTreeDto[]): MenuTreeDto[] {
const userMenuIdSet = new Set<string>();
const userPermissionSet = new Set(
(rbacUserStore.permissions || []).map((p) => String(p).toLowerCase()),
);
function collectMenuIds(menus: MenuTreeDto[]) {
menus.forEach(menu => {
if (menu.id) {
userMenuIdSet.add(String(menu.id));
}
if (menu.children && menu.children.length > 0) {
collectMenuIds(menu.children);
}
});
}
collectMenuIds(userMenus);
function hasMenuPermission(menu: MenuTreeDto): boolean {
const menuId = menu.id ? String(menu.id) : '';
if (menuId && userMenuIdSet.has(menuId)) {
return true;
}
const code = menu.permissionCode?.trim();
if (code && userPermissionSet.has(code.toLowerCase())) {
return true;
}
return false;
}
function filterMenus(menus: MenuTreeDto[]): MenuTreeDto[] {
const result: MenuTreeDto[] = [];
for (const menu of menus) {
let filteredChildren: MenuTreeDto[] | null = null;
if (menu.children && menu.children.length > 0) {
filteredChildren = filterMenus(menu.children);
}
const allowed = hasMenuPermission(menu)
|| (filteredChildren != null && filteredChildren.length > 0);
if (allowed) {
result.push({
...menu,
children: filteredChildren && filteredChildren.length > 0 ? filteredChildren : null,
});
}
}
return result;
}
return filterMenus(allMenus);
}
// 将 MenuTreeDto 转换为导航栏菜单格式
function convertMenuTreeToNavMenu(menuTree: MenuTreeDto[]): any[] {
return menuTree
.filter(menu => {
// 只显示一级菜单(type === 1 或没有 type)
if (menu.type === 2) return false; // 过滤按钮
if (menu.isHidden) return false; // 过滤隐藏菜单
return true;
})
.map(menu => {
const menuRoute = String(menu.route || '');
const navMenu: any = {
id: menu.id,
name: menu.name,
path: menu.route,
route: menu.route,
type: menu.type,
meta: {
title: menu.name,
icon: menu.icon,
order: menu.order,
isHide: menu.isHidden || false,
customerPortalOnly: isCustomerPortalOnlyPath(menuRoute),
adminPortalOnly: isAdminPortalOnlyPath(menuRoute),
},
};
// 只保留二级菜单,移除三级及更深层级
if (menu.children && menu.children.length > 0) {
navMenu.children = menu.children
.filter((child: MenuTreeDto) => {
if (child.type === 2) return false; // 过滤按钮
if (child.isHidden) return false; // 过滤隐藏菜单
return true;
})
.map((child: MenuTreeDto) => {
const childRoute = String(child.route || '');
// 移除二级菜单的 children
return {
id: child.id,
name: child.name,
path: child.route,
route: child.route,
type: child.type,
meta: {
title: child.name,
icon: child.icon,
order: child.order,
isHide: child.isHidden || false,
customerPortalOnly: isCustomerPortalOnlyPath(childRoute),
adminPortalOnly: isAdminPortalOnlyPath(childRoute),
},
};
});
}
return navMenu;
});
}
// 菜单数据 - 优先用用户 menus 立即渲染;全量树到位后按权限过滤
const menuData = computed(() => {
const userMenus = userMenuTreeData.value;
if (userMenus.length === 0) {
return [];
}
if (allMenuTreeData.value.length > 0) {
const filteredMenus = filterMenuByUserPermission(
allMenuTreeData.value,
userMenus,
);
return convertMenuTreeToNavMenu(filteredMenus);
}
return convertMenuTreeToNavMenu(userMenus);
});
const visibleTopMenus = computed(() =>
menuData.value.filter((item: any) => !item.meta?.isHide),
);
/** 最长路径匹配,避免短前缀误高亮 */
const belongTopMenu = computed(() => {
const currentPath = route.path;
let bestKey = '';
let bestLen = -1;
for (const menu of visibleTopMenus.value) {
const menuPath = String(menu.path || menu.route || '');
const key = menuKey(menu);
if (!key) continue;
let score = -1;
if (menuPath && (currentPath === menuPath || currentPath.startsWith(`${menuPath}/`))) {
score = menuPath.length;
}
if (menu.children) {
for (const child of menu.children) {
const childPath = String(child.path || child.route || '');
if (
childPath &&
(currentPath === childPath || currentPath.startsWith(`${childPath}/`)) &&
childPath.length > score
) {
score = childPath.length;
}
}
}
if (score > bestLen) {
bestLen = score;
bestKey = key;
}
}
return bestKey;
});
function isTopMenuActive(item: any) {
return belongTopMenu.value === menuKey(item);
}
// 加载菜单:优先复用登录带回的 user menus,避免再打全量 get-menu-tree
async function loadMenuTree() {
menuLoading.value = true;
try {
if (!rbacUserStore.isLoggedIn) {
allMenuTreeData.value = [];
menuDataReady.value = true;
return;
}
let currentUserMenus = rbacUserStore.menus;
if (!currentUserMenus || currentUserMenus.length === 0) {
try {
await rbacUserStore.ensureCurrentUser();
currentUserMenus = rbacUserStore.menus;
} catch (userError) {
console.error('获取用户信息失败:', userError);
}
}
if (currentUserMenus && currentUserMenus.length > 0) {
allMenuTreeData.value = [];
await nextTick();
menuDataReady.value = true;
tabBarStore.refreshTagTitles();
return;
}
// 兜底:用户 menus 仍为空时再拉全量树(管理页等特殊场景)
try {
const allMenus = await MenuService.getRbacMenuGetMenuTree() || [];
allMenuTreeData.value = allMenus;
await nextTick();
menuDataReady.value = true;
tabBarStore.refreshTagTitles();
} catch (menuError) {
console.error('加载全量菜单树失败:', menuError);
allMenuTreeData.value = [];
await nextTick();
menuDataReady.value = true;
tabBarStore.refreshTagTitles();
}
} catch (error) {
console.error('加载菜单树或用户信息失败:', error);
allMenuTreeData.value = [];
await nextTick();
menuDataReady.value = true;
} finally {
menuLoading.value = false;
}
}
// 顶部菜单标题:按路由 locale / 中文名映射走 i18n,随语言切换更新
const formatMenuTitle = (menu: any) => {
if (!menu) return '';
if (typeof menu === 'string') {
return translateMenuTitle(t, { name: menu });
}
return translateMenuTitle(t, {
name: menu.name,
route: menu.route || menu.path,
path: menu.path || menu.route,
meta: menu.meta,
});
};
// 中文菜单名称到 locale key(图标辅助;与 menu-i18n 共用)
const menuNameToLocaleKeyMap = MENU_NAME_TO_LOCALE_KEY;
// 过滤页面 - 只显示2级菜单,忽略3级菜单
const filterPage = (pages?: any[]) => {
if (!pages) return [];
return pages
.filter(item => {
// 过滤掉隐藏的菜单
if (item.meta?.isHide || item.meta?.hideInMenu) return false;
// 过滤掉按钮类型的菜单
if (item.type === 2) return false;
const itemPath = String(item.path || item.route || '');
if (
!canAccessCustomerPortalRoute(rbacUserStore.loginPortalType, {
path: itemPath,
customerPortalOnly: item.meta?.customerPortalOnly,
adminPortalOnly: item.meta?.adminPortalOnly,
})
) {
return false;
}
if (
!rbacUserStore.isCustomerPortal() &&
(item.name === 'UserProfile' || item.title === '个人中心')
) {
return false;
}
return true;
})
.map(item => {
// 确保二级菜单不包含 children(三级菜单)
const { children, ...itemWithoutChildren } = item;
return itemWithoutChildren;
});
};
// 检查是否有子菜单
const hasChildren = (item: any) => {
if (!item.children || item.children.length === 0) return false;
// 检查是否有可见的子菜单
return item.children.some((child: any) => {
if (child.meta?.isHide || child.meta?.hideInMenu) return false;
if (child.type === 2) return false; // 过滤按钮
const childPath = String(child.path || child.route || '');
if (
!canAccessCustomerPortalRoute(rbacUserStore.loginPortalType, {
path: childPath,
customerPortalOnly: child.meta?.customerPortalOnly,
adminPortalOnly: child.meta?.adminPortalOnly,
})
) {
return false;
}
if (
!rbacUserStore.isCustomerPortal() &&
(child.name === 'UserProfile' || child.title === '个人中心')
) {
return false;
}
return true;
});
};
// 将菜单项按每行4个分组
const getMenuRows = (items: any[]) => {
const rows: any[][] = [];
for (let i = 0; i < items.length; i += 4) {
rows.push(items.slice(i, i + 4));
}
return rows;
};
// 路径到路由名称的映射(用于回退,避免使用中文显示名称)
const pathToRouteNameMap: Record<string, string> = {
'/channel/salesProducts': 'SalesProducts',
'/channel/serviceChannels': 'ServiceChannels',
'/channel/priceArea': 'PriceArea',
'/channel/blacklist': 'Blacklist',
'/channel/remoteZones': 'RemoteZones',
'/channel/weightRules': 'WeightRules',
'/channel/weightRounding': 'WeightRounding',
'/channel/autoTrigger': 'AutoTrigger',
'/channel/issueTemplate': 'IssueTemplate',
'/channel/guide': 'ChannelGuide',
'/order/guide': 'OrderGuide',
// Track 相关路由
'/track/guide': 'TrackGuide',
'/track/order': 'TrackOrder',
'/track/warningOrder': 'TrackWarningOrder',
'/track/state': 'TrackState',
'/track/flag': 'TrackFlag',
'/track/content': 'TrackContent',
'/track/pull': 'TrackPull',
'/track/push': 'TrackPush',
'/track/kpiRegion': 'KpiRegion',
'/track/kpiEarlyWarningConfig': 'KpiEarlyWarningConfig',
'/xhs/startService': 'XhsStartService',
'/xhs/analysis': 'XhsAnalysis',
'/xhs/creation': 'XhsCreation',
'/xhs/publishHistory': 'XhsPublishHistory',
'/xhs/skills': 'XhsSkills',
'/xhs/humanSettings': 'XhsHumanSettings',
'/user/marketing/invite': 'UserMarketingInvite',
'/user/marketing/member': 'UserMarketingMember',
'/user/marketing/coupons': 'UserMarketingCoupons',
'/user/marketing/commission': 'UserMarketingCommission',
};
// 跳转
const goto = (item: any) => {
if (!item) {
return;
}
// 获取路径,优先使用 path,其次使用 route
let targetPath = item.path || item.route;
if (!targetPath) {
return;
}
// 确保路径以 / 开头
if (!targetPath.startsWith('/') && !regexUrl.test(targetPath)) {
targetPath = '/' + targetPath;
}
// 如果是外部链接,使用 openWindow
if (regexUrl.test(targetPath)) {
openWindow(targetPath);
return;
}
// 使用路由 path 跳转
router.push(targetPath).catch((error) => {
console.error(`路由跳转失败: path=${targetPath}`, error);
// 尝试使用路由名称跳转(从映射中获取,而不是使用 item.name,因为 item.name 是中文显示名称)
const routeName = pathToRouteNameMap[targetPath];
if (routeName) {
router.push({ name: routeName }).catch((nameError) => {
console.error(`使用路由名称跳转也失败: name=${routeName}`, nameError);
});
} else {
// 如果没有映射,尝试使用 item.name(可能是路由名称,也可能是显示名称)
// 但这种情况应该尽量避免,因为 item.name 通常是显示名称
if (item.name && /^[A-Z]/.test(item.name)) {
// 如果 item.name 以大写字母开头,可能是路由名称
router.push({ name: item.name }).catch((nameError) => {
console.error(`使用路由名称跳转也失败: name=${item.name}`, nameError);
});
}
}
});
};
/** 子菜单点击后跳转(路由变化会关闭弹层) */
const gotoFromSubmenu = (item: any) => {
goto(item);
};
const showBalanceNav = computed(() => rbacUserStore.isCustomerPortal());
const customerBalance = ref<number | null>(null);
const balanceDisplay = computed(() => {
const raw = customerBalance.value;
if (raw == null) {
return '0.00';
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed.toFixed(2) : '0.00';
});
async function loadCustomerBalance() {
if (!rbacUserStore.isCustomerPortal() || !rbacUserStore.isLoggedIn) {
customerBalance.value = null;
return;
}
try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
const account = rbacUserStore.userInfo?.account?.trim() || '';
const data = await resolveCurrentCustomer({ mapId, account });
const raw = data?.accountBalance ?? data?.balance;
if (raw == null || raw === '') {
customerBalance.value = 0;
return;
}
const parsed = Number(raw);
customerBalance.value = Number.isFinite(parsed) ? parsed : 0;
} catch (error) {
console.error('Failed to load customer balance', error);
customerBalance.value = 0;
}
}
// 点击余额菜单项
const onBalanceMenuClick = (event: any) => {
const { key } = event;
if (key === 'recharge') {
router.push({ name: 'VipRechargeDeposit' });
} else if (key === 'rechargeRecord') {
router.push({ name: 'FinanceRechargeRecord' });
} else if (key === 'accountFlow') {
router.push({ name: 'FinanceAccountFlow' });
}
};
watch(
() => [rbacUserStore.isLoggedIn, rbacUserStore.loginPortalType, rbacUserStore.accessToken] as const,
() => {
void loadCustomerBalance();
},
{ immediate: true }
);
function handleCustomerBalanceChanged() {
void loadCustomerBalance();
}
watch(
() => [belongTopMenu.value, menuDataReady.value, visibleTopMenus.value.length] as const,
async () => {
await nextTick();
updateActiveIndicator();
},
{ flush: 'post' },
);
watch(
() => route.fullPath,
async () => {
await nextTick();
updateActiveIndicator();
},
);
function onWindowResize() {
updateActiveIndicator();
}
onMounted(async () => {
window.addEventListener(CUSTOMER_BALANCE_CHANGED_EVENT, handleCustomerBalanceChanged);
window.addEventListener('resize', onWindowResize, { passive: true });
initHiprintClientState().catch(() => undefined);
await loadMenuTree();
await nextTick();
updateActiveIndicator();
});
onUnmounted(() => {
window.removeEventListener(CUSTOMER_BALANCE_CHANGED_EVENT, handleCustomerBalanceChanged);
window.removeEventListener('resize', onWindowResize);
});
</script>
<style lang="less" scoped>
@headerHeight: 50px;
@headerTabNavHeight: 36px;
.top-nav {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: (@headerHeight + @headerTabNavHeight);
box-shadow: 0 1px 4px rgba(114, 115, 116, 0.08);
z-index: 100;
background: var(--app-navbar-bg);
.top-nav-header {
display: flex;
width: 100%;
height: @headerHeight;
background: var(--app-navbar-bg) !important;
background-color: var(--app-navbar-bg) !important;
color: #fff;
align-items: center;
.top-nav-logo {
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px;
flex-shrink: 0;
img {
height: 36px;
width: 36px;
object-fit: contain;
display: block;
}
}
.top-nav-logo__brand {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
line-height: 1.15;
}
.top-nav-logo__name {
font-size: 16px;
font-weight: 800;
letter-spacing: 0.08em;
color: #fff;
}
.top-nav-logo__full {
margin-top: 2px;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.72);
white-space: nowrap;
}
.top-nav-menu {
position: relative;
height: @headerHeight;
line-height: @headerHeight;
flex: 1;
overflow: hidden;
overflow-x: auto;
display: flex;
align-items: center;
scroll-behavior: smooth;
.top-nav-menu-indicator {
position: absolute;
top: 50%;
left: 0;
z-index: 0;
height: 32px;
margin-top: -16px;
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.2);
pointer-events: none;
will-change: transform, width;
&.is-ready {
transition:
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
width 0.16s cubic-bezier(0.22, 1, 0.36, 1);
}
}
.top-nav-menu-item {
position: relative;
z-index: 1;
display: inline-block;
padding: 6px 20px;
height: auto;
line-height: 1.5;
text-decoration: none;
color: #fff;
font-size: 16px;
border-radius: 20px;
white-space: nowrap;
transition: color 0.12s ease, opacity 0.12s ease;
margin: 0 16px;
cursor: pointer;
background-color: transparent;
&:hover {
color: #fff;
opacity: 0.92;
}
&.active {
color: #fff;
// 选中背景由 indicator 滑动承担,避免双重高亮闪烁
background-color: transparent;
}
}
}
.top-nav-right {
display: flex;
align-items: center;
white-space: nowrap;
height: @headerHeight;
vertical-align: middle;
.top-nav-right-item {
display: flex;
align-items: center;
height: 100%;
padding: 0 16px;
position: relative;
&:not(:first-child)::before {
content: '';
position: absolute;
left: 0;
top: 25%;
width: 1px;
height: 50%;
background-color: rgba(255, 255, 255, 0.2);
}
}
.print-connection-trigger {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
cursor: pointer;
color: inherit;
text-decoration: none;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
}
.print-connection-icon {
font-size: 16px;
}
.print-connection-state {
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
}
.print-connection-state--enabled {
color: #22c55e;
}
.balance-dropdown-trigger {
display: flex;
align-items: center;
white-space: nowrap;
cursor: pointer;
padding: 0 8px;
flex-shrink: 0;
color: inherit;
text-decoration: none;
transition: all 0.3s;
font-weight: 500;
font-size: 16px;
&:hover {
opacity: 0.8;
}
.balance-label {
margin-right: 4px;
}
.balance-value {
color: #fff;
font-size: 16px;
}
}
.user-menu-item {
margin-left: auto;
}
}
}
.top-nav-tabs {
height: @headerTabNavHeight;
background: #fff;
border-bottom: 1px solid #e5e6eb;
}
&.tabNavEnable {
height: (@headerHeight + @headerTabNavHeight);
}
&:not(.tabNavEnable) {
height: @headerHeight;
}
}
.nav-submenu-panel {
position: relative;
min-width: 600px;
max-width: 720px;
padding: 12px 16px 16px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
/* 填补触发条与面板之间的空隙,鼠标移入二级时不会断 hover */
.nav-submenu-panel__bridge {
position: absolute;
top: -12px;
left: 0;
right: 0;
height: 12px;
}
.nav-submenu-panel__title {
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-border-2, #e5e6eb);
font-size: 14px;
font-weight: 600;
color: var(--color-text-1, #1d2129);
}
.child-window {
width: 100%;
}
.child-window-item {
border-bottom: 1px dashed #eee;
padding-bottom: 1rem;
margin-bottom: 1rem;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem 1rem;
&:last-child {
border-bottom: none;
margin-bottom: 0;
}
}
.child-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
color: var(--color-text-1);
transition: color 0.12s ease, background-color 0.12s ease;
&:hover {
color: rgb(var(--primary-6));
background-color: var(--color-fill-2);
.child-menu-item__icon {
color: rgb(var(--primary-6));
}
}
&:active {
background-color: var(--color-fill-3);
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 16px;
height: 16px;
font-size: 16px;
color: var(--color-text-3);
transition: color 0.12s ease;
}
&__text {
line-height: 1.4;
font-size: 14px;
word-break: break-all;
}
}
// 滚动条样式
.top-nav-menu {
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.5);
}
}
@media screen and (max-width: 1100px) {
.top-nav-header .top-nav-logo__full {
display: none;
}
}
</style>