Ver Fonte

feat(告警管理): 新增告警模板功能及菜单路由重构

重构菜单路由结构,支持排序和图标配置
新增告警模板管理页面及相关API接口
优化侧边栏菜单组件,支持动态路由和排序
修复设备详情页告警计划弹窗标题显示问题
liujia há 1 mês atrás
pai
commit
f1de6de

+ 1 - 0
components.d.ts

@@ -49,6 +49,7 @@ declare module 'vue' {
     ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
     ASpace: typeof import('ant-design-vue/es')['Space']
     ASpin: typeof import('ant-design-vue/es')['Spin']
+    ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
     ASwitch: typeof import('ant-design-vue/es')['Switch']
     ATable: typeof import('ant-design-vue/es')['Table']
     ATag: typeof import('ant-design-vue/es')['Tag']

+ 34 - 0
src/api/alarm/index.ts

@@ -44,3 +44,37 @@ export const enableAlarmPlan = (params: {
 }): Promise<ResponseData<null>> => {
   return request.post('/alarm/plan/enable', params)
 }
+
+/**
+ * 告警计划模板保存
+ */
+export const saveAlarmPlanTemplate = (params: TYPE.AlarmPlanTemplateParams) => {
+  return request.post('/alarm/plan/saveTpl', params)
+}
+
+/**
+ * 告警计划模板查询
+ */
+export const getAlarmPlanTemplateList = (params: {
+  enable: 0 | 1 | null
+  eventVal: number | null
+}): Promise<ResponseData<TYPE.AlarmPlanTemplateListResponseData>> => {
+  return request.post('/alarm/plan/queryTpl', params)
+}
+
+/**
+ * 告警计划模板删除
+ */
+export const deleteAlarmPlanTemplate = (params: { id: number }): Promise<ResponseData<null>> => {
+  return request.post('/alarm/plan/delTpl', params)
+}
+
+/**
+ * 告警计划模板禁启用
+ */
+export const enableAlarmPlanTemplate = (params: {
+  id: number
+  enable: 0 | 1
+}): Promise<ResponseData<null>> => {
+  return request.post('/alarm/plan/enableTpl', params)
+}

+ 50 - 1
src/api/alarm/types.ts

@@ -38,7 +38,6 @@ interface AlarmTimePlan {
 }
 /**
  * 保存告警计划请求参数
- * @see http://8.130.28.21:31090/doc.html#/%E9%97%A8%E6%88%B7%E6%9C%8D%E5%8A%A1/web%E7%AB%AF%E5%91%8A%E8%AD%A6%E7%9B%B8%E5%85%B3/savePlan
  */
 export interface AlarmPlanParams {
   alarmPlanId?: number // 告警计划ID
@@ -48,6 +47,7 @@ export interface AlarmPlanParams {
   region: string // 检测区域 '[left, top, width, height]'
   eventVal: number // 告警事件值
   param: string // 告警参数 需要根据 eventVal 传对应的参数
+  linkagePushWechatService: SwitchType // 是否开启服务号消息推送 1:开启 0:关闭
   alarmTimePlan: AlarmTimePlan // 告警时间计划
   enable: 0 | 1 // 是否启用(number) 1: 启用 0: 禁用
 }
@@ -85,9 +85,58 @@ export interface AlarmPlanListResponseData {
   region: string // 检测区域 '[left, top, width, height]'
   eventVal: string // 告警事件值
   param: string // 告警参数 需要根据 eventVal 传对应的参数
+  linkagePushWechatService: SwitchType // 是否开启服务号消息推送 1:开启 0:关闭
   alarmTimePlan: AlarmTimePlan // 告警时间计划
   enable: 0 | 1 // 是否启用(number) 1: 启用 0: 禁用
   createTime: string // 创建时间
   updateTime: string // 更新时间
   remark: string // 备注
 }
+
+/**
+ * 告警计划模板查询响应数据rows
+ */
+export interface AlarmPlanTemplateItem {
+  id: number // 告警计划ID
+  uuid: string // 告警计划UUID
+  name: string // 告警计划名称
+  enable: 0 | 1 // 是否启用(number) 1: 启用 0: 禁用
+  region: string // 检测区域 '[left, top, width, height]'
+  eventVal: string // 告警事件值
+  clientId: string // 设备ID
+  param: string // 告警参数 需要根据 eventVal 传对应的参数
+  linkagePushWechatService: SwitchType // 是否开启服务号消息推送 1:开启 0:关闭
+  alarmTimePlanTpl: AlarmTimePlan // 告警时间计划
+  createTime: string // 创建时间
+  updateTime: string // 更新时间
+  remark: string // 备注
+}
+
+/**
+ * 告警计划模板查询响应数据
+ */
+export interface AlarmPlanTemplateListResponseData {
+  rows: AlarmPlanTemplateItem[]
+  total: number
+  pageNum: number
+  pageSize: number
+  outTotalPageNum: number
+  totalPageNum: number
+}
+
+/**
+ * 告警计划模板保存请求参数
+ */
+export interface AlarmPlanTemplateParams {
+  id?: number // 告警表主键ID
+  name: string // 告警计划名称
+  enable: 0 | 1 // 是否启用(number) 1: 启用 0: 禁用
+  region?: string // 检测区域 '[left, top, width, height]'
+  eventVal: number // 告警事件值
+  param: string // 告警参数 需要根据 eventVal 传对应的参数s
+  thresholdTime?: number // 异常消失时间阈值(单位:秒)
+  mergeTime?: number // 	归并时间(秒)
+  linkagePushWechatService: SwitchType // 是否开启服务号消息推送 1:开启 0:关闭
+  alarmTimePlanTpl: AlarmTimePlan // 告警时间计划
+  remark?: string // 备注
+}

+ 19 - 7
src/const/menus.ts

@@ -2,7 +2,7 @@ import {
   // PieChartOutlined,
   DatabaseOutlined,
   InsertRowLeftOutlined,
-  // AlertOutlined,
+  AlertOutlined,
   // UserOutlined,
   // SettingOutlined,
 } from '@ant-design/icons-vue'
@@ -33,12 +33,24 @@ export const menus = ref([
     label: '设备管理',
     title: '设备管理',
   },
-  // {
-  //   key: 'alarm',
-  //   icon: () => h(AlertOutlined),
-  //   label: '告警管理',
-  //   title: '告警管理',
-  // },
+  {
+    key: 'alarm',
+    icon: () => h(AlertOutlined),
+    label: '告警管理',
+    title: '告警管理',
+    children: [
+      {
+        key: 'alarmTemplate',
+        label: '告警模板',
+        title: '告警模板',
+      },
+      {
+        key: 'alarmPlan',
+        label: '告警计划',
+        title: '告警计划',
+      },
+    ],
+  },
   // {
   //   key: 'user',
   //   icon: () => h(UserOutlined),

+ 107 - 0
src/layout/components/sideMenu/index.vue

@@ -0,0 +1,107 @@
+<template>
+  <a-menu
+    v-model:selectedKeys="selectedKeys"
+    v-model:openKeys="openKeys"
+    mode="inline"
+    theme="dark"
+    :inline-collapsed="props.collapsed"
+    @click="handleMenuClick"
+  >
+    <template v-for="item in sortedMenuItems" :key="item.name">
+      <a-menu-item v-if="!item.children" :key="item.name">
+        <template #icon>
+          <component :is="item.meta.icon" v-if="item.meta.icon" />
+        </template>
+        {{ item.meta.title }}
+      </a-menu-item>
+
+      <a-sub-menu v-else :key="item.name">
+        <template #icon>
+          <component :is="item.meta.icon" v-if="item.meta.icon" />
+        </template>
+        <template #title>{{ item.meta.title }}</template>
+        <a-menu-item v-for="child in item.children" :key="child.name">
+          {{ child.meta.title }}
+        </a-menu-item>
+      </a-sub-menu>
+    </template>
+  </a-menu>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue'
+import { useRouter, useRoute, type RouteRecordRaw } from 'vue-router'
+import { type MenuProps } from 'ant-design-vue'
+import { routesToMenuItems, sortMenuItems } from '@/utils/menuUtils'
+
+const router = useRouter()
+const route = useRoute()
+
+defineOptions({
+  name: 'SideMenu',
+})
+
+const props = defineProps<{
+  collapsed: boolean
+}>()
+
+const selectedKeys = ref<string[]>([])
+const openKeys = ref<string[]>([])
+
+// 获取排序后的菜单项
+const sortedMenuItems = computed(() => {
+  const menuItems = routesToMenuItems(
+    router.options.routes.filter(
+      (route) => route.meta && route.meta.title && !route.meta.hidden
+    ) as RouteRecordRaw[]
+  )
+  return sortMenuItems(menuItems)
+})
+
+// 处理菜单点击
+const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
+  router.push({ name: key as string })
+}
+
+// 根据当前路由更新菜单状态
+const updateMenuStatus = () => {
+  const currentRoute = route
+  const routeName = currentRoute.name as string
+
+  selectedKeys.value = [routeName]
+
+  // 查找父级路由设置展开菜单
+  const findParentRoute = (routes: RouteRecordRaw[], targetName: string): string | null => {
+    for (const route of routes) {
+      if (route.name === targetName) {
+        return null
+      }
+
+      if (route.children) {
+        for (const child of route.children) {
+          if (child.name === targetName) {
+            return route.name as string
+          }
+        }
+
+        const parentName = findParentRoute(route.children, targetName)
+        if (parentName) {
+          return route.name as string
+        }
+      }
+    }
+    return null
+  }
+
+  const parentName = findParentRoute(router.options.routes as RouteRecordRaw[], routeName)
+  if (parentName) {
+    openKeys.value = [parentName]
+  }
+}
+
+// 监听路由变化
+watch(() => route.fullPath, updateMenuStatus)
+
+// 初始化
+updateMenuStatus()
+</script>

+ 32 - 12
src/layout/index.vue

@@ -5,6 +5,7 @@
       collapsible
       :theme="theme"
       class="layout-sider"
+      @breakpoint="onBreakpoint"
     >
       <div :class="['logoWrap', theme === 'light' ? 'light' : 'dark']">
         <div class="logo">
@@ -12,14 +13,15 @@
         </div>
         <div class="text">雷能信息后台管理</div>
       </div>
-      <a-menu
+      <!-- <a-menu
         v-model:selectedKeys="state.selectedKeys"
         mode="inline"
         :theme="theme"
         :inline-collapsed="state.collapsed"
         :items="menus"
         @click="clickMenuItemHandler"
-      ></a-menu>
+      ></a-menu> -->
+      <SideMenu :collapsed="collapsed" />
     </a-layout-sider>
 
     <a-layout class="layout-content">
@@ -60,6 +62,7 @@
           <span>&nbsp;&nbsp;&nbsp;&nbsp;版本号:{{ version }}</span>
           <!-- <span>&nbsp;&nbsp;&nbsp;&nbsp;构建时间:{{ buildTime }}</span> -->
         </slot>
+        {{ route.name }}
       </a-layout-footer>
     </a-layout>
 
@@ -70,7 +73,7 @@
 <script setup lang="ts">
 import { ref, reactive, watchEffect, computed, h, onUnmounted, onMounted } from 'vue'
 import userDropdown from './components/userDropdown/index.vue'
-import { menus } from '@/const/menus'
+// import { menus } from '@/const/menus'
 import { useRoute, useRouter } from 'vue-router'
 import timeNow from './components/timeNow/index.vue'
 import type { Route } from 'ant-design-vue/es/breadcrumb/Breadcrumb'
@@ -78,6 +81,7 @@ import mqtt, { MqttClient } from 'mqtt'
 import { useUserStore } from '@/stores/user'
 import AlertModal from './components/alertModal/index.vue'
 import { ArrowLeftOutlined, SyncOutlined } from '@ant-design/icons-vue'
+import SideMenu from './components/sideMenu/index.vue'
 
 const userStore = useUserStore()
 const userId = ref(userStore?.userInfo?.userId || '')
@@ -90,6 +94,19 @@ const refresh = () => {
   emit('refresh')
 }
 
+const collapsed = ref<boolean>(false)
+
+const onBreakpoint = (broken: boolean) => {
+  if (broken) {
+    collapsed.value = true
+  }
+}
+
+// 需要缓存的组件列表
+// const keepAliveComponents = computed(() => {
+//   return route.matched.filter((item) => item.meta.keepAlive).map((item) => item.name as string)
+// })
+
 let mqttClient: MqttClient | null = null
 // let mqttTimeout: number | null = null
 // const MQTT_TIMEOUT_MS = 10000 // 10秒
@@ -291,18 +308,21 @@ const itemRender = (options: { route: Route; params: any; routes: Route[]; paths
   )
 }
 
+const clickedMenuItem = ref('')
 watchEffect(() => {
-  if (routes.value.length > 0 && routes.value[0].path) {
-    state.selectedKeys = [routes.value[0].path.split('/')[1] || 'home']
-  }
+  // if (routes.value.length > 0 && routes.value[0].path) {
+  //   state.selectedKeys = [routes.value[0].path.split('/')[1] || 'home']
+  // }
+  state.selectedKeys = [clickedMenuItem.value]
 })
 
-const clickMenuItemHandler = ({ key, keyPath }: { key: string; keyPath: string[] }) => {
-  console.log('🚀clickMenuItemHandler🚀', key, keyPath)
-  if (route.name !== key) {
-    router.push({ name: key })
-  }
-}
+// const clickMenuItemHandler = ({ key, keyPath }: { key: string; keyPath: string[] }) => {
+//   console.log('🚀clickMenuItemHandler🚀', key, keyPath)
+//   clickedMenuItem.value = key
+//   if (route.name !== key) {
+//     router.push({ name: key })
+//   }
+// }
 
 const canGoBack = ref(false)
 const navigationHistory = ref<string[]>([])

+ 2 - 2
src/router/index.ts

@@ -19,13 +19,13 @@ const router = createRouter({
       // component: HomeView,
       // meta: { title: '首页看板', isFullScreen: false, keepAlive: false },
       redirect: '/community/list',
-      meta: { title: '小区管理', isFullScreen: false, keepAlive: false },
+      meta: { title: '小区管理', isFullScreen: false, keepAlive: false, hidden: true },
     },
     {
       path: '/pointCloud',
       name: 'pointCloud',
       component: () => import('@/views/pointCloudMap/index.vue'),
-      meta: { title: '设备点云图', isFullScreen: true, keepAlive: false },
+      meta: { title: '设备点云图', isFullScreen: true, keepAlive: false, hidden: true },
     },
   ],
 })

