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>
1132 lines
32 KiB
Vue
1132 lines
32 KiB
Vue
<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>
|