4 Commits e28be6f929 ... 5998a1fe01

Author SHA1 Message Date
  曾坤森 5998a1fe01 feat: 新增首页和列表页面 3 weeks ago
  曾坤森 50cf7540ad feat: 修改主题样式添加logo 3 weeks ago
  曾坤森 a761eb8295 perf: 新增top nav导航 3 weeks ago
  曾坤森 0126ee01db perf: 字典数据缓存浏览器 1 month ago

+ 0 - 1
src/api/dashboard.ts

@@ -1,4 +1,3 @@
-import axios from 'axios';
 import type { TableData } from '@arco-design/web-vue/es/table/interface';
 import instance from './interceptor';
 export interface RootObject {

+ 38 - 0
src/api/home.ts

@@ -0,0 +1,38 @@
+import instance from './interceptor';
+export interface Data {
+  id: number;
+  entityType: number;
+  name: string;
+  station: string;
+  address: string;
+  status: number;
+  time: string;
+  data: string;
+}
+
+export interface Tree {
+  id: number;
+  parentId: number;
+  name: string;
+  abbreviation: string;
+  high: number;
+  medium: number;
+  low: number;
+  child: any[];
+}
+export interface AlarmTotalRes {
+  success: boolean;
+  message: string;
+  code: string;
+  data: Data[];
+  totalCount: number;
+  totalPage: number;
+  high: number;
+  medium: number;
+  low: number;
+  tree: Tree[];
+}
+export async function fetchHomeAlarmTotal(): Promise<AlarmTotalRes> {
+  const res = await instance.post('/api/RouteInfo/AlarmTotal');
+  return res.data;
+}

BIN
src/assets/MTR-logo.jpg


BIN
src/assets/logo.png


+ 0 - 12
src/assets/logo.svg

@@ -1,12 +0,0 @@
-<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0)">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
-</g>
-<defs>
-<clipPath id="clip0">
-<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
-</clipPath>
-</defs>
-</svg>

+ 60 - 17
src/assets/style/global.less

@@ -9,69 +9,83 @@ body {
   margin: 0;
   padding: 0;
   font-size: 14px;
-  background-color: var(--color-bg-1);
+  background-color: var(--color-theme-2);
   -moz-osx-font-smoothing: grayscale;
   -webkit-font-smoothing: antialiased;
+
+  --color-theme-1: #0b2349;
+  --color-theme-2: #3c4e69;
+  --table-th-bg: #5f6c7f;
+  --color-input-bg: #77818e;
+  --color-border: #5f6c7f;
 }
 
 .echarts-tooltip-diy {
   background: linear-gradient(
     304.17deg,
-    rgba(253, 254, 255, 0.6) -6.04%,
-    rgba(244, 247, 252, 0.6) 85.2%
+    rgb(253 254 255 / 60%) -6.04%,
+    rgb(244 247 252 / 60%) 85.2%
   ) !important;
   border: none !important;
-  backdrop-filter: blur(10px) !important;
+
   /* Note: backdrop-filter has minimal browser support */
 
   border-radius: 6px !important;
+  backdrop-filter: blur(10px) !important;
+
   .content-panel {
     display: flex;
     justify-content: space-between;
-    padding: 0 9px;
-    background: rgba(255, 255, 255, 0.8);
     width: 164px;
     height: 32px;
+    margin-bottom: 4px;
+    padding: 0 9px;
     line-height: 32px;
-    box-shadow: 6px 0px 20px rgba(34, 87, 188, 0.1);
+    background: rgb(255 255 255 / 80%);
     border-radius: 4px;
-    margin-bottom: 4px;
+    box-shadow: 6px 0 20px rgb(34 87 188 / 10%);
   }
+
   .tooltip-title {
-    margin: 0 0 10px 0;
+    margin: 0 0 10px;
   }
+
   p {
     margin: 0;
   }
+
   .tooltip-title,
   .tooltip-value {
-    font-size: 13px;
-    line-height: 15px;
     display: flex;
     align-items: center;
-    text-align: right;
     color: #1d2129;
     font-weight: bold;
+    font-size: 13px;
+    line-height: 15px;
+    text-align: right;
   }
+
   .tooltip-item-icon {
     display: inline-block;
-    margin-right: 8px;
     width: 10px;
     height: 10px;
+    margin-right: 8px;
     border-radius: 50%;
   }
 }
 
 .general-card {
-  border-radius: 4px;
   border: none;
+  border-radius: 4px;
+
   & > .arco-card-header {
     height: auto;
     padding: 20px;
     border: none;
   }
+
   & > .arco-card-body {
-    padding: 0 20px 20px 20px;
+    padding: 0 20px 20px;
   }
 }
 
