408249787@qq.com hai 2 horas
pai
achega
6d5f2bc9fe

+ 2 - 0
src/router/generator-platform-routers.js

@@ -357,6 +357,8 @@ const constantRouterComponents = {
   'WaitWork': () => import('@/views/workplace/publish/WaitWork'),
   'Executed': () => import('@/views/workplace/publish/Executed'),
   'MyPublish': () => import('@/views/workplace/publish/MyPublish'),
+  //
+  'HitchScreen': () => import('@/views/hitch-screen/index'),
   // threeJs
   'Workflow': () => import('@/views/workflow/workflow/Workflow.vue')
 }

+ 522 - 0
src/views/hitch-screen/index.vue

@@ -0,0 +1,522 @@
+<template>
+  <div class="hitch-screen">
+
+
+    <div class="screen-body">
+
+      <!-- 地图区:背景图拉伸铺满,打点用百分比定位,随容器等比缩放不偏移 -->
+      <div class="map-box">
+        <div class="positon">
+          <div class="title-name">设备运行状态一览图</div>
+          <div>
+            底图:
+            <a-select v-model="mapName" style="width: 120px" @change="handleMapChange">
+              <a-select-option v-for="item in mapList" :key="item.targetId" :value="item.targetId">
+                {{ item.targetId }}
+              </a-select-option>
+            </a-select>
+          </div>
+          <div class="title-icon">
+            <label class="legend-item legend-normal"><input type="checkbox" v-model="showNormal"> 正常设备</label>
+            <label class="legend-item legend-yellow"><input type="checkbox" v-model="showPlanned"> 问题设备</label>
+          </div>
+        </div>
+
+        <div style="position: relative;">
+          <img v-if="mapUrl" class="map-bg" :src="mapUrl" alt="">
+          <div v-for="item in visibleNormalList" :key="'normal-' + item.no" class="sb-point normal"
+            :style="pointStyle(item)" @mouseenter="handlePointEnter(item)" @mouseleave="handlePointLeave">
+            <span class="ripple"></span>
+            <span class="dot"></span>
+            <span class="no-label">{{ item.no }}</span>
+          </div>
+          <div v-for="item in visibleYellowList" :key="'yellow-' + item.no" class="sb-point yellow"
+            :style="pointStyle(item)" @mouseenter="handlePointEnter(item)" @mouseleave="handlePointLeave">
+            <span class="ripple"></span>
+            <span class="dot"></span>
+            <span class="no-label">{{ item.no }}</span>
+          </div>
+          <div v-if="tooltip.visible" class="sb-tooltip" :style="tooltipStyle">
+            <div>设备编号:{{ tooltip.info.no }}</div>
+            <div>设备名称:{{ tooltip.info.name }}</div>
+            <div>设备型号:{{ tooltip.info.model }}</div>
+            <div>使用位置:{{ tooltip.info.cph }}</div>
+            <div>设备维修员:{{ tooltip.info.repairUserName }}</div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 右侧数据面板:纯 CSS 绘制,不再使用 right.png 背景图 -->
+      <div class="data-panel" :style="{ height: screenHeight + 'px' }">
+        <div class="panel-title">本月保养计划</div>
+        <div class="panel-card">
+          <div class="bar-title">完成/计划</div>
+          <div class="month-num">
+            {{ jobData.monthNum != null ? jobData.finishNum + '/' + jobData.monthNum : '暂无' }}
+          </div>
+          <div class="rate-line">
+            <span>待完成:{{ jobData.waitNum != null ? jobData.waitNum : '暂无' }}</span>
+            <span>完成率:{{ jobData.rate != null ? jobData.rate + '%' : '暂无' }}</span>
+          </div>
+          <div class="bar-title">本周任务</div>
+          <div class="job-box">
+            <div class="item title">
+              <span>设备编号</span>
+              <span>保养任务</span>
+              <span>保养计划时间</span>
+            </div>
+            <div class="scroll-wrap" ref="scrollWrap">
+              <div class="scroll-inner" :class="{ scrolling: needScroll }"
+                :style="{ animationDuration: scrollDuration }">
+                <div v-for="(item, index) in doubledWeekList" :key="index" class="item">
+                  <span>{{ item.sbNo }}</span>
+                  <span class="ellipsis">{{ item.requirement || '' }}</span>
+                  <span>{{ item.startTime }}</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { axios } from '@/utils/request'
+
+// 打点页面(public/hitch-screen/point.html)取坐标的容器基准尺寸,
+// 见 public/hitch-screen/static/index.css 的 .echart;
+// 打点保存的 zjm/jbdh 为基于该尺寸的像素坐标,展示时换算成百分比,
+// 使打点与背景图等比缩放,避免固定像素定位在不同屏幕下发生偏移
+const BASE_WIDTH = 1334
+const BASE_HEIGHT = 1080
+
+export default {
+  name: 'HitchScreen',
+  data() {
+    return {
+      mapList: [],
+      mapName: null,
+      showNormal: true,
+      showPlanned: true,
+      screenData: null,
+      jobData: {},
+      tooltip: { visible: false, left: '0%', top: '0%', info: {} },
+      needScroll: false,
+      timer: null,
+      // 动态计算的页面高度:刚好铺满视口剩余区域
+      screenHeight: 600
+    }
+  },
+  computed: {
+    mapUrl() {
+      const current = (this.mapList || []).find(item => item.targetId === this.mapName)
+      return current ? current.url : ''
+    },
+    visibleNormalList() {
+      if (!this.showNormal || !this.screenData) return []
+      return this.screenData.normalSbInfoList || []
+    },
+    visibleYellowList() {
+      if (!this.showPlanned || !this.screenData) return []
+      return this.screenData.repairSbInfoListYellow || []
+    },
+    weekList() {
+      return this.jobData.weekList || []
+    },
+    // 内容超出容器时复制一份用于无缝循环滚动
+    doubledWeekList() {
+      return this.needScroll ? this.weekList.concat(this.weekList) : this.weekList
+    },
+    scrollDuration() {
+      return this.weekList.length * 3 + 's'
+    },
+    tooltipStyle() {
+      return { left: this.tooltip.left, top: this.tooltip.top }
+    }
+  },
+  created() {
+    this.getMapList()
+    this.getScreenJobData()
+    // 与 index.html 一致:60 秒轮询设备打点数据
+    this.timer = setInterval(this.getData, 60000)
+  },
+  mounted() {
+    this.calcHeight()
+    window.addEventListener('resize', this.calcHeight)
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.calcHeight)
+    if (this.timer) {
+      clearInterval(this.timer)
+      this.timer = null
+    }
+  },
+  methods: {
+    // 高度 = 视口高 - 元素顶部偏移 - 底部页脚高,刚好铺满且不产生滚动条
+    calcHeight() {
+      const top = this.$el.getBoundingClientRect().top
+      const footer = document.querySelector('.ant-layout-footer')
+      this.screenHeight = Math.max(window.innerHeight - top, 400)
+      this.$nextTick(this.checkScroll)
+    },
+    // 像素坐标转百分比定位:背景图拉伸铺满容器后,点与图保持相同比例,不偏移
+    pointStyle(item) {
+      return {
+        left: Number(item.zjm) * 100 + '%',
+        top: Number(item.jbdh) * 100 + '%'
+      }
+    },
+    getMapList() {
+      axios.get('/upms/files', { params: { type: 56 } }).then(res => {
+        this.mapList = res.data || []
+        if (this.mapList.length > 0) {
+          this.mapName = this.mapList[0].targetId
+          this.getData()
+        }
+      })
+    },
+    handleMapChange() {
+      this.getData()
+    },
+    getData() {
+      axios.get('/sb/infos/screen').then(res => {
+        const dataList = res.data || []
+        this.screenData = dataList.find(item => item.mapName === this.mapName) || null
+      })
+    },
+    getScreenJobData() {
+      axios.get('/check/jobs/screen').then(res => {
+        this.jobData = res.data || {}
+        this.$nextTick(this.checkScroll)
+      })
+    },
+    // 内容不足时不滚动,超出容器高度时开启无缝滚动
+    checkScroll() {
+      const wrap = this.$refs.scrollWrap
+      if (!wrap) {
+        this.needScroll = false
+        return
+      }
+      this.needScroll = wrap.scrollHeight > wrap.clientHeight
+    },
+    handlePointEnter(item) {
+      this.tooltip = {
+        visible: true,
+        left: Number(item.zjm) * 100 + '%',
+        top: Number(item.jbdh) * 100 + '%',
+        info: { no: item.no }
+      }
+      axios.get('/sb/infos/no/' + item.no).then(res => {
+        if (this.tooltip.visible) {
+          this.tooltip.info = res.data || {}
+        }
+      })
+    },
+    handlePointLeave() {
+      this.tooltip.visible = false
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.hitch-screen {
+  position: relative;
+  width: 100%;
+  /* 高度由 JS 按视口动态计算,刚好铺满剩余可见区域 */
+  overflow: hidden;
+  background: #fff;
+}
+
+.positon {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px;
+}
+
+.title-name {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 10px;
+  font-size: 20px;
+}
+
+.title-icon {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 10px;
+
+  label.legend-item {
+    display: inline-flex;
+    align-items: center;
+    margin-right: 24px;
+    font-size: 14px;
+    color: #333;
+    cursor: pointer;
+    user-select: none;
+  }
+
+  label.legend-item::before {
+    content: '';
+    display: inline-block;
+    width: 12px;
+    height: 12px;
+    border-radius: 50%;
+    margin-right: 8px;
+  }
+
+  label.legend-normal::before {
+    background-color: #174FBF;
+    box-shadow: 0 0 6px #174FBF;
+  }
+
+  label.legend-yellow::before {
+    background-color: yellow;
+    box-shadow: 0 0 6px yellow;
+  }
+
+  input[type='checkbox'] {
+    margin-right: 5px;
+    cursor: pointer;
+    transform: scale(1.2);
+  }
+}
+
+.screen-body {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  justify-content: space-between;
+}
+
+.map-box {
+  position: relative;
+  flex: 1;
+  height: 100%;
+  overflow: hidden;
+
+  .map-bg {
+    display: block;
+    width: 100%;
+    height: 100%;
+  }
+}
+
+/* 打点:百分比定位 + 居中偏移,ripple 为波纹效果,no-label 为编号气泡 */
+.sb-point {
+  position: absolute;
+  transform: translate(-50%, -50%);
+  z-index: 10;
+  cursor: pointer;
+
+  .dot {
+    display: block;
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+  }
+
+  .ripple {
+    position: absolute;
+    left: 50%;
+    top: 50%;
+    width: 10px;
+    height: 10px;
+    margin: -5px 0 0 -5px;
+    border-radius: 50%;
+    border: 2px solid;
+    animation: ripple 2s ease-out infinite;
+  }
+
+  .no-label {
+    position: absolute;
+    bottom: 14px;
+    left: 50%;
+    transform: translateX(-50%);
+    padding: 2px 6px;
+    border-radius: 6px;
+    font-size: 11px;
+    color: #000;
+    white-space: nowrap;
+  }
+
+  &.normal {
+
+    .dot,
+    .no-label {
+      background: #174FBF;
+    }
+
+    .ripple {
+      border-color: #174FBF;
+      box-shadow: 0 0 6px #174FBF;
+    }
+  }
+
+  &.yellow {
+
+    .dot,
+    .no-label {
+      background: yellow;
+    }
+
+    .ripple {
+      border-color: yellow;
+      box-shadow: 0 0 6px yellow;
+    }
+  }
+}
+
+@keyframes ripple {
+  0% {
+    transform: scale(1);
+    opacity: 0.8;
+  }
+
+  100% {
+    transform: scale(4);
+    opacity: 0;
+  }
+}
+
+.sb-tooltip {
+  position: absolute;
+  z-index: 100;
+  transform: translate(-50%, calc(-100% - 14px));
+  padding: 8px 12px;
+  background: rgba(255, 255, 255, 0.95);
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  font-size: 12px;
+  color: #333;
+  line-height: 20px;
+  white-space: nowrap;
+  pointer-events: none;
+}
+
+.data-panel {
+  width: 386px;
+  // CSS 绘制深蓝渐变背景,替代原 right.png 背景图
+  background: linear-gradient(180deg, #07344a 0%, #05263b 60%, #041d2c 100%);
+  color: #fff;
+  display: flex;
+  flex-direction: column;
+  padding: 10px 12px 8px;
+  box-sizing: border-box;
+
+  .panel-title {
+    font-size: 24px;
+    font-weight: bold;
+    letter-spacing: 6px;
+    text-align: center;
+    margin-bottom: 18px;
+  }
+
+  .panel-card {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    border: 1px solid rgba(36, 149, 191, 0.45);
+    border-radius: 8px;
+    background: rgba(10, 60, 90, 0.25);
+    padding: 10 14px;
+    box-sizing: border-box;
+    overflow: hidden;
+  }
+
+  // 中间亮两边暗的渐变标题条
+  .bar-title {
+    height: 36px;
+    line-height: 36px;
+    text-align: center;
+    font-size: 16px;
+    background: linear-gradient(90deg, rgba(23, 111, 158, 0) 0%, rgba(23, 111, 158, 0.8) 50%, rgba(23, 111, 158, 0) 100%);
+  }
+
+  .month-num {
+    font-size: 20px;
+    text-align: center;
+    margin: 10px 0;
+  }
+
+  .rate-line {
+    font-size: 14px;
+    text-align: center;
+    margin-bottom: 10px;
+
+    span {
+      margin: 0 4px;
+    }
+  }
+
+  .job-box {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+
+  .item {
+    display: flex;
+    align-items: center;
+    padding-left: 10px;
+    margin-bottom: 5px;
+    font-size: 12px;
+
+    >span {
+      line-height: 20px;
+      text-align: center;
+    }
+
+    >span:nth-child(1) {
+      flex: 2;
+    }
+
+    >span:nth-child(2) {
+      flex: 4;
+
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      padding: 0 5px;
+    }
+
+    >span:nth-child(3) {
+      flex: 3;
+    }
+  }
+
+  .title {
+    color: #2495bf;
+  }
+
+  .scroll-wrap {
+    flex: 1;
+    overflow: hidden;
+    position: relative;
+  }
+
+  .scroll-inner {
+    width: 100%;
+
+    &.scrolling {
+      animation: scroll-up linear infinite;
+    }
+  }
+}
+
+@keyframes scroll-up {
+  from {
+    transform: translateY(0);
+  }
+
+  to {
+    transform: translateY(-50%);
+  }
+}
+</style>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 165 - 513
src/views/sb/info/modules/BaseForm.vue


+ 165 - 0
src/views/sb/info/modules/Point.vue

@@ -0,0 +1,165 @@
+<template>
+  <a-modal title="设备打点设置" :width="1300" :visible="visible" :footer="null" @cancel="handleCancel">
+    <div class="point-toolbar">
+      <span>底图:</span>
+      <a-select v-model="mapName" style="width: 140px">
+        <a-select-option v-for="item in mapList" :key="item.targetId" :value="item.targetId">
+          {{ item.targetId }}
+        </a-select-option>
+      </a-select>
+      <span class="ml">水平位置(%):</span>
+      <a-input-number v-model="xPct" :min="0" :max="100" :step="0.0001" style="width: 110px" placeholder="水平" />
+      <span class="ml">垂直位置(%):</span>
+      <a-input-number v-model="yPct" :min="0" :max="100" :step="0.0001" style="width: 110px" placeholder="垂直" />
+      <a-button class="ml" type="primary" :loading="saving" @click="save">保存位置</a-button>
+      <span class="tip ml">点击底图选取设备位置,点位信息以百分比保存</span>
+    </div>
+    <div class="point-map" ref="mapBox" @click="handleMapClick">
+      <img v-if="mapUrl" class="map-bg" :src="mapUrl" alt="">
+      <div v-if="hasPoint" class="point-marker" :style="{ left: xPct + '%', top: yPct + '%' }"></div>
+    </div>
+  </a-modal>
+</template>
+
+<script>
+import { axios } from '@/utils/request'
+
+export default {
+  name: 'SbPointModal',
+  data() {
+    return {
+      visible: false,
+      saving: false,
+      id: null,
+      mapList: [],
+      mapName: null,
+      // 点位信息采用百分比(0-100,4 位小数)展示与编辑,提交时转小数并保留 6 位小数,与大屏 *100 渲染约定一致
+      xPct: null,
+      yPct: null
+    }
+  },
+  computed: {
+    mapUrl() {
+      const current = (this.mapList || []).find(item => item.targetId === this.mapName)
+      return current ? current.url : ''
+    },
+    hasPoint() {
+      return this.xPct != null && this.yPct != null
+    }
+  },
+  methods: {
+    /**
+     * 打开打点弹框
+     * @param record 设备记录,含 id/zjm/jbdh/mapName
+     */
+    base(record) {
+      this.visible = true
+      this.id = record.id
+      this.xPct = this.toPct(record.zjm)
+      this.yPct = this.toPct(record.jbdh)
+      this.mapName = record.mapName || null
+      this.getMapList()
+    },
+    // 小数(0-1)转百分比(0-100)展示,百分比保留 4 位小数(对应小数 6 位)
+    toPct(value) {
+      if (value == null || value === '') {
+        return null
+      }
+      const num = Number(value)
+      if (isNaN(num)) {
+        return null
+      }
+      return Math.round(num * 1000000) / 10000
+    },
+    getMapList() {
+      axios.get('/upms/files', { params: { type: 56 } }).then(res => {
+        this.mapList = res.data || []
+        if (!this.mapName && this.mapList.length > 0) {
+          this.mapName = this.mapList[0].targetId
+        }
+      })
+    },
+    // 点击底图取百分比位置:背景图拉伸铺满,百分比与大屏渲染同基准不偏移
+    handleMapClick(e) {
+      const rect = this.$refs.mapBox.getBoundingClientRect()
+      if (!rect.width || !rect.height) {
+        return
+      }
+      const x = ((e.clientX - rect.left) / rect.width) * 100
+      const y = ((e.clientY - rect.top) / rect.height) * 100
+      this.xPct = Math.round(x * 10000) / 10000
+      this.yPct = Math.round(y * 10000) / 10000
+    },
+    save() {
+      if (this.id == null) {
+        this.$message.error('请先保存设备再设置打点')
+        return
+      }
+      if (!this.hasPoint) {
+        this.$message.error('请点击底图选取位置')
+        return
+      }
+      this.saving = true
+      // 百分比转小数提交,保留 6 位小数,与大屏 zjm*100 渲染约定一致
+      const data = {
+        id: this.id,
+        zjm: Math.round((this.xPct / 100) * 1000000) / 1000000,
+        jbdh: Math.round((this.yPct / 100) * 1000000) / 1000000
+      }
+      axios.put('/sb/infos/' + this.id, data).then(() => {
+        this.saving = false
+        this.$message.success('保存成功')
+        this.$emit('ok', data)
+      }).catch(() => {
+        this.saving = false
+      })
+    },
+    handleCancel() {
+      this.visible = false
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.point-toolbar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+
+  .ml {
+    margin-left: 16px;
+  }
+
+  .tip {
+    color: #999;
+    font-size: 12px;
+  }
+}
+
+.point-map {
+  position: relative;
+  height: 620px;
+  border: 1px solid #e8e8e8;
+  overflow: hidden;
+  cursor: crosshair;
+
+  .map-bg {
+    display: block;
+    width: 100%;
+    height: 100%;
+  }
+
+  .point-marker {
+    position: absolute;
+    width: 12px;
+    height: 12px;
+    margin: -6px 0 0 -6px;
+    border-radius: 50%;
+    background: #174FBF;
+    box-shadow: 0 0 8px #174FBF;
+    z-index: 10;
+    pointer-events: none;
+  }
+}
+</style>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio