yeg
6 天以前 3721379cf11a47d36dab3c37f20766ab007cc3cd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCurrentUser, logout } from '@/utils/auth'
 
const props = defineProps<{ open: boolean; collapsed: boolean }>()
const emit = defineEmits<{ close: [] }>()
 
const route = useRoute()
const router = useRouter()
const currentUser = getCurrentUser()
 
const menuGroups = [
  { title: '首页', icon: 'HomeFilled', path: '/dashboard' },
  { title: '测试管理', icon: 'Document', children: [
    { path: '/test-list', title: '测试列表' }, { path: '/battery-group', title: '电池组管理' },
    { path: '/test-type', title: '测试类型管理' }, { path: '/data-revision', title: '数据修订记录' },
    { path: '/exception', title: '异常管理' },
  ]},
  { title: '设备管理', icon: 'SetUp', children: [
    { path: '/device-cabinet', title: '检测柜' }, { path: '/device-collector', title: '采集器' },
  ]},
  { title: '数据分析', icon: 'TrendCharts', children: [
    { path: '/report-compare', title: '对比报表' }, { path: '/report-formation', title: '化成报表' },
    { path: '/report-splice', title: '拼接报表' },
  ]},
  { title: '待办管理', icon: 'List', children: [{ path: '/todo-list', title: '待办列表' }] },
  { title: '角色与权限', icon: 'UserFilled', children: [{ path: '/account', title: '账号管理' }, { path: '/role', title: '角色管理' }, { path: '/operation-log', title: '操作日志' }] },
]
 
const activePath = computed(() => route.path)
const expandedGroups = ref(new Set(menuGroups.filter(group => group.children).map(group => group.title)))
 
function isPathActive(path: string) {
  return activePath.value === path || activePath.value.startsWith(`${path}/`)
}
 
function isGroupActive(group: (typeof menuGroups)[number]) {
  return group.children?.some(child => isPathActive(child.path)) ?? false
}
 
function toggleGroup(title: string) {
  if (props.collapsed) return
 
  const next = new Set(expandedGroups.value)
  next.has(title) ? next.delete(title) : next.add(title)
  expandedGroups.value = next
}
 
watch(activePath, () => {
  const activeGroup = menuGroups.find(group => isGroupActive(group))
  if (activeGroup && !expandedGroups.value.has(activeGroup.title)) {
    expandedGroups.value = new Set(expandedGroups.value).add(activeGroup.title)
  }
})
 
async function navigate(path: string) {
  await router.push(path)
  emit('close')
}
 
async function handleLogout() {
  try {
    await ElMessageBox.confirm('退出后需要重新登录,是否继续?', '退出登录', {
      confirmButtonText: '退出',
      cancelButtonText: '取消',
      type: 'warning',
    })
    await logout()
    ElMessage.success('已安全退出')
    await router.replace('/login')
  } catch {
    // 用户取消退出
  }
}
</script>
 
<template>
  <div v-if="open" class="sidebar-scrim" @click="$emit('close')"></div>
  <aside class="sidebar" :class="{ open, collapsed }">
    <div class="brand">
      <div class="brand-mark"><el-icon><Monitor /></el-icon></div>
      <div><strong>检测室数据可视化</strong><span>OPERATIONS CENTER</span></div>
      <button type="button" class="close-menu" aria-label="关闭导航" @click="$emit('close')"><el-icon><Close /></el-icon></button>
    </div>
 
    <section class="environment">
      <div><span>当前账号</span><strong>{{ currentUser?.username || '—' }}</strong></div>
    </section>
 
    <nav class="navigation">
      <template v-for="group in menuGroups" :key="group.title">
        <el-tooltip v-if="group.path" :content="group.title" placement="right" :disabled="!collapsed">
          <button
            type="button"
            class="nav-parent"
            :class="{ active: activePath === group.path }"
            @click="navigate(group.path)"
          >
            <el-icon><component :is="group.icon" /></el-icon><span>{{ group.title }}</span>
          </button>
        </el-tooltip>
        <div v-else class="nav-group">
          <el-tooltip :content="group.title" placement="right" :disabled="!collapsed">
            <button
              type="button"
              class="nav-parent group-toggle"
              :class="{ active: isGroupActive(group), expanded: expandedGroups.has(group.title) }"
              :aria-expanded="expandedGroups.has(group.title) ? 'true' : 'false'"
              :data-expanded="expandedGroups.has(group.title)"
              :aria-label="`${expandedGroups.has(group.title) ? '收起' : '展开'}${group.title}`"
              @click="toggleGroup(group.title)"
            >
              <el-icon><component :is="group.icon" /></el-icon>
              <span>{{ group.title }}</span>
              <el-icon class="group-arrow"><ArrowRight /></el-icon>
            </button>
          </el-tooltip>
          <div
            class="nav-children"
            :class="{ expanded: expandedGroups.has(group.title) }"
            :aria-hidden="!expandedGroups.has(group.title)"
            :inert="!expandedGroups.has(group.title)"
          >
            <div class="nav-children-inner">
              <button
                v-for="child in group.children"
                :key="child.path"
                type="button"
                class="nav-child"
                :class="{ active: isPathActive(child.path) }"
                @click="navigate(child.path)"
              >{{ child.title }}</button>
            </div>
          </div>
        </div>
      </template>
    </nav>
 
    <button class="sidebar-account" type="button" aria-label="退出当前账号" @click="handleLogout">
      <span class="avatar">{{ currentUser?.displayName.slice(0, 1) || 'A' }}</span>
      <div><strong>{{ currentUser?.displayName || 'Admin' }}</strong><small>{{ currentUser?.role || '—' }}</small></div>
    </button>
  </aside>