@@ -82,13 +96,42 @@ body {
 .arco-table-cell {
   .circle {
     display: inline-block;
-    margin-right: 4px;
     width: 6px;
     height: 6px;
-    border-radius: 50%;
+    margin-right: 4px;
     background-color: rgb(var(--blue-6));
+    border-radius: 50%;
+
     &.pass {
       background-color: rgb(var(--green-6));
     }
   }
 }
+
+.arco-table {
+  tr th {
+    color: var(--color-white);
+    font-weight: 600;
+    background-color: var(--table-th-bg);
+  }
+
+  tr td {
+    color: var(--color-white);
+    font-weight: 600;
+    background-color: var(--color-theme-2);
+    border-color: var(--color-border);
+  }
+
+  tr:hover td {
+    background-color: var(--table-th-bg) !important;
+  }
+}
+
+.arco-form-item-label {
+  color: var(--color-white) !important;
+  font-weight: 600;
+}
+
+.arco-modal {
+  background-color: var(--color-theme-2);
+}

+ 1 - 1
src/components/global-breadcrumb/index.vue

@@ -74,7 +74,7 @@ const goToRoute = (path: string) => {
   :deep(.arco-breadcrumb) {
     display: flex;
     align-items: center;
-    height: 50px;
+    height: 40px;
   }
 
   :deep(.arco-breadcrumb-item) {

+ 110 - 0
src/components/menu/nav-menu.vue

@@ -0,0 +1,110 @@
+<template>
+  <div>
+    <ul class="menu-container">
+      <li
+        v-for="item in menuTree"
+        :class="[
+          'menu-item',
+          {
+            active:
+              item.name === route.matched[0].name ||
+              item.name === hoveredMenuItem?.name,
+          },
+        ]"
+        @mouseenter="handleMouseEnter(item)"
+        @mouseleave="handleMouseLeave"
+      >
+        <span>{{ item.name }}</span>
+      </li>
+    </ul>
+  </div>
+</template>
+<script lang="tsx" setup name="NavMenu">
+import useMenuTree from './use-menu-tree';
+import { defineComponent, ref, h, compile, computed, watch } from 'vue';
+import { useRoute, useRouter, RouteRecordNormalized } from 'vue-router';
+interface NavMenuEmits {
+  (e: 'menuHover', val: RouteRecordNormalized): void;
+}
+interface NavMenuProps {
+  internalHoveredItem: RouteRecordNormalized;
+}
+const route = useRoute();
+
+const { menuTree } = useMenuTree();
+// 定义 emits
+const emit = defineEmits<NavMenuEmits>();
+const props = withDefaults(defineProps<NavMenuProps>(), {
+  internalHoveredItem: () => ({}) as RouteRecordNormalized,
+});
+const hoveredMenuItem = ref<RouteRecordNormalized>({} as RouteRecordNormalized);
+watch(
+  () => props.internalHoveredItem,
+  value => {
+    hoveredMenuItem.value = value;
+  }
+);
+let hoverTimeout: number | null = null;
+
+// 处理鼠标进入事件
+const handleMouseEnter = (item: RouteRecordNormalized) => {
+  // 清除之前的定时器
+  if (hoverTimeout) {
+    clearTimeout(hoverTimeout);
+    hoverTimeout = null;
+  }
+  // 发送事件给父组件,传递当前菜单项
+  emit('menuHover', item);
+};
+
+// 处理鼠标离开事件
+const handleMouseLeave = () => {
+  // 设置延迟,避免快速移动时菜单消失
+  hoverTimeout = setTimeout(() => {
+    // 发送 null 值表示鼠标离开
+    emit('menuHover', {} as RouteRecordNormalized);
+  }, 100);
+};
+</script>
+<style lang="less" scoped>
+.menu-container {
+  display: flex;
+  flex-direction: row;
+  justify-content: start;
+  margin-top: 1px;
+
+  .menu-item {
+    height: 40px;
+    padding-right: 20px;
+    padding-left: 20px;
+    line-height: 40px;
+    list-style: none;
+    border-top-left-radius: 10px;
+    border-top-right-radius: 10px;
+    cursor: pointer;
+
+    span {
+      color: black;
+      font-weight: 600;
+      font-size: 20px;
+      text-decoration: none;
+    }
+  }
+
+  .menu-item:hover {
+    span {
+      color: var(--color-white);
+    }
+
+    background-color: var(--color-theme-1);
+  }
+
+  .active {
+    span {
+      color: var(--color-white);
+    }
+
+    background-color: var(--color-theme-1);
+  }
+}
+</style>

+ 137 - 0
src/components/menu/nav-second-menu.vue

@@ -0,0 +1,137 @@
+<template>
+  <ul
+    class="menu-container"
+    @mouseenter="handleSelfMouseEnter"
+    @mouseleave="handleSelfMouseLeave"
+  >
+    <li v-for="item in displayedMenuList" @click="handleJumpRoute(item)">
+      <div
+        :class="['menu-item', { active: item.name === route.name }]"
+        v-if="!item.meta?.hideInMenu"
+      >
+        <span>{{ item.name }}</span>
+      </div>
+    </li>
+  </ul>
+</template>
+<script lang="tsx" setup name="NavSecondMenu">
+import useMenuTree from './use-menu-tree';
+import { defineComponent, ref, h, compile, computed } from 'vue';
+import {
+  useRoute,
+  useRouter,
+  RouteRecordNormalized,
+  RouteRecordRaw,
+} from 'vue-router';
+interface NavSecondMenuEmits {
+  (e: 'internalHoveredItemFun', val: RouteRecordNormalized): void;
+}
+interface NavSecondMenuProps {
+  hoveredMenuItem: RouteRecordNormalized;
+}
+const route = useRoute();
+const router = useRouter();
+const menuList = computed(() => route.matched[0].children);
+// 接收来自 NavMenu 的事件
+const props = withDefaults(defineProps<NavSecondMenuProps>(), {
+  hoveredMenuItem: () => ({}) as RouteRecordNormalized,
+});
+const emits = defineEmits<NavSecondMenuEmits>();
+// 内部状态管理
+const internalHoveredItem = ref<RouteRecordNormalized>(
+  {} as RouteRecordNormalized
+);
+let mouseInSelf = false;
+let clearTimer: number | null = null;
+
+// 计算显示的菜单列表
+const displayedMenuList = computed(() => {
+  // 优先使用内部锁定的菜单项
+  if (internalHoveredItem.value?.children) {
+    return internalHoveredItem.value.children;
+  }
+
+  // 其次使用外部传入的菜单项
+  if (props.hoveredMenuItem?.children) {
+    return props.hoveredMenuItem.children;
+  }
+
+  // 最后使用默认菜单
+  return menuList.value || [];
+});
+console.log('displayedMenuList', displayedMenuList.value);
+// 处理自身鼠标进入事件
+const handleSelfMouseEnter = () => {
+  mouseInSelf = true;
+  // 清除可能存在的清除定时器
+  if (clearTimer) {
+    clearTimeout(clearTimer);
+    clearTimer = null;
+  }
+  // 锁定当前显示的菜单项
+  if (props.hoveredMenuItem) {
+    emits('internalHoveredItemFun', props.hoveredMenuItem);
+    internalHoveredItem.value = props.hoveredMenuItem;
+  }
+};
+
+// 处理自身鼠标离开事件
+const handleSelfMouseLeave = () => {
+  mouseInSelf = false;
+  // 延迟清除内部状态,给人类操作留出时间
+  clearTimer = setTimeout(() => {
+    emits('internalHoveredItemFun', {} as RouteRecordNormalized);
+    internalHoveredItem.value = {} as RouteRecordNormalized;
+  }, 100);
+};
+const handleJumpRoute = (item: RouteRecordRaw) => {
+  console.log('item', item.name);
+  // 使用 name 跳转避免路径解析问题
+  router.push({ name: item.name });
+};
+</script>
+<style lang="less" scoped>
+li {
+  list-style: none;
+}
+
+.menu-container {
+  display: flex;
+  flex-direction: row;
+  justify-content: start;
+  width: 100%;
+  margin: 0;
+  padding-left: 70px;
+  background-color: #0b2349;
+
+  .menu-item {
+    height: 45px;
+    margin-top: 1px;
+    padding: 10px 20px;
+    cursor: pointer;
+
+    span {
+      color: #9ba6b5;
+      font-weight: 600;
+      font-size: 20px;
+      text-decoration: none;
+    }
+  }
+
+  .menu-item:hover {
+    span {
+      color: var(--color-white);
+    }
+
+    background-color: var(--color-theme-2);
+  }
+
+  .active {
+    span {
+      color: var(--color-white);
+    }
+
+    background-color: var(--color-theme-2);
+  }
+}
+</style>

+ 234 - 184
src/components/navbar/index.vue

@@ -1,86 +1,96 @@
 <template>
-  <div class="navbar" :style="{ width: currentWidth }">
-    <div class="left-side">
-      <a-button
-        type="text"
-        :style="{
-          padding: '0 7px',
-          height: '25px',
-          lineHeight: '25px',
-          color: 'var(--color-text-3)',
-        }"
-        @click="toggleCollapse"
-      >
-        <icon-menu-unfold v-if="collapsed" />
-        <icon-menu-fold v-else />
-      </a-button>
-    </div>
-    <GlobalBreadcrumb v-show="!topMenu" />
-
-    <div class="center-side">
-      <Menu v-if="topMenu" />
-    </div>
-    <ul class="right-side">
-      <li>
-        <a-tooltip :content="$t('settings.search')">
-          <a-button class="nav-btn" type="outline" :shape="'circle'">
-            <template #icon>
-              <icon-search />
+  <div
+    class="navbar-container"
+    :style="{ width: topMenu ? '100%' : currentWidth }"
+  >
+    <div class="navbar">
+      <div v-if="topMenu" class="logo" @click="handleGoHome">
+        <img style="width: 100%; height: 100%" src="@/assets/MTR-logo.jpg" />
+      </div>
+      <div v-if="!topMenu" class="left-side">
+        <a-button
+          type="text"
+          :style="{
+            padding: '0 7px',
+            height: '25px',
+            lineHeight: '25px',
+            color: 'var(--color-text-3)',
+          }"
+          @click="toggleCollapse"
+        >
+          <icon-menu-unfold v-if="collapsed" />
+          <icon-menu-fold v-else />
+        </a-button>
+      </div>
+      <GlobalBreadcrumb v-show="!topMenu" />
+      <div class="center-side">
+        <NavMenu
+          v-if="topMenu"
+          :internalHoveredItem="internalHoveredItem"
+          @menuHover="handleMenuHover"
+        ></NavMenu>
+      </div>
+      <ul class="right-side">
+        <li>
+          <a-tooltip :content="$t('settings.search')">
+            <a-button class="nav-btn" type="outline" :shape="'circle'">
+              <template #icon>
+                <icon-search />
+              </template>
+            </a-button>
+          </a-tooltip>
+        </li>
+        <li>
+          <a-tooltip :content="$t('settings.language')">
+            <a-button
+              class="nav-btn"
+              type="outline"
+              :shape="'circle'"
+              @click="setDropDownVisible"
+            >
+              <template #icon>
+                <icon-language />
+              </template>
+            </a-button>
+          </a-tooltip>
+          <a-dropdown trigger="click" @select="changeLocale as any">
+            <div ref="triggerBtn" class="trigger-btn"></div>
+            <template #content>
+              <a-doption
+                v-for="item in locales"
+                :key="item.value"
+                :value="item.value"
+              >
+                <template #icon>
+                  <icon-check v-show="item.value === currentLocale" />
+                </template>
+                {{ item.label }}
+              </a-doption>
             </template>
-          </a-button>
-        </a-tooltip>
-      </li>
-      <li>
-        <a-tooltip :content="$t('settings.language')">
-          <a-button
-            class="nav-btn"
-            type="outline"
-            :shape="'circle'"
-            @click="setDropDownVisible"
+          </a-dropdown>
+        </li>
+        <li>
+          <a-tooltip
+            :content="
+              theme === 'light'
+                ? $t('settings.navbar.theme.toDark')
+                : $t('settings.navbar.theme.toLight')
+            "
           >
-            <template #icon>
-              <icon-language />
-            </template>
-          </a-button>
-        </a-tooltip>
-        <a-dropdown trigger="click" @select="changeLocale as any">
-          <div ref="triggerBtn" class="trigger-btn"></div>
-          <template #content>
-            <a-doption
-              v-for="item in locales"
-              :key="item.value"
-              :value="item.value"
+            <a-button
+              class="nav-btn"
+              type="outline"
+              :shape="'circle'"
+              @click="handleToggleTheme"
             >
               <template #icon>
-                <icon-check v-show="item.value === currentLocale" />
+                <icon-moon-fill v-if="theme === 'dark'" />
+                <icon-sun-fill v-else />
               </template>
-              {{ item.label }}
-            </a-doption>
-          </template>
-        </a-dropdown>
-      </li>
-      <li>
-        <a-tooltip
-          :content="
-            theme === 'light'
-              ? $t('settings.navbar.theme.toDark')
-              : $t('settings.navbar.theme.toLight')
-          "
-        >
-          <a-button
-            class="nav-btn"
-            type="outline"
-            :shape="'circle'"
-            @click="handleToggleTheme"
-          >
-            <template #icon>
-              <icon-moon-fill v-if="theme === 'dark'" />
-              <icon-sun-fill v-else />
-            </template>
-          </a-button>
-        </a-tooltip>
-      </li>
-      <!-- <li>
+            </a-button>
+          </a-tooltip>
+        </li>
+        <!-- <li>
         <a-tooltip :content="$t('settings.navbar.alerts')">
           <div class="message-box-trigger">
             <a-badge :count="9" dot>
@@ -107,51 +117,51 @@
           </template>
         </a-popover>
       </li> -->
-      <li>
-        <a-tooltip
-          :content="
-            isFullscreen
-              ? $t('settings.navbar.screen.toExit')
-              : $t('settings.navbar.screen.toFull')
-          "
-        >
-          <a-button
-            class="nav-btn"
-            type="outline"
-            :shape="'circle'"
-            @click="toggleFullScreen"
-          >
-            <template #icon>
-              <icon-fullscreen-exit v-if="isFullscreen" />
-              <icon-fullscreen v-else />
-            </template>
-          </a-button>
-        </a-tooltip>
-      </li>
-      <li>
-        <a-tooltip :content="$t('settings.title')">
-          <a-button
-            class="nav-btn"
-            type="outline"
-            :shape="'circle'"
-            @click="setVisible"
-          >
-            <template #icon>
-              <icon-settings />
-            </template>
-          </a-button>
-        </a-tooltip>
-      </li>
-      <li>
-        <a-dropdown trigger="click">
-          <a-avatar
-            :size="32"
-            :style="{ marginRight: '8px', cursor: 'pointer' }"
+        <li>
+          <a-tooltip
+            :content="
+              isFullscreen
+                ? $t('settings.navbar.screen.toExit')
+                : $t('settings.navbar.screen.toFull')
+            "
           >
-            <img alt="avatar" :src="avatar" />
-          </a-avatar>
-          <template #content>
-            <!-- <a-doption>
+            <a-button
+              class="nav-btn"
+              type="outline"
+              :shape="'circle'"
+              @click="toggleFullScreen"
+            >
+              <template #icon>
+                <icon-fullscreen-exit v-if="isFullscreen" />
+                <icon-fullscreen v-else />
+              </template>
+            </a-button>
+          </a-tooltip>
+        </li>
+        <li>
+          <a-tooltip :content="$t('settings.title')">
+            <a-button
+              class="nav-btn"
+              type="outline"
+              :shape="'circle'"
+              @click="setVisible"
+            >
+              <template #icon>
+                <icon-settings />
+              </template>
+            </a-button>
+          </a-tooltip>
+        </li>
+        <li>
+          <a-dropdown trigger="click">
+            <a-avatar
+              :size="32"
+              :style="{ marginRight: '8px', cursor: 'pointer' }"
+            >
+              <img alt="avatar" :src="avatar" />
+            </a-avatar>
+            <template #content>
+              <!-- <a-doption>
               <a-space @click="switchRoles">
                 <icon-tag />
                 <span>
@@ -159,26 +169,33 @@
                 </span>
               </a-space>
             </a-doption> -->
-            <a-doption>
-              <a-space @click="$router.push({ name: 'Setting' })">
-                <icon-settings />
-                <span>
-                  {{ $t('messageBox.userSettings') }}
-                </span>
-              </a-space>
-            </a-doption>
-            <a-doption>
-              <a-space @click="handleLogout">
-                <icon-export />
-                <span>
-                  {{ $t('messageBox.logout') }}
-                </span>
-              </a-space>
-            </a-doption>
-          </template>
-        </a-dropdown>
-      </li>
-    </ul>
+              <a-doption>
+                <a-space @click="$router.push({ name: 'Setting' })">
+                  <icon-settings />
+                  <span>
+                    {{ $t('messageBox.userSettings') }}
+                  </span>
+                </a-space>
+              </a-doption>
+              <a-doption>
+                <a-space @click="handleLogout">
+                  <icon-export />
+                  <span>
+                    {{ $t('messageBox.logout') }}
+                  </span>
+                </a-space>
+              </a-doption>
+            </template>
+          </a-dropdown>
+        </li>
+      </ul>
+    </div>
+    <NavSecondMenu
+      v-if="topMenu"
+      :hoveredMenuItem="currentHoveredMenu"
+      @internalHoveredItemFun="internalHoveredItemFun"
+    ></NavSecondMenu>
+    <!-- <div :style="{ width: '100%', height: '50px' }">dddd</div> -->
   </div>
 </template>
 
