Explorar o código

feat(layout): 添加公共布局组件

1. 编写布局、侧栏、菜单项组件
2. 添加网关管理、设备管理和协议管理等模块的路由及初始页面组件
wangcong hai 3 meses
pai
achega
737c7f9233

+ 18 - 1
src/i18n/locales/zh.json

@@ -2,6 +2,7 @@
   "common": {
     "add": "添加",
     "advancedSettings": "高级设置",
+    "aiCtrl": "AI智控",
     "basicInfo": "基础信息",
     "binding": "绑定",
     "cancel": "取消",
@@ -36,6 +37,7 @@
     "status": "状态",
     "tip": "提示",
     "turnOff": "关闭",
+    "unimatIoT": "亿维物联",
     "verification": "验证",
     "viewDoc": "查看文档",
     "warning": "警告"
@@ -103,6 +105,7 @@
     "whetherProcessData": "是否过程数据",
     "withinGroupRanking": "组内排序"
   },
+  "deviceList": {},
   "firstUsage": {
     "chooseItemToBeginSetup": "选择一项开始配置",
     "createCustomer": "为你的客户创建账号",
@@ -110,6 +113,19 @@
     "registerGateway": "注册网关,绑定网关与协议",
     "setupProtocol": "按项目点表配置协议"
   },
+  "gatewayList": {},
+  "hvacHome": {},
+  "keywordLibrary": {},
+  "navigation": {
+    "deviceList": "设备列表",
+    "deviceManage": "设备管理",
+    "gatewayList": "网关列表",
+    "gatewayManage": "网关管理",
+    "hvacHome": "首页",
+    "keywordLibrary": "关键词词库",
+    "protocolManage": "协议管理",
+    "standardProtocolLibrary": "平台标准协议库"
+  },
   "registerGateway": {
     "addInterface": "添加接口",
     "addStation": "添加从站",
@@ -242,5 +258,6 @@
     "waitingForRecognition": "等待识别",
     "wrongProtocolFileFize": "协议文件最大不能超过 {size}MB",
     "wrongProtocolFileType": "协议文件格式错误,请上传 .xls,xlsx 格式的文件"
-  }
+  },
+  "standardProtocolLibrary": {}
 }

+ 29 - 0
src/layout/AsideItem.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+import type { IconfontIcon } from '@/icons/fonts/iconfont';
+
+interface Props {
+  title: string;
+  icon?: IconfontIcon;
+}
+
+defineProps<Props>();
+</script>
+
+<template>
+  <SvgIcon v-if="icon" :name="icon" class="aside-item-icon" />
+  <span class="aside-item-title">{{ title }}</span>
+</template>
+
+<style lang="scss" scoped>
+.aside-item-icon {
+  margin-right: 9px;
+  font-size: 20px;
+  font-weight: 600;
+}
+
+.aside-item-title {
+  font-size: 14px;
+  font-weight: 500;
+  line-height: 22px;
+}
+</style>

+ 248 - 0
src/layout/HvacAside.vue

