front/src/components/top-nav/index.vue
2026-03-02 09:30:14 +08:00

851 lines
26 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="/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="cursor-pointer hover:text-blue-600 text-gray-800"
@click="() => goto(menuItem)"
>
{{ formatMenuTitle(menuItem.name || menuItem.meta?.title) }}
</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-popover placement="bottomLeft" :mouseEnterDelay="0.3">
<template #content>
<div class="contact-content p-3" style="width: 280px;">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-800">{{ t('nav.contactService.title') }}</h3>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('nav.contactService.selectWarehouse') }}</label>
<a-select
v-model:value="serviceWarehouseSelect"
:options="serviceWarehouseSelectData"
style="width: 100%"
:getPopupContainer="trigger => trigger?.parentElement || document.body"
></a-select>
</div>
<div class="space-y-4">
<div class="warehouse-detail">
<div class="mb-3">
<h4 class="text-sm font-medium text-gray-500 mb-1">{{ t('nav.contactService.whseName') }}</h4>
<p class="text-gray-800 font-medium">{{ serviceIsWarehouse.whseName || '--' }}</p>
</div>
<div class="mb-3" v-if="serviceIsWarehouse.serviceWechat">
<h4 class="text-sm font-medium text-gray-500 mb-2">{{ t('nav.contactService.wechatQrcode') }}</h4>
<div class="border-2 border-dashed rounded-xl mx-auto p-2 flex items-center justify-center">
<img :src="MixinTransitionUrl(serviceIsWarehouse.serviceWechat)" width="128" height="128" alt="微信二维码" />
</div>
</div>
<div class="mb-3" v-if="serviceIsWarehouse.servicePhone">
<h4 class="text-sm font-medium text-gray-500 mb-1">{{ t('nav.contactService.phone') }}</h4>
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-500 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span class="text-gray-800 font-medium">{{ serviceIsWarehouse.servicePhone }}</span>
</div>
</div>
</div>
</div>
<div class="text-xs text-gray-500 mt-4 pt-4 border-t border-gray-200">
<p>{{ t('nav.contactService.workingTime') }}</p>
</div>
</div>
</template>
<a class="service-button-trigger" @click="e => e.preventDefault()">
<i class="fa fa-weixin text-green-500" aria-hidden="true"></i>
<span>{{ t('nav.contactService.contactUs') }}</span>
</a>
</a-popover>
</div>
<!-- 余额 -->
<div 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>
</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, watch, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAppStore, useUserStore } 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 { openWindow, regexUrl } from '@/utils';
import { MenuService, AuthService } from '@/api/rbac';
import type { MenuTreeDto } from '@/api/rbac';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const userStore = useUserStore();
const rbacUserStore = useRbacUserStore();
const topMenuCon = ref<HTMLElement | null>(null);
const topNavEnable = computed(() => true); // 启用顶部导航
const tabNavEnable = computed(() => appStore.tabBar);
// 菜单数据 - 全量菜单树和用户菜单权限
const allMenuTreeData = ref<MenuTreeDto[]>([]);
const menuLoading = ref(false);
const menuDataReady = ref(false);
// 使用 store 中的用户菜单权限(computed,自动响应式)
const userMenuTreeData = computed(() => rbacUserStore.menus || []);
// 根据用户菜单权限过滤菜单树
function filterMenuByUserPermission(allMenus: MenuTreeDto[], userMenus: MenuTreeDto[]): MenuTreeDto[] {
// 创建用户菜单ID集合,用于快速查找
const userMenuIdSet = new Set<string>();
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 filterMenus(menus: MenuTreeDto[]): MenuTreeDto[] {
const result: MenuTreeDto[] = [];
for (const menu of menus) {
const menuId = menu.id ? String(menu.id) : null;
const hasPermission = menuId && userMenuIdSet.has(menuId);
// 先处理子菜单
let filteredChildren: MenuTreeDto[] | null = null;
if (menu.children && menu.children.length > 0) {
filteredChildren = filterMenus(menu.children);
}
// 如果菜单本身有权限,或者子菜单有权限,则保留
if (hasPermission || (filteredChildren && filteredChildren.length > 0)) {
const filteredMenu: MenuTreeDto = {
...menu,
children: filteredChildren && filteredChildren.length > 0 ? filteredChildren : null,
};
result.push(filteredMenu);
}
}
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 key 的映射表
const menuNameToLocaleKeyMap: Record<string, string> = {
'首页': 'menu.home',
'客户': 'menu.user',
'客户管理': 'menu.user.customer',
'个人中心': 'menu.user.profile',
'资金充值': 'menu.user.recharge',
'充值记录': 'menu.user.rechargeRecord',
'订单': 'menu.order',
'我要查价': 'menu.order.priceCheck',
'我要下单': 'menu.order.placeOrder',
'我的订单': 'menu.order.myOrders',
'渠道': 'menu.channel',
'销售产品': 'menu.channel.salesProducts',
'服务渠道': 'menu.channel.serviceChannels',
'黑名单区': 'menu.channel.blacklist',
'偏远分区': 'menu.channel.remoteZones',
'计重规则': 'menu.channel.weightRules',
'计重进位': 'menu.channel.weightRounding',
'自动触发': 'menu.channel.autoTrigger',
'轨迹': 'menu.track',
'轨迹订单': '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',
};
// 格式化菜单标题
const formatMenuTitle = (title?: string) => {
if (!title) return '';
// 如果是 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;
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; // 过滤按钮
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/blacklist': 'Blacklist',
'/channel/remoteZones': 'RemoteZones',
'/channel/weightRules': 'WeightRules',
'/channel/weightRounding': 'WeightRounding',
'/channel/autoTrigger': 'AutoTrigger',
// Track 相关路由
'/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',
};
// 跳转
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);
});
}
}
});
};
// 联系客服相关数据
interface ServiceWarehouseItem {
whseName: string;
serviceWechat: string;
servicePhone: string;
}
const serviceAddressData = ref<ServiceWarehouseItem[]>([]);
const serviceWarehouseSelectData = ref<Array<{label: string; value: string}>>([]);
const serviceWarehouseSelect = ref<string | null>(null);
// 加载联系客服仓库列表
const loadServiceDataList = async () => {
try {
// TODO: 替换为实际的 API 调用
// const res = await ApiWhseAddress.serviceList();
// if (res.code == 0) {
// serviceAddressData.value = res.data;
// serviceWarehouseSelectData.value = serviceAddressData.value.map(item => ({
// label: item.whseName,
// value: item.whseName
// }));
// serviceWarehouseSelect.value = serviceWarehouseSelectData.value[0]?.value || null;
// }
} catch (error) {
console.error('Failed to load service data', error);
}
};
// 当前选中的联系客服仓库
const serviceIsWarehouse = computed(() => {
if (!serviceWarehouseSelect.value) return {} as ServiceWarehouseItem;
return serviceAddressData.value.find(item => item.whseName == serviceWarehouseSelect.value) || {} as ServiceWarehouseItem;
});
// 图片URL转换方法
const MixinTransitionUrl = (url: string) => {
if (!url) return '';
if (url.indexOf("//") == -1) {
if (url.indexOf("QiNiuYunFile/") == -1) {
return `/api/upload/${url}`;
}
return `/${url}`;
}
return url;
};
// 余额显示
const balanceDisplay = computed(() => {
// TODO: 从 userStore 或 API 获取余额
const balance = (userStore as any).balance;
if (balance === null || balance === undefined) {
return '--';
}
const parsed = Number(balance);
return Number.isFinite(parsed) ? parsed.toFixed(2) : String(balance);
});
// 点击余额菜单项
const onBalanceMenuClick = (event: any) => {
const { key } = event;
if (key === 'recharge') {
router.push({ name: 'VipRechargeDeposit' });
} else if (key === 'rechargeRecord') {
router.push({ name: 'VipRechargeRechargeRecord' });
}
};
onMounted(async () => {
loadServiceDataList();
// 加载菜单树
await loadMenuTree();
});
</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: 15px;
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);
}
}
.service-button-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;
&:hover {
opacity: 0.8;
}
i {
font-size: 18px;
margin-right: 4px;
}
span {
font-size: 14px;
}
}
.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: var(--app-primary-color);
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: 1rem;
&:last-child {
border-bottom: none;
margin-bottom: 0;
}
}
// 滚动条样式
.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>