</template>
 
<style scoped>
.sidebar {
  position: fixed;
  inset: 0 auto 0 0;
  z-index: 100;
  width: var(--sidebar-width);
  display: flex;
  flex-direction: column;
  color: #8fa7bf;
  background: var(--navy);
  border-right: 1px solid #10335f;
  transition: width .22s ease, transform .22s ease;
}
.brand { height: 72px; flex: 0 0 72px; display: flex; align-items: center; gap: 12px; padding: 0 18px; border-bottom: 1px solid #10335f; }
.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; color: #fff; background: var(--blue); }
.brand-mark .el-icon { font-size: 17px; }
.brand strong, .brand span { display: block; }
.brand strong { color: #fff; font-size: 14px; font-weight: 600; line-height: 1.3; }
.brand span { margin-top: 3px; color: #5f7d9a; font-size: 10px; letter-spacing: .8px; }
.close-menu { display: none; margin-left: auto; border: 0; background: none; color: #8fa7bf; font-size: 18px; }
.environment { height: 70px; flex: 0 0 70px; display: flex; justify-content: center; flex-direction: column; gap: 7px; padding: 0 18px; border-bottom: 1px solid #10335f; }
.environment div { display: flex; justify-content: space-between; font-size: 12px; }
.environment strong { color: #fff; font-weight: 500; }
.environment p { display: flex; align-items: center; gap: 7px; margin: 0; color: #42c68a; font-size: 11px; }
.environment i { width: 6px; height: 6px; border-radius: 50%; background: #42c68a; box-shadow: 0 0 0 3px rgba(66,198,138,.12); }
.navigation { flex: 1; min-height: 0; padding: 14px 10px 24px; overflow-x: hidden; overflow-y: auto; scrollbar-gutter: stable; }
.nav-parent, .nav-child { width: 100%; border: 0; color: #8fa7bf; background: transparent; cursor: pointer; font-family: inherit; text-align: left; }
.nav-parent { height: 38px; display: flex; align-items: center; gap: 10px; padding: 0 9px; font-size: 15px; border-left: 3px solid transparent; }
.nav-parent .el-icon { font-size: 16px; }
.nav-parent:hover, .nav-parent.active { color: #fff; background: #176fcb; border-left-color: #2188f5; }
.group-toggle { color: #a9bbcd; }
.group-arrow { margin-left: auto; font-size: 13px !important; transition: transform .18s ease; }
.group-toggle.expanded .group-arrow { transform: rotate(90deg); }
.nav-children { max-height: 0; overflow: hidden; opacity: 0; transition: max-height .22s ease, opacity .16s ease; }
.nav-children.expanded { max-height: 420px; opacity: 1; }
.nav-children-inner { overflow: visible; }
.nav-child { height: 33px; padding: 0 9px 0 42px; font-size: 14px; border-left: 3px solid transparent; }
.nav-child:hover, .nav-child.active { color: #fff; background: rgba(33,136,245,.13); border-left-color: #2188f5; }
.nav-group { margin-top: 3px; }
.sidebar-account { width: 100%; height: 62px; flex: 0 0 62px; display: flex; align-items: center; gap: 10px; padding: 0 18px; border: 0; border-top: 1px solid #10335f; color: inherit; background: transparent; font-family: inherit; text-align: left; cursor: pointer; }
.sidebar-account:hover { background: rgba(33,136,245,.08); }
.avatar { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid var(--blue); color: var(--blue); font-size: 13px; font-weight: 700; }
.sidebar-account strong, .sidebar-account small { display: block; }
.sidebar-account strong { color: #fff; font-size: 13px; font-weight: 500; }
.sidebar-account small { margin-top: 2px; color: #5f7d9a; font-size: 11px; }
.sidebar-scrim { display: none; }
 
@media (max-width: 900px) {
  .sidebar { width: var(--sidebar-width); transform: translateX(-100%); box-shadow: 12px 0 35px rgba(0,20,45,.22); }
  .sidebar.open { transform: translateX(0); }
  .sidebar-scrim { display: block; position: fixed; inset: 0; z-index: 90; background: rgba(4,20,38,.48); }
  .close-menu { display: inline-flex; cursor: pointer; }
}
 
@media (min-width: 901px) {
  .sidebar.collapsed { width: var(--sidebar-collapsed-width); }
  .sidebar.collapsed .brand { justify-content: center; gap: 0; padding: 0; }
  .sidebar.collapsed .brand > div:not(.brand-mark) { display: none; }
  .sidebar.collapsed .environment { height: 54px; flex-basis: 54px; align-items: center; padding: 0; }
  .sidebar.collapsed .environment div { display: none; }
  .sidebar.collapsed .environment p { font-size: 0; }
  .sidebar.collapsed .environment p i { width: 8px; height: 8px; }
  .sidebar.collapsed .navigation { padding: 14px 10px; overflow-x: hidden; }
  .sidebar.collapsed .nav-parent { justify-content: center; gap: 0; padding: 0; border-left-width: 0; }
  .sidebar.collapsed .nav-parent .el-icon { font-size: 18px; }
  .sidebar.collapsed .nav-parent span, .sidebar.collapsed .nav-child, .sidebar.collapsed .group-arrow { display: none; }
  .sidebar.collapsed .nav-children { max-height: 0; opacity: 0; }
  .sidebar.collapsed .nav-group { margin-top: 3px; }
  .sidebar.collapsed .group-toggle { cursor: help; }
  .sidebar.collapsed .sidebar-account { justify-content: center; gap: 0; padding: 0; }
  .sidebar.collapsed .sidebar-account div { display: none; }
}
</style>