index.vue 12 KB

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