generate-menu-excel、vite dev SPA 白名单、top-nav path 映射与 locale 区分同名「新手指引」。 Co-authored-by: Cursor <cursoragent@cursor.com>
969 lines
28 KiB
Vue
969 lines
28 KiB
Vue
<template>
|
||
<div id="top-nav" class="top-nav">
|
||
<div class="top-nav-header">
|
||
<!-- Logo 区域 -->
|
||
<div class="top-nav-logo">
|
||
<img src="/src/assets/logo.png" alt="logo" />
|
||
</div>
|
||
|
||
<!-- 顶部水平菜单 -->
|
||
<div class="top-nav-menu" ref="topMenuCon">
|
||
<template v-if="topNavEnable">
|
||
<template v-for="(item, key) in menuData" :key="key">
|
||
<a-popover
|
||
v-if="hasChildren(item) && menuDataReady"
|
||
placement="bottomLeft"
|
||
:mouseEnterDelay="0.3"
|
||
>
|
||
<template #content>
|
||
<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="childItem"
|
||
class="child-menu-item"
|
||
@click="() => goto(menuItem)"
|
||
>
|
||
<span class="child-menu-item__icon">
|
||
<component :is="getSubMenuIcon(menuItem, item, menuNameToLocaleKeyMap)" />
|
||
</span>
|
||
<span class="child-menu-item__text">
|
||
{{ formatMenuTitle(menuItem.name || menuItem.meta?.title, menuItem.path || menuItem.route) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template #title>
|
||
<span>{{ formatMenuTitle(item.name || item.meta?.title) }}</span>
|
||
</template>
|
||
<span
|
||
:key="key"
|
||
v-if="!item.meta?.isHide"
|
||
:class="{'active': belongTopMenu === (item.path || item.route) || (item.path && belongTopMenu.startsWith(item.path)) }"
|
||
class="top-nav-menu-item"
|
||
@click="(e) => {
|
||
e.preventDefault();
|
||
if (item.children && item.children.length > 0) {
|
||
// 有子菜单时不跳转,只显示弹出层
|
||
return;
|
||
} else {
|
||
// 如果没有子菜单,执行跳转
|
||
goto(item);
|
||
}
|
||
}"
|
||
>
|
||
{{ formatMenuTitle(item.name || item.meta?.title) }}
|
||
</span>
|
||
</a-popover>
|
||
<span
|
||
v-else
|
||
:key="key"
|
||
v-if="!item.meta?.isHide"
|
||
:class="{'active': belongTopMenu === (item.path || item.route) || (item.path && belongTopMenu.startsWith(item.path)) }"
|
||
class="top-nav-menu-item"
|
||
@click="(e) => {
|
||
e.preventDefault();
|
||
goto(item);
|
||
}"
|
||
>
|
||
{{ formatMenuTitle(item.name || item.meta?.title) }}
|
||
</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 } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import { useI18n } from 'vue-i18n';
|
||
import { useAppStore } from '@/store';
|
||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||
import TabBar from '@/components/tab-bar/index.vue';
|
||
import RightTopMessage from './RightTopMessage.vue';
|
||
import RightTopUser from './RightTopUser.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 {
|
||
initHiprintClientState,
|
||
setHiprintClientEnabled,
|
||
useHiprintClientState,
|
||
} from '@/utils/hiprint/hiprintClientState';
|
||
import { getMapIdFromAccessToken } from '@/utils/jwt';
|
||
import { getSubMenuIcon, PATH_TO_LOCALE_KEY } from '@/utils/menu-icon';
|
||
import { canAccessCustomerPortalRoute } 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 rbacUserStore = useRbacUserStore();
|
||
|
||
const topMenuCon = ref<HTMLElement | null>(null);
|
||
const topNavEnable = computed(() => true); // 启用顶部导航
|
||
const tabNavEnable = computed(() => appStore.tabBar);
|
||
const printConnectionState = useHiprintClientState();
|
||
|
||
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 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,
|
||
},
|
||
};
|
||
|
||
// 只保留二级菜单,移除三级及更深层级
|
||
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) => {
|
||
// 移除二级菜单的 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,
|
||
},
|
||
};
|
||
});
|
||
}
|
||
|
||
return navMenu;
|
||
});
|
||
}
|
||
|
||
// 菜单数据 - 根据用户权限过滤后的菜单列表
|
||
const menuData = computed(() => {
|
||
const userMenus = userMenuTreeData.value;
|
||
|
||
// 如果全量菜单树为空,返回空数组
|
||
if (allMenuTreeData.value.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
// 如果用户菜单权限为空,返回空数组
|
||
if (userMenus.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
// 根据用户菜单权限过滤全量菜单树
|
||
const filteredMenus = filterMenuByUserPermission(
|
||
allMenuTreeData.value,
|
||
userMenus
|
||
);
|
||
|
||
// 转换为导航栏格式
|
||
return convertMenuTreeToNavMenu(filteredMenus);
|
||
});
|
||
|
||
// 加载菜单树和用户菜单权限
|
||
async function loadMenuTree() {
|
||
menuLoading.value = true;
|
||
menuDataReady.value = false;
|
||
try {
|
||
// 先检查用户是否已登录
|
||
if (!rbacUserStore.isLoggedIn) {
|
||
allMenuTreeData.value = [];
|
||
menuLoading.value = false;
|
||
menuDataReady.value = true;
|
||
return;
|
||
}
|
||
|
||
// 确保用户信息已加载(如果菜单为空,重新获取)
|
||
const currentUserMenus = rbacUserStore.menus;
|
||
if (!currentUserMenus || currentUserMenus.length === 0) {
|
||
try {
|
||
await rbacUserStore.getCurrentUser();
|
||
} catch (userError) {
|
||
console.error('获取用户信息失败:', userError);
|
||
}
|
||
}
|
||
|
||
// 加载全量菜单树
|
||
try {
|
||
const allMenus = await MenuService.getRbacMenuGetMenuTree() || [];
|
||
allMenuTreeData.value = allMenus;
|
||
// 等待渲染完成后再标记数据就绪
|
||
await nextTick();
|
||
menuDataReady.value = true;
|
||
} catch (menuError) {
|
||
console.error('加载全量菜单树失败:', menuError);
|
||
allMenuTreeData.value = [];
|
||
await nextTick();
|
||
menuDataReady.value = true;
|
||
}
|
||
} catch (error) {
|
||
console.error('加载菜单树或用户信息失败:', error);
|
||
allMenuTreeData.value = [];
|
||
await nextTick();
|
||
menuDataReady.value = true;
|
||
} finally {
|
||
menuLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// 当前路由所属的顶部菜单
|
||
const belongTopMenu = computed(() => {
|
||
const currentPath = route.path;
|
||
// 查找当前路由所属的顶级菜单
|
||
for (const menu of menuData.value) {
|
||
const menuPath = menu.path || menu.route;
|
||
if (menuPath && currentPath.startsWith(menuPath)) {
|
||
return menuPath;
|
||
}
|
||
// 检查子菜单
|
||
if (menu.children) {
|
||
for (const child of menu.children) {
|
||
const childPath = child.path || child.route;
|
||
if (childPath && currentPath.startsWith(childPath)) {
|
||
return menuPath || '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return '';
|
||
});
|
||
|
||
// 路径 → locale(同名菜单如「新手指引」须按 path 区分)
|
||
const pathToLocaleKeyMap = PATH_TO_LOCALE_KEY;
|
||
|
||
// 中文菜单名称到 locale key 的映射表
|
||
const menuNameToLocaleKeyMap: Record<string, string> = {
|
||
'首页': 'menu.home',
|
||
'客户': 'menu.user',
|
||
'客户管理': 'menu.user.customer',
|
||
'个人中心': 'menu.user.profile',
|
||
'资金充值': 'menu.user.recharge',
|
||
'财务': 'menu.finance',
|
||
'充值记录': 'menu.finance.rechargeRecord',
|
||
'资金流水': 'menu.finance.accountFlow',
|
||
'应收应付': 'menu.finance.receivablePayable',
|
||
'订单': 'menu.order',
|
||
'我要查价': 'menu.order.priceCheck',
|
||
'我要下单': 'menu.order.placeOrder',
|
||
'我的订单': 'menu.order.myOrders',
|
||
'渠道': 'menu.channel',
|
||
'销售产品': 'menu.channel.salesProducts',
|
||
'服务渠道': 'menu.channel.serviceChannels',
|
||
'价格分区': 'menu.channel.priceArea',
|
||
'黑名单区': 'menu.channel.blacklist',
|
||
'偏远分区': 'menu.channel.remoteZones',
|
||
'计重规则': 'menu.channel.weightRules',
|
||
'计重进位': 'menu.channel.weightRounding',
|
||
'自动触发': 'menu.channel.autoTrigger',
|
||
'问题模板': 'menu.channel.issueTemplate',
|
||
'轨迹': 'menu.track',
|
||
'新手指引': 'menu.track.trackGuide',
|
||
'轨迹订单': 'menu.track.trackOrder',
|
||
'预警订单': 'menu.track.warningOrder',
|
||
'轨迹状态': 'menu.track.trackState',
|
||
'轨迹标记': 'menu.track.trackFlag',
|
||
'轨迹内容': 'menu.track.trackContent',
|
||
'轨迹拉取': 'menu.track.trackPull',
|
||
'轨迹推送': 'menu.track.trackPush',
|
||
'预警分区': 'menu.track.kpiRegion',
|
||
'预警配置': 'menu.track.kpiEarlyWarningConfig',
|
||
'操作': 'menu.operation',
|
||
'入库扫描': 'menu.operation.inboundScan',
|
||
'出库扫描': 'menu.operation.outboundScan',
|
||
'打印标签': 'menu.operation.printLabel',
|
||
'API': 'menu.api',
|
||
'上游接口': 'menu.api.upstreamInterface',
|
||
'下游接口': 'menu.api.downstreamInterface',
|
||
'创建接口': 'menu.api.createInterface',
|
||
'修改接口': 'menu.api.editInterface',
|
||
'App列表': 'menu.api.appList',
|
||
'App管理': 'menu.api.appList',
|
||
'应用管理': 'menu.api.appList',
|
||
'请求日志': 'menu.api.requestLog',
|
||
'调用统计': 'menu.api.callStatistics',
|
||
'创建应用': 'menu.api.createApplication',
|
||
'API调试': 'menu.api.apiDebugger',
|
||
'API LIKE': 'menu.api.apiLike',
|
||
'API测试': 'menu.api.apiTest',
|
||
'系统': 'menu.system',
|
||
'组织管理': 'menu.system.organization',
|
||
'用户管理': 'menu.system.user',
|
||
'职位管理': 'menu.system.position',
|
||
'角色管理': 'menu.system.role',
|
||
'模块管理': 'menu.system.module',
|
||
'菜单管理': 'menu.system.menu',
|
||
'仓库地址': 'menu.system.warehouseAddress',
|
||
'其他配置': 'menu.system.otherConfig',
|
||
'小红书': 'menu.xhs',
|
||
'启动服务': 'menu.xhs.startService',
|
||
'内容分析': 'menu.xhs.analysis',
|
||
'爆款创作': 'menu.xhs.creation',
|
||
'发布记录': 'menu.xhs.publishHistory',
|
||
'技能卡片': 'menu.xhs.skills',
|
||
'拟人设置': 'menu.xhs.humanSettings',
|
||
'AI 助手': 'menu.system.xhsAiAssistant',
|
||
'数据权限': 'menu.system.dataPerm',
|
||
'用户数据权限': 'menu.system.dataPermUsers',
|
||
'权限模板': 'menu.system.dataPermTemplates',
|
||
'权限审计': 'menu.system.dataPermAudit',
|
||
};
|
||
|
||
// 格式化菜单标题
|
||
const formatMenuTitle = (title?: string, path?: string) => {
|
||
if (!title) return '';
|
||
const routePath = path?.trim();
|
||
if (routePath && pathToLocaleKeyMap[routePath]) {
|
||
return t(pathToLocaleKeyMap[routePath]) || title;
|
||
}
|
||
// 如果是 locale key,直接翻译
|
||
if (title.startsWith('menu.')) {
|
||
return t(title) || title;
|
||
}
|
||
// 如果是中文菜单名称,查找对应的 locale key
|
||
if (menuNameToLocaleKeyMap[title]) {
|
||
return t(menuNameToLocaleKeyMap[title]) || title;
|
||
}
|
||
// 否则直接返回原文本
|
||
return title;
|
||
};
|
||
|
||
// 过滤页面 - 只显示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,
|
||
})
|
||
) {
|
||
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,
|
||
})
|
||
) {
|
||
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',
|
||
'/order/priceCheck': 'PriceCheck',
|
||
'/order/placeOrder': 'PlaceOrder',
|
||
'/order/myOrders': 'MyOrders',
|
||
'/order/batchJobs': 'OrderBatchJobs',
|
||
// 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',
|
||
};
|
||
|
||
// 跳转
|
||
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 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();
|
||
}
|
||
|
||
onMounted(async () => {
|
||
window.addEventListener(CUSTOMER_BALANCE_CHANGED_EVENT, handleCustomerBalanceChanged);
|
||
initHiprintClientState().catch(() => undefined);
|
||
// 加载菜单树
|
||
await loadMenuTree();
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener(CUSTOMER_BALANCE_CHANGED_EVENT, handleCustomerBalanceChanged);
|
||
});
|
||
</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;
|
||
padding: 0 16px;
|
||
flex-shrink: 0;
|
||
|
||
img {
|
||
height: 36px;
|
||
width: auto;
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.top-nav-menu {
|
||
height: @headerHeight;
|
||
line-height: @headerHeight;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
overflow-x: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
.top-nav-menu-item {
|
||
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: all 0.3s ease;
|
||
margin: 0 16px;
|
||
cursor: pointer;
|
||
|
||
&:hover,
|
||
&.active {
|
||
background-color: rgba(255, 255, 255, 0.2);
|
||
color: #fff;
|
||
}
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
|
||
.child-window {
|
||
width: 600px;
|
||
}
|
||
|
||
.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.2s ease, background-color 0.2s ease;
|
||
|
||
&:hover {
|
||
color: rgb(var(--primary-6));
|
||
background-color: var(--color-fill-2);
|
||
|
||
.child-menu-item__icon {
|
||
color: rgb(var(--primary-6));
|
||
}
|
||
}
|
||
|
||
&__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.2s 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);
|
||
}
|
||
}
|
||
</style>
|