Files
smart_grid_iot/client/src/layouts/index.vue
T

821 lines
21 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>
<a-layout class="admin-layout">
<!-- 侧边栏 -->
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible :width="240" :collapsed-width="80"
theme="dark" class="sider">
<div class="sider-inner">
<!-- Logo区域 -->
<div class="logo" @click="$router.push('/dashboard')">
<ThunderboltOutlined class="logo-img" />
<h1 v-show="!collapsed" class="logo-title">鑫潼智电</h1>
</div>
<!-- 菜单 -->
<a-menu v-model:selectedKeys="selectedKeys" v-model:openKeys="openKeys" mode="inline" theme="dark"
:items="menuItems" @click="handleMenuClick" />
<!-- 折叠按钮 -->
<div class="collapse-btn" @click="toggleCollapse">
<component :is="collapsed ? MenuUnfoldOutlined : MenuFoldOutlined" />
</div>
</div>
</a-layout-sider>
<a-layout>
<!-- 顶栏 -->
<a-layout-header class="header">
<div class="header-left">
<!-- 折叠触发器 -->
<div class="trigger" @click="toggleCollapse">
<component :is="collapsed ? MenuUnfoldOutlined : MenuFoldOutlined" />
</div>
<!-- 面包屑导航 -->
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item>
<HomeOutlined />
<span>首页</span>
</a-breadcrumb-item>
<template v-for="(item, index) in breadcrumbList" :key="index">
<a-breadcrumb-item v-if="item.path">
<router-link v-if="index < breadcrumbList.length - 1" :to="item.path">
{{ item.title }}
</router-link>
<span v-else>{{ item.title }}</span>
</a-breadcrumb-item>
</template>
</a-breadcrumb>
</div>
<div class="header-right">
<!-- 消息中心铃铛:未读数为 0 时角标自动隐藏 -->
<a-badge :count="unreadCount" :overflow-count="99" size="small">
<div class="notice-bell" title="消息中心" @click="openNoticeDrawer">
<BellOutlined />
</div>
</a-badge>
<!-- 用户信息下拉菜单 -->
<a-dropdown :trigger="['click']">
<a-space class="user-info">
<a-avatar :size="32" :style="{ backgroundColor: '#1890ff' }">
{{ userInitial }}
</a-avatar>
<span class="username">{{ displayName }}</span>
<DownOutlined />
</a-space>
<template #overlay>
<a-menu @click="handleUserMenuClick">
<a-menu-item key="profile" disabled>
<UserOutlined />
<span>个人中心</span>
</a-menu-item>
<a-menu-item key="password" disabled>
<LockOutlined />
<span>修改密码</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<LogoutOutlined />
<span style="color: #ff4d4f">退出登录</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
<!-- 主内容区域 -->
<a-layout-content class="content">
<router-view v-slot="{ Component, route: currentRoute }">
<transition name="fade-slide" mode="out-in">
<div :key="currentRoute.path" class="view-wrapper">
<component :is="Component" />
</div>
</transition>
</router-view>
</a-layout-content>
</a-layout>
<!-- 消息中心抽屉 -->
<a-drawer v-model:open="noticeVisible" title="消息中心" placement="right" :width="440">
<div class="notice-drawer">
<div class="notice-toolbar">
<span class="notice-summary">共 {{ noticeTotal }} 条通知,{{ unreadCount }} 条未读</span>
<a-button type="link" size="small" :disabled="!unreadCount" @click="handleReadAll">全部已读</a-button>
</div>
<a-spin :spinning="noticeLoading">
<div v-if="noticeList.length" class="notice-list">
<div
v-for="item in noticeList"
:key="item.id"
class="notice-item"
:class="{ unread: item.isRead === 0 }"
@click="handleNoticeClick(item)"
>
<div class="notice-item-head">
<span v-if="item.isRead === 0" class="notice-dot"></span>
<span class="notice-title">{{ item.title }}</span>
<span class="notice-time">{{ formatNoticeTime(item) }}</span>
</div>
<p class="notice-content">{{ item.content }}</p>
</div>
</div>
<a-empty v-else description="暂无通知" />
</a-spin>
</div>
</a-drawer>
<!-- 全局悬浮警灯(登录后所有页面共享,活跃严重报警提示) -->
<AlarmSirenLight />
</a-layout>
</template>
<script setup lang="ts">
import { ref, computed, watch, h, onMounted, onBeforeUnmount, type Component, type VNode } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import {
DashboardOutlined,
SettingOutlined,
UserOutlined,
SafetyCertificateOutlined,
MenuOutlined,
TeamOutlined,
ProjectOutlined,
ScheduleOutlined,
MonitorOutlined,
BarChartOutlined,
BookOutlined,
FileTextOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
HomeOutlined,
DownOutlined,
LogoutOutlined,
LockOutlined,
ThunderboltOutlined,
BellOutlined,
} from '@ant-design/icons-vue'
import { usePermissionStore, type MenuNode } from '@/stores/permission'
import { noticeApi, type NoticeInfo } from '@/api/notice'
import AlarmSirenLight from '@/components/AlarmSirenLight.vue'
const router = useRouter()
const route = useRoute()
const permissionStore = usePermissionStore()
// 状态管理
const collapsed = ref(false)
const selectedKeys = ref<string[]>([])
const openKeys = ref<string[]>([])
// 菜单图标映射:菜单 icon 字符串 → 图标组件,未知图标显示默认图标
const iconMap: Record<string, Component> = {
TeamOutlined,
ProjectOutlined,
ScheduleOutlined,
MonitorOutlined,
BarChartOutlined,
SettingOutlined,
UserOutlined,
SafetyCertificateOutlined,
MenuOutlined,
BookOutlined,
FileTextOutlined,
DashboardOutlined,
}
// iconfont 图标类名前缀(对应 assets/fonts/iconfont/iconfont.css 的 css_prefix_text)
const ICONFONT_PREFIX = 'xt-'
function resolveIcon(icon?: string | null) {
// icon 值形如 xt-xxx 时渲染 iconfont 图标,否则回退到 ant-design 图标
if (icon && icon.startsWith(ICONFONT_PREFIX)) {
return () => h('i', { class: `iconfont ${icon}` })
}
const iconComponent = icon ? iconMap[icon] : undefined
return () => h(iconComponent || MenuOutlined)
}
// 侧边栏菜单项结构(与 a-menu items 兼容)
interface MenuItem {
key: string
icon: () => VNode
label: string
children?: MenuItem[]
}
// 由后端菜单树构建侧边栏菜单:menuType=1 目录 → 子菜单组,menuType=2 菜单 → 菜单项
function buildMenuItems(nodes: MenuNode[]): MenuItem[] {
const items: MenuItem[] = []
for (const node of nodes) {
if (node.menuType === 1) {
const children = (node.children || [])
.filter(child => child.menuType === 2 && child.menuPath)
.map(child => ({
key: child.menuPath as string,
icon: resolveIcon(child.icon),
label: child.menuName,
}))
if (children.length) {
items.push({
key: node.menuPath || `dir-${node.id}`,
icon: resolveIcon(node.icon),
label: node.menuName,
children,
})
}
} else if (node.menuType === 2 && node.menuPath) {
items.push({
key: node.menuPath,
icon: resolveIcon(node.icon),
label: node.menuName,
})
}
}
return items
}
// 菜单配置:仪表盘固定展示在最前 + 后端菜单树动态渲染
const menuItems = computed<MenuItem[]>(() => [
{
key: '/dashboard',
icon: () => h(DashboardOutlined),
label: '仪表盘',
},
...buildMenuItems(permissionStore.menus),
])
// 子菜单路径 → 所属父级目录的菜单 key,用于按当前路由展开对应的父菜单
// (不能按路径首段推导:如“客户拜访”归属项目管理,但路径仍为 /customer/visit)
const parentKeyMap = computed<Record<string, string>>(() => {
const map: Record<string, string> = {}
for (const node of permissionStore.menus) {
if (node.menuType !== 1) continue
const dirKey = node.menuPath || `dir-${node.id}`
for (const child of node.children || []) {
if (child.menuPath) map[child.menuPath] = dirKey
}
}
return map
})
// 计算属性
const userInfo = computed(() => permissionStore.userInfo)
const displayName = computed(() => userInfo.value?.realName || userInfo.value?.username || '管理员')
const userInitial = computed(() => (displayName.value?.charAt(0) || 'A').toUpperCase())
// 面包屑列表
interface BreadcrumbItem {
title: string
path?: string
}
const breadcrumbList = computed<BreadcrumbItem[]>(() => {
const matched = route.matched.filter(item => item.meta && item.meta.title)
const items: BreadcrumbItem[] = []
for (const item of matched) {
// 详情/表单类页面路由与之平铺,需在 meta 中声明上级菜单以补齐缺失层级
if (item.meta?.parentTitle) {
items.push({
title: item.meta.parentTitle as string,
path: item.meta.parentPath as string | undefined,
})
}
items.push({
title: item.meta?.title as string,
path: item.path,
})
}
return items
})
// 监听路由变化,更新菜单选中状态
watch(
() => route.path,
(path) => {
selectedKeys.value = [path]
// 自动展开当前菜单所属的父级目录
const parentKey = parentKeyMap.value[path]
if (parentKey) {
openKeys.value = [parentKey]
}
},
{ immediate: true }
)
// 监听侧边栏折叠状态,折叠时关闭所有子菜单
watch(collapsed, (val) => {
if (val) {
openKeys.value = []
} else {
// 展开时根据当前路由自动打开对应的父菜单
const parentKey = parentKeyMap.value[route.path]
if (parentKey) {
openKeys.value = [parentKey]
}
}
})
// 方法
function toggleCollapse() {
collapsed.value = !collapsed.value
}
function handleMenuClick({ key }: { key: string }) {
router.push(key)
}
function handleUserMenuClick({ key }: { key: string }) {
if (key === 'logout') {
permissionStore.logout()
}
}
// ==================== 消息中心 ====================
const noticeVisible = ref(false)
const noticeLoading = ref(false)
const noticeList = ref<NoticeInfo[]>([])
const noticeTotal = ref(0)
const unreadCount = ref(0)
const NOTICE_POLL_INTERVAL = 60000
let noticeTimer: ReturnType<typeof setInterval> | null = null
// 未读数轮询(顶栏红点)
async function fetchUnreadCount() {
try {
const res = await noticeApi.getUnreadCount()
if (res.code === 200) unreadCount.value = res.data?.count || 0
} catch (error) {
// 未读数拉取失败不阻断主界面
}
}
async function fetchNoticeList() {
noticeLoading.value = true
try {
const res = await noticeApi.getList({ page: 1, pageSize: 20 })
if (res.code === 200 && res.data) {
noticeList.value = res.data.list || []
noticeTotal.value = res.data.total || 0
}
} catch (error) {
// 列表拉取失败不阻断抽屉
} finally {
noticeLoading.value = false
}
}
function openNoticeDrawer() {
noticeVisible.value = true
fetchNoticeList()
}
async function handleNoticeClick(item: NoticeInfo) {
if (item.isRead === 0) {
try {
await noticeApi.markRead(item.id);
item.isRead = 1;
fetchUnreadCount();
} catch (error) {
// 标记失败不阻断后续跳转
}
}
const target = resolveNoticeRoute(item);
if (target) {
noticeVisible.value = false;
router.push(target);
}
// 无映射关系时仅标记已读,不做跳转
}
// 通知类型 / 业务类型 → 目标路由映射(无匹配返回 null)
const NOTICE_TYPE_ROUTE: Record<string, string> = {
// 电工资质证照到期提醒
cert_expire: '/dispatch/worker',
// 电工入驻审核结果通知
worker_audit_result: '/dispatch/worker',
// 兼容旧数据
worker_cert: '/dispatch/worker',
};
// 派单通知按工单业务类型跳转对应派单页
const NOTICE_BIZ_ROUTE: Record<string, string> = {
repair_dispatch: '/dispatch/repair',
maintenance_order: '/dispatch/maintenance',
install_order: '/dispatch/install',
survey_order: '/dispatch/survey',
};
function resolveNoticeRoute(item: NoticeInfo): string | null {
const byType = NOTICE_TYPE_ROUTE[item.noticeType];
if (byType) return byType;
if (item.noticeType === 'dispatch_assign' && item.bizType) {
return NOTICE_BIZ_ROUTE[item.bizType] || null;
}
// 业务类型兜底
return item.bizType ? NOTICE_BIZ_ROUTE[item.bizType] || NOTICE_TYPE_ROUTE[item.bizType] || null : null;
}
async function handleReadAll() {
try {
const res = await noticeApi.readAll()
if (res.code === 200) {
message.success('已全部标记为已读')
noticeList.value = noticeList.value.map((n) => ({ ...n, isRead: 1 }))
unreadCount.value = 0
}
} catch (error) {
// 由请求拦截器统一提示
}
}
function formatNoticeTime(item: NoticeInfo) {
const time = item.createdAt || item.created_at
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : ''
}
onMounted(() => {
fetchUnreadCount()
noticeTimer = setInterval(fetchUnreadCount, NOTICE_POLL_INTERVAL)
})
onBeforeUnmount(() => {
if (noticeTimer) {
clearInterval(noticeTimer)
noticeTimer = null
}
})
</script>
<style scoped lang="less">
.admin-layout {
min-height: 100vh;
.sider {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
:deep(.ant-layout-sider-children) {
height: 100%;
}
.sider-inner {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.logo {
flex-shrink: 0;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s;
overflow: hidden;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
.logo-img {
font-size: 32px;
color: #1890ff;
filter: drop-shadow(0 2px 4px rgba(24, 144, 255, 0.3));
flex-shrink: 0;
}
.logo-title {
color: #fff;
font-size: 18px;
font-weight: 600;
margin: 0 0 0 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 1px;
}
}
:deep(.ant-menu) {
border-right: none;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
// iconfont 图标统一:该图标集为彩色字体,颜色写死在字形里,无法用 color 覆盖,
// 这里用 filter 统一压成单色,并按菜单状态在灰 / 白之间切换,与 ant-design 图标观感保持一致
// 尺寸统一:ant-design 菜单图标为 14px,而 iconfont.css 自带 font-size: 16px,
// 故此处显式对齐为 14px,并重置 line-height,避免字体图标在 antd 的 line-height: 0 下出现基线偏移
:deep(.ant-menu) .iconfont {
display: inline-block;
font-size: 14px;
line-height: 1;
vertical-align: -0.125em;
filter: brightness(0) invert(0.65);
}
// 折叠态与 ant-design 图标同步放大到 16px
:deep(.ant-menu-inline-collapsed) .iconfont {
font-size: 16px;
}
:deep(.ant-menu-item:hover) .iconfont,
:deep(.ant-menu-item-selected) .iconfont,
:deep(.ant-menu-submenu-title:hover) .iconfont,
:deep(.ant-menu-submenu-selected) .iconfont {
filter: brightness(0) invert(1);
}
.collapse-btn {
flex-shrink: 0;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.65);
font-size: 18px;
transition: all 0.3s;
&:hover {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
}
}
>.ant-layout {
margin-left: 240px;
transition: margin-left 0.2s;
&.ant-layout-has-sider+& {
margin-left: 80px;
}
}
// 当侧边栏折叠时的样式调整
.ant-layout-sider-collapsed~.ant-layout {
margin-left: 80px !important;
}
.header {
position: sticky;
top: 0;
z-index: 99;
background: #fff;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
height: 64px;
line-height: 64px;
.header-left {
display: flex;
align-items: center;
gap: 16px;
.trigger {
font-size: 18px;
padding: 0 12px;
cursor: pointer;
color: #333;
transition: all 0.3s;
border-radius: 4px;
&:hover {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
}
.breadcrumb {
font-size: 14px;
:deep(.anticon) {
margin-right: 4px;
font-size: 12px;
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 18px;
.notice-bell {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
font-size: 18px;
color: #424655;
cursor: pointer;
transition: all 0.3s;
&:hover {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
}
.user-info {
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.3s;
&:hover {
background: rgba(0, 0, 0, 0.04);
color: #1890ff;
}
.username {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
}
}
}
.content {
margin: 0;
height: calc(100vh - 64px);
overflow: hidden;
.view-wrapper {
background: #fff;
padding: 0;
border-radius: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: none;
}
}
}
// 页面切换过渡动画
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.25s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(10px);
}
// 响应式设计
@media screen and (max-width: 768px) {
.admin-layout {
.ant-layout-sider {
position: fixed !important;
z-index: 101;
}
>.ant-layout {
margin-left: 80px !important;
}
.header {
padding: 0 12px;
.breadcrumb {
display: none; // 小屏幕隐藏面包屑
}
.username {
display: none; // 小屏幕隐藏用户名
}
}
.content {
margin: 0;
.view-wrapper {
padding: 0;
border-radius: 0;
}
}
}
}
// ===== 消息中心抽屉(抽屉内容被 teleport 到 body,样式不可嵌套在 .admin-layout 内) =====
.notice-drawer {
display: flex;
flex-direction: column;
gap: 12px;
.notice-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #eef1f6;
.notice-summary {
font-size: 12px;
color: #5b6b7e;
}
}
.notice-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.notice-item {
padding: 12px 14px;
border: 1px solid #eef1f6;
border-radius: 10px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #dbe4ee;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
}
&.unread {
background: #f4f7ff;
border-color: #d6e4ff;
}
.notice-item-head {
display: flex;
align-items: center;
gap: 8px;
}
.notice-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ff4d4f;
flex-shrink: 0;
}
.notice-title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: #0d1c2c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-time {
font-size: 11px;
color: #8a97a8;
flex-shrink: 0;
font-family: Consolas, Menlo, monospace;
}
.notice-content {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.7;
color: #5b6b7e;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
</style>