@@ -0,0 +1,248 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { routes } from '@/router';
+import { translateNavigation } from '@/utils';
+
+import AsideItem from './AsideItem.vue';
+
+import type { MenuInfo } from 'ant-design-vue/es/menu/src/interface';
+
+const router = useRouter();
+const route = useRoute();
+const selectedKeys = ref<string[]>([route.path]);
+const openKeys = ref<string[]>([]);
+
+onMounted(() => {
+  const firstPath = route.path.split('/')[1];
+
+  if (firstPath) {
+    openKeys.value.push('/' + firstPath);
+  }
+});
+
+const handleMenuClick = ({ key }: MenuInfo) => {
+  router.push(key as string);
+};
+
+const collapsed = ref<boolean>(false);
+</script>
+
+<template>
+  <ALayoutSider class="aside-container" v-model:collapsed="collapsed" collapsible :trigger="null" :width="246">
+    <div class="aside-header">
+      <div class="aside-header-logo"></div>
+      <span class="aside-header-title">{{ $t('common.unimatIoT') }}</span>
+    </div>
+    <AMenu
+      class="aside-menu"
+      v-model:selected-keys="selectedKeys"
+      v-model:open-keys="openKeys"
+      mode="inline"
+      @click="handleMenuClick"
+    >
+      <template v-for="{ path, meta, children } in routes" :key="path">
+        <template v-if="meta && !meta.hideInMenu">
+          <ASubMenu v-if="!meta.hideSubMenu && children" :key="path">
+            <template #title>
+              <AsideItem :title="translateNavigation(meta.title)" :icon="meta.icon" />
+            </template>
+            <AMenuItem v-for="{ path: subPath, meta } in children" :key="`${path}/${subPath}`">
+              <AsideItem :title="translateNavigation(meta?.title)" />
+            </AMenuItem>
+          </ASubMenu>
+          <AMenuItem v-else :key="`${path}/index`">
+            <AsideItem :title="translateNavigation(meta.title)" :icon="meta.icon" />
+          </AMenuItem>
+        </template>
+      </template>
+      <div class="aside-menu-ai-ctrl">{{ $t('common.aiCtrl') }}</div>
+      <AMenuItem key="ai-1" disabled>
+        <AsideItem title="一级菜单" icon="setting" />
+      </AMenuItem>
+      <AMenuItem key="ai-2" disabled>
+        <AsideItem title="一级菜单" icon="setting" />
+      </AMenuItem>
+      <AMenuItem key="ai-3" disabled>
+        <AsideItem title="一级菜单" icon="setting" />
+      </AMenuItem>
+    </AMenu>
+    <div class="aside-footer">
+      <div class="aside-footer-avatar"></div>
+    </div>
+  </ALayoutSider>
+</template>
+
+<style lang="scss" scoped>
+.aside-container {
+  --aside-border-radius: 18px;
+  --aside-padding: 12px;
+
+  background-color: var(--hvac-layout-bg);
+
+  :deep(.ant-layout-sider-children) {
+    display: flex;
+    flex-direction: column;
+  }
+
+  :deep(.aside-menu) {
+    flex: 1;
+    border-inline-end: none;
+
+    & > .ant-menu-item,
+    .ant-menu-submenu-title {
+      padding-left: 6px !important;
+    }
+
+    .ant-menu-item,
+    .ant-menu-submenu-title {
+      width: calc(100%);
+      height: 36px;
+      padding-inline: 12px;
+      margin-block: 0;
+      margin-inline: 0;
+      overflow: hidden;
+      line-height: 36px;
+      text-overflow: ellipsis;
+    }
+
+    & > .ant-menu-submenu,
+    & > .ant-menu-item {
+      & + .ant-menu-submenu,
+      & + .ant-menu-item {
+        margin-top: 16px;
+      }
+    }
+
+    .ant-menu-submenu-title + .ant-menu-sub > li:first-child {
+      margin-top: 13px;
+    }
+
+    .ant-menu-submenu-arrow {
+      right: var(--aside-padding);
+
+      &::before,
+      &::after {
+        height: 1px;
+      }
+    }
+
+    .ant-menu-sub.ant-menu-inline {
+      background: var(--antd-color-bg-base);
+    }
+
+    .ant-menu-sub .ant-menu-item {
+      padding-left: 42px !important;
+
+      &::before {
+        position: absolute;
+        left: 15px;
+        height: 100%;
+        content: '';
+        border: 1px solid var(--antd-color-primary-bg-hover);
+      }
+
+      &.ant-menu-item-selected::before {
+        border-color: var(--antd-color-primary);
+      }
+    }
+
+    .ant-menu-item-selected {
+      background-color: var(--antd-color-bg-base);
+    }
+
+    .ant-menu-submenu-selected > .ant-menu-submenu-title {
+      color: var(--antd-color-text-secondary);
+      background-color: var(--antd-color-primary-bg);
+    }
+
+    .ant-menu-item:not(.ant-menu-item-selected):hover,
+    .ant-menu-submenu-title:hover {
+      color: var(--antd-color-primary);
+      background-color: initial;
+    }
+
+    & > .ant-menu-item.ant-menu-item-selected {
+      background-color: var(--antd-color-primary-bg);
+    }
+
+    .ant-menu-title-content {
+      display: flex;
+      align-items: center;
+    }
+  }
+}
+
+.aside-header {
+  display: flex;
+  align-items: center;
+  padding: 24px var(--aside-padding) 32px;
+  background-color: var(--antd-color-bg-base);
+  border-top-left-radius: var(--aside-border-radius);
+  border-top-right-radius: var(--aside-border-radius);
+}
+
+.aside-header-logo {
+  width: 40px;
+  height: 40px;
+  margin-right: 12px;
+  background-color: var(--antd-color-primary);
+  border-radius: 16px;
+}
+
+.aside-header-title {
+  font-size: 16px;
+  font-style: normal;
+  font-weight: 600;
+  line-height: 24px;
+  color: var(--antd-color-text);
+}
+
+.aside-menu {
+  padding: 0 var(--aside-padding);
+  color: var(--antd-color-text-secondary);
+}
+
+.aside-menu-ai-ctrl {
+  width: 48px;
+  height: 22px;
+  margin: 24px 0;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 22px;
+  color: var(--antd-color-text-secondary);
+  text-align: center;
+  background-color: var(--antd-color-primary-bg);
+  border-radius: 8px;
+}
+
+.aside-footer {
+  height: 65px;
+  padding: var(--aside-padding);
+  padding-right: 23px;
+  margin-top: 2px;
+  background-color: var(--antd-color-bg-base);
+  border-bottom-right-radius: var(--aside-border-radius);
+  border-bottom-left-radius: var(--aside-border-radius);
+}
+
+.aside-footer-avatar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 40px;
+  height: 40px;
+  background: var(--antd-color-primary);
+  border-radius: 50%;
+
+  // 临时使用
+  &::before {
+    font-size: 14px;
+    font-weight: 500;
+    line-height: 22px;
+    color: #fff;
+    content: '贾';
+  }
+}
+</style>

