Files
xmc-Assets/web/src/components/Sidebar.vue
caopeng a254aae503 feat(web): 引入 Vite 前端应用并扩展仓库忽略规则
将整套 web 源码纳入仓库,并为 web/node_modules、构建产物及本地环境文件配置 .gitignore,同时移除占位用的 assets/.gitkeep。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 15:22:29 +08:00

534 lines
15 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>
<aside class="sidebar" :class="{ collapsed: isCollapsed }">
<div class="sider_top">
<!-- <div class="img_box">
<img :src="isCollapsed ? foldLogo : siderTopLogo" alt="">
</div> -->
<!-- GENUINE OPTICS (THAILAND) -->
</div>
<ul>
<li v-for="(item, index) in allMenuItems" :key="item.groupIndex" class="menu-group">
<!-- 父级菜单项 -->
<div class="menu-parent" :class="{ expanded: item.expanded, 'has-active-child': hasActiveChild(item), 'parent-active': isParentActive(item, item.groupIndex) }"
@click="toggleMenu(item.groupIndex)">
<div class="menu-parent-inner">
<img v-if="item.iconImg" :src="item.iconImg" class="menu-icon-img" :alt="item.label" />
<i v-else class="pi" :class="item.icon"></i>
<span v-if="!isCollapsed">{{ item.label }}</span>
</div>
<i v-if="!isCollapsed" class="pi pi-chevron-down caret" :class="{ expanded: item.expanded }"></i>
</div>
<!-- 子级菜单项 -->
<ul v-if="!isCollapsed && item.expanded && item.children" class="sub-menu">
<li v-for="(child, childIndex) in item.children" :key="childIndex" @click="goPage(child.name)"
:class="{ active: currentName === child.name }" class="sub-menu-item">
<div class="sub-menu-inner">
<img v-if="child.iconImg" :src="child.iconImg" class="sub-menu-icon-img" alt="" />
<i v-else class="dot"></i>
<span>{{ child.label }}</span>
</div>
<div v-if="currentName === child.name" class="active-indicator"></div>
</li>
</ul>
</li>
</ul>
<div class="sidebar-footer">
<div class="watermark">
<img :src="isCollapsed ? foldCompanyLogo : companyLogo" alt="">
</div>
</div>
</aside>
</template>
<script setup>
import { getTimesheetRouteName } from "../configs/common";
import { ref, watch, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { useUiStore } from "../store/ui";
import systemIcon from "../images/sider/system.png";
import assetsIcon from "../images/sider/assets.png";
import meteringIcon from "../images/sider/metering.png";
import operationLogIcon from "../images/sider/log.svg";
import overviewDashboardIcon from "../images/sider/overview.svg";
import siderTopLogo from "../images/sider/newLogo.svg";
import foldLogo from "../images/sider/newLogo.svg";
import companyLogo from "../images/logo.png";
import foldCompanyLogo from "../images/sider/foldCompanyLoago.svg";
import { storeToRefs } from "pinia";
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const uiStore = useUiStore();
const { isSidebarCollapsed: isCollapsed } = storeToRefs(uiStore);
// 菜单展开状态 - 与 fullMenuConfig 下标一致0 概览看板、1 资产管理、2 计量管理、3 系统管理、4 操作日志
const menuExpandedStates = ref({
0: true, // 概览看板默认展开(与默认首页一致)
1: false,
2: false,
3: false,
4: false,
});
// 当前“选中”的父级索引(仅此父级显示蓝色,其他保持灰色)
const lastClickedParentIndex = ref(0);
// 所有菜单项配置(路由 name 与 Vue Router 一致)
const fullMenuConfig = [
{
// 概览看板
labelKey: "menu.overviewDashboard",
icon: "pi-chart-line",
iconImg: overviewDashboardIcon,
isAsset: false,
children: [
{ labelKey: "menu.overviewDashboard", name: "OverviewDashboard", icon: "pi-chart-line" },
// { labelKey: "role.pagePermission.equipmentRepairReport", name: "EquipmentRepairReport", icon: "pi-file" },
// { labelKey: "role.pagePermission.equipmentAcceptanceReport", name: "EquipmentAcceptanceReport", icon: "pi-file" },
{ labelKey: "role.pagePermission.equipmentMaintenanceReport", name: "EquipmentMaintenanceReport", icon: "pi-file" },
{ labelKey: "role.pagePermission.equipmentMeteringReport", name: "EquipmentMeteringReport", icon: "pi-file" },
],
},
{
// 资产管理
labelKey: "menu.assetManagement",
icon: "pi-database",
iconImg: assetsIcon,
isAsset: true,
children: [
{ labelKey: "menu.assetList", name: "AssetList", icon: "pi-box" },
{ labelKey: "menu.assetInventory", name: "AssetInventory", icon: "pi-list" },
{ labelKey: "menu.deviceMaintain", name: "DeviceMaintain", icon: "pi-wrench" },
{ labelKey: "menu.deviceRepair", name: "DeviceRepair", icon: "pi-box" },
{ labelKey: "menu.assetAlarmRecord", name: "AssetAlarmRecord", icon: "pi-bell" },
{ labelKey: "menu.accessControlManage", name: "AccessControlManage", icon: "pi-lock" },
{ labelKey: "menu.attachmentManage", name: "AttachmentManage", icon: "pi-paperclip" },
],
},
{
// 计量管理
labelKey: "menu.measurementManagement",
icon: "pi-database",
iconImg: meteringIcon,
isAsset: true,
children: [
{ labelKey: "menu.deviceMetering", name: "DeviceMetering", icon: "pi-paperclip" },
{ labelKey: "menu.measureMaterialManage", name: "MeasureMaterialManage", icon: "pi-box" },
],
},
{
// 系统管理
labelKey: "menu.systemManagement",
icon: "pi-cog",
iconImg: systemIcon,
isAsset: false,
children: [
{ labelKey: "menu.userManagement", name: "UserManage", icon: "pi-users" },
{ labelKey: "menu.roleManagement", name: "RoleManage", icon: "pi-shield" },
{ labelKey: "menu.employeeManagement", name: "EmployeeManage", icon: "pi-user" },
{ labelKey: "menu.factoryManagement", name: "FactoryManage", icon: "pi-building" },
{ labelKey: "menu.sensorManagement", name: "SensorManage", icon: "pi-microchip" },
],
},
{
// 操作日志(子项:操作日志页)
labelKey: "menu.operationLogParent",
icon: "pi-history",
iconImg: operationLogIcon,
isAsset: false,
children: [
{ labelKey: "menu.operationLog", name: "OperationLog", icon: "pi-history" },
],
},
];
// 左侧菜单始终展示全部配置项(不再按接口 permission_codes 过滤)
const allMenuItems = computed(() => {
const expanded = menuExpandedStates.value;
return fullMenuConfig.map((group, groupIndex) => {
const visibleChildren = group.children || [];
return {
groupIndex,
label: t(group.labelKey),
icon: group.icon,
iconImg: group.iconImg,
expanded: expanded[groupIndex],
isAsset: group.isAsset,
children: visibleChildren.map((child) => ({
label: t(child.labelKey),
name: getTimesheetRouteName(child.name),
icon: child.icon,
iconImg: child.iconImg,
})),
};
});
});
// 获取当前可见菜单的所有路由名称
const getAllMenuRouteNames = () => {
const routeNames = [];
allMenuItems.value.forEach((item) => {
if (item.children) {
item.children.forEach((child) => {
if (child.name) routeNames.push(child.name);
});
}
});
return routeNames;
};
// 默认路由名称(概览看板);当前路由不在菜单内时的回退路由
const defaultRouteName = getTimesheetRouteName("OverviewDashboard");
// 计算当前选中的路由名称
const currentName = computed(() => {
const routeName = route.name;
const menuRouteNames = getAllMenuRouteNames();
// 如果当前路由不在菜单项中,或者路由为空/未定义,则默认选中概览看板
if (!routeName || !menuRouteNames.includes(routeName)) {
return defaultRouteName;
}
return routeName;
});
// 判断该父菜单下是否有当前激活的子项(用于父级标题变色)
const hasActiveChild = (item) => {
if (!item.children) return false;
return item.children.some((child) => child.name === currentName.value);
};
// 当前父级是否为“选中”状态(仅一个父级为 true用于蓝色高亮
const isParentActive = (item, groupIndex) => {
return groupIndex === lastClickedParentIndex.value && (hasActiveChild(item) || item.expanded);
};
const toggleMenu = (index) => {
if (isCollapsed.value) {
const targetGroup = allMenuItems.value.find((item) => item.groupIndex === index);
if (targetGroup?.children?.length) {
goPage(targetGroup.children[0].name);
}
lastClickedParentIndex.value = index;
return;
}
menuExpandedStates.value[index] = !menuExpandedStates.value[index];
lastClickedParentIndex.value = index;
};
const goPage = (name) => {
if (name) {
router.push({ name });
}
};
const handleLogout = () => {
// 清除本地存储的 token 和 uid
localStorage.removeItem("token");
localStorage.removeItem("uid");
// 跳转到登录页
router.push({ name: getTimesheetRouteName("Login") });
};
watch(
() => route.name,
(newName) => {
const menuRouteNames = getAllMenuRouteNames();
if (newName && !menuRouteNames.includes(newName)) {
router.push({ name: defaultRouteName });
}
// 手风琴:只展开当前路由所在分组,其余分组折叠
const newExpanded = { 0: false, 1: false, 2: false, 3: false, 4: false };
allMenuItems.value.forEach((item) => {
if (item.children && item.children.some((child) => child.name === newName)) {
newExpanded[item.groupIndex] = true;
lastClickedParentIndex.value = item.groupIndex;
}
});
menuExpandedStates.value = newExpanded;
},
{ immediate: true }
);
</script>
<style scoped>
.sidebar {
background: #FFFFFF;
box-shadow: 2px 0px 15px 0px rgba(0, 69, 143, 0.05);
border-radius: 0px 0px 0px 0px;
padding: 1rem 0;
width: 200px;
min-width: 200px;
max-width: 200px;
padding: 0 12px;
position: relative;
padding-bottom: 4rem;
flex-shrink: 0;
.sider_top {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
margin-bottom: 25px;
.img_box {
width: 140px;
height: 26px;
img {
width: 100%;
height: 100%;
}
}
}
}
.sidebar.collapsed {
width: 72px;
min-width: 72px;
max-width: 72px;
padding: 0 8px;
.sider_top .img_box {
width: 44px;
height: 26px;
}
}
.sidebar>ul {
margin: 0;
padding: 0;
list-style: none;
}
.menu-group {
list-style: none;
margin: 0;
padding: 0;
}
/* 父级菜单样式 */
.menu-parent {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.7rem 0.6rem;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.sidebar.collapsed .menu-parent {
justify-content: center;
padding: 0.7rem 0;
}
.menu-parent-inner {
display: flex;
align-items: center;
gap: 0.5rem;
/* flex: 1; */
min-width: 0;
overflow: hidden;
}
.sidebar.collapsed .menu-parent-inner {
justify-content: center;
}
.menu-parent .pi {
font-size: 1rem;
transition: color 0.2s;
}
.menu-parent .menu-icon-img {
width: 1rem;
height: 1rem;
object-fit: contain;
flex-shrink: 0;
}
/* 父级菜单默认正常色(灰) */
.menu-parent .pi-cog,
.menu-parent .pi-database,
.menu-parent .pi-ruler,
.menu-parent .pi-chart-line,
.menu-parent .pi-history {
color: #6b7280;
}
.menu-parent span {
color: #6b7280;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
transition: color 0.2s;
}
/* 仅当前“选中”的父级parent-active显示主题色其他父级保持灰色 */
.menu-parent.parent-active .pi,
.menu-parent.parent-active span {
color: #3067E5;
}
.caret {
margin-top: 3px;
font-size: 0.75rem;
transition: transform 0.2s, color 0.2s;
}
.menu-parent .caret {
color: #6b7280;
scale: .75;
}
.menu-parent.parent-active .caret {
color: #3067E5;
}
.caret.expanded {
transform: rotate(180deg);
}
/* 子菜单容器 */
.sub-menu {
margin: 0;
padding: 0;
list-style: none;
background: #fff;
}
/* 子菜单项样式 */
.sub-menu-item {
display: flex;
align-items: center;
list-style: none;
position: relative;
cursor: pointer;
transition: background 0.2s;
height: 36px;
}
.sub-menu-item:hover {
background: #EFF4FF;
border-radius: 4px 4px 4px 4px;
}
/* .sub-menu-item.active {
background: #fff5f0;
} */
.sub-menu-inner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem 0.75rem 3rem;
min-width: 0;
overflow: hidden;
}
.sub-menu-item .pi {
font-size: 0.875rem;
transition: color 0.2s;
}
.sub-menu-item:not(.active) .pi {
color: #6b7280;
}
.sub-menu-item:not(.active) span {
color: #6b7280;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* flex: 1; */
min-width: 0;
}
.sub-menu-item.active .pi {
color: #3067E5;
}
.sub-menu-item.active span {
color: #3067E5;
font-size: 0.875rem;
/* white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; */
}
/* 激活指示器(右侧橙色竖条) */
.active-indicators {
display: block;
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 3px;
background: #3067E5;
}
.sidebar-footer {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.logout-btn {
width: 100%;
justify-content: center;
color: #6b7280;
font-size: 0.875rem;
}
.logout-btn:hover {
color: #3067E5;
background-color: #fff5f0;
}
.watermark {
text-align: center;
width: 110px;
height: 20px;
img{
width: 100%;
height: 100%;
}
}
.sidebar.collapsed .watermark {
width: 36px;
height: 20px;
}
.dot {
width: 5px;
height: 5px;
background: #3067E5;
border-radius: 5px;
}
.sub-menu-icon-img {
width: 14px;
height: 14px;
object-fit: contain;
flex-shrink: 0;
}
</style>