Browse Source

增加leaft , 2d掌子面

caner 1 year ago
parent
commit
06728f214f

+ 4 - 1
package.json

@@ -23,9 +23,12 @@
     "socket.io-client": "^4.7.5",
     "vue": "^3.4.31",
     "vue-router": "^4.4.0",
-    "xlsx": "^0.18.5"
+    "xlsx": "^0.18.5",
+    "leaflet": "^1.9.4",
+    "uuid": "^10.0.0"
   },
   "devDependencies": {
+    "@types/leaflet": "^1.9.12",
     "@types/node": "^20.10.4",
     "@typescript-eslint/parser": "^7.16.0",
     "@vitejs/plugin-vue": "^5.0.5",

+ 10 - 0
src/components/notifaiction.vue

@@ -0,0 +1,10 @@
+<template>
+  <div />
+</template>
+
+<script setup lang="ts">
+import { useNotification } from 'naive-ui'
+
+window.$notification = useNotification()
+
+</script>

+ 2 - 0
src/pages/App.vue

@@ -9,6 +9,7 @@
   >
     <n-notification-provider>
       <router-view />
+      <GlobalNotif />
     </n-notification-provider>
   </n-config-provider>
 </template>
@@ -18,6 +19,7 @@ import useStore from './store/index'
 import { computed } from 'vue'
 import { zhCN, dateZhCN } from 'naive-ui'
 import Theme from '@/assets/naive-theme'
+import GlobalNotif from '@/component/notifaiction.vue'
 
 const store = useStore()
 const show = computed(() => store.loading)

+ 23 - 1
src/pages/store/index.ts

@@ -6,6 +6,23 @@ export interface UserInfo {
   opmId: number,
   satoken: string
 }
+export interface ObtainedDevices {
+  name:string,
+  state:string
+}
+
+export interface CurrentStratumType {
+  width: number,
+  height: number,
+  mapId: string,
+  imgUrl: string,
+  name?: string
+  trackType?: number, // 显示系统设备
+  showTip?: boolean,
+  mapConfig?: string,
+  uuid?: string,
+  sid?: string
+}
 
 // id必填,且需要唯一
 const useStore = defineStore('index', {
@@ -13,7 +30,9 @@ const useStore = defineStore('index', {
     userInfro: {} as UserInfo,
     token: '',
     isCheckPermission: false,
-    loading: false
+    loading: false,
+    obtainedDevices:[] as ObtainedDevices[], //当前系统绑定的设备
+    currentStratum: {} as CurrentStratumType, // 当前层级
   }),
   actions: {
     setUserInfo(data: UserInfo) {
@@ -22,6 +41,9 @@ const useStore = defineStore('index', {
     },
     setCheckPermission(data: boolean) {
       this.isCheckPermission = data
+    },
+    setObtainedDevices(data:ObtainedDevices[]) {
+      this.obtainedDevices = data
     }
   },
   persist: {

BIN
src/pages/views/face/img/1.png


File diff suppressed because it is too large
+ 26 - 0
src/pages/views/face/index.vue


+ 687 - 0
src/pages/views/leaft/index.vue

@@ -0,0 +1,687 @@
+<template>
+    <div class="mapBox">
+      <div
+        id="map"
+        @dragover="e => e.preventDefault()"
+        @dragleave="isCheck = false"
+        @dragenter="isCheck = true"
+      />
+      <div
+        v-if="isEdit"
+        class="mapBox-menu"
+      >
+        <n-tree-select
+          v-model:value="selected"
+          :options="options"
+          label-field="name"
+          key-field="uuid"
+          children-field="childList"
+          check-strategy="child"
+          placeholder="请选择车站"
+          @update:value="changeMap"
+        />
+        <div class="mapBox-menu-content">
+          <n-scrollbar>
+            <template
+              v-for="(item, index) in iconList"
+              :key="index"
+            >
+              <div
+                class="mapBox-menu-content-item"
+                draggable="true"
+                @dragstart="currentItem = item"
+                @dragend="itemDragEnd"
+              >
+                <img
+                  :src="item.icon"
+                  alt=""
+                  srcset=""
+                  draggable="false"
+                >
+                <p>
+                  {{ item.name }}
+                </p>
+              </div>
+            </template>
+          </n-scrollbar>
+        </div>
+      </div>
+      <div
+        v-if="isEdit && showPoup"
+        class="mapBox-poup"
+      >
+        <n-form
+          ref="formRef"
+          :model="formMode"
+          label-placement="left"
+          label-width="auto"
+          size="small"
+          :rules="rules"
+        >
+          <n-form-item
+            label="元素编号"
+            path="uuid"
+          >
+            <n-input
+              v-model:value="formMode.uuid"
+              disabled
+            />
+          </n-form-item>
+          <n-form-item
+            label="资产编号"
+            path="guid"
+          >
+            <n-select
+              v-model:value="formMode.guid"
+              placeholder="请选择"
+              label-field="name"
+              value-field="guid"
+              :options="assetOption"
+              @update:value="selectChange"
+            />
+          </n-form-item>
+          <n-form-item
+            label="元素类型"
+            path="type"
+          >
+            <n-input
+              v-model:value="formMode.type"
+              disabled
+            />
+          </n-form-item>
+          <n-form-item
+            label="x 坐 标 "
+            path="x"
+          >
+            <n-input-number
+              v-model:value="formMode.x"
+              clearable
+              :show-button="false"
+              @blur="resetMarker('lng', formMode.x)"
+            />
+          </n-form-item>
+          <n-form-item
+            label="y 坐 标 "
+            path="y"
+          >
+            <n-input-number
+              v-model:value="formMode.y"
+              clearable
+              :show-button="false"
+              @blur="resetMarker('lat', formMode.y)"
+            />
+          </n-form-item>
+          <n-form-item
+            label="横向缩放"
+            path="scaleX"
+          >
+            <n-input-number
+              v-model:value="formMode.scaleX"
+              clearable
+              :min="0.1"
+              :show-button="false"
+              @blur="resetMarker('scaleX', formMode.scaleX, '0')"
+            />
+          </n-form-item>
+          <n-form-item
+            label="纵向缩放"
+            path="scaleY"
+          >
+            <n-input-number
+              v-model:value="formMode.scaleY"
+              clearable
+              :min="0.1"
+              :show-button="false"
+              @blur="resetMarker('scaleY', formMode.scaleY, '1')"
+            />
+          </n-form-item>
+          <n-form-item
+            label="水平翻转"
+            path="flipX"
+          >
+            <n-switch
+              v-model:value="formMode.flipX"
+              @update:value="resetMarker('flipX', formMode.flipX)"
+            />
+          </n-form-item>
+          <n-form-item
+            label="垂直翻转"
+            path="flipY"
+          >
+            <n-switch
+              v-model:value="formMode.flipY"
+              @update:value="resetMarker('flipY', formMode.flipY)"
+            />
+          </n-form-item>
+        </n-form>
+        <div class="mapBox-poup-btns">
+          <n-button
+            type="error"
+            ghost
+            @click="del"
+          >
+            删除
+          </n-button>
+          <n-button
+            type="primary"
+            @click="save"
+          >
+            保存
+          </n-button>
+        </div>
+      </div>
+    </div>
+  </template>
+  
+  <script setup lang='ts'>
+  import * as L from 'leaflet'
+  import {
+    onMounted,
+    onUnmounted,
+    Ref, ref, watch
+  } from 'vue'
+  import '@/../node_modules/leaflet/dist/leaflet.css'
+  import SocketService from '@/services/socket.service'
+  import { v1 as uuid } from 'uuid'
+  import useStore from '@/pages/store'
+  // ----- 修复L 增加tooltip 缩放报错 重要!!
+  
+  (L.Tooltip as any).prototype._animateZoom = function (e: { zoom: any; center: any; }) {
+    if (!(this as any)._map) {
+      return
+    }
+    const pos = (this as any)._map._latLngToNewLayerPoint((this as any)._latlng, e.zoom, e.center) as any
+    (this as any)._setPosition(pos)
+  };
+  
+  (L.Tooltip as any).prototype._updatePosition = function () {
+    if (!(this as any)._map) {
+      return
+    }
+    const pos = (this as any)._map.latLngToLayerPoint((this as any)._latlng) as any
+    (this as any)._setPosition(pos)
+  }
+  // ---------
+  
+  export interface Item {
+    icon: string,
+    x?: number,
+    y?: number,
+    scaleX?: number,
+    scaleY?: number,
+    guid?: string,
+    type?: string,
+    uuid?: string,
+    item?: string,
+    isAdd?: boolean,
+    deviceId?: string,
+    itemName?: string
+  }
+  
+  export interface BaseMap {
+    DID?: number,
+    width: number,
+    height: number,
+    mapId: string,
+    name?: string
+    imgUrl: string,
+    trackType?: number, // 1电扶梯 2给排水 3通风 4照明 5车站环境检测 6车站暖通 7车站动环
+    showTip?: boolean
+  }
+  
+  const socketService = new SocketService()
+  const props = withDefaults(defineProps<{
+    isEdit?: boolean, // 是否编辑
+    baseMap?: BaseMap
+  }>(), {
+    isEdit: false,
+    baseMap: () => ({
+      imgUrl: '', width: 0, height: 0, mapId: '', showTip: false
+    })
+  })
+  const emit = defineEmits<{(evt: 'markerClick', value: Item): void }>()
+  const Map = ref()
+  const iconList = ref([] as { icon: string, name: string }[])
+  const currentItem: Ref<Item | undefined> = ref()
+  const isCheck = ref(false)
+  const options = ref([] as BaseMap[])
+  const selected = ref('')
+  const assetOption = ref([])
+  const formRef = ref()
+  const formMode = ref({
+    uuid: '',
+    guid: '',
+    type: '',
+    x: 0,
+    y: 0,
+    scaleX: 1,
+    scaleY: 1,
+    flipX: false,
+    flipY: false,
+    icon: '',
+    tip: '',
+    isAdd: false
+  })
+  const rules = { guid: { required: true, message: '请选择资产' } }
+  const showPoup = ref(false)
+  const currentMap: Ref<BaseMap | undefined> = ref()
+  const currentMarker = ref()
+  const allMarker = ref([] as L.Marker[])
+  const store = useStore()
+  
+  // 资产下拉
+  function selectChange(_: string, option: Item) {
+    formMode.value.type = option.type || ''
+  }
+  
+  // 重置marker样式
+  function resetMarker(type: string, num: number | boolean, id?: string) {
+    if (type === 'lat' || type === 'lng') {
+      const obj = currentMarker.value.getLatLng()
+      obj[type] = num
+      currentMarker.value.setLatLng(obj)
+      console.log('位置更新', obj)
+    } else {
+      const newIcon = currentMarker.value.getIcon()
+      if (type === 'scaleX' || type === 'scaleY') {
+        newIcon.options.iconSize[id!] = newIcon.options.iconSize[id!] * (num as number)
+      } else {
+        const newHtml = newIcon.options.html.replace(/style="[^=>]*"/g, (s: string) => {
+          const a = s.split(' ')
+          const c = type === 'flipY' ? 'rotateX' : 'rotateY'
+          const n = type === 'flipY' ? 1 : 2
+          a[n] = num ? `${c}(180deg)${n === 2 ? ';"' : ''}` : `${c}(0deg)${n === 2 ? ';"' : ''}`
+          const b = a.join(' ')
+          return b
+        })
+        newIcon.options.html = newHtml
+      }
+      currentMarker.value.setIcon(newIcon)
+    }
+  }
+  
+  // marker选中状态
+  function selectMaker(marker: L.Marker) {
+    for (let k = 0; k < allMarker.value.length; k++) {
+      const el = allMarker.value[k]
+      const dom = el.getElement()
+      if ((el as Any)._leaflet_id === (marker as Any)._leaflet_id) {
+        dom!.style.boxShadow = '0 0 4px 2px #ff891a'
+      } else {
+        dom!.style.boxShadow = 'none'
+      }
+    }
+    currentMarker.value = marker
+  }
+  
+  // 查询设备列表+设置poup
+  function setPoupData(marker: L.Marker, option: Item) {
+    // 查询设备列表
+    socketService.send('item.query', { itemtype: option.deviceId, isPaging: 0, region: selected.value }).then((res) => {
+      const { success, data: { list } } = res
+      if (success && list) {
+        assetOption.value = list
+      }
+    })
+  
+    formMode.value.uuid = option.uuid || uuid()
+    formMode.value.guid = option.guid || option.item || ''
+    formMode.value.type = option.type || ''
+    formMode.value.x = option.x || 0
+    formMode.value.y = option.y || 0
+    formMode.value.isAdd = option.isAdd || false
+    // 移除其它选中状态
+    selectMaker(marker)
+    showPoup.value = true
+  }
+  
+  // 添加marker
+  function addMarker(option: Item) {
+    if (!Map.value) throw 'Map 未初始化'
+    if (!option.icon) return console.log('缺少参数')
+  
+    // 自定义图标
+    const img = `<img src="${option.icon}" style="width:100%;height:100%;transform: rotateX(0deg) rotateY(0deg);"/>`
+    const ICON = L.divIcon({
+      iconSize: [ (option.scaleX || 1) * 30, (option.scaleY || 1) * 30 ],
+      html: img
+    })
+    const marker = L.marker([ option.y || 0, option.x || 0 ], {
+      icon: ICON,
+      draggable: props.isEdit, // 是否可通过鼠标/触摸拖动。
+      riseOnHover: true
+    }).addTo(Map.value)
+    // add
+    allMarker.value.push(marker)
+    // 显示tip
+    if (props.baseMap.showTip && option.itemName) marker.bindTooltip(option.itemName, { direction: 'top', className: 'resetTips' })
+    // 显示poup
+    if (isCheck.value) {
+      console.log('添加marker', option)
+      setPoupData(marker, option)
+    }
+    // 事件
+    marker.addEventListener('click', () => {
+      console.log('marker点击', option)
+      setPoupData(marker, option)
+      emit('markerClick', option)
+    })
+    marker.addEventListener('dragend', (e) => {
+      console.log('marker拖动', option)
+      const { lat, lng } = e.target.getLatLng()
+      option.x = lng
+      option.y = lat
+      setPoupData(marker, option)
+    })
+  }
+  
+  // 拖拽=>添加marker
+  function itemDragEnd(e: DragEvent) {
+    e.preventDefault()
+    const point = Map.value.containerPointToLayerPoint([ e.layerX, e.layerY ]) // 给定相对于origin pixel的相应像素坐标
+    const lnglat = Map.value.layerPointToLatLng(point) // 给定地理坐标,转换为相对于origin pixel的相应像素坐标
+    if (isCheck.value) {
+      addMarker({
+        ...currentItem.value, x: lnglat.lng, y: lnglat.lat, isAdd: true
+      } as Item)
+    }
+    isCheck.value = false
+  }
+  
+  // 查询marker+绑定
+  async function bindMarker(mapId: string) {
+    if (!mapId) return
+    // 清除
+    if (allMarker.value.length) {
+      for (let k = 0; k < allMarker.value.length; k++) {
+        const marker = allMarker.value[k]
+        marker.remove()
+      }
+      allMarker.value = []
+    }
+    // 添加
+    let page = 1
+    let pages = 0
+    do {
+      const obj = {
+        map: mapId,
+        pageSize: 100,
+        isPaging: 0,
+        page: page++
+      } as { trackType?: number }
+      if (props.baseMap.trackType) obj.trackType = props.baseMap.trackType
+      const { data } = await socketService.send('monitor.itemQuery', obj)
+      store.setObtainedDevices(data.list)
+      console.log('底图关联的marker', data.list)
+      if (data.list) {
+        for (let k = 0; k < data.list.length; k++) {
+          const el = data.list[k]
+          const obj = {
+            parent: el,
+            ...el.mapElement,
+            icon: import.meta.env.VITE_IMG_URL + el.icon,
+            showTip: props.baseMap.showTip
+          }
+          addMarker(obj)
+        }
+      }
+      pages = data.pages
+    } while (page <= pages)
+  }
+  
+  // 切换底图
+  async function changeMap(_: string, option: BaseMap) {
+    console.log('切换底图', option)
+    if (!Map.value) return
+    // 清除layer
+    Map.value.eachLayer((layer: L.Layer) => {
+      Map.value.removeLayer(layer)
+    })
+    // 添加底图
+    if (!option.imgUrl || !option.width || !option.height || !option.mapId) return window.$notification.error({ content: '底图缺少相关参数', duration: 3000, keepAliveOnHover: true })
+    currentMap.value = option
+    L.imageOverlay(import.meta.env.VITE_IMG_URL + option.imgUrl, [ [ 0, 0 ], [ option.height, option.width ] ]).addTo(Map.value)
+    Map.value.setView([ option.height / 2, option.width / (props.isEdit ? 3.5 : 2) ], 0)
+    // 获取底图绑定的maker
+    bindMarker(option.mapId)
+  }
+  
+  // 初始化底图
+  function initMap(option: BaseMap) {
+    if (!option) return
+    Map.value = L.map('map', {
+      zoom: 0,
+      maxZoom: 1,
+      minZoom: 0,
+      zoomSnap: 0.1,
+      center: [ option.height / 2, option.width / (props.isEdit ? 3.5 : 2) ],
+      crs: L.CRS.Simple,
+      attributionControl: false,
+      zoomControl: false
+    }).addEventListener('click', (e) => {
+      console.log('map点击', e)
+      if (currentMarker.value) currentMarker.value.getElement().style.boxShadow = 'none'
+      formMode.value.flipX = false
+      formMode.value.flipY = false
+      formMode.value.scaleX = 1
+      formMode.value.scaleY = 1
+      formMode.value.uuid = ''
+      showPoup.value = false
+    })
+  
+    // 获取底图列表
+    socketService.send('region.map').then((res) => {
+      const { success, data } = res
+      if (success && data.length) {
+        options.value = data.map((el: { childList: Any[]; }) => {
+          if (el.childList) {
+            el.childList.forEach((es) => {
+              if (es.mapId === props.baseMap.mapId) selected.value = es.id
+              try {
+                const { width, height, imgUrl } = JSON.parse(es.mapConfig)
+                es.width = width
+                es.height = height
+                es.imgUrl = imgUrl
+              } catch (error) {
+                console.log('底图配置数据格式错误')
+              }
+            })
+          }
+          return el
+        })
+      }
+    })
+  
+    // 获取所有maker类型
+    socketService.send('itemType.search', { belongs: '3' }).then((res) => {
+      const { success, data } = res
+      if (success && data.length) iconList.value = data.map((el: { imageUrl: string; name: string; guid: string }) => ({ icon: import.meta.env.VITE_IMG_URL + el.imageUrl, name: el.name, deviceId: el.guid }))
+    })
+  
+    if (props.isEdit) { window.$notification.warning({ content: '请选择车站', duration: 3000, keepAliveOnHover: true }) } else {
+      changeMap('', option)
+    }
+  }
+  
+  // 保存->更新marker
+  async function save() {
+    await formRef.value.validate()
+    const mapElementList = [ {
+      flipX: formMode.value.flipX,
+      flipY: formMode.value.flipY,
+      item: formMode.value.guid,
+      map: currentMap.value!.mapId,
+      scaleX: formMode.value.scaleX,
+      scaleY: formMode.value.scaleY,
+      type: formMode.value.type,
+      uuid: formMode.value.uuid,
+      visiable: true,
+      x: formMode.value.x,
+      y: formMode.value.y
+    } ]
+    console.log('保存', mapElementList, formMode.value.isAdd)
+    const url = formMode.value.isAdd ? 'mapElement.insert' : 'mapElement.update'
+    const { success } = await socketService.send(url, { mapElementList })
+    if (success) {
+      bindMarker(currentMap.value!.mapId)
+      window.$notification.success({ content: formMode.value.isAdd ? '添加成功' : '修改成功', duration: 2000 })
+      showPoup.value = false
+    }
+  }
+  
+  // 删除当前
+  async function del() {
+    if (!currentMarker.value) return
+    const fdid = allMarker.value.findIndex((el: Any) => el._leaflet_id === currentMarker.value._leaflet_id)
+    if (fdid !== -1) {
+      if (!formMode.value.isAdd) {
+        const { success } = await socketService.send('mapElement.delete', [ formMode.value.uuid ])
+        if (success) window.$notification.success({ content: '删除成功', duration: 2000 })
+      }
+      allMarker.value.splice(fdid, 1)
+      currentMarker.value.remove()
+      showPoup.value = false
+    } else {
+      throw '删除失败!'
+    }
+  }
+  
+  // 销毁
+  function destory() {
+    if (!Map.value) return
+    console.log('销毁')
+    Map.value.eachLayer((layer: L.Layer) => {
+      Map.value.removeLayer(layer)
+    })
+    allMarker.value = []
+    currentMarker.value = undefined
+    currentMap.value = undefined
+    Map.value = null
+  }
+  
+  onMounted(() => {
+    if (Object.keys(store.currentStratum).length) {
+      if (Map.value) {
+        changeMap('', store.currentStratum)
+      } else {
+        initMap(store.currentStratum)
+      }
+    }
+  })
+  
+  watch(() => props.baseMap, (v) => {
+    if (Map.value) {
+      changeMap('', v)
+    } else {
+      initMap(v)
+    }
+  }, { deep: true })
+  
+  onUnmounted(() => destory())
+  </script>
+  <style lang="scss" scoped>
+  .mapBox {
+    width: 100%;
+    height: 100%;
+    background: none;
+    position: relative;
+  
+    #map {
+      width: 100%;
+      height: 100%;
+      background: #011719;
+  
+      :deep(.leaflet-div-icon) {
+        background: none;
+        border: none;
+        border-radius: 6px;
+      }
+  
+      :deep(.resetTips) {
+        height: 33px;
+        background: #06353A;
+        border: 1px solid #34C0AE;
+        color: white;
+  
+        &::before {
+          border-top-color: #34C0AE;
+        }
+  
+        p {
+          margin: 0;
+          font-size: 14px;
+        }
+      }
+    }
+  
+    &-menu {
+      position: absolute;
+      left: 0;
+      top: 0;
+      z-index: 601;
+      background: white;
+      width: 380px;
+      height: 100%;
+      padding: 10px;
+  
+      &-content {
+        width: 100%;
+        height: calc(100% - 44px);
+        :deep(.n-scrollbar-content) {
+          display: flex;
+          flex-wrap: wrap;
+          align-content: flex-start;
+        }
+  
+        &-item {
+          text-align: center;
+          width: 78px;
+          height: 78px;
+          padding: 10px 0;
+          border: 1px solid #C9D3DD;
+          cursor: grab;
+          margin-top: 16px;
+  
+          &:not(:nth-child(4n)) {
+            margin-right: 16px;
+          }
+  
+          img {
+            width: 50px;
+            height: 40px;
+          }
+  
+          p {
+            margin: 0;
+            color: #2c3e50;
+            font-size: 12px;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+          }
+        }
+      }
+  
+    }
+  
+    &-poup {
+      position: absolute;
+      right: 0;
+      top: 0;
+      width: 334px;
+      background: #FFFFFF;
+      box-shadow: 0px 3px 6px 0px rgba(0, 0, 0, 0.1);
+      backdrop-filter: blur(29.99999943901511px);
+      z-index: 601;
+      padding: 20px;
+  
+      &-btns {
+        text-align: center;
+  
+        &>button:first-child {
+          margin-right: 20px;
+        }
+      }
+    }
+  }
+  </style>

+ 305 - 0
src/services/socket.service.ts

@@ -0,0 +1,305 @@
+import { injectable, Service } from './service'
+import io from 'socket.io-client'
+import TimerService from './timer.service'
+import useStore from './user.data'
+
+/**
+ * socket io 返回数据格式
+ */
+type ResponseData = {
+  status: number;
+  body: object | null;
+};
+
+/**
+ * 网络服务返回数据格式
+ */
+interface Result<T = Any> {
+  success: boolean;
+  data: T;
+}
+
+interface Request<T = Any> {
+  code: string;
+  timestamp: number;
+  nonestr: string;
+  body: T;
+}
+
+/**
+ * 约束socket 请求响应参数,code声明,规范请求、返回参数
+ */
+interface SendKey<T = Record<string, Any>, P = Record<string, Any>> { }
+
+/**
+ * API网络请求服务
+ */
+@injectable
+export default class SocketService extends Service {
+  private URL = import.meta.env.VITE_SOCKET_URL
+
+  private userData = useStore()
+
+  private timerService = new TimerService()
+
+  /**
+     * 是否已经完成时间同步
+     * 防止刷新之后因为时间同步问题导致超时
+     */
+  private timeSynced_ = false
+
+  /**
+     * 是否已经完成时间同步
+     */
+  public get timeSynced() {
+    return this.timeSynced_
+  }
+
+  /**
+   * 网络请求监听集合
+   */
+  private listeners: { [code: string]: [(response: object) => void] } = {}
+
+  /**
+     * 请求回调集合
+     */
+  private callbacks: { [key: string]: (response: ResponseData) => void } = {}
+
+  /**
+     * 服务器当前时间戳
+     */
+  private timestamp = Math.floor(Date.now() / 1000)
+
+  /**
+     * socketio对象
+     */
+  private socket!: SocketIOClient.Socket
+
+  /**
+     * 接口超时列表
+     */
+  private timeoutList: { [code: string]: number } = {
+    'system.common': 60 * 1000
+  }
+
+  constructor() {
+    super()
+    // 建立连接
+    this.reset()
+    // 设置定时心跳
+    this.timerService.on('10s', this.ping.bind(this))
+    this.timerService.on('5m', this.syncTime.bind(this))
+    this.timerService.on('5s', () => {
+      this.timestamp += 5
+    })
+    this.aysncTime()
+  }
+
+  public async aysncTime() {
+    while (true) {
+      await (new Promise((res) => {
+        setTimeout(res, 500)
+      }))
+      if (this.timeSynced_) {
+        break
+      }
+      console.log('等待时间同步')
+    }
+  }
+
+  /**
+    * 全局发送消息
+    * @param code 代码
+    * @param message 信息
+    * @param timeout 超时 默认6秒
+    */
+  public async send<R = Any, Q = Any>(code: string | SendKey<Q, R>, data?: Q, timeout = 1000 * 15): Promise<Result<R | null>> {
+    if (this.timeoutList[code as string]) {
+      timeout = this.timeoutList[code as string]
+    }
+    // if (!this.timeSynced_) throw 没同步成功会阻断请求
+    return await this.request(code, data || {}, timeout)
+  }
+
+  /**
+     * 全局监听socketio事件
+     * @param code 命令
+     * @param handle 处理接口
+     */
+  public on(code: string, handle: (response: Any) => void, unique = false) {
+    if (unique) {
+      this.listeners[code] = [ handle ]
+    } else {
+      this.listeners[code] = this.listeners[code] || []
+      this.listeners[code].push(handle)
+    }
+    return true
+  }
+
+  /**
+     * 取消全局监听socketio事件
+     * @param code 命令 * 代表清除所有
+     * @param handle 处理接口
+     */
+  public off(code: string, handle?: (response: object) => void) {
+    if (code === '*') {
+      this.listeners = {}
+      return true
+    }
+    this.listeners[code] = this.listeners[code] || []
+    const index = this.listeners[code].indexOf(handle!)
+    if (index > -1) {
+      this.listeners[code].splice(index, 1)
+      return true
+    }
+    return false
+  }
+
+  /**
+    * 设置身份令牌
+    * @param token 令牌
+    */
+  public setToken(token: string) {
+    this.userData.token = token
+  }
+
+  /**
+     * 发起请求
+     * @param code 命令码
+     * @param data 数据
+     */
+  private request<T, P>(code: string | SendKey<T, P>, data: T | {}, timeout = 1000 * 6): Promise<Result> {
+    const that = this
+    const nonestr = `${Math.floor(Math.random() * 1000000)}`
+    // proto 加密
+    // 发送
+    this.socket.emit(
+      'request',
+      JSON.stringify({
+        code,
+        body: data,
+        timestamp: this.timestamp,
+        nonestr,
+        token: this.userData.token
+      })
+    )
+    return new Promise((res, rej) => {
+      const timer = setTimeout(() => {
+        if (that.callbacks[nonestr]) {
+          delete that.callbacks[nonestr]
+        }
+        throw '请求超时'
+      }, timeout)
+      that.callbacks[nonestr] = (response: ResponseData) => {
+        clearTimeout(timer)
+        delete that.callbacks[nonestr]
+        const status = +response.status
+        if (status !== 0) {
+          if (status === 99) this.reset()
+          if (status === 401) {
+            this.userData.setToken('')
+            throw 401
+          }
+          throw response.body
+        } else {
+          res({ success: true, data: response.body })
+        }
+      }
+    })
+  }
+
+  /**
+     * 心跳监测
+     */
+  private async ping() {
+    if (!this.userData.token) {
+      return
+    }
+    await this.request('user.heart', {})
+  }
+
+  /**
+     * 时间同步
+     */
+  private async syncTime() {
+    const { success, data } = await this.request('server.getNowTime', {})
+    if (success && data) {
+      this.timestamp = data
+      this.timeSynced_ = true
+      console.log('同步成功')
+    }
+  }
+
+  /**
+     * 接收socketio返回的消息
+     * @param res 回复消息体
+     */
+  private onResponse(res: string) {
+    let response = null
+    try {
+      response = JSON.parse(res)
+    } catch (error) {
+      throw `接口返回数据异常 ${res}`
+    }
+    if (response && this.callbacks[response.nonestr]) {
+      this.callbacks[response.nonestr](response)
+    }
+  }
+
+  /**
+     * 接收socketio 的请求消息
+     * @param request 回复消息体
+     */
+  private onRequest(request: string | Request) {
+    let data!: Request
+    // 解析数据
+    if (typeof request === 'string') {
+      const temp = JSON.parse(request)
+      if (temp) data = temp
+    } else {
+      data = request
+    }
+    // 调用监听接口
+    if (this.listeners[data.code]) {
+      const errs: string[] = []
+      for (const handle of this.listeners[data.code]) {
+        try {
+          handle(JSON.parse(JSON.stringify(data)))
+        } catch (err) {
+          errs.push((err as Any).stack)
+        }
+      }
+      if (errs.length) {
+        throw `${errs}`
+      }
+    }
+  }
+
+  /**
+     * 重置连接
+     */
+  private reset() {
+    if (this.socket) {
+      this.socket.close()
+    }
+    try {
+      if (process.env.NODE_ENV === 'development') {
+        const opts = { transports: [ 'websocket' ] }
+        this.URL.includes('https') && Object.assign(opts, { forceNew: true, path: '/wss' })
+        this.socket = io(this.URL, opts)
+      } else {
+        this.socket = io({
+          forceNew: true,
+          transports: [ 'websocket' ],
+          path: '/wss'
+        })
+      }
+      this.socket.on('request', this.onRequest.bind(this))
+      this.socket.on('response', this.onResponse.bind(this))
+      setTimeout(this.ping.bind(this), 300)
+      setTimeout(this.syncTime.bind(this), 100)
+    } catch (err) {
+      throw `${err}`
+    }
+  }
+}

+ 176 - 0
src/services/timer.service.ts

@@ -0,0 +1,176 @@
+import moment from 'moment'
+import { injectable, Service } from './service'
+import useStore from './user.data'
+
+/**
+ * 回调定义
+ */
+declare interface Callback {
+    /**
+     * 类型 0 永久 1 单次
+     */
+    type: number;
+    fn: () => void;
+}
+
+const INTERVALS = [
+  '1s', '5s', '10s', '30s', '1m', '5m', '10m', '12h'
+]
+
+/**
+ * 时间服务
+ */
+@injectable
+export default class TimerService extends Service {
+  private callbacks: { [interval: string]: Callback[] } = {}
+
+  private userData = useStore()
+
+  /**
+     * 累计秒数
+     */
+  private ticks = 0
+
+  constructor() {
+    super()
+    setInterval(this.onTick.bind(this), 1000)
+  }
+
+  /**
+     * 监听定时器回调
+     *
+     * @param interval - 事件间隔,必须是 1s、5s、10s、30s 1m、5m 中的1个
+     * @param callback - 定时器回调函数
+     * @returns 成功返回 `true`
+     */
+  public on(interval: '1s' | '5s' | '10s' | '30s' | '1m' | '5m' | '10m' | '12h', callback: () => void) {
+    if (!INTERVALS.includes(interval)) {
+      console.error(`定时器时间间隔必须为: ${INTERVALS.join(',')}`)
+      return false
+    }
+    this.callbacks[interval] = this.callbacks[interval] || []
+    this.callbacks[interval].push({
+      type: 0,
+      fn: callback
+    })
+    return true
+  }
+
+  /**
+     * 监听定时器回调 单次
+     *
+     * @param interval - 事件间隔,必须是 1s、5s、10s、30s 1m、5m、10m 中的1个
+     * @param callback - 定时器回调函数
+     * @returns 成功返回 `true`
+     */
+  public once(interval: '1s' | '5s' | '10s' | '30s' | '1m' | '5m' | '10m' | '12h', callback: () => void) {
+    if (!INTERVALS.includes(interval)) {
+      console.error(`定时器时间间隔必须在 ${INTERVALS.join(',')}`)
+      return false
+    }
+    this.callbacks[interval] = this.callbacks[interval] || []
+    this.callbacks[interval].push({
+      type: 1,
+      fn: callback
+    })
+    return true
+  }
+
+  /**
+     * 取消计时器监听
+     *
+     * @param interval - 事件间隔,必须是 1s、5s、10s、30s 1m、5m、10m 中的1个
+     * @param callback - 定时器回调函数
+     * @returns 成功返回 `true`
+     */
+  public off(interval: '1s' | '5s' | '10s' | '30s' | '1m' | '5m' | '10m' | '12h', callback: () => void) {
+    if (!this.callbacks[interval]) {
+      return false
+    }
+    const index = this.callbacks[interval].findIndex(callback)
+    if (index > -1) {
+      this.callbacks[interval].splice(index, 1)
+    }
+    return true
+  }
+
+  /**
+     * 每秒回调
+     */
+  private onTick() {
+    this.ticks++
+    // 1 秒
+    this.fireCallback('1s')
+    // 5 秒
+    if (this.ticks % 5 === 0) {
+      this.fireCallback('5s')
+    }
+    // 10 秒
+    if (this.ticks % 10 === 0) {
+      this.fireCallback('10s')
+    }
+    // 30 秒
+    if (this.ticks % 30 === 0) {
+      this.fireCallback('30s')
+    }
+    // 1 分钟
+    if (this.ticks % 60 === 0) {
+      this.fireCallback('1m')
+    }
+    // 5 分钟
+    if (this.ticks % 300 === 0) {
+      this.fireCallback('5m')
+    }
+    // 10 分钟
+    if (this.ticks % 600 === 0) {
+      this.fireCallback('10m')
+    }
+
+    // 每小时检测一下是否快过期,快过期就直接刷新token
+    if (this.ticks % 3600 === 0) {
+      const nowTime = Math.floor(Date.now() / 1000)
+      const { user } = this.userData
+      if (!user || !this.userData.token) return
+      const loginTime = user.timestamp ? Math.floor(user.timestamp / 1000) : Math.floor(this.userData.user.timestamp / 1000)
+      if (nowTime - loginTime > 60 * 60 * 11) {
+        this.fireCallback('12h')
+        user.timestamp = Date.now()
+        this.userData.setUser(user)
+      }
+    }
+  }
+
+  /**
+     * 触发定时执行函数
+     * @param cb 回调配置
+     */
+  private fireCallback(cbs: string) {
+    const callbacks = this.callbacks[cbs] || []
+    callbacks.forEach((cb) => {
+      try {
+        cb.fn()
+      } catch (e) {
+        console.log(e)
+      }
+    })
+    this.callbacks[cbs] = callbacks.filter((cb) => cb.type === 0)
+  }
+
+  /**
+     * 获取时间 今天23:59:59 和 多少天前的 00:00:00
+     * @param num 多少天前
+     */
+  public defaultDate(num: number) {
+    // 今天时间
+    const date = new Date()
+    const y = date.getFullYear()
+    const m = date.getMonth() + 1 < 10 ? `0${date.getMonth() + 1}` : date.getMonth() + 1
+    const d = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate()
+    const timeStr = `${y}-${m}-${d}`
+    const endTime = timeStr
+    // num 天前时间
+    const sT = new Date(timeStr).getTime() / 1000 - num * 24 * 60 * 60 - 8 * 60 * 60
+    const sTime = moment(sT * 1000).format('YYYY-MM-DD')
+    return { sTime, endTime }
+  }
+}

+ 37 - 0
src/services/user.data.ts

@@ -0,0 +1,37 @@
+import { defineStore } from 'pinia'
+
+export interface User {
+  timestamp:number,
+  user:{
+    name:string
+  }
+}
+
+// id必填,且需要唯一
+const useStore = defineStore('userData', {
+  state: () => ({
+    token: '',
+    user: {
+      timestamp: 0,
+      user: {
+        name: ''
+      }
+    }
+  }),
+  actions: {
+    setUser(data: User) {
+      this.user = data
+    },
+    setToken(data:string) {
+      this.token = data
+    },
+    clear() {
+      this.token = ''
+      this.user.timestamp = 0
+    }
+  },
+  persist: {
+    enabled: true // true 表示开启持久化保存
+  }
+})
+export default useStore

+ 3 - 1
src/vite-env.d.ts

@@ -10,4 +10,6 @@ declare module '*.vue' {
   export default component
 }
 declare const wx
-
+interface Window {
+  $notification: NotificationApiInjection;
+}

Some files were not shown because too many files changed in this diff