@@ -190,10 +207,13 @@ import { useAppStore, useUserStore } from '@/store';
 import { LOCALE_OPTIONS } from '@/locale';
 import useLocale from '@/hooks/locale';
 import useUser from '@/hooks/user';
-import Menu from '@/components/menu/index.vue';
+import NavMenu from '@/components/menu/nav-menu.vue';
+import NavSecondMenu from '@/components/menu/nav-second-menu.vue';
 import MessageBox from '../message-box/index.vue';
 import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
+import { useRoute, useRouter, RouteRecordNormalized } from 'vue-router';
 
+const router = useRouter();
 const appStore = useAppStore();
 const userStore = useUserStore();
 const { logout } = useUser();
@@ -220,10 +240,10 @@ const currentWidth = computed(() => {
 
 const isDark = useDark({
   selector: 'body',
-  attribute: 'arco-theme',
+  attribute: 'smms-theme',
   valueDark: 'dark',
   valueLight: 'light',
-  storageKey: 'arco-theme',
+  storageKey: 'smms-theme',
   onChanged(dark: boolean) {
     // overridden default behavior
     appStore.toggleTheme(dark);
@@ -269,62 +289,92 @@ const toggleCollapse = () => {
   setCollapse(!collapsed.value);
   // toggleWidth(collapsed.value);
 };
+const handleGoHome = () => {
+  router.push({ name: 'HomePage' });
+};
+const currentHoveredMenu = ref<RouteRecordNormalized>(
+  {} as RouteRecordNormalized
+);
+const internalHoveredItem = ref<RouteRecordNormalized>(
+  {} as RouteRecordNormalized
+);
+
+const handleMenuHover = (menuItem: RouteRecordNormalized) => {
+  currentHoveredMenu.value = menuItem;
+};
+const internalHoveredItemFun = (item: RouteRecordNormalized) => {
+  internalHoveredItem.value = item;
+};
 </script>
 
 <style scoped lang="less">
-.navbar {
-  display: flex;
-  justify-content: space-between;
-  justify-self: end;
-  height: 100%;
-  background-color: var(--color-bg-2);
-  border-bottom: 1px solid var(--color-border);
-}
-
-.left-side {
+.navbar-container {
   display: flex;
-  align-items: center;
-  padding-left: 20px;
-}
-
-.center-side {
-  flex: 1;
-}
+  flex-direction: column;
+  width: 100%;
 
-.right-side {
-  display: flex;
-  padding-right: 20px;
-  list-style: none;
+  .navbar {
+    display: flex;
+    justify-content: space-between;
+    justify-self: end;
+    height: 100%;
+    background-color: var(--color-bg-2);
+    border-bottom: 1px solid var(--color-border);
 
-  :deep(.locale-select) {
-    border-radius: 20px;
+    .logo {
+      width: 110px;
+      height: 35px;
+      margin-top: 3px;
+      margin-left: 20px;
+      cursor: pointer;
+    }
   }
 
-  li {
+  .left-side {
     display: flex;
     align-items: center;
-    padding: 0 10px;
+    padding-left: 20px;
   }
 
-  a {
-    color: var(--color-text-1);
-    text-decoration: none;
+  .center-side {
+    flex: 1;
   }
 
-  .nav-btn {
-    color: rgb(var(--gray-8));
-    font-size: 16px;
-    border-color: rgb(var(--gray-2));
-  }
+  .right-side {
+    display: flex;
+    padding-right: 20px;
+    list-style: none;
 
-  .trigger-btn,
-  .ref-btn {
-    position: absolute;
-    bottom: 14px;
-  }
+    :deep(.locale-select) {
+      border-radius: 20px;
+    }
+
+    li {
+      display: flex;
+      align-items: center;
+      padding: 0 10px;
+    }
+
+    a {
+      color: var(--color-text-1);
+      text-decoration: none;
+    }
+
+    .nav-btn {
+      color: rgb(var(--gray-8));
+      font-size: 16px;
+      border-color: rgb(var(--gray-2));
+    }
+
+    .trigger-btn,
+    .ref-btn {
+      position: absolute;
+      bottom: 14px;
+    }
 
-  .trigger-btn {
-    margin-left: 14px;
+    .trigger-btn {
+      margin-left: 14px;
+    }
   }
 }
 </style>

+ 1 - 1
src/config/settings.json

@@ -3,7 +3,7 @@
   "colorWeak": false,
   "navbar": true,
   "menu": true,
-  "topMenu": false,
+  "topMenu": true,
   "hideMenu": false,
   "menuCollapse": false,
   "footer": false,

+ 25 - 0
src/hooks/dict-list.ts

@@ -0,0 +1,25 @@
+import type { AdditionalProp } from '@/api/dict';
+import { getDictQueryList } from '@/api/dict';
+import { useStorage } from '@vueuse/core';
+const SMMS_DICT = 'smms-dict';
+const useDictList = async (params: string[]) => {
+  const data = {} as { [key: string]: AdditionalProp[] };
+  const list = useStorage(SMMS_DICT, {} as { [key: string]: AdditionalProp[] }); // 索引签名,允许任意字符串键);
+  // 使用 for...of 确保异步操作按顺序执行并等待完成
+  for (const item of params) {
+    if (list.value[item] && list.value[item].length > 0) {
+      data[item] = list.value[item];
+    } else {
+      try {
+        const res = await getDictQueryList({ names: [item] });
+        data[item] = res.data[item] || [];
+        list.value[item] = res.data[item] || [];
+      } catch (error) {
+        data[item] = [];
+      }
+    }
+  }
+
+  return data;
+};
+export default useDictList;

+ 17 - 11
src/layout/default-layout.vue

@@ -22,11 +22,7 @@
             :style="{ paddingLeft: collapsed ? '10px' : '20px' }"
           >
             <a-space>
-              <img
-                class="logo"
-                alt="logo"
-                src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image"
-              />
+              <img class="logo" alt="logo" src="@/assets/logo.png" />
               <a-typography-title
                 :style="{ fontSize: '18px' }"
                 :heading="5"
@@ -82,13 +78,15 @@ const router = useRouter();
 const route = useRoute();
 const permission = usePermission();
 useResponsive(true);
-const navbarHeight = `50px`;
+const navbarHeight = `40px`;
+const topNavbarHeight = `76px`;
 const navbar = computed(() => appStore.navbar);
+const topMenu = computed(() => appStore.topMenu);
 const renderMenu = computed(() => appStore.menu && !appStore.topMenu);
 const hideMenu = computed(() => appStore.hideMenu);
 const footer = computed(() => appStore.footer);
 const menuWidth = computed(() => {
-  return appStore.menuCollapse ? 48 : appStore.menuWidth;
+  return appStore.menuCollapse ? 40 : appStore.menuWidth;
 });
 const collapsed = computed(() => {
   return appStore.menuCollapse;
@@ -98,7 +96,9 @@ const paddingStyle = computed(() => {
     renderMenu.value && !hideMenu.value
       ? { paddingLeft: `${menuWidth.value}px` }
       : {};
-  const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {};
+  const paddingTop = navbar.value
+    ? { paddingTop: topMenu.value ? topNavbarHeight : navbarHeight }
+    : {};
   return { ...paddingLeft, ...paddingTop };
 });
 const setCollapsed = (val: boolean) => {
@@ -125,7 +125,7 @@ onMounted(() => {
 </script>
 
 <style scoped lang="less">
-@nav-size-height: 50px;
+@nav-size-height: 40px;
 @layout-max-width: 1100px;
 
 .layout {
@@ -142,7 +142,7 @@ onMounted(() => {
   justify-content: end;
   width: 100%;
   height: @nav-size-height;
-  background-color: white;
+  background-color: var(--color-bg-2);
 }
 
 .layout-sider {
@@ -179,6 +179,8 @@ onMounted(() => {
   overflow: hidden;
 
   .logo {
+    width: 30px;
+    height: 30px;
     margin-right: 10px;
   }
 }
@@ -208,9 +210,13 @@ onMounted(() => {
 }
 
 .layout-content {
+  :deep(.arco-card) {
+    background-color: var(--color-theme-2);
+  }
+
   min-height: 100vh;
   overflow-y: hidden;
-  background-color: var(--color-fill-2);
+  background-color: var(--color-theme-1) !important;
   transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
 }
 </style>

+ 1 - 1
src/router/index.ts

@@ -17,7 +17,7 @@ const router = createRouter({
   routes: [
     {
       path: '/',
-      redirect: '/dashboard/workplace',
+      redirect: '/home/index',
     },
     {
       path: '/login',

+ 29 - 0
src/router/routes/modules/home.ts

@@ -0,0 +1,29 @@
+import { DEFAULT_LAYOUT } from '../base';
+import { AppRouteRecordRaw } from '../types';
+
+const HOME: AppRouteRecordRaw = {
+  path: '/home',
+  name: 'home',
+  component: DEFAULT_LAYOUT,
+  meta: {
+    locale: 'menu.home',
+    requiresAuth: true,
+    icon: 'icon-settings',
+    order: 0,
+  },
+  children: [
+    {
+      path: 'index',
+      name: 'HomePage',
+      component: () => import('@/views/home/index.vue'),
+      meta: {
+        locale: 'menu.home.dictItem',
+        requiresAuth: true,
+        hideInMenu: true,
+        roles: ['*'],
+      },
+    },
+  ],
+};
+
+export default HOME;

+ 4 - 3
src/views/dashboard/manage/edit.vue

@@ -56,9 +56,10 @@ import { reactive, ref, shallowRef, watch, getCurrentInstance } from 'vue';
 import { getDeviceDetails, saveDeviceDetails } from '@/api/dashboard';
 import type { DataList } from '@/api/dashboard';
 import type { AdditionalProp } from '@/api/dict';
-import { getDictQueryList } from '@/api/dict';
 import { useI18n } from 'vue-i18n';
 import { getRules } from '@/utils/const';
+import useDictList from '@/hooks/dict-list';
+
 const formRef = ref();
 const { t } = useI18n();
 
@@ -122,8 +123,8 @@ const handleBeforeOk = (done: (closed: boolean) => void) => {
     }
   });
 };
-getDictQueryList({ names: ['DeviceType'] }).then(res => {
-  deviceTypeList.value = res.data['DeviceType'];
+useDictList(['DeviceType']).then(res => {
+  deviceTypeList.value = res['DeviceType'];
 });
 const handleCancel = () => {
   form.value = formModel();

+ 7 - 5
src/views/dashboard/manage/index.vue

@@ -206,6 +206,7 @@ import {
   h,
   getCurrentInstance,
   computed,
+  onMounted,
 } from 'vue';
 import {
   queryDashboardList,
@@ -221,10 +222,10 @@ import useLoading from '@/hooks/loading';
 import { useI18n } from 'vue-i18n';
 import dayjs from 'dayjs';
 import { downLoadFun, DeviceInfo } from '@/utils/const';
-import { useIntervalFn } from '@vueuse/core';
 import { Modal } from '@arco-design/web-vue';
 import type { AdditionalProp } from '@/api/dict';
-import { getDictQueryList } from '@/api/dict';
+import useDictList from '@/hooks/dict-list';
+
 const { t } = useI18n();
 
 const { loading, setLoading } = useLoading(true);
@@ -304,10 +305,11 @@ const deviceInfo = ref<DeviceInfo[]>([] as DeviceInfo[]);
 const deviceTypeList = ref<AdditionalProp[]>([] as AdditionalProp[]);
 const deviceStatusList = ref<AdditionalProp[]>([] as AdditionalProp[]);
 
-getDictQueryList({ names: ['DeviceType', 'DeviceStatus'] }).then(res => {
-  deviceTypeList.value.push(...res.data['DeviceType']);
-  deviceStatusList.value.push(...res.data['DeviceStatus']);
+useDictList(['DeviceType', 'DeviceStatus']).then(res => {
+  deviceTypeList.value.push(...res['DeviceType']);
+  deviceStatusList.value.push(...res['DeviceStatus']);
 });
+
 function searchTable() {
   // setLoading(true);
   const [startTime, endTime] = formModel.value.time

+ 2 - 2
src/views/dashboard/workplace/device-info/index.vue

@@ -17,8 +17,8 @@
         <a-row :gutter="8">
           <a-col :span="10">
             <a-form-item
-              field="name"
-              :label="t('dashboard.form.name')"
+              field="timeRange"
+              :label="t('dashboard.form.timeRange')"
               :rules="getRules(t).required"
             >
               <a-range-picker

+ 5 - 4
src/views/dashboard/workplace/index.vue

@@ -180,11 +180,12 @@ import useLoading from '@/hooks/loading';
 import { useI18n } from 'vue-i18n';
 import { DeviceInfo } from '@/utils/const';
 import type { AdditionalProp } from '@/api/dict';
-import { getDictQueryList } from '@/api/dict';
 import dayjs from 'dayjs';
 import { downLoadFun } from '@/utils/const';
 import { useIntervalFn } from '@vueuse/core';
 import DeviceInfoDialog from './device-info/index.vue';
+import useDictList from '@/hooks/dict-list';
+
 const { t } = useI18n();
 
 const { loading, setLoading } = useLoading(true);
@@ -257,9 +258,9 @@ const deviceTypeList = ref<AdditionalProp[]>([] as AdditionalProp[]);
 const deviceStatusList = ref<AdditionalProp[]>([] as AdditionalProp[]);
 const deviceId = shallowRef<number>(0);
 const deviceType = shallowRef<number | null>(1);
-getDictQueryList({ names: ['DeviceType', 'DeviceStatus'] }).then(res => {
-  deviceTypeList.value.push(...res.data['DeviceType']);
-  deviceStatusList.value.push(...res.data['DeviceStatus']);
+useDictList(['DeviceType', 'DeviceStatus']).then(res => {
+  deviceTypeList.value.push(...res['DeviceType']);
+  deviceStatusList.value.push(...res['DeviceStatus']);
 });
 function searchTable() {
   // setLoading(true);

+ 59 - 0
src/views/home/index.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="container">
+    <div class="card-box">
+      <div class="left-box">
+        <a-card class="count-card"> </a-card>
+        <a-card class="list-card">
+          <HomePage :data></HomePage>
+        </a-card>
+      </div>
+      <a-card class="right-card"> </a-card>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup name="HomePage">
+import {
+  ref,
+  reactive,
+  shallowRef,
+  h,
+  getCurrentInstance,
+  computed,
+  onMounted,
+} from 'vue';
+import type { AlarmTotalRes } from '@/api/home';
+import { fetchHomeAlarmTotal } from '@/api/home';
+
+import HomePage from './list/index.vue';
+
+const data = ref<AlarmTotalRes>({} as AlarmTotalRes);
+fetchHomeAlarmTotal().then(res => {
+  data.value = res;
+  console.log('res', res);
+});
+</script>
+<style lang="less" scoped>
+.container {
+  padding: 10px 10px 20px;
+
+  .card-box {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    width: 100%;
+  }
+
+  .left-box {
+    width: 40%;
+  }
+
+  .right-card {
+    width: 60%;
+    margin-left: 10px;
+  }
+
+  .count-card {
+    margin-bottom: 10px;
+  }
+}
+</style>

+ 201 - 0
src/views/home/list/index.vue

@@ -0,0 +1,201 @@
+<template>
+  <a-table
+    class="table-list"
+    row-key="name"
+    :loading="loading"
+    :pagination="pagination"
+    :columns="cloneColumns"
+    :data="data.data"
+    :bordered="false"
+    :size="size"
+    :scrollbar="true"
+    @page-change="onPageChange"
+    @row-dblclick="handleClick"
+  >
+    <template #index="{ rowIndex }">
+      {{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
+    </template>
+
+    <template #entityType="{ record }">
+      <span>
+        {{
+          deviceTypeList.find(item => item.dictCode === record.entityType)?.name
+        }}
+      </span>
+    </template>
+    <template #status="{ record }">
+      <BTag :status="record.status" size="small">
+        {{
+          deviceStatusList.find(item => item.dictCode === record.status)?.name
+        }}
+      </BTag>
+    </template>
+    <template #time="{ record }">
+      <span>{{
+        record.time && dayjs(record.time).format('YYYY-MM-DD HH:mm:ss')
+      }}</span>
+    </template>
+  </a-table>
+</template>
+
+<script lang="ts" setup name="ListPage">
+import {
+  ref,
+  reactive,
+  shallowRef,
+  h,
+  getCurrentInstance,
+  computed,
+  onMounted,
+  watch,
+} from 'vue';
+import {
+  queryDashboardList,
+  exportDashboardList,
+  deleteDeviceDetails,
+} from '@/api/dashboard';
+import type { DashboardParams, DataList } from '@/api/dashboard';
+import type { AlarmTotalRes } from '@/api/home';
+import { SizeProps, Pagination } from '@/types/global';
+import BTag from '@/components/business/b-tag/index.vue';
+import type { TableColumnData } from '@arco-design/web-vue';
+import useLoading from '@/hooks/loading';
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
+import { DeviceInfo } from '@/utils/const';
+import { Modal } from '@arco-design/web-vue';
+import type { AdditionalProp } from '@/api/dict';
+import useDictList from '@/hooks/dict-list';
+
+interface ListPageProps {
+  data: AlarmTotalRes;
+}
+const props = withDefaults(defineProps<ListPageProps>(), {
+  data: () => ({}) as AlarmTotalRes,
+});
+const { t } = useI18n();
+
+const { loading, setLoading } = useLoading(true);
+const cloneColumns = computed(() => [
+  {
+    title: t('searchTable.table.number'),
+    dataIndex: 'index',
+    slotName: 'index',
+    ellipsis: true,
+    tooltip: true,
+    width: 70,
+  },
+  {
+    title: t('dashboard.table.time'),
+    dataIndex: 'time',
+    slotName: 'time',
+    ellipsis: true,
+    width: 120,
+  },
+  {
+    title: t('dashboard.form.status'),
+    dataIndex: 'status',
+    slotName: 'status',
+    width: 120,
+  },
+  {
+    title: t('dashboard.form.name'),
+    dataIndex: 'name',
+    slotName: 'name',
+    width: 120,
+  },
+  {
+    title: t('dashboard.form.entityType'),
+    dataIndex: 'entityType',
+    slotName: 'entityType',
+    width: 120,
+  },
+
+  {
+    title: t('dashboard.form.address'),
+    dataIndex: 'address',
+    ellipsis: true,
+    tooltip: true,
+    width: 120,
+  },
+]);
+
+const basePagination: Pagination = {
+  current: 1,
+  pageSize: 20,
+};
+const pagination = reactive({
+  ...basePagination,
+});
+const generateFormModel = () => {
+  return {
+    pageIndex: 1,
+    pageSize: 20,
+    name: null,
+    address: null,
+    status: null,
+    startTime: null,
+    endTime: null,
+    time: ['', ''],
+    entityType: null,
+  } as DashboardParams;
+};
+const renderData = ref<DataList[]>([] as DataList[]);
+const size = ref<SizeProps>('medium');
+const formModel = ref<DashboardParams>(generateFormModel());
+const visible = shallowRef<boolean>(false);
+const this_ = getCurrentInstance()?.appContext.config.globalProperties;
+const deviceInfo = ref<DeviceInfo[]>([] as DeviceInfo[]);
+const deviceTypeList = ref<AdditionalProp[]>([] as AdditionalProp[]);
+const deviceStatusList = ref<AdditionalProp[]>([] as AdditionalProp[]);
+
+useDictList(['DeviceType', 'DeviceStatus']).then(res => {
+  deviceTypeList.value.push(...res['DeviceType']);
+  deviceStatusList.value.push(...res['DeviceStatus']);
+});
+
+watch(
+  () => props.data,
+  newVal => {
+    if (newVal) {
+      const { totalCount } = newVal;
+      pagination.current = 1;
+      pagination.pageSize = totalCount;
+      pagination.total = totalCount;
+      setLoading(false);
+    }
+  },
+  { deep: true }
+);
+
+const onPageChange = (current: number) => {
+  formModel.value.pageIndex = current;
+};
+
+const handleClick = (value: DataList) => {
+  if (value.data) {
+    deviceInfo.value.length = 0;
+    const obj = JSON.parse(value.data);
+    for (const key in obj) {
+      deviceInfo.value.push({
+        label: key,
+        value: obj[key],
+      });
+    }
+    visible.value = true;
+  } else {
+    this_ &&
+      this_.$message.warning('No device information available at the moment');
+  }
+};
+</script>
+
+<style lang="less" scoped>
+.container {
+  padding: 10px 10px 20px;
+
+  .table-list {
+    margin-top: 0;
+  }
+}
+</style>

+ 3 - 4
src/views/user/manage/index.vue

@@ -128,8 +128,7 @@ import { useI18n } from 'vue-i18n';
 // import { privilegeList } from '@/utils/const';
 import { Modal } from '@arco-design/web-vue';
 import type { AdditionalProp } from '@/api/dict';
-import { getDictQueryList } from '@/api/dict';
-
+import useDictList from '@/hooks/dict-list';
 const { t } = useI18n();
 
 const { loading, setLoading } = useLoading(true);
@@ -184,8 +183,8 @@ const userId = shallowRef<number>(0);
 const showEditDialog = shallowRef<boolean>(false);
 const this_ = getCurrentInstance()?.appContext.config.globalProperties;
 const privilegeList = ref<AdditionalProp[]>([]);
-getDictQueryList({ names: ['Privilege'] }).then(res => {
-  privilegeList.value.push(...res.data['Privilege']);
+useDictList(['Privilege']).then(res => {
+  privilegeList.value.push(...res['Privilege']);
 });
 const search = () => {
   searchTable();