+ 29 - 2
src/router/modules/alarm.ts

@@ -1,8 +1,35 @@
+import { AlertOutlined } from '@ant-design/icons-vue'
 export default [
   {
     path: '/alarm',
     name: 'alarm',
-    component: () => import('@/views/alarm/index.vue'),
-    meta: { title: '告警管理', isFullScreen: false, keepAlive: false },
+    redirect: '/alarm/template',
+    meta: {
+      title: '告警管理',
+      sort: 3,
+      icon: AlertOutlined,
+      isFullScreen: false,
+      keepAlive: false,
+    },
+    children: [
+      {
+        path: '/alarm/template',
+        name: 'alarmTemplate',
+        component: () => import('@/views/alarm/template/index.vue'),
+        meta: { title: '告警模板', sort: 1, isFullScreen: false, keepAlive: true },
+      },
+      {
+        path: '/alarm/plan',
+        name: 'alarmPlan',
+        component: () => import('@/views/alarm/plan/index.vue'),
+        meta: { title: '告警计划', sort: 2, isFullScreen: false, keepAlive: true, hidden: true },
+      },
+      {
+        path: '/alarm/history',
+        name: 'alarmHistory',
+        component: () => import('@/views/alarm/history/index.vue'),
+        meta: { title: '告警历史', sort: 3, isFullScreen: false, keepAlive: true, hidden: true },
+      },
+    ],
   },
 ]