+ 26 - 0
src/layout/HvacLayout.vue

@@ -0,0 +1,26 @@
+<script setup lang="ts">
+import HvacAside from './HvacAside.vue';
+</script>
+
+<template>
+  <ALayout class="hvac-layout-container">
+    <HvacAside />
+    <ALayout>
+      <ALayoutContent class="hvac-layout-main">
+        <RouterView />
+      </ALayoutContent>
+    </ALayout>
+  </ALayout>
+</template>
+
+<style lang="scss" scoped>
+.hvac-layout-container {
+  height: 100%;
+}
+
+.hvac-layout-main {
+  padding: 25px 25px 0;
+  overflow: auto;
+  background-color: var(--hvac-layout-bg);
+}
+</style>

+ 182 - 31
src/router/index.ts

@@ -1,38 +1,189 @@
 import { createRouter, createWebHistory } from 'vue-router';
 
+import HvacLayout from '@/layout/HvacLayout.vue';
+import type { IconfontIcon } from '@/icons/fonts/iconfont';
+
+import type { RouteRecordRaw } from 'vue-router';
+
+declare module 'vue-router' {
+  interface RouteMeta {
+    title?: string;
+    icon?: IconfontIcon;
+    hideInMenu?: boolean; // 该页面是否在侧栏菜单中显示
+    hideSubMenu?: boolean; // 该页面是否不显示在侧栏的子菜单,即作为一级菜单使用
+    requiresAuth: boolean;
+  }
+}
+
+const routes: Readonly<RouteRecordRaw[]> = [
+  {
+    path: '/',
+    redirect: '/first-usage',
+  },
+  {
+    path: '/home',
+    redirect: '/home/index',
+    component: HvacLayout,
+    meta: {
+      title: 'hvacHome',
+      icon: 'setting',
+      hideSubMenu: true,
+      requiresAuth: true,
+    },
+    children: [
+      {
+        path: 'index',
+        name: 'hvacHome',
+        component: () => import('@/views/hvac-home/HvacHome.vue'),
+        meta: {
+          requiresAuth: true,
+        },
+      },
+    ],
+  },
+  {
+    path: '/protocol-manage',
+    name: 'protocolManage',
+    redirect: '/protocol-manage/keyword-library',
+    component: HvacLayout,
+    meta: {
+      title: 'protocolManage',
+      icon: 'setting',
+      requiresAuth: true,
+    },
+    children: [
+      {
+        path: 'keyword-library',
+        name: 'keywordLibrary',
+        component: () => import('@/views/keyword-library/KeywordLibrary.vue'),
+        meta: {
+          title: 'keywordLibrary',
+          requiresAuth: true,
+        },
+      },
+      {
+        path: 'standard-protocol-library',
+        name: 'standardProtocolLibrary',
+        component: () => import('@/views/standard-protocol-library/StandardProtocolLibrary.vue'),
+        meta: {
+          title: 'standardProtocolLibrary',
+          requiresAuth: true,
+        },
+      },
+    ],
+  },
+  {
+    path: '/device-manage',
+    name: 'deviceManage',
+    redirect: '/device-manage/device-list',
+    component: HvacLayout,
+    meta: {
+      title: 'deviceManage',
+      icon: 'setting',
+      requiresAuth: true,
+    },
+    children: [
+      {
+        path: 'device-list',
+        name: 'deviceList',
+        component: () => import('@/views/device-list/DeviceList.vue'),
+        meta: {
+          title: 'deviceList',
+          requiresAuth: true,
+        },
+      },
+    ],
+  },
+  {
+    path: '/gateway-manage',
+    name: 'gatewayManage',
+    redirect: '/gateway-manage/gateway-list',
+    component: HvacLayout,
+    meta: {
+      title: 'gatewayManage',
+      icon: 'setting',
+      requiresAuth: true,
+    },
+    children: [
+      {
+        path: 'gateway-list',
+        name: 'gatewayList',
+        component: () => import('@/views/gateway-list/GatewayList.vue'),
+        meta: {
+          title: 'gatewayList',
+          requiresAuth: true,
+        },
+      },
+    ],
+  },
+  {
+    path: '/first-usage',
+    name: 'firstUsage',
+    component: () => import('@/views/first-usage/FirstUsage.vue'),
+    meta: {
+      hideInMenu: true,
+      requiresAuth: true,
+    },
+  },
+  {
+    path: '/create-customer',
+    name: 'createCustomer',
+    component: () => import('@/views/create-customer/CreateCustomer.vue'),
+    meta: {
+      hideInMenu: true,
+      requiresAuth: true,
+    },
+  },
+  {
+    path: '/setup-protocol',
+    name: 'setupProtocol',
+    component: () => import('@/views/setup-protocol/SetupProtocol.vue'),
+    meta: {
+      hideInMenu: true,
+      requiresAuth: true,
+    },
+  },
+  {
+    path: '/register-gateway',
+    name: 'registerGateway',
+    component: () => import('@/views/register-gateway/RegisterGateway.vue'),
+    meta: {
+      hideInMenu: true,
+      requiresAuth: true,
+    },
+  },
+  {
+    path: '/create-device',
+    name: 'createDevice',
+    component: () => import('@/views/create-device/CreateDevice.vue'),
+    meta: {
+      hideInMenu: true,
+      requiresAuth: true,
+    },
+  },
+];
+
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
-  routes: [
-    {
-      path: '/',
-      redirect: '/first-usage',
-    },
-    {
-      path: '/first-usage',
-      name: 'firstUsage',
-      component: () => import('@/views/first-usage/FirstUsage.vue'),
-    },
-    {
-      path: '/create-customer',
-      name: 'createCustomer',
-      component: () => import('@/views/create-customer/CreateCustomer.vue'),
-    },
-    {
-      path: '/setup-protocol',
-      name: 'setupProtocol',
-      component: () => import('@/views/setup-protocol/SetupProtocol.vue'),
-    },
-    {
-      path: '/register-gateway',
-      name: 'registerGateway',
-      component: () => import('@/views/register-gateway/RegisterGateway.vue'),
-    },
-    {
-      path: '/create-device',
-      name: 'createDevice',
-      component: () => import('@/views/create-device/CreateDevice.vue'),
-    },
-  ],
+  routes,
 });
 
