index.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. <template>
  2. <a-layout style="min-height: 100vh">
  3. <a-layout-sider
  4. v-model:collapsed="state.collapsed"
  5. collapsible
  6. :theme="theme"
  7. class="layout-sider"
  8. @breakpoint="onBreakpoint"
  9. >
  10. <div :class="['logoWrap', theme === 'light' ? 'light' : 'dark']">
  11. <div class="logo">
  12. <slot name="logo"><img src="@/assets/logo.png" alt="" /></slot>
  13. </div>
  14. <div class="text">雷能信息后台管理</div>
  15. </div>
  16. <!-- <a-menu
  17. v-model:selectedKeys="state.selectedKeys"
  18. mode="inline"
  19. :theme="theme"
  20. :inline-collapsed="state.collapsed"
  21. :items="menus"
  22. @click="clickMenuItemHandler"
  23. ></a-menu> -->
  24. <SideMenu :collapsed="collapsed" />
  25. </a-layout-sider>
  26. <a-layout class="layout-content">
  27. <a-layout-header>
  28. <slot name="header">
  29. <base-weather class="weather" mode="text"></base-weather>
  30. <div class="smartScreen" @click="openSmartScreen">智慧大屏</div>
  31. <time-now class="timeNow"></time-now>
  32. <SyncOutlined class="refresh" @click="refresh" />
  33. <user-dropdown class="userDropdown"></user-dropdown>
  34. </slot>
  35. </a-layout-header>
  36. <a-layout-content>
  37. <slot name="breadcrumb">
  38. <a-page-header
  39. v-if="routes && routes.length > 1"
  40. :title="routes[routes.length - 1]?.breadcrumbName"
  41. :breadcrumb="breadcrumbConfig"
  42. @back="backHandler"
  43. >
  44. <template #backIcon>
  45. <div
  46. :style="{
  47. pointerEvents: !canGoBack ? 'none' : 'auto',
  48. cursor: !canGoBack ? 'none' : 'pointer',
  49. color: !canGoBack ? 'rgba(0, 0, 0, 0.25)' : 'inherit',
  50. }"
  51. >
  52. <ArrowLeftOutlined />
  53. </div>
  54. </template>
  55. </a-page-header>
  56. </slot>
  57. <slot></slot>
  58. </a-layout-content>
  59. <a-layout-footer>
  60. <slot name="footer">
  61. <span>合肥雷能信息技术有限公司 © 2025 All Rights Reserved.</span>
  62. <span>&nbsp;&nbsp;&nbsp;&nbsp;版本号:{{ version }}</span>
  63. <!-- <span>&nbsp;&nbsp;&nbsp;&nbsp;构建时间:{{ buildTime }}</span> -->
  64. </slot>
  65. </a-layout-footer>
  66. </a-layout>
  67. <alert-modal v-model:open="openAlertModal" :data="alertModalState"></alert-modal>
  68. </a-layout>
  69. </template>
  70. <script setup lang="ts">
  71. import { ref, reactive, watchEffect, computed, h, onUnmounted, onMounted } from 'vue'
  72. import userDropdown from './components/userDropdown/index.vue'
  73. // import { menus } from '@/const/menus'
  74. import { useRoute, useRouter } from 'vue-router'
  75. import timeNow from './components/timeNow/index.vue'
  76. import type { Route } from 'ant-design-vue/es/breadcrumb/Breadcrumb'
  77. import mqtt, { MqttClient } from 'mqtt'
  78. import { useUserStore } from '@/stores/user'
  79. import AlertModal from './components/alertModal/index.vue'
  80. import { ArrowLeftOutlined, SyncOutlined } from '@ant-design/icons-vue'
  81. import SideMenu from './components/sideMenu/index.vue'
  82. const userStore = useUserStore()
  83. const userId = ref(userStore?.userInfo?.userId || '')
  84. const version = __APP_VERSION__
  85. // const buildTime = __BUILD_TIME__
  86. const emit = defineEmits(['refresh'])
  87. // 刷新
  88. const refresh = () => {
  89. emit('refresh')
  90. }
  91. const collapsed = ref<boolean>(false)
  92. const onBreakpoint = (broken: boolean) => {
  93. if (broken) {
  94. collapsed.value = true
  95. }
  96. }
  97. // 需要缓存的组件列表
  98. // const keepAliveComponents = computed(() => {
  99. // return route.matched.filter((item) => item.meta.keepAlive).map((item) => item.name as string)
  100. // })
  101. let mqttClient: MqttClient | null = null
  102. // let mqttTimeout: number | null = null
  103. // const MQTT_TIMEOUT_MS = 10000 // 10秒
  104. const resetMqttTimeout = () => {
  105. console.log('🚀关闭MQTT连接')
  106. closeMqtt()
  107. // if (mqttTimeout) clearTimeout(mqttTimeout)
  108. // mqttTimeout = window.setTimeout(() => {
  109. // console.log('MQTT超时未收到新消息')
  110. // closeMqtt()
  111. // }, MQTT_TIMEOUT_MS)
  112. }
  113. const MqttData = ref()
  114. const initMqttManager = async () => {
  115. try {
  116. const mqttConfig = {
  117. host: import.meta.env.VITE_MQTT_HOST_ALARM,
  118. username: import.meta.env.VITE_MQTT_USERNAME,
  119. password: import.meta.env.VITE_MQTT_PASSWORD,
  120. clientId: `web_mqtt_cmd${Math.random().toString(16).slice(2)}`,
  121. }
  122. mqttClient = mqtt.connect(mqttConfig.host, {
  123. clientId: mqttConfig.clientId,
  124. username: mqttConfig.username,
  125. password: mqttConfig.password,
  126. will: {
  127. // ✅ 添加遗嘱消息配置
  128. topic: '/mps/client/connect',
  129. payload: JSON.stringify({
  130. userId: userId.value,
  131. deviceType: 'wb',
  132. msgType: 'disconnect',
  133. }),
  134. qos: 2,
  135. retain: false,
  136. },
  137. })
  138. mqttClient.on('connect', () => {
  139. console.log('MQTT已连接')
  140. const messageData = {
  141. userId: userId.value, // 用户ID
  142. deviceType: 'wb', // 设备类型
  143. msgType: 'connect', // 消息类型
  144. }
  145. // 发布消息
  146. mqttClient?.publish('/mps/client/connect', JSON.stringify(messageData), { qos: 2 }, (err) => {
  147. if (err) console.error('发布失败', err)
  148. else console.log('✅ 已发送参数:', messageData)
  149. })
  150. // 订阅所有主题
  151. mqttClient?.subscribe(`/mps/client/connect/`, { qos: 2 }, (err) => {
  152. if (err) {
  153. console.error('MQTT订阅失败', err)
  154. } else {
  155. console.log(`🔥已订阅主题 /mps/client/connect`)
  156. }
  157. })
  158. mqttClient?.subscribe(`/mps/wb_${userStore.userInfo?.userId}/notice`, { qos: 2 }, (err) => {
  159. if (err) {
  160. console.error('MQTT订阅失败', err)
  161. } else {
  162. console.log(`🔥已订阅主题 /mps/wb_${userStore.userInfo?.userId}/notice`)
  163. }
  164. })
  165. })
  166. mqttClient.on('error', (err) => {
  167. console.error('MQTT连接错误', err)
  168. })
  169. mqttClient.on('message', (topic: string, message: Uint8Array) => {
  170. resetMqttTimeout()
  171. try {
  172. const data = JSON.parse(message.toString())
  173. console.log('👏👏收到MQTT消息', topic, data)
  174. MqttData.value = data
  175. } catch (e) {
  176. console.error('👏👏MQTT消息解析失败', e)
  177. }
  178. })
  179. } catch (error) {
  180. console.error('MQTT连接失败:', error)
  181. }
  182. }
  183. initMqttManager()
  184. // 报警弹窗
  185. const openAlertModal = ref(false)
  186. const alertModalState = reactive({
  187. devName: '',
  188. tenantName: '',
  189. clientId: '',
  190. eventListId: '',
  191. })
  192. watchEffect(() => {
  193. const { msgType, event, devName, tenantName, clientId, eventListId } = MqttData.value || {}
  194. if (msgType === 'fall' && event === 'fall_confirmed') {
  195. alertModalState.devName = devName
  196. alertModalState.tenantName = tenantName
  197. alertModalState.clientId = clientId
  198. alertModalState.eventListId = eventListId
  199. openAlertModal.value = true
  200. }
  201. // 退出连接,取消mqtt连接
  202. if (!userStore.userInfo.tokenValue) {
  203. console.log('🚀🚀🚀退出连接,取消mqtt连接')
  204. closeMqtt()
  205. }
  206. })
  207. const closeMqtt = () => {
  208. mqttClient?.end(true)
  209. }
  210. onUnmounted(() => {
  211. resetMqttTimeout()
  212. })
  213. defineOptions({
  214. name: 'baseLayout',
  215. })
  216. const route = useRoute()
  217. const router = useRouter()
  218. const theme = ref('dark') // 主题 dark / light
  219. const state = reactive({
  220. collapsed: false,
  221. selectedKeys: [route.name],
  222. })
  223. const routes = computed(() => {
  224. const currentRoute = router.currentRoute.value
  225. const items = []
  226. if (currentRoute.matched) {
  227. currentRoute.matched.forEach((matchedRoute) => {
  228. if (matchedRoute.meta?.title) {
  229. items.push({
  230. breadcrumbName: matchedRoute.meta.title,
  231. path: matchedRoute.path,
  232. })
  233. }
  234. })
  235. }
  236. if (items.length === 0 && currentRoute.meta?.title) {
  237. items.push({
  238. breadcrumbName: currentRoute.meta.title,
  239. path: currentRoute.path,
  240. })
  241. }
  242. return items
  243. })
  244. const breadcrumbConfig = computed(() => {
  245. return {
  246. routes: routes.value,
  247. itemRender,
  248. }
  249. })
  250. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  251. const itemRender = (options: { route: Route; params: any; routes: Route[]; paths: string[] }) => {
  252. console.log('itemRender', options)
  253. if (!Array.isArray(options.routes)) {
  254. return h('span', options.route.breadcrumbName || '')
  255. }
  256. const currentIndex = routes.value.indexOf(options.route)
  257. if (currentIndex === -1 || currentIndex === routes.value.length - 1) {
  258. return h(
  259. 'span',
  260. {
  261. class: 'current-breadcrumb',
  262. },
  263. options.route.breadcrumbName
  264. )
  265. }
  266. return h(
  267. 'a',
  268. {
  269. class: 'breadcrumb-link',
  270. onClick: (e) => {
  271. console.log('🚀customRouterLinkRende onClickr🚀', e)
  272. e.preventDefault()
  273. if (options.route.path) {
  274. router.push(options.route.path)
  275. }
  276. },
  277. },
  278. options.route.breadcrumbName
  279. )
  280. }
  281. const clickedMenuItem = ref('')
  282. watchEffect(() => {
  283. // if (routes.value.length > 0 && routes.value[0].path) {
  284. // state.selectedKeys = [routes.value[0].path.split('/')[1] || 'home']
  285. // }
  286. state.selectedKeys = [clickedMenuItem.value]
  287. })
  288. // const clickMenuItemHandler = ({ key, keyPath }: { key: string; keyPath: string[] }) => {
  289. // console.log('🚀clickMenuItemHandler🚀', key, keyPath)
  290. // clickedMenuItem.value = key
  291. // if (route.name !== key) {
  292. // router.push({ name: key })
  293. // }
  294. // }
  295. const canGoBack = ref(false)
  296. const navigationHistory = ref<string[]>([])
  297. let isBackNavigation = false
  298. onMounted(() => {
  299. // 初始化导航历史
  300. navigationHistory.value = [router.currentRoute.value.path]
  301. canGoBack.value = false
  302. })
  303. // 监听路由变化,更新导航历史和canGoBack状态
  304. watchEffect(() => {
  305. const currentPath = router.currentRoute.value.path
  306. if (isBackNavigation) {
  307. // 如果是返回导航,不添加到历史记录
  308. isBackNavigation = false
  309. } else {
  310. // 确保只添加新的路径
  311. if (navigationHistory.value[navigationHistory.value.length - 1] !== currentPath) {
  312. navigationHistory.value.push(currentPath)
  313. }
  314. }
  315. // 当历史记录长度大于1时可以返回
  316. canGoBack.value = navigationHistory.value.length > 1
  317. })
  318. const backHandler = async () => {
  319. if (navigationHistory.value.length > 1) {
  320. // 移除当前路径
  321. navigationHistory.value.pop()
  322. // 设置返回导航标志
  323. isBackNavigation = true
  324. // 返回到上一个路径
  325. const prevPath = navigationHistory.value[navigationHistory.value.length - 1]
  326. await router.push(prevPath)
  327. }
  328. }
  329. // 打开智慧大屏
  330. const openSmartScreen = () => {
  331. window.open('/dashboard', '_blank')
  332. }
  333. </script>
  334. <style scoped lang="less">
  335. .layout-sider {
  336. position: relative;
  337. background-color: #1a3a5f;
  338. :deep(.ant-layout-sider-children) {
  339. position: sticky;
  340. top: 0;
  341. height: auto;
  342. background-color: #1a3a5f;
  343. .ant-menu {
  344. background-color: #1a3a5f;
  345. }
  346. }
  347. :deep(.ant-layout-sider-trigger) {
  348. background-color: #1a3a5f;
  349. }
  350. }
  351. .logoWrap {
  352. height: 32px;
  353. margin: 16px;
  354. display: flex;
  355. align-items: center;
  356. overflow: hidden;
  357. transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  358. color: #fff;
  359. .logo {
  360. width: 45px;
  361. height: 32px;
  362. flex-shrink: 0;
  363. font-size: 20px;
  364. display: flex;
  365. justify-content: center;
  366. align-items: center;
  367. img {
  368. width: 100%;
  369. height: 100%;
  370. }
  371. }
  372. .text {
  373. margin-left: 10px;
  374. flex-grow: 1;
  375. opacity: 1;
  376. transition:
  377. opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
  378. width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  379. overflow: hidden;
  380. text-overflow: ellipsis;
  381. display: -webkit-box;
  382. -webkit-box-orient: vertical;
  383. -webkit-line-clamp: 2;
  384. line-clamp: 2;
  385. }
  386. }
  387. .light {
  388. color: #141414;
  389. }
  390. .dark {
  391. color: #fff;
  392. }
  393. .layout-content {
  394. .ant-layout-header {
  395. background-color: #1a3a5f;
  396. color: #fff;
  397. padding: 0 32px;
  398. text-align: right;
  399. display: flex;
  400. justify-content: flex-end;
  401. position: sticky;
  402. top: 0;
  403. z-index: 1000;
  404. .smartScreen {
  405. cursor: pointer;
  406. color: #eee;
  407. font-size: 1.2em;
  408. font-weight: bold;
  409. background: linear-gradient(to right, #ff6ec4, #f9d423, #00c9ff, #92fe9d);
  410. -webkit-background-clip: text;
  411. background-clip: text;
  412. -webkit-text-fill-color: transparent;
  413. text-shadow: 0 0 2px rgba(255, 255, 255, 0.3);
  414. }
  415. .refresh {
  416. margin-right: 14px;
  417. font-size: 18px;
  418. font-weight: 600;
  419. }
  420. }
  421. .ant-layout-content {
  422. margin: 16px;
  423. }
  424. .ant-layout-footer {
  425. text-align: center;
  426. background-color: transparent;
  427. color: #4774a7;
  428. display: flex;
  429. justify-content: center;
  430. align-items: center;
  431. }
  432. }
  433. .ant-layout-sider-collapsed & {
  434. .text {
  435. opacity: 0;
  436. width: 0;
  437. margin-left: 0;
  438. }
  439. }
  440. .site-layout .site-layout-background {
  441. background: #101830b3;
  442. }
  443. [data-theme='dark'] .site-layout .site-layout-background {
  444. background: #101830b3;
  445. }
  446. </style>