+ 7 - 5
src/router/modules/community.ts

@@ -1,21 +1,23 @@
+import { InsertRowLeftOutlined } from '@ant-design/icons-vue'
+
 export default [
   {
     path: '/community',
     name: 'community',
     redirect: '/community/list',
-    meta: { title: '小区管理', isFullScreen: false },
+    meta: { title: '小区管理', sort: 1, icon: InsertRowLeftOutlined, isFullScreen: false },
     children: [
       {
-        path: 'list',
+        path: '/community/list',
         name: 'communityList',
         component: () => import('@/views/community/list/index.vue'),
-        meta: { title: '小区列表', isFullScreen: false, keepAlive: true },
+        meta: { title: '小区列表', sort: 1, isFullScreen: false, keepAlive: true },
       },
       {
-        path: 'detail',
+        path: '/community/detail',
         name: 'communityDetail',
         component: () => import('@/views/community/detail/index.vue'),
-        meta: { title: '小区详情', isFullScreen: false, keepAlive: false },
+        meta: { title: '小区详情', isFullScreen: false, keepAlive: false, hidden: true },
       },
     ],
   },

+ 7 - 5
src/router/modules/device.ts

@@ -1,21 +1,23 @@
+import { DatabaseOutlined } from '@ant-design/icons-vue'
+
 export default [
   {
     path: '/device',
     name: 'device',
     redirect: '/device/list',
-    meta: { title: '设备管理', isFullScreen: false },
+    meta: { title: '设备管理', sort: 2, icon: DatabaseOutlined, isFullScreen: false },
     children: [
       {
-        path: 'list',
+        path: '/device/list',
         name: 'deviceList',
         component: () => import('@/views/device/list/index.vue'),
-        meta: { title: '设备列表', isFullScreen: false, keepAlive: true },
+        meta: { title: '设备列表', sort: 1, isFullScreen: false, keepAlive: true },
       },
       {
-        path: 'detail',
+        path: '/device/detail',
         name: 'deviceDetail',
         component: () => import('@/views/device/detail/index.vue'),
-        meta: { title: '设备详情', isFullScreen: false, keepAlive: false },
+        meta: { title: '设备详情', isFullScreen: false, keepAlive: false, hidden: true },
       },
     ],
   },

+ 1 - 1
src/router/modules/login.ts

@@ -3,6 +3,6 @@ export default [
     path: '/login',
     name: 'login',
     component: () => import('@/views/login/index.vue'),
-    meta: { title: '登录', isFullScreen: true, keepAlive: false },
+    meta: { title: '登录', isFullScreen: true, keepAlive: false, hidden: true },
   },
 ]

+ 18 - 2
src/router/modules/system.ts

@@ -1,8 +1,24 @@
+import { SettingOutlined } from '@ant-design/icons-vue'
 export default [
   {
     path: '/system',
     name: 'system',
-    component: () => import('@/views/system/index.vue'),
-    meta: { title: '系统管理', isFullScreen: false, keepAlive: false },
+    redirect: '/system/config',
+    meta: {
+      title: '系统管理',
+      sort: 4,
+      icon: SettingOutlined,
+      isFullScreen: false,
+      keepAlive: false,
+      hidden: false,
+    },
+    children: [
+      {
+        path: '/system/config',
+        name: 'systemConfig',
+        component: () => import('@/views/system/config/index.vue'),
+        meta: { title: '参数配置', sort: 1, isFullScreen: false, keepAlive: false, hidden: false },
+      },
+    ],
   },
 ]

+ 1 - 1
src/router/modules/user.ts

@@ -3,6 +3,6 @@ export default [
     path: '/user',
     name: 'user',
     component: () => import('@/views/user/index.vue'),
-    meta: { title: '用户管理', isFullScreen: false, keepAlive: false },
+    meta: { title: '用户管理', isFullScreen: false, keepAlive: false, hidden: true },
   },
 ]