+// router.beforeEach((to, from, next) => {
+//   if (to.meta.requiresAuth) {
+//     const token = getToken();
+
+//     if (token) {
+//       const { saveToken } = useUserInfoStore();
+//       saveToken(token);
+//       next();
+//     } else {
+//       next('/login');
+//     }
+//   } else {
+//     next();
+//   }
+// });
+
+export { routes };
+
 export default router;

+ 4 - 0
src/styles/global.scss

@@ -1,5 +1,9 @@
 @import '../icons/fonts/iconfont.css';
 
+:root {
+  --hvac-layout-bg: #edf5f6;
+}
+
 .use-guide-title {
   font-size: 20px;
   font-weight: 500;

+ 4 - 0
src/utils/index.ts

@@ -81,6 +81,10 @@ export const request = async <T>(url: string, init: RequestInit = {}, timeout?:
   return data;
 };
 
+export const translateNavigation = (title?: string) => {
+  return title ? t('navigation.' + title) : '';
+};
+
 /**
  * 根据 Antd 当前主题的 token 生成 css var
  */

+ 1 - 1
src/views/first-usage/FirstUsage.vue

@@ -66,7 +66,7 @@ const configList = computed<ConfigItem[]>(() => {
           </div>
         </ACol>
       </ARow>
-      <AButton class="skip-button" type="text">{{ $t('common.skip') }}</AButton>
+      <AButton class="skip-button" type="text" @click="$router.push('/home')">{{ $t('common.skip') }}</AButton>
     </div>
   </div>
 </template>

+ 3 - 0
src/views/gateway-list/GatewayList.vue

@@ -0,0 +1,3 @@
+<template>
+  <div>网关列表</div>
+</template>

+ 3 - 0
src/views/hvac-home/HvacHome.vue

@@ -0,0 +1,3 @@
+<template>
+  <div>首页</div>
+</template>

+ 3 - 0
src/views/keyword-library/KeywordLibrary.vue

@@ -0,0 +1,3 @@
+<template>
+  <div>关键词词库</div>
+</template>

+ 3 - 0
src/views/standard-protocol-library/StandardProtocolLibrary.vue

@@ -0,0 +1,3 @@
+<template>
+  <div>标准协议库</div>
+</template>