+ 49 - 0
src/utils/menuUtils.ts

@@ -0,0 +1,49 @@
+import { type RouteRecordRaw } from 'vue-router'
+
+// 菜单项接口
+export interface MenuItem {
+  name: string
+  path: string
+  meta: {
+    title: string
+    icon?: string
+    sort?: number
+    hidden?: boolean
+    [key: string]: unknown
+  }
+  children?: MenuItem[]
+}
+
+// 排序函数
+export const sortMenuItems = (items: MenuItem[]): MenuItem[] => {
+  return items
+    .filter((item) => !item.meta.hidden) // 过滤隐藏的菜单
+    .sort((a, b) => {
+      const aSort = a.meta.sort ?? 999 // 默认值确保未设置sort的排在后面
+      const bSort = b.meta.sort ?? 999
+      return aSort - bSort
+    })
+    .map((item) => {
+      if (item.children) {
+        return {
+          ...item,
+          children: sortMenuItems(item.children),
+        }
+      }
+      return item
+    })
+}
+
+// 将路由转换为菜单项
+export const routesToMenuItems = (routes: RouteRecordRaw[]): MenuItem[] => {
+  return routes.map((route) => ({
+    name: route.name as string,
+    path: route.path,
+    meta: {
+      ...route.meta,
+      title: (route.meta?.title as string) || '',
+      sort: (route.meta?.sort as number) || 999,
+    },
+    children: route.children ? routesToMenuItems(route.children) : undefined,
+  }))
+}

+ 11 - 0
src/views/alarm/history/index.vue

@@ -0,0 +1,11 @@
+<template>
+  <div> 告警历史 </div>
+</template>
+
+<script setup lang="ts">
+defineOptions({
+  name: 'AlarmHistoryIndex',
+})
+</script>
+
+<style scoped lang="less"></style>

+ 2 - 2
src/views/alarm/index.vue → src/views/alarm/plan/index.vue

@@ -1,10 +1,10 @@
 <template>
-  <div> 事件告警管理 </div>
+  <div> 告警计划 </div>
 </template>
 
 <script setup lang="ts">
 defineOptions({
-  name: 'AlarmIndex',
+  name: 'AlarmPlanIndex',
 })
 </script>
 

+ 55 - 0
src/views/alarm/template/const.ts

@@ -0,0 +1,55 @@
+import type { SelectProps } from 'ant-design-vue'
+
+export const columns = [
+  {
+    title: '计划名称',
+    dataIndex: 'name',
+    key: 'name',
+    align: 'center',
+    width: 200,
+  },
+  {
+    title: '创建时间',
+    dataIndex: 'createTime',
+    key: 'createTime',
+    align: 'center',
+    width: 200,
+  },
+  {
+    title: '事件类型',
+    key: 'eventType',
+    dataIndex: 'eventType',
+    align: 'center',
+    width: 200,
+  },
+  {
+    title: '是否启用',
+    dataIndex: 'enable',
+    key: 'enable',
+    align: 'center',
+    width: 120,
+  },
+  {
+    title: '操作',
+    key: 'action',
+    dataIndex: 'action',
+    align: 'center',
+    width: 120,
+  },
+]
+
+// 是否启用
+export const enableOptions: SelectProps['options'] = [
+  {
+    label: '全部',
+    value: null,
+  },
+  {
+    label: '启用',
+    value: 1,
+  },
+  {
+    label: '禁用',
+    value: 0,
+  },
+]

+ 347 - 0
src/views/alarm/template/index.vue

@@ -0,0 +1,347 @@
+<template>
+  <div class="communityPage">
+    <div class="searchBar">
+      <a-form layout="inline" @keydown.enter="searchHandler">
+        <a-form-item label="事件类型" name="eventType">
+          <a-select
+            v-model:value="searchState.eventType"
+            :options="eventTypeList"
+            placeholder="请选择"
+            style="width: 150px"
+          />
+        </a-form-item>
+
+        <a-form-item label="是否启用">
+          <a-select
+            v-model:value="searchState.enable"
+            style="width: 120px"
+            :options="enableOptions"
+            @change="searchHandler"
+          />
+        </a-form-item>
+
+        <a-form-item>
+          <a-space>
+            <a-button type="primary" @click="searchHandler"> 搜索 </a-button>
+            <a-button @click="resetHandler"> 重置 </a-button>
+          </a-space>
+        </a-form-item>
+      </a-form>
+    </div>
+
+    <div class="tableCard">
+      <div class="tableCard-header">
+        <div class="tableCard-header-title">告警模板列表</div>
+        <div class="tableCard-header-extra">
+          <a-space>
+            <a-button type="primary" @click="addTemplateHandler">添加模板</a-button>
+          </a-space>
+        </div>
+      </div>
+      <a-table
+        :columns="columns"
+        :data-source="listData"
+        :loading="loading"
+        :pagination="false"
+        :scroll="{ x: 'max-content' }"
+      >
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'time'">
+            <div>{{ record.createTime }}</div>
+            <div>{{ record.updateTime }}</div>
+          </template>
+
+          <template v-if="column.key === 'eventType'">
+            {{ record.eventTypeName }}
+          </template>
+
+          <template v-if="column.key === 'enable'">
+            <a-switch
+              v-model:checked="record.isEnable"
+              @click="swtichTemplateHandler(record.id, record.enable, record)"
+            />
+          </template>
+
+          <template v-if="column.key === 'action'">
+            <a-button type="link" @click="editTemplateHandler(record)">编辑</a-button>
+            <a-button type="link" @click="deleteTemplateHandler(record.id)">删除</a-button>
+          </template>
+        </template>
+      </a-table>
+
+      <base-pagination
+        v-if="listTotal > 0"
+        v-model:current="current"
+        v-model:pageSize="pageSize"
+        :total="listTotal"
+        @change="paginationChange"
+        @showSizeChange="paginationSizeChange"
+      ></base-pagination>
+    </div>
+
+    <alarmPlanModal
+      v-if="alarmPlanVisible"
+      v-model:open="alarmPlanVisible"
+      :title="alarmPlanTitle"
+      type="template"
+      :alarm-plan-id="alarmPlanId"
+      :data="alarmPlanDataWithType"
+      :area="{
+        width: 400,
+        height: 400,
+        ranges: [-200, 200, -200, 200],
+      }"
+      @success="fetchList"
+    ></alarmPlanModal>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onActivated, computed } from 'vue'
+import { columns, enableOptions } from './const'
+import { useSearch } from '@/hooks/useSearch'
+// import { useRouter } from 'vue-router'
+import * as alarmApi from '@/api/alarm'
+import type { AlarmPlanTemplateItem } from '@/api/alarm/types'
+import alarmPlanModal from '@/views/device/detail/components/alarmPlanModal/index.vue'
+import { message } from 'ant-design-vue'
+import { useDictName } from '@/hooks/useDictName'
+
+// const router = useRouter()
+
+defineOptions({
+  name: 'AlarmTemplateIndex',
+})
+
+interface SearchData {
+  eventType: number | null // 事件类型
+  enable: SwitchType | null // 是否启用
+}
+// 默认搜索条件
+const defaultSearch: SearchData = {
+  eventType: null,
+  enable: null,
+}
+
+const [searchState, resetHandler] = useSearch(defaultSearch, { afterReset: () => searchHandler() })
+
+const listData = ref<AlarmPlanTemplateItem[]>()
+const listTotal = ref<number>(0)
+const current = ref<number>(1)
+const pageSize = ref<number>(10)
+
+// 分页变化
+const paginationChange = (current: number, pageSize: number) => {
+  console.log('change', current, pageSize)
+  fetchList()
+}
+// 分页大小变化
+const paginationSizeChange = (current: number, pageSize: number) => {
+  console.log('showSizeChange', current, pageSize)
+}
+
+const eventTypeList = ref<{ label: string; value: string }[]>([])
+// 获取事件类型下拉列表
+const fetchEventTypeList = async () => {
+  try {
+    const res = await alarmApi.getAlarmEventTypeList()
+    console.log('获取事件类型下拉列表成功✅', res)
+    const data = res.data
+    eventTypeList.value =
+      (Array.isArray(data) &&
+        data.map((item) => ({
+          label: item.eventDesc,
+          value: item.eventVal,
+        }))) ||
+      []
+  } catch (err) {
+    console.log('获取事件类型下拉列表失败❌', err)
+  }
+}
+
+const { dictNameMap: alarmEventTypeName } = useDictName(eventTypeList)
+
+const loading = ref(false)
+// 获取列表
+const fetchList = async () => {
+  try {
+    loading.value = true
+    await alarmApi
+      .getAlarmPlanTemplateList({
+        enable: searchState.enable,
+        eventVal: searchState.eventType,
+      })
+      .then((res) => {
+        console.log('获取告警计划模板成功✅', res)
+        const { rows, total } = res.data
+        try {
+          listData.value =
+            rows &&
+            rows.map((item) => ({
+              ...item,
+              isEnable: item.enable === 1 ? true : false,
+              eventTypeName: alarmEventTypeName(item.eventVal),
+            }))
+        } catch (err) {
+          console.log('获取告警计划模板失败❌', err)
+        }
+        listTotal.value = Number(total)
+        loading.value = false
+      })
+      .catch((err) => {
+        console.log('获取告警计划模板失败❌', err)
+        loading.value = false
+      })
+  } catch (err) {
+    console.log('获取告警计划列表失败❌', err)
+  }
+}
+
+// 搜索
+const searchHandler = async () => {
+  console.log('searchState', searchState)
+  current.value = 1
+  pageSize.value = 10
+  await fetchList()
+}
+
+const alarmPlanVisible = ref(false) // 告警计划弹窗
+const alarmPlanId = ref<number | null>(null) // 当前编辑的告警计划id
+const alarmPlanTitle = ref<string>('添加告警计划')
+
+type AlarmPlan = {
+  id: number
+  uuid: ID
+  name: string
+  clientId: string
+  enable: SwitchType
+  region: string
+  eventVal: number
+  alarmTimePlanId: ID
+  thresholdTime: ID
+  mergeTime: ID
+  param: string
+  createTime: string
+  updateTime: string
+  remark: string | null
+  alarmTimePlan: {
+    createId: ID
+    updateId: ID
+    createTime: ID
+    updateTime: ID
+    isDeleted: SwitchType | null
+    remark: ID
+    id: ID
+    startDate: string
+    stopDate: string
+    timeRange: string
+    monthDays: string
+    weekdays: string
+  }
+}
+
+interface AlarmPlanItem {
+  id?: number
+  loading?: boolean
+  [key: string]: unknown
+}
+const alarmPlanData = ref<AlarmPlanItem | undefined>(undefined)
+const alarmPlanDataWithType = computed(() => alarmPlanData.value as AlarmPlan)
+
+// 添加告警模板
+const addTemplateHandler = () => {
+  console.log('addTemplateHandler')
+  alarmPlanVisible.value = true
+  alarmPlanTitle.value = '添加告警模板'
+  alarmPlanData.value = undefined
+  alarmPlanId.value = null
+}
+
+// 编辑告警模板
+const editTemplateHandler = async (item: AlarmPlanItem) => {
+  console.log('editTemplateHandler', item)
+  alarmPlanVisible.value = true
+  alarmPlanTitle.value = '编辑告警模板'
+  alarmPlanData.value = item
+  alarmPlanId.value = item?.id ?? null
+}
+
+// 删除告警模板
+const deleteTemplateHandler = async (id: number) => {
+  console.log('deleteTemplateHandler', id)
+  try {
+    alarmApi.deleteAlarmPlanTemplate({ id }).then(() => {
+      message.success('删除成功')
+      fetchList()
+    })
+  } catch (err) {
+    console.log('删除告警计划失败❌', err)
+    message.error('删除失败')
+  }
+}
+
+// 启用/禁用告警计划
+const swtichTemplateHandler = async (id: number, swtich: boolean, item: AlarmPlanItem) => {
+  console.log('swtichTemplateHandler', id, swtich, item)
+  try {
+    item.loading = true
+    alarmApi
+      .enableAlarmPlanTemplate({ id, enable: Number(!swtich) as 0 | 1 })
+      .then(() => {
+        message.success('变更成功')
+        item.loading = false
+        fetchList()
+      })
+      .catch((err) => {
+        console.log('启用/禁用告警计划失败❌', err)
+        item.loading = false
+        message.error('操作失败')
+      })
+  } catch (err) {
+    console.log('启用/禁用告警计划失败❌', err)
+    message.error('变更失败')
+  }
+}
+
+onActivated(async () => {
+  await fetchEventTypeList().catch((err) => {
+    console.error('fetchEventTypeList 失败', err)
+  })
+
+  await fetchList().catch((err) => {
+    console.error('fetchList 失败', err)
+  })
+})
+</script>
+
+<style scoped lang="less">
+.communityPage {
+  .searchBar {
+    padding: 20px;
+    background-color: #fff;
+    margin-bottom: 20px;
+    display: flex;
+    justify-content: space-between;
+    .ant-form {
+      flex-grow: 1;
+    }
+    :deep(.ant-form-inline .ant-form-item) {
+      margin-bottom: 16px !important;
+    }
+  }
+
+  .tableCard {
+    background-color: #fff;
+
+    &-header {
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      &-title {
+        font-size: 18px;
+        font-weight: 600;
+      }
+    }
+  }
+}
+</style>

+ 3 - 2
src/views/community/list/index.vue

@@ -175,8 +175,9 @@ const searchHandler = async () => {
 
 // 搜索条件变化时清空搜索结果
 const clearHandler = (e: InputEvent) => {
-  console.log('clearHandler', e, e.target, e.target?.value)
-  if (!e.target?.value) {
+  const target = e.target as HTMLInputElement
+  console.log('clearHandler', e, target, target?.value)
+  if (!target?.value) {
     fetchList()
   }
 }

+ 141 - 32
src/views/device/detail/components/alarmPlanModal/index.vue

@@ -3,7 +3,7 @@
     <a-modal
       :get-container="() => $refs.mod"
       :open="props.open"
-      :title="modelTitle"
+      :title="props.title"
       :mask-closable="false"
       width="600px"
       @cancel="cancel"
@@ -217,7 +217,7 @@
         </a-form-item>
 
         <a-form-item
-          v-if="[1, 2, 3, 9].includes(formState?.eventType as number)"
+          v-if="props.type === 'plan' && [1, 2, 3, 9].includes(formState?.eventType as number)"
           label="检测区域"
           name="region"
           style="user-select: none"
@@ -268,6 +268,12 @@
           </a-form-item-rest>
         </a-form-item>
 
+        <a-form-item label="告警联动">
+          <a-checkbox v-model:checked="formState.linkagePushWechatService">
+            微信服务号推送
+          </a-checkbox>
+        </a-form-item>
+
         <a-form-item label="是否启用">
           <a-switch v-model:checked="formState.enable" />
         </a-form-item>
@@ -288,6 +294,8 @@ import { ref, reactive, watch, computed } from 'vue'
 import { message, type FormInstance } from 'ant-design-vue'
 import * as alarmApi from '@/api/alarm'
 import { getOriginPosition } from '@/utils/index'
+import type { AlarmPlanParams } from '@/api/alarm/types'
+import dayjs from 'dayjs'
 
 defineOptions({
   name: 'AlarmPlanModal',
@@ -328,8 +336,9 @@ type AlarmPlan = {
 
 type Props = {
   open: boolean
+  type?: 'plan' | 'template'
   title?: string
-  clientId: string // 设备ID
+  clientId?: string // 设备ID
   alarmPlanId?: number | null // 告警计划ID 编辑时传入
   data?: AlarmPlan // 编辑数据
   area?: {
@@ -345,15 +354,16 @@ const emit = defineEmits<{
 
 const props = withDefaults(defineProps<Props>(), {
   open: false,
+  type: 'plan',
   title: '告警计划',
   clientId: '',
   alarmPlanId: null,
   data: undefined,
 })
 
-const modelTitle = computed(() => {
-  return props.alarmPlanId ? '编辑告警计划' : '新增告警计划'
-})
+// const modelTitle = computed(() => {
+//   return props.alarmPlanId ? '编辑告警计划' : '新增告警计划'
+// })
 
 // 检测区域宽度
 const areaWidth = computed(() => {
@@ -465,6 +475,7 @@ type FormState = {
   effectTimeFrame: string[] // 生效时段 单条 00:00:00 - 23:59:59
   effectTimeFrames: { startTime: string; endTime: string }[] // 生效时段 多条
   enable: boolean // 是否启用
+  linkagePushWechatService: boolean // 是否开启服务号消息推送
   statisticsTime: string[] // 统计时间
   count?: number | null // 异常阈值
   timeThreshold?: number | null // 异常消失时间阈值(单位:秒)
@@ -483,6 +494,7 @@ const formState = reactive<FormState>({
   effectTimeFrame: [],
   effectTimeFrames: [],
   enable: true,
+  linkagePushWechatService: true,
   statisticsTime: [],
   count: 3,
   timeThreshold: 300,
@@ -493,10 +505,10 @@ const initBlocks = () => {
     {
       x: 0,
       y: 0,
-      ox: formState.region[0] ?? 0,
-      oy: formState.region[1] ?? 0,
-      width: formState.region[2] ?? 50,
-      height: formState.region[3] ?? 50,
+      ox: formState.region[0],
+      oy: formState.region[1],
+      width: formState.region[2],
+      height: formState.region[3],
     },
   ]
 }
@@ -586,17 +598,57 @@ interface SourceData {
   eventVal?: number | null
   param?: string
   alarmTimePlan?: AlarmTimePlan
+  alarmTimePlanTpl?: AlarmTimePlan
   region?: string
   enable?: number
+  linkagePushWechatService?: number
+}
+
+// 是否为有效json字符串
+const isValidJSON = (str: string) => {
+  try {
+    JSON.parse(str)
+    return true
+  } catch {
+    return false
+  }
 }
 
 const echoFormState = (val: SourceData) => {
   const paramObj = safeParse<ParamType>(val.param, {})
-  const weekdays = safeParse<string[]>(val.alarmTimePlan?.weekdays, [])
-  const monthDays = safeParse<string[]>(val.alarmTimePlan?.monthDays, [])
-  const timeFrames = safeParse<TimeFrame[]>(val.alarmTimePlan?.timeRange, [])
+  let weekdays: string[] = []
+  let monthDays: string[] = []
+  let timeFrames: TimeFrame[] = []
+  let planTimes: string[] = []
+  if (props.type === 'plan') {
+    weekdays = safeParse<string[]>(val.alarmTimePlan?.weekdays, [])
+    monthDays = safeParse<string[]>(val.alarmTimePlan?.monthDays, [])
+    timeFrames = safeParse<TimeFrame[]>(val.alarmTimePlan?.timeRange, [])
+    planTimes = [
+      dayjs(val.alarmTimePlan?.startDate).format('YYYY-MM-DD') ?? '',
+      dayjs(val.alarmTimePlan?.stopDate).format('YYYY-MM-DD') ?? '',
+    ]
+  }
+  if (props.type === 'template') {
+    weekdays = safeParse<string[]>(val.alarmTimePlanTpl?.weekdays, [])
+    monthDays = safeParse<string[]>(val.alarmTimePlanTpl?.monthDays, [])
+    timeFrames = safeParse<TimeFrame[]>(val.alarmTimePlanTpl?.timeRange, [])
+    planTimes = [
+      dayjs(val.alarmTimePlanTpl?.startDate).format('YYYY-MM-DD') ?? '',
+      dayjs(val.alarmTimePlanTpl?.stopDate).format('YYYY-MM-DD') ?? '',
+    ]
+  }
+
   plainOptions.value = weekdays.length === 0 ? monthOptions : weekOptions
 
+  let effectTimeRanges: (number | string)[] = []
+  try {
+    effectTimeRanges =
+      weekdays.length === 0 ? monthDays : weekdays.map((item: string) => numToWeekMap[Number(item)])
+  } catch {
+    effectTimeRanges = []
+  }
+
   return {
     planName: val.name ?? '',
     remark: val.remark ?? '',
@@ -606,20 +658,18 @@ const echoFormState = (val: SourceData) => {
     statisticsTime: [paramObj.start_time ?? null, paramObj.end_time ?? null] as string[],
     count: paramObj.count ?? null,
     timeThreshold: paramObj.time_threshold ?? null,
-    planTime: [val.alarmTimePlan?.startDate ?? '', val.alarmTimePlan?.stopDate ?? ''],
+    planTime: planTimes,
     effectType: (weekdays.length === 0 ? 'month' : 'week') as 'week' | 'month',
-    effectTimeRanges:
-      weekdays.length === 0
-        ? monthDays
-        : weekdays.map((item: string) => numToWeekMap[Number(item)]),
+    effectTimeRanges,
     effectTimeFrames: Array.isArray(timeFrames)
       ? timeFrames.map((item) => ({
           startTime: item?.start_time ?? '',
           endTime: item?.end_time ?? '',
         }))
       : [],
-    region: JSON.parse(val.region ?? '[0, 0, 50, 50]'),
+    region: isValidJSON(val?.region ?? '') && JSON.parse(val?.region ?? '[0, 0, 50, 50]'),
     enable: val?.enable === 1,
+    linkagePushWechatService: val?.linkagePushWechatService === 1,
   }
 }
 
@@ -642,6 +692,8 @@ watch(
       formState.effectTimeRanges = echoFormState(val).effectTimeRanges
       formState.effectTimeFrames = echoFormState(val).effectTimeFrames
       formState.region = echoFormState(val).region
+      formState.enable = echoFormState(val).enable
+      formState.linkagePushWechatService = echoFormState(val).linkagePushWechatService
       initBlocks()
     }
   },
@@ -703,6 +755,7 @@ const cancel = () => {
   formState.effectTimeFrame = []
   formState.effectTimeFrames = []
   formState.enable = true
+  formState.linkagePushWechatService = true
   formState.statisticsTime = []
   formState.count = 3
   formState.timeThreshold = 300
@@ -796,8 +849,8 @@ const submit = () => {
         region: [1, 2, 3, 9].includes(formState.eventType as number)
           ? JSON.stringify(formState.region)
           : '[]', // 检测区域
+        linkagePushWechatService: Number(formState.linkagePushWechatService) as 0 | 1, // 是否开启服务号消息推送 1:开启 0:关闭
         enable: Number(formState.enable) as 0 | 1, // 是否启用 0否 1是
-
         // 生效方式
         alarmTimePlan: {
           startDate: formState.planTime[0], // 计划开始时间
@@ -834,23 +887,79 @@ const submit = () => {
       // }
 
       submitLoading.value = true
-      alarmApi
-        .saveAlarmPlan(params)
-        .then((res) => {
-          console.log('添加成功', res)
-          submitLoading.value = false
-          message.success('添加成功')
-          emit('success')
-          cancel()
-        })
-        .catch(() => {
-          submitLoading.value = false
-        })
+      // alarmApi
+      //   .saveAlarmPlan(params)
+      //   .then((res) => {
+      //     console.log('添加成功', res)
+      //     submitLoading.value = false
+      //     message.success('添加成功')
+      //     emit('success')
+      //     cancel()
+      //   })
+      //   .catch(() => {
+      //     submitLoading.value = false
+      //   })
+      if (props.type === 'plan') savePlan(params)
+      if (props.type === 'template') saveTemplate(params)
     })
     .catch((err) => {
       console.log('校验失败', err)
     })
 }
+
+// 保存计划
+const savePlan = async (params: AlarmPlanParams) => {
+  console.log('保存计划')
+  try {
+    await alarmApi
+      .saveAlarmPlan(params)
+      .then((res) => {
+        console.log('添加成功', res)
+        submitLoading.value = false
+        message.success('添加成功')
+        emit('success')
+        cancel()
+      })
+      .catch(() => {
+        submitLoading.value = false
+      })
+  } catch (error) {
+    console.log('添加失败', error)
+    message.error('添加失败')
+  }
+}
+
+// 保存模板
+const saveTemplate = async (params: AlarmPlanParams) => {
+  console.log('saveTemplate', formState)
+  try {
+    await alarmApi
+      .saveAlarmPlanTemplate({
+        ...params,
+        id: props.alarmPlanId as number,
+        name: params.name,
+        eventVal: params.eventVal,
+        param: params.param,
+        region: params.region,
+        linkagePushWechatService: params.linkagePushWechatService,
+        enable: params.enable,
+        alarmTimePlanTpl: params.alarmTimePlan,
+      })
+      .then((res) => {
+        console.log('添加成功', res)
+        submitLoading.value = false
+        message.success('添加成功')
+        emit('success')
+        cancel()
+      })
+      .catch(() => {
+        submitLoading.value = false
+      })
+  } catch (error) {
+    console.log('添加失败', error)
+    message.error('添加失败')
+  }
+}
 </script>
 
 <style scoped lang="less">

+ 5 - 0
src/views/device/detail/index.vue

@@ -291,6 +291,8 @@
 
       <alarmPlanModal
         v-model:open="alarmPlanVisible"
+        :title="alarmPlanTitle"
+        type="plan"
         :client-id="clientId"
         :alarm-plan-id="alarmPlanId"
         :data="alarmPlanDataWithType"
@@ -657,6 +659,7 @@ onUnmounted(() => {
 const openFullView = ref(false) // 全屏展示点位图
 
 const alarmPlanVisible = ref(false) // 告警计划弹窗
+const alarmPlanTitle = ref('新增告警计划') // 告警计划弹窗标题
 const alarmPlanId = ref<number | null>(null) // 当前编辑的告警计划id
 const alarmPlanLoading = ref(false) // 告警计划加载中
 const alarmPlans = ref<
@@ -740,6 +743,7 @@ const alarmPlanDataWithType = computed(() => alarmPlanData.value as AlarmPlan)
 const editAlarmItem = async (item: AlarmPlanItem) => {
   console.log('editAlarmItem', item)
   alarmPlanVisible.value = true
+  alarmPlanTitle.value = '编辑告警计划'
   alarmPlanData.value = item
   alarmPlanId.value = item?.id ?? null
 }
@@ -747,6 +751,7 @@ const editAlarmItem = async (item: AlarmPlanItem) => {
 // 添加告警计划
 const addPlanHandler = () => {
   alarmPlanVisible.value = true
+  alarmPlanTitle.value = '新增告警计划'
   alarmPlanData.value = undefined
   alarmPlanId.value = null
 }

+ 12 - 0
src/views/system/config/index.vue

@@ -0,0 +1,12 @@
+vabse
+<template>
+  <div> 参数配置 </div>
+</template>
+
+<script setup lang="ts">
+defineOptions({
+  name: 'SystemConfigIndex',
+})
+</script>
+
+<style scoped lang="less"></style>