Explorar o código

feat(app): 新增重复报修提醒、MTTR/MTBF报表及大屏面板重构

- 报修提交时检查设备是否存在未完结报修单,弹出确认提醒
- 新增 MTTR 按月趋势和 MTBF 按设备统计接口及导出功能
- 维修工时导出接口从 GET 改为 POST 以支持携带图表截图
- 大屏右侧面板重构为"当前问题设备"和"近期保养计划"两块
- 多标签页保活引入 routeCacheMap 实现精准缓存管理
- 新增全局混入在页面 mounted 时自动注册真实组件缓存名
- 报修明细弹窗扩展支持 MTTR/MTBF 维度及额外筛选条件
- 大屏默认隐藏正常设备标记,仅显示问题与停机设备
dazhaxie hai 1 día
pai
achega
ec01860246

+ 97 - 46
public/hitch-screen/index.html

@@ -26,25 +26,31 @@
   <div style="display:flex;width:1920px;justify-content:space-between;align-items:center;">
     <div id="echart" class="echart"></div>
     <div class="data">
-      <div>
-        <span id="monthNum">暂无</span>
-      </div>
-      <div>
-        <span id="waitNum">暂无</span>
-        <span id="rate">暂无</span>
-      </div>
-      <div style="height: 80%;">
-        <div id="weekJob" class="item title">
+      <!-- 右上:当前问题设备(有进行中报修单的设备) -->
+      <div class="panel panel-problem">
+        <div class="panel-name">当前问题设备</div>
+        <div class="item title">
           <span>设备编号</span>
-          <span>保养任务</span>
-          <span>保养计划时间</span>
+          <span>设备名称</span>
+          <span>维修状态</span>
+        </div>
+        <div id="problemList" class="scrollDiv str_vertical"></div>
+      </div>
+      <!-- 右下:近期保养计划 -->
+      <div class="panel panel-plan">
+        <div class="panel-name">近期保养计划</div>
+        <div class="bar-title">本月完成/计划</div>
+        <div class="sum-line">
+          <span id="monthNum">暂无</span>
+          <span id="waitNum">暂无</span>
+          <span id="rate">暂无</span>
+        </div>
+        <div class="item title">
+          <span>设备</span>
+          <span>周期</span>
+          <span>计划日期</span>
         </div>
-        <div class="scrollDiv str_wrap str_vertical"></div>
-        <!--      <div class="item">
-        <span>设备名称</span>
-        <span>正常</span>
-        <span>2021/03/21</span>
-      </div>-->
+        <div id="planList" class="scrollDiv str_vertical"></div>
       </div>
     </div>
   </div>
@@ -53,7 +59,6 @@
   /*Plugin CSS*/
   .str_wrap {
     overflow: hidden;
-    height: 56%;
     width: 100%;
     position: relative;
     -moz-user-select: none;
@@ -62,6 +67,16 @@
     white-space: nowrap;
   }
 
+  /* 保养计划列表:设备名称列加宽,周期列变窄 */
+  .data .panel-plan .item > span:nth-child(1) {
+    flex: 4;
+  }
+
+  .data .panel-plan .item > span:nth-child(2) {
+    flex: 2;
+    padding: 0;
+  }
+
   .positon {
     position: absolute;
     top: 20px;
@@ -408,6 +423,7 @@
   //初始化加载数据,定时器只能在定时到了才发生
   getData();
   getScreenJobData();
+  getScreenProblemData();
 
   // 使用刚指定的配置项和数据显示图表
   myChart.setOption(option);
@@ -430,32 +446,43 @@
     });
   }
 
-  // 任务数据
+  // 近期保养计划:设备、周期、计划日期
   function getScreenJobData() {
-    console.log(21);
     $.ajax({
       type: "get",
       url: "/api/check/jobs/screen",
       headers: getHeader(),
       success: function (response) {
-        $('#monthNum').text(response.data.finishNum + "/" + response.data.monthNum);
-        $('#waitNum').text("待完成:" + response.data.waitNum);
-        $('#rate').text("完成率:" + response.data.rate + "%");
-        let weekJob = '';
-        response.data.weekList.forEach(item => {
-          weekJob = weekJob + '  <div class=\'item\'>\n' +
-            '          <span>' + item.sbNo + '</span>\n' +
-            '          <span>' + (item.requirement || '') + '</span>\n' +
-            '          <span>' + item.startTime + '</span>\n' +
-            '        </div>'
+        let data = response.data || {};
+        $('#monthNum').text((data.finishNum || 0) + "/" + (data.monthNum || 0));
+        $('#waitNum').text("待完成:" + (data.waitNum || 0));
+        $('#rate').text("完成率:" + (data.rate || 0) + "%");
+        let planHtml = '';
+        (data.planList || []).forEach(item => {
+          planHtml += buildRow(item.sbName || item.sbNo, item.periodText || '', item.startTime,
+            '设备编号:' + (item.sbNo || '') + ';计划完成:' + (item.endTime || ''));
         })
-        $('.scrollDiv').empty().append(weekJob);
+        renderMarquee('planList', planHtml);
+      },
+      error: function (xhr, textStatus, errorThrown) {
+        error(xhr, textStatus, errorThrown);
+      }
+    });
+  }
 
-        $('.scrollDiv').liMarquee({
-          direction: 'up',//身上滚动
-          runshort: false,//内容不足时不滚动
-          scrollamount: 20//速度
-        });
+  // 当前问题设备:有进行中报修单(待接收/维修中/转派审批中/委外维修中)的设备
+  function getScreenProblemData() {
+    $.ajax({
+      type: "get",
+      url: "/api/sb/infos/screen/problem-list",
+      headers: getHeader(),
+      success: function (response) {
+        let problemHtml = '';
+        (response.data || []).forEach(item => {
+          problemHtml += buildRow(item.no, item.name, item.repairStatusLabel,
+            '报修单:' + (item.repairNo || '') + ';报修时间:' + formatTime(item.applyTime));
+        })
+        renderMarquee('problemList', problemHtml);
       },
       error: function (xhr, textStatus, errorThrown) {
         error(xhr, textStatus, errorThrown);
@@ -463,9 +490,42 @@
     });
   }
 
+  // 列表行:三列内容 + 鼠标悬停提示
+  function buildRow(col1, col2, col3, tip) {
+    return '<div class="item"' + (tip ? ' title="' + tip + '"' : '') + '>' +
+      '<span>' + (col1 || '') + '</span>' +
+      '<span>' + (col2 || '') + '</span>' +
+      '<span>' + (col3 || '') + '</span>' +
+      '</div>'
+  }
+
+  // 报修时间只展示到分钟,避免提示过长
+  function formatTime(time) {
+    if (!time) return ''
+    return String(time).replace('T', ' ').substring(0, 16)
+  }
+
+  // 渲染滚动列表:刷新前先停掉上一轮动画并移除插件生成的节点,避免克隆节点与定时器叠加
+  function renderMarquee(id, html) {
+    const $box = $('#' + id)
+    const moveId = $box.data('moveId')
+    if (moveId) {
+      clearTimeout(moveId)
+      $box.data('moveId', null)
+    }
+    $box.find('.str_move').stop(true).remove()
+    $box.empty().append(html || '')
+    $box.liMarquee({
+      direction: 'up',//向上滚动
+      runshort: false,//内容不足时不滚动
+      scrollamount: 20//速度
+    })
+  }
+
   // setInterval(mockData(), 10000);
   setInterval(function () { getData() }, 60000);
-  // setInterval(function(){ getScreenJobData() }, 60000);
+  // 右侧两个列表变化不频繁,5 分钟刷新一次
+  setInterval(function () { getScreenJobData(); getScreenProblemData() }, 300000);
 
   function mockData() {
 
@@ -606,15 +666,6 @@
     }
   }
 
-  (function () {
-    console.log("滚动")
-    $('.scrollDiv').liMarquee({
-      direction: 'up',//身上滚动
-      runshort: false,//内容不足时不滚动
-      scrollamount: 20//速度
-    });
-  });
-
 </script>
 
 </html>

+ 65 - 26
public/hitch-screen/static/index.css

@@ -14,66 +14,105 @@ body {
 .data {
   width: 586px;
   height: 1080px;
-  background: url("right.png") no-repeat;
+  box-sizing: border-box;
+  padding: 20px 18px;
+  /* CSS 绘制深蓝渐变背景,不再使用 right.png(背景图上的“本月保养计划/本周任务”标题已不适用) */
+  background: linear-gradient(180deg, #07344a 0%, #05263b 60%, #041d2c 100%);
+  color: #ffffff;
 }
 
-.data > div:nth-child(1) {
-  margin-top: 240px;
-  width: 100%;
-  font-size: 40px;
-  color: #ffffff;
+/* 右侧分为上下两块:上-当前问题设备,下-近期保养计划 */
+.data .panel {
+  box-sizing: border-box;
+  border: 1px solid rgba(36, 149, 191, 0.45);
+  border-radius: 8px;
+  background: rgba(10, 60, 90, 0.25);
+  padding: 10px 12px;
+  overflow: hidden;
+}
+
+.data .panel-problem {
+  height: 480px;
+}
+
+.data .panel-plan {
+  height: 520px;
+  margin-top: 18px;
+}
+
+.data .panel-name {
+  font-size: 22px;
+  font-weight: bold;
+  letter-spacing: 4px;
+  text-align: center;
+  margin-bottom: 8px;
+}
+
+/* 中间亮两边暗的渐变标题条 */
+.data .bar-title {
+  height: 30px;
+  line-height: 30px;
   text-align: center;
+  font-size: 15px;
+  background: linear-gradient(90deg,
+      rgba(23, 111, 158, 0) 0%,
+      rgba(23, 111, 158, 0.8) 50%,
+      rgba(23, 111, 158, 0) 100%);
 }
 
-.data > div:nth-child(2) {
-  margin-top: 30px;
-  width: 100%;
+.data .sum-line {
   font-size: 16px;
-  color: #ffffff;
   text-align: center;
+  margin: 6px 0;
 }
 
-/*.data>div:nth-child(2)>span:nth-child(1){padding-left:22px;background:url("red.png") no-repeat 5px 5px;}
-.data>div:nth-child(2)>span:nth-child(2){padding-left:22px;background:url("green.png") no-repeat 5px 5px;}*/
-.data > div:nth-child(3) {
-  margin-top: 70px;
-  color: #ffffff;
+.data .sum-line span {
+  margin: 0 8px;
 }
 
 .data .item {
   display: flex;
   align-items: center;
-
-  padding-left: 60px;
-  margin-bottom: 10px;
+  padding-left: 10px;
+  margin-bottom: 6px;
+  font-size: 14px;
 }
 
 .data .item > span {
-  line-height: 30px;
+  line-height: 26px;
   text-align: center;
 }
 
 .data .item > span:nth-child(1) {
-  width: 100px;
+  flex: 2;
 }
 
 .data .item > span:nth-child(2) {
-  width: 200px;
+  flex: 4;
   /* 溢出隐藏 */
-  white-space: nowrap;     
-  overflow: hidden;         
-  text-overflow: ellipsis;  
-  padding: 0 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  padding: 0 6px;
 }
 
 .data .item > span:nth-child(3) {
-  width: 150px;
+  flex: 3;
 }
 
 .data .title {
   color: #2495bf;
 }
 
+/* 滚动列表容器:liMarquee 需要确定高度 */
+.data #problemList {
+  height: 370px;
+}
+
+.data #planList {
+  height: 350px;
+}
+
 .title-icon {
   position: absolute;
   top: 135px;

+ 16 - 0
src/api/repair/application-form.js

@@ -50,6 +50,22 @@ export function saveFormByApplication(parameter) {
 }
 
 /**
+ * 查询设备未完结的报修单(排除已完成/已拒单),用于提交报修前的重复报修提醒
+ * parameter: { sbId }
+ * @param parameter
+ * @returns {*}
+ */
+export function queryUncompletedRepairs(parameter) {
+  return axios({
+    url: '/repair/application-forms/uncompleted?' + stringify(parameter),
+    method: 'get',
+    headers: {
+      'Content-Type': 'application/json;charset=UTF-8'
+    }
+  })
+}
+
+/**
  * update func
  * parameter: { }
  * @param parameter

+ 76 - 6
src/api/report/application-form.js

@@ -298,14 +298,15 @@ export function getWorkHourDetail (parameter) {
 }
 
 /**
- * 维修工时统计导出:按维修人
+ * 维修工时统计导出:按维修人(POST,可携带 chartBase64 统计图截图,图片嵌入“统计图表”sheet)
  * @param parameter
  * @returns {*}
  */
 export function exportWorkHourUser (parameter) {
   return axios({
-    url: '/report/repair/workhour/export/user?' + stringify(parameter),
-    method: 'get',
+    url: '/report/repair/workhour/export/user',
+    method: 'post',
+    data: parameter,
     headers: {
       'Content-Type': 'application/json;charset=UTF-8'
     },
@@ -314,14 +315,15 @@ export function exportWorkHourUser (parameter) {
 }
 
 /**
- * 维修工时统计导出:按设备
+ * 维修工时统计导出:按设备(POST,可携带 chartBase64 统计图截图,图片嵌入“统计图表”sheet)
  * @param parameter
  * @returns {*}
  */
 export function exportWorkHourSb (parameter) {
   return axios({
-    url: '/report/repair/workhour/export/sb?' + stringify(parameter),
-    method: 'get',
+    url: '/report/repair/workhour/export/sb',
+    method: 'post',
+    data: parameter,
     headers: {
       'Content-Type': 'application/json;charset=UTF-8'
     },
@@ -378,3 +380,71 @@ export function exportWorkHourDetail (parameter) {
     responseType: 'blob'
   })
 }
+
+/**
+ * MTTR(平均故障修复时间)按月趋势统计
+ * parameter: { mttrStartMonth(YYYY-MM 必填), mttrEndMonth(YYYY-MM 必填), levelList[], typeIdList[], keyword }
+ * @param parameter
+ * @returns {*}
+ */
+export function getMttrMonth (parameter) {
+  return axios({
+    url: '/report/repair/workhour/mttr/month?' + stringify(parameter),
+    method: 'get',
+    headers: {
+      'Content-Type': 'application/json;charset=UTF-8'
+    }
+  })
+}
+
+/**
+ * MTBF(平均故障间隔时间)统计:按设备明细 + 按月趋势
+ * parameter: { mttrStartMonth(YYYY-MM 必填), mttrEndMonth(YYYY-MM 必填), levelList[], typeIdList[], keyword }
+ * @param parameter
+ * @returns {*}
+ */
+export function getMtbfReport (parameter) {
+  return axios({
+    url: '/report/repair/workhour/mtbf?' + stringify(parameter),
+    method: 'get',
+    headers: {
+      'Content-Type': 'application/json;charset=UTF-8'
+    }
+  })
+}
+
+/**
+ * MTTR 按月趋势导出(POST,可携带 chartBase64 统计图截图)
+ * parameter: { mttrStartMonth(YYYY-MM 必填), mttrEndMonth(YYYY-MM 必填), levelList[], typeIdList[], keyword, chartBase64 }
+ * @param parameter
+ * @returns {*}
+ */
+export function exportMttrMonth (parameter) {
+  return axios({
+    url: '/report/repair/workhour/mttr/export',
+    method: 'post',
+    data: parameter,
+    headers: {
+      'Content-Type': 'application/json;charset=UTF-8'
+    },
+    responseType: 'blob'
+  })
+}
+
+/**
+ * MTBF 按设备统计导出(POST,可携带 chartBase64 统计图截图)
+ * parameter: { mttrStartMonth(YYYY-MM 必填), mttrEndMonth(YYYY-MM 必填), levelList[], typeIdList[], keyword, chartBase64 }
+ * @param parameter
+ * @returns {*}
+ */
+export function exportMtbfReport (parameter) {
+  return axios({
+    url: '/report/repair/workhour/mtbf/export',
+    method: 'post',
+    data: parameter,
+    headers: {
+      'Content-Type': 'application/json;charset=UTF-8'
+    },
+    responseType: 'blob'
+  })
+}

+ 14 - 23
src/components/MultiTab/MultiTab.vue

@@ -37,10 +37,7 @@ export default {
     this.pages.push(this.$route)
     this.fullPathList.push(this.$route.fullPath)
     this.selectedLastPath()
-    // 初始化时将当前页面组件加入缓存
-    this.$nextTick(() => {
-      this.addCurrentToCache()
-    })
+    // 当前页面的保活由全局混入在组件 mounted 时自动注册,这里无需手动处理
   },
   methods: {
     onEdit (targetKey, action) {
@@ -129,43 +126,40 @@ export default {
       )
     },
     /**
-     * 解析路由对应的组件名称
-     * 优先从 $route.matched 获取已解析的组件 name,回退到路由 name
+     * 解析路由对应的组件名称(关闭页签时的默认处理,正常情况优先用 store 中 mounted 记录的真实 name)
      */
     getRouteComponentName (route) {
       if (!route) return null
       const layoutComponents = ['BasicLayout', 'RouteView', 'PageView', 'UserLayout', 'BlankLayout']
-      // 从 matched 路由记录中获取组件名称
       if (route.matched && route.matched.length > 0) {
         const lastRecord = route.matched[route.matched.length - 1]
+        const instance = lastRecord.instances && lastRecord.instances.default
+        const instanceName = instance && instance.$options && instance.$options.name
+        if (instanceName && !layoutComponents.includes(instanceName)) {
+          return instanceName
+        }
         const component = lastRecord.components && lastRecord.components.default
         if (component && component.name && !layoutComponents.includes(component.name)) {
           return component.name
         }
       }
-      // 回退到路由名称
       return route.name || null
     },
     /**
-     * 将当前路由组件加入缓存
-     */
-    addCurrentToCache () {
-      const name = this.getRouteComponentName(this.$route)
-      if (name) {
-        this.$store.dispatch('AddCachedView', name)
-      }
-    },
-    /**
      * 关闭标签页时,从缓存中移除对应组件
      * 仅当没有其他打开的标签使用同一组件时才移除
      */
     removePageFromCache (closedPage) {
       if (!closedPage) return
-      const componentName = this.getRouteComponentName(closedPage)
+      const fullPath = closedPage.fullPath
+      // 优先用页面 mounted 时记入 store 的真实组件名(异步组件名称与路由名常常不一致)
+      const componentName = this.$store.state.app.routeCacheMap[fullPath] || this.getRouteComponentName(closedPage)
+      // 移除该页签的映射
+      this.$store.commit('DEL_ROUTE_CACHE_MAP', fullPath)
       if (!componentName) return
       // 检查是否还有其他打开的标签页使用同一组件
       const stillInUse = this.pages.some(page => {
-        return this.getRouteComponentName(page) === componentName
+        return (this.$store.state.app.routeCacheMap[page.fullPath] || this.getRouteComponentName(page)) === componentName
       })
       if (!stillInUse) {
         this.$store.dispatch('DelCachedView', componentName)
@@ -179,10 +173,7 @@ export default {
         this.fullPathList.push(newVal.fullPath)
         this.pages.push(newVal)
       }
-      // 将当前页面组件加入缓存保活列表
-      this.$nextTick(() => {
-        this.addCurrentToCache()
-      })
+      // 页面保活的注册由全局混入在组件 mounted 时完成,无需在此手动加入
     },
     activeKey: function (newPathKey) {
       this.$router.push({ path: newPathKey })

+ 4 - 0
src/main.js

@@ -17,6 +17,7 @@ import './core/use'
 import './permission' // permission control
 import './utils/filter' // global filter
 import BaseTool from './utils/install'
+import { installKeepAliveMixin } from './core/keep_alive_mixin'
 import echarts from 'echarts'
 import VueI18n from 'vue-i18n'
 import Print from 'vue-print-nb'
@@ -32,6 +33,9 @@ Vue.use(BaseTool)
 Vue.use(VueI18n) // 通过插件的形式挂载
 Vue.use(Viewer)
 
+// 多标签页保活:注册全局混入,页面 mounted 时用真实组件 name 加入 keep-alive 缓存
+installKeepAliveMixin(Vue)
+
 export const eventBus = new Vue()
 Vue.prototype.$eventBus = eventBus
 

+ 41 - 2
src/permission.js

@@ -14,6 +14,45 @@ NProgress.configure({ showSpinner: false }) // NProgress Configuration
 
 const whiteList = ['login', 'register', 'test', 'registerResult', GlobalConstant.SINGLE_LOGIN_ROUTER_NAME] // no redirect whitelist
 
+// 多页签保活:透传型分组容器(仅用于菜单分组、不渲染实际页面内容)
+const PASSTHROUGH_CONTAINERS = ['RouteView', 'PageView']
+
+// 取路由记录组件的 name(异步工厂函数 name 不会是容器名,故不会误判为容器)
+function getComponentName (component) {
+  if (!component) return ''
+  if (typeof component === 'object') return component.name || (component.options && component.options.name) || ''
+  return component.name || ''
+}
+
+/**
+ * 把「BasicLayout -> 分组(RouteView) -> 叶子页面」的两级嵌套,就地拍平成
+ * 「BasicLayout -> 叶子页面」,使所有叶子都落在同一个 route-view 的 keep-alive 中缓存。
+ * 这样跨分组切换(如 设备台账 -> 我的通知 / 设备运行状态一览图)不会再因为销毁中间
+ * 分组容器而连带丢失其它页签的缓存。仅拍平透传容器(RouteView/PageView),真正的页面
+ * (含带子路由的页面)保持原有嵌套不变,避免破坏其自身布局。
+ * 注意:这里只处理「注册进 router 的副本」,store 中的嵌套菜单树不受影响。
+ */
+function flattenKeepAliveRoutes (routes) {
+  const out = []
+  ;(routes || []).forEach(record => {
+    if (!record) return
+    const name = getComponentName(record.component)
+    const hasChildren = record.children && record.children.length
+    if (PASSTHROUGH_CONTAINERS.includes(name) && hasChildren) {
+      // 保留分组节点本身(redirect / 父级守卫仍可用),但把它的叶子提升到同级
+      const group = { ...record }
+      delete group.children
+      out.push(group)
+      out.push(...flattenKeepAliveRoutes(record.children))
+    } else {
+      const clone = { ...record }
+      if (hasChildren) clone.children = flattenKeepAliveRoutes(record.children)
+      out.push(clone)
+    }
+  })
+  return out
+}
+
 router.beforeEach((to, from, next) => {
   if (to.path === GlobalConstant.PC_INDEX_PATH) {
     if (BaseTool.Util._isMobile()) {
@@ -42,8 +81,8 @@ router.beforeEach((to, from, next) => {
             const menus = res.data.menuTrees
             store.dispatch('GenerateRoutes', { menus }).then(() => {
               // 根据roles权限生成可访问的路由表
-              // 动态添加可访问路由表
-              router.addRoutes(store.getters.addRouters)
+              // 动态添加可访问路由表(拍平透传分组容器,使所有叶子共享同一 keep-alive 缓存,跨分组切换不丢状态)
+              router.addRoutes(flattenKeepAliveRoutes(store.getters.addRouters))
               const redirect = decodeURIComponent(from.query.redirect || to.path)
               if (to.path === redirect) {
                 // hack方法 确保addRoutes已完成 ,set the replace: true so the navigation will not leave a history record

+ 25 - 1
src/store/modules/app.js

@@ -26,7 +26,9 @@ const app = {
     weak: false,
     multiTab: true,
     // 多标签页保活:记录需要缓存的组件名称
-    cachedViews: []
+    cachedViews: [],
+    // 页签(fullPath) -> 真实组件 name 的映射,关闭页签时据此精准移除缓存
+    routeCacheMap: {}
   },
   mutations: {
     SET_SIDEBAR_TYPE: (state, type) => {
@@ -82,6 +84,24 @@ const app = {
         state.cachedViews.push(name)
       }
     },
+    // 记录某个页签对应的真实组件名并加入缓存列表(由页面组件 mounted 时触发)
+    CACHE_ROUTE_VIEW: (state, { fullPath, name }) => {
+      if (!fullPath || !name) return
+      state.routeCacheMap = { ...state.routeCacheMap, [fullPath]: name }
+      if (!state.cachedViews.includes(name)) {
+        state.cachedViews.push(name)
+      }
+    },
+    // 清除某个页签的映射,返回被移除的 name(供调用方判断是否还需保留缓存)
+    DEL_ROUTE_CACHE_MAP: (state, fullPath) => {
+      const name = state.routeCacheMap[fullPath]
+      if (name !== undefined) {
+        const map = { ...state.routeCacheMap }
+        delete map[fullPath]
+        state.routeCacheMap = map
+      }
+      return name
+    },
     DEL_CACHED_VIEW: (state, name) => {
       const index = state.cachedViews.indexOf(name)
       if (index > -1) {
@@ -90,6 +110,7 @@ const app = {
     },
     CLEAR_CACHED_VIEW: (state) => {
       state.cachedViews = []
+      state.routeCacheMap = {}
     }
   },
   actions: {
@@ -135,6 +156,9 @@ const app = {
     AddCachedView ({ commit }, name) {
       commit('ADD_CACHED_VIEW', name)
     },
+    CacheRouteView ({ commit }, payload) {
+      commit('CACHE_ROUTE_VIEW', payload)
+    },
     DelCachedView ({ commit }, name) {
       commit('DEL_CACHED_VIEW', name)
     },

+ 139 - 51
src/views/hitch-screen/index.vue

@@ -53,10 +53,31 @@
         </div>
       </div>
 
-      <!-- 右侧数据面板:纯 CSS 绘制,不再使用 right.png 背景图 -->
+      <!-- 右侧数据面板:上-当前问题设备,下-近期保养计划;纯 CSS 绘制,不再使用 right.png 背景图 -->
       <div class="data-panel">
-        <div style="flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
-          <div class="panel-title">本月保养计划</div>
+        <div class="panel-block">
+          <div class="panel-title">当前问题设备</div>
+          <div class="panel-card">
+            <div class="item title">
+              <span>设备编号</span>
+              <span>设备名称</span>
+              <span>维修状态</span>
+            </div>
+            <div class="scroll-wrap" ref="problemWrap">
+              <div class="scroll-inner" :class="{ scrolling: needScrollProblem }"
+                :style="{ animationDuration: problemDuration }">
+                <div v-for="(item, index) in doubledProblemList" :key="'problem-' + index" class="item"
+                  :title="'报修单:' + (item.repairNo || '') + ';报修时间:' + formatTime(item.applyTime)">
+                  <span>{{ item.no }}</span>
+                  <span class="ellipsis">{{ item.name }}</span>
+                  <span>{{ item.repairStatusLabel }}</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="panel-block">
+          <div class="panel-title">近期保养计划</div>
           <div class="panel-card">
             <div class="bar-title">完成/计划</div>
             <div class="month-num">
@@ -66,21 +87,19 @@
               <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 class="item title plan-item">
+              <span>设备</span>
+              <span>周期</span>
+              <span>计划日期</span>
+            </div>
+            <div class="scroll-wrap" ref="planWrap">
+              <div class="scroll-inner" :class="{ scrolling: needScrollPlan }"
+                :style="{ animationDuration: planDuration }">
+                <div v-for="(item, index) in doubledPlanList" :key="'plan-' + index" class="item plan-item"
+                  :title="'设备编号:' + (item.sbNo || '') + ';计划完成:' + (item.endTime || '')">
+                  <span class="ellipsis">{{ item.sbName || item.sbNo }}</span>
+                  <span>{{ item.periodText || '' }}</span>
+                  <span>{{ item.startTime }}</span>
                 </div>
               </div>
             </div>
@@ -105,14 +124,19 @@ export default {
     return {
       mapList: [],
       mapName: null,
-      showNormal: true,
+      // 进入大屏默认只看黄(问题设备)+ 红(停机设备),蓝色正常设备默认隐藏,需要时手动勾选
+      showNormal: false,
       showPlanned: true,
       showStopped: true,
       screenData: null,
       jobData: {},
+      // 右上:当前问题设备(有进行中报修单的设备)
+      problemList: [],
       tooltip: { visible: false, left: '0%', top: '0%', info: {} },
-      needScroll: false,
-      timer: null
+      needScrollProblem: false,
+      needScrollPlan: false,
+      timer: null,
+      listTimer: null
     }
   },
   computed: {
@@ -133,15 +157,22 @@ export default {
       if (!this.showStopped || !this.screenData) return []
       return this.screenData.repairSbInfoListRed || []
     },
-    weekList() {
-      return this.jobData.weekList || []
+    // 近期保养计划:后端返回今起 14 天内未完成的保养任务
+    planList() {
+      return this.jobData.planList || []
     },
     // 内容超出容器时复制一份用于无缝循环滚动
-    doubledWeekList() {
-      return this.needScroll ? this.weekList.concat(this.weekList) : this.weekList
+    doubledProblemList() {
+      return this.needScrollProblem ? this.problemList.concat(this.problemList) : this.problemList
+    },
+    doubledPlanList() {
+      return this.needScrollPlan ? this.planList.concat(this.planList) : this.planList
+    },
+    problemDuration() {
+      return this.problemList.length * 3 + 's'
     },
-    scrollDuration() {
-      return this.weekList.length * 3 + 's'
+    planDuration() {
+      return this.planList.length * 3 + 's'
     },
     tooltipStyle() {
       return { left: this.tooltip.left, top: this.tooltip.top }
@@ -150,19 +181,29 @@ export default {
   created() {
     this.getMapList()
     this.getScreenJobData()
+    this.getScreenProblemData()
     // 与 index.html 一致:60 秒轮询设备打点数据
     this.timer = setInterval(this.getData, 60000)
+    // 右侧两个列表变化不频繁,5 分钟刷新一次
+    this.listTimer = setInterval(this.refreshPanelData, 300000)
   },
   beforeDestroy() {
     if (this.timer) {
       clearInterval(this.timer)
       this.timer = null
     }
+    if (this.listTimer) {
+      clearInterval(this.listTimer)
+      this.listTimer = null
+    }
   },
   methods: {
     // 底图加载完成后左侧高度才确定,此时重测右侧滚动列表
     handleImgLoad() {
-      this.$nextTick(this.checkScroll)
+      this.$nextTick(() => {
+        this.checkScroll('problemWrap', 'needScrollProblem')
+        this.checkScroll('planWrap', 'needScrollPlan')
+      })
     },
     // 像素坐标转百分比定位:背景图拉伸铺满容器后,点与图保持相同比例,不偏移
     pointStyle(item) {
@@ -190,19 +231,37 @@ export default {
       })
     },
     getScreenJobData() {
+      // 先关掉滚动标记,让下一次测量基于未复制的原始内容,避免数据变少后仍持续滚动
+      this.needScrollPlan = false
       axios.get('/check/jobs/screen').then(res => {
         this.jobData = res.data || {}
-        this.$nextTick(this.checkScroll)
+        this.$nextTick(() => this.checkScroll('planWrap', 'needScrollPlan'))
+      })
+    },
+    getScreenProblemData() {
+      this.needScrollProblem = false
+      axios.get('/sb/infos/screen/problem-list').then(res => {
+        this.problemList = res.data || []
+        this.$nextTick(() => this.checkScroll('problemWrap', 'needScrollProblem'))
       })
     },
+    refreshPanelData() {
+      this.getScreenJobData()
+      this.getScreenProblemData()
+    },
+    // 报修时间只展示到分钟,避免悬停提示过长
+    formatTime(time) {
+      if (!time) return ''
+      return String(time).replace('T', ' ').substring(0, 16)
+    },
     // 内容不足时不滚动,超出容器高度时开启无缝滚动
-    checkScroll() {
-      const wrap = this.$refs.scrollWrap
+    checkScroll(wrapRef, flagName) {
+      const wrap = this.$refs[wrapRef]
       if (!wrap) {
-        this.needScroll = false
+        this[flagName] = false
         return
       }
-      this.needScroll = wrap.scrollHeight > wrap.clientHeight
+      this[flagName] = wrap.scrollHeight > wrap.clientHeight
     },
     handlePointEnter(item) {
       this.tooltip = {
@@ -435,58 +494,65 @@ export default {
   padding: 10px 12px 8px;
   box-sizing: border-box;
 
+  /* 右侧上下两块:上-当前问题设备,下-近期保养计划 */
+  .panel-block {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    overflow: hidden;
+
+    & + .panel-block {
+      margin-top: 12px;
+    }
+  }
+
   .panel-title {
-    font-size: 24px;
+    font-size: 20px;
     font-weight: bold;
-    letter-spacing: 6px;
+    letter-spacing: 4px;
     text-align: center;
-    margin-bottom: 18px;
+    margin-bottom: 10px;
   }
 
   .panel-card {
     flex: 1;
     display: flex;
     flex-direction: column;
+    min-height: 0;
     border: 1px solid rgba(36, 149, 191, 0.45);
     border-radius: 8px;
     background: rgba(10, 60, 90, 0.25);
-    padding: 10 14px;
+    padding: 10px 14px;
     box-sizing: border-box;
     overflow: hidden;
   }
 
   // 中间亮两边暗的渐变标题条
   .bar-title {
-    height: 36px;
-    line-height: 36px;
+    height: 28px;
+    line-height: 28px;
     text-align: center;
-    font-size: 16px;
+    font-size: 15px;
     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;
+    font-size: 18px;
     text-align: center;
-    margin: 10px 0;
+    margin: 4px 0;
   }
 
   .rate-line {
-    font-size: 14px;
+    font-size: 13px;
     text-align: center;
-    margin-bottom: 10px;
+    margin-bottom: 6px;
 
     span {
       margin: 0 4px;
     }
   }
 
-  .job-box {
-    flex: 1;
-    display: flex;
-    flex-direction: column;
-    overflow: hidden;
-  }
-
   .item {
     display: flex;
     align-items: center;
@@ -515,6 +581,27 @@ export default {
     >span:nth-child(3) {
       flex: 3;
     }
+
+    // 保养计划行:设备名称列加宽,周期列变窄
+    &.plan-item {
+      >span:nth-child(1) {
+        flex: 4;
+        padding: 0 5px 0 0;
+
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+
+      >span:nth-child(2) {
+        flex: 2;
+        padding: 0;
+      }
+
+      >span:nth-child(3) {
+        flex: 3;
+      }
+    }
   }
 
   .title {
@@ -523,6 +610,7 @@ export default {
 
   .scroll-wrap {
     flex: 1;
+    min-height: 0;
     overflow: hidden;
     position: relative;
   }

+ 46 - 8
src/views/repair/application-form/modules/BaseForm.vue

@@ -176,7 +176,7 @@
 <script>
 import pick from 'lodash.pick'
 import BaseTool from '../../../../utils/tool'
-import { addRepairApplicationForm, updateRepairApplicationForm } from '@/api/repair/application-form'
+import { addRepairApplicationForm, updateRepairApplicationForm, queryUncompletedRepairs } from '@/api/repair/application-form'
 import PartInfoSelectModal from '@/views/part/info/modules/PartInfoSelectModal'
 import SbInfoSelectModal from '@/views/sb/info/modules/SbInfoSelectModal'
 import { queryUser, queryRepairTeamLeaders } from '@/api/upms/user'
@@ -364,13 +364,8 @@ export default {
         // 通知班长:多选数组转逗号分隔字符串
         values.notifyTeamLeaders = (values.notifyTeamLeaders || []).join(',')
         if (this.BaseTool.String.isBlank(values.id)) {
-          addRepairApplicationForm(values)
-            .then(() => {
-              this.handleCancel(values)
-            })
-            .catch(() => {
-              this.confirmLoading = false
-            })
+          // 重复报修检查:该设备存在未完结报修单时需先确认再提交(允许一台设备多个报修单)
+          this.confirmDuplicateAdd(values)
         } else {
           updateRepairApplicationForm(values)
             .then(() => {
@@ -382,6 +377,49 @@ export default {
         }
       })
     },
+    // 提交前检查该设备是否存在未完结报修单,存在则弹确认框,确认后仍允许提交
+    confirmDuplicateAdd(values) {
+      queryUncompletedRepairs({ sbId: values.sbId })
+        .then((res) => {
+          const list = res.data || []
+          if (list.length === 0) {
+            this.doAddRepair(values)
+            return
+          }
+          this.confirmLoading = false
+          const [first] = list
+          const h = this.$createElement
+          // 展示最新一笔未完结报修单的状态与报修内容,内容过长截断
+          const desc = (first.content || '').length > 30 ? `${first.content.slice(0, 30)}…` : first.content || ''
+          const detail = `${first.no} ${first.statusLabel || ''}${desc ? ',内容:' + desc : ''}`
+          const content = values.sbName
+            ? `【${values.sbName}】已有 ${list.length} 个未完结报修单(最新:${detail}),是否继续提交新报修单?`
+            : `该设备已有 ${list.length} 个未完结报修单(最新:${detail}),是否继续提交新报修单?`
+          this.$confirm({
+            title: '重复报修提醒',
+            content: h('div', content),
+            okText: '继续提交',
+            cancelText: '取消',
+            onOk: () => {
+              this.confirmLoading = true
+              this.doAddRepair(values)
+            },
+          })
+        })
+        .catch(() => {
+          this.confirmLoading = false
+        })
+    },
+    doAddRepair(values) {
+      this.confirmLoading = true
+      addRepairApplicationForm(values)
+        .then(() => {
+          this.handleCancel(values)
+        })
+        .catch(() => {
+          this.confirmLoading = false
+        })
+    },
     changePlanFlag(value) {
       this.category = value
       const {

+ 43 - 7
src/views/repair/application-form/modules/ImpBaseForm.vue

@@ -182,7 +182,7 @@
 
 <script>
 import pick from 'lodash.pick'
-import { addRepairApplicationForm, updateRepairApplicationForm } from '@/api/repair/application-form'
+import { addRepairApplicationForm, updateRepairApplicationForm, queryUncompletedRepairs } from '@/api/repair/application-form'
 import { queryUser } from '@/api/upms/user'
 
 export default {
@@ -270,12 +270,8 @@ export default {
         // 日期处理
         values.applyTime = this.BaseTool.Date.formatter(values.applyTime, this.BaseTool.Date.PICKER_NORM_DATETIME_PATTERN)
         if (this.BaseTool.String.isBlank(values.id)) {
-          addRepairApplicationForm(values)
-            .then(() => {
-              this.handleCancel(values)
-            }).catch(() => {
-              this.confirmLoading = false
-            })
+          // 重复报修检查:该设备存在未完结报修单时需先确认再提交(允许一台设备多个报修单)
+          this.confirmDuplicateAdd(values)
         } else {
           updateRepairApplicationForm(values)
             .then(() => {
@@ -286,6 +282,46 @@ export default {
         }
       })
     },
+    // 提交前检查该设备是否存在未完结报修单,存在则弹确认框,确认后仍允许提交
+    confirmDuplicateAdd (values) {
+      queryUncompletedRepairs({ sbId: values.sbId }).then(res => {
+        const list = res.data || []
+        if (list.length === 0) {
+          this.doAddRepair(values)
+          return
+        }
+        this.confirmLoading = false
+        const first = list[0]
+        const h = this.$createElement
+        // 展示最新一笔未完结报修单的状态与报修内容,内容过长截断
+        const desc = (first.content || '').length > 30 ? `${first.content.slice(0, 30)}…` : first.content || ''
+        const detail = `${first.no} ${first.statusLabel || ''}${desc ? ',内容:' + desc : ''}`
+        const content = values.sbName
+          ? `【${values.sbName}】已有 ${list.length} 个未完结报修单(最新:${detail}),是否继续提交新报修单?`
+          : `该设备已有 ${list.length} 个未完结报修单(最新:${detail}),是否继续提交新报修单?`
+        this.$confirm({
+          title: '重复报修提醒',
+          content: h('div', content),
+          okText: '继续提交',
+          cancelText: '取消',
+          onOk: () => {
+            this.confirmLoading = true
+            this.doAddRepair(values)
+          },
+        })
+      }).catch(() => {
+        this.confirmLoading = false
+      })
+    },
+    doAddRepair (values) {
+      this.confirmLoading = true
+      addRepairApplicationForm(values)
+        .then(() => {
+          this.handleCancel(values)
+        }).catch(() => {
+          this.confirmLoading = false
+        })
+    },
     handleCancel (values) {
       this.visible = false
       this.confirmLoading = false

+ 525 - 79
src/views/report/repairworkhour/RepairWorkHour.vue

@@ -4,9 +4,8 @@
       <a-tabs v-model="activeTab" @change="handleTabChange">
         <a-tab-pane tab="按维修人统计" key="user"></a-tab-pane>
         <a-tab-pane tab="按设备统计" key="sb"></a-tab-pane>
-        <!-- 按月度统计功能暂不正确,先隐藏页签,逻辑代码保留待修复后恢复
-        <a-tab-pane tab="按月度统计(设备平均维修工时)" key="month"></a-tab-pane>
-        -->
+        <a-tab-pane tab="月度MTTR统计" key="mttr"></a-tab-pane>
+        <a-tab-pane tab="月度MTBF统计" key="mtbf"></a-tab-pane>
       </a-tabs>
 
       <div class="table-page-search-wrapper" @keyup.enter="handleSearch">
@@ -17,12 +16,13 @@
                 <a-input v-model="queryParam.repairUserName" placeholder="请输入维修人姓名" allow-clear/>
               </a-form-item>
             </a-col>
-            <a-col :md="8" :sm="24" v-else>
+            <a-col :md="6" :sm="24" v-if="activeTab === 'sb'">
               <a-form-item label="设备">
                 <a-input v-model="queryParam.keyword" placeholder="请输入设备名称/编号" allow-clear/>
               </a-form-item>
             </a-col>
-            <a-col :md="10" :sm="24" v-if="activeTab !== 'month'">
+            <!-- user/sb 页签:按维修开始时间范围过滤 -->
+            <a-col :md="10" :sm="24" v-if="!isMonth">
               <a-form-item label="维修开始时间">
                 <a-range-picker
                   v-model="dateRange"
@@ -31,15 +31,48 @@
                   :placeholder="['开始日期', '结束日期']"/>
               </a-form-item>
             </a-col>
-            <a-col :md="10" :sm="24" v-else>
-              <a-form-item label="统计月份">
-                <a-month-picker
-                  v-model="queryParam.yearMonth"
-                  format="YYYY-MM"
-                  placeholder="选择月份"
-                  style="width: 100%"/>
-              </a-form-item>
-            </a-col>
+            <!-- month 页签:统计时间段(年月) + 设备等级(A/B/C) + 设备类别 + 设备(置底) -->
+            <template v-else-if="isMonth">
+              <a-col :md="8" :sm="24">
+                <a-form-item label="统计时间段">
+                  <a-month-picker
+                    v-model="mttrStartMonth"
+                    format="YYYY-MM"
+                    placeholder="开始月份"
+                    style="width: 46%"/>
+                  <span style="margin: 0 2%">至</span>
+                  <a-month-picker
+                    v-model="mttrEndMonth"
+                    format="YYYY-MM"
+                    placeholder="结束月份"
+                    style="width: 46%"/>
+                </a-form-item>
+              </a-col>
+              <a-col :md="5" :sm="24">
+                <a-form-item label="设备等级">
+                  <a-select mode="multiple" v-model="queryParam.levelList" placeholder="全部" allowClear
+                            :maxTagCount="1" style="width: 100%">
+                    <a-select-option v-for="(label, value) in sbLevelMap" :key="value" :label="label"
+                                     :value="parseInt(value)">{{ label }}
+                    </a-select-option>
+                  </a-select>
+                </a-form-item>
+              </a-col>
+              <a-col :md="6" :sm="24">
+                <a-form-item label="设备类别">
+                  <a-tree-select style="width: 100%" :dropdownStyle="{ maxHeight: '400px', overflow: 'auto' }"
+                                 :treeData="sbTypeTreeData" :treeNodeFilterProp="'title'" :showSearch="true"
+                                 v-model="queryParam.typeIdList" multiple allowClear :maxTagCount="1"
+                                 placeholder="全部">
+                  </a-tree-select>
+                </a-form-item>
+              </a-col>
+              <a-col :md="6" :sm="24">
+                <a-form-item label="设备">
+                  <a-input v-model="queryParam.keyword" placeholder="请输入设备名称/编号" allow-clear/>
+                </a-form-item>
+              </a-col>
+            </template>
             <a-col :md="6" :sm="24">
               <span class="table-page-search-submitButtons">
                 <a-button type="primary" @click="handleSearch">查询</a-button>
@@ -54,22 +87,44 @@
         <a-button type="primary" icon="download" @click="doExport">导出</a-button>
       </div>
 
-      <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
-        <span style="font-weight: 600">{{ chartTitle }}(按平均工时降序)</span>
-        <span>
-          <a-radio-group v-if="activeTab === 'sb'" v-model="chartDim" size="small" style="margin-right: 8px" @change="doRenderChart">
-            <a-radio-button value="sb">按设备</a-radio-button>
-            <a-radio-button value="type">按设备类型</a-radio-button>
-          </a-radio-group>
-          <a-radio-group v-model="chartTop" size="small" @change="doRenderChart">
+      <!-- 月度MTTR/MTBF页签:专属图表区(已拆为两个独立页签) -->
+      <template v-if="isMonth">
+        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
+          <span style="font-weight: 600">{{ monthChartTitle }}</span>
+          <a-radio-group v-if="monthMetric === 'mtbf'" v-model="mtbfChartTop" size="small" @change="renderMonthCharts">
             <a-radio-button value="top20">前20名</a-radio-button>
             <a-radio-button value="all">全部</a-radio-button>
           </a-radio-group>
-        </span>
-      </div>
-      <a-spin :spinning="chartLoading">
-        <div :id="chartContainerId" style="min-height: 400px"></div>
-      </a-spin>
+        </div>
+        <a-spin :spinning="monthLoading">
+          <!-- MTTR:月度趋势折线 -->
+          <div v-if="monthMetric === 'mttr'" id="monthTrendChartContainer" key="monthTrendChartContainer" style="min-height: 360px"></div>
+          <!-- MTBF:仅按设备统计柱状图(不需要月度趋势) -->
+          <template v-else>
+            <div id="monthMtbfSbChartContainer" key="monthMtbfSbChartContainer" style="min-height: 360px"></div>
+          </template>
+        </a-spin>
+      </template>
+
+      <!-- user/sb 页签图表区 -->
+      <template v-else>
+        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
+          <span style="font-weight: 600">{{ chartTitle }}(按平均工时降序)</span>
+          <span>
+            <a-radio-group v-if="activeTab === 'sb'" v-model="chartDim" size="small" style="margin-right: 8px" @change="doRenderChart">
+              <a-radio-button value="sb">按设备</a-radio-button>
+              <a-radio-button value="type">按设备类型</a-radio-button>
+            </a-radio-group>
+            <a-radio-group v-model="chartTop" size="small" @change="doRenderChart">
+              <a-radio-button value="top20">前20名</a-radio-button>
+              <a-radio-button value="all">全部</a-radio-button>
+            </a-radio-group>
+          </span>
+        </div>
+        <a-spin :spinning="chartLoading">
+          <div :id="chartContainerId" key="userSbChartContainer" style="min-height: 400px"></div>
+        </a-spin>
+      </template>
 
       <s-table
         ref="table"
@@ -99,17 +154,24 @@ import { STable } from '@/components'
 import { Chart } from '@antv/g2'
 import moment from 'moment'
 import DetailModal from './modules/DetailModal'
+import { fetchSbTypeTree } from '@/api/sb/type'
 import {
   getWorkHourByUser,
   getWorkHourBySb,
-  getWorkHourByMonth,
   exportWorkHourUser,
   exportWorkHourSb,
-  exportWorkHourMonth,
-  exportWorkHourMonthDetail,
-  exportWorkHourDetail
+  exportWorkHourDetail,
+  getMttrMonth,
+  getMtbfReport,
+  exportMttrMonth,
+  exportMtbfReport
 } from '@/api/report/application-form'
 
+// 非计划性维修类别值(后端 RepairApplicationFormCategoryEnum.PLAN_NOT),MTTR/MTBF 明细过滤用
+const CATEGORY_NOT_PLAN = 2
+// MTTR/MTBF 统计时间段跨度上限(月),与后端 MTTR_MAX_MONTH_SPAN 保持一致
+const MTTR_MAX_MONTH_SPAN = 36
+
 export default {
   name: 'RepairWorkHourReport',
   components: {
@@ -118,18 +180,24 @@ export default {
   },
   data () {
     return {
-      // 当前统计维度:user-按维修人,sb-按设备,month-按月度统计(月度页签已临时隐藏,逻辑保留)
+      // 当前统计维度:user-按维修人,sb-按设备,mttr-月度MTTR统计,mtbf-月度MTBF统计
       activeTab: 'user',
+      // MTTR 目标值(分钟),会议确认候选值 90,趋势图上画目标横线
+      MTTR_TARGET_MINUTES: 90,
       // 查询参数
       queryParam: {
         repairUserName: null,
         keyword: null,
-        // 月度统计 Tab 使用:a-month-picker 的 moment 值
-        yearMonth: moment()
+        // 月度页签:设备筛选
+        levelList: [],
+        typeIdList: []
       },
+      // 月度页签统计时间段(moment 对象,查询时转 yyyy-MM),默认本年 1-12 月
+      mttrStartMonth: moment().startOf('year'),
+      mttrEndMonth: moment().month(11).startOf('month'),
       // 维修开始时间范围 默认本月,避免一次性拉取全部历史数据
       dateRange: [moment().startOf('month'), moment().endOf('month')],
-      // 图表
+      // user/sb 图表
       chart: null,
       chartLoading: false,
       chartContainerId: 'workHourChartContainer',
@@ -139,14 +207,36 @@ export default {
       chartDim: 'sb',
       // 图表全量数据缓存,切换前20/全部时不重复请求
       chartRows: [],
+      // 月度页签图表与数据缓存(MTTR / MTBF 分属不同页签,各自拉取)
+      monthChart: null,
+      monthSbChart: null,
+      // MTBF 按设备柱状图展示范围:top20-前20名,all-全部
+      mtbfChartTop: 'top20',
+      monthLoading: false,
+      mttrRows: [],
+      mtbfSbRows: [],
+      mtbfMonthRows: [],
+      // 设备等级字典(SBINFO_LEVEL:A/B/C类)与设备类别树
+      sbLevelMap: {},
+      sbTypeTreeData: [],
       // 加载数据方法 必须为 Promise 对象
       loadData: parameter => {
+        // 月度页签:数据已一次拉全,前端切片分页
+        if (this.isMonth) {
+          const list = this.monthMetric === 'mttr' ? this.mttrRows : this.mtbfSbRows
+          const pageNo = parameter.pageNo || parameter.pageNum || 1
+          const pageSize = parameter.pageSize || 20
+          return Promise.resolve({
+            rows: list.slice((pageNo - 1) * pageSize, pageNo * pageSize),
+            total: list.length
+          })
+        }
         const query = this.buildQuery()
         parameter = {
           ...parameter,
           ...query
         }
-        const fetchFn = this.activeTab === 'user' ? getWorkHourByUser : (this.activeTab === 'month' ? getWorkHourByMonth : getWorkHourBySb)
+        const fetchFn = this.activeTab === 'user' ? getWorkHourByUser : getWorkHourBySb
         return fetchFn(parameter)
           .then(res => {
             return res.data
@@ -155,17 +245,42 @@ export default {
     }
   },
   computed: {
+    // 是否为月度统计页签(MTTR 或 MTBF)
+    isMonth () {
+      return this.activeTab === 'mttr' || this.activeTab === 'mtbf'
+    },
+    // 月度页签当前指标:由所在页签决定
+    monthMetric () {
+      return this.activeTab === 'mtbf' ? 'mtbf' : 'mttr'
+    },
+    // 统计时间段展示文案:yyyy-MM ~ yyyy-MM
+    mttrPeriodLabel () {
+      if (!this.mttrStartMonth || !this.mttrEndMonth) {
+        return ''
+      }
+      return this.mttrStartMonth.format('YYYY-MM') + ' ~ ' + this.mttrEndMonth.format('YYYY-MM')
+    },
     chartTitle () {
       if (this.activeTab === 'user') {
         return '维修人平均工时统计图'
       }
-      if (this.activeTab === 'month') {
-        return this.chartDim === 'type' ? '设备类型月度平均工时统计图' : '设备月度平均工时统计图'
-      }
       return this.chartDim === 'type' ? '设备类型平均工时统计图' : '设备平均工时统计图'
     },
+    monthChartTitle () {
+      const period = this.mttrPeriodLabel
+      if (this.monthMetric === 'mttr') {
+        return period + ' MTTR月度趋势(平均故障修复时间,分钟)'
+      }
+      return period + ' 设备MTBF统计(平均故障间隔时间,小时,MTBF升序)'
+    },
     rowKey () {
-      return this.activeTab === 'user' ? 'repairUserId' : 'sbId'
+      if (this.activeTab === 'user') {
+        return 'repairUserId'
+      }
+      if (this.activeTab === 'mttr') {
+        return 'month'
+      }
+      return 'sbId'
     },
     columns () {
       const indexColumn = {
@@ -184,6 +299,95 @@ export default {
       const hourRender = (text) => {
         return this.BaseTool.Object.isBlank(text) ? '0.00' : Number(text).toFixed(2)
       }
+      const actionColumn = {
+        title: '操作',
+        checked: true,
+        key: 'action',
+        width: '180px',
+        align: 'center',
+        scopedSlots: { customRender: 'action' }
+      }
+      // 月度页签:MTTR/MTBF 两套表头
+      if (this.isMonth) {
+        if (this.monthMetric === 'mttr') {
+          return [
+            indexColumn,
+            {
+              title: '月份',
+              checked: true,
+              dataIndex: 'month'
+            },
+            {
+              title: '故障次数',
+              checked: true,
+              dataIndex: 'num'
+            },
+            {
+              title: '总维修工时(小时)',
+              checked: true,
+              dataIndex: 'totalRepairHours',
+              customRender: hourRender
+            },
+            {
+              title: 'MTTR(分钟)',
+              checked: true,
+              dataIndex: 'mttrMinutes',
+              customRender: hourRender
+            },
+            {
+              title: '目标达成',
+              checked: true,
+              key: 'target',
+              customRender: (text, record) => {
+                if (!record.num) {
+                  return '-'
+                }
+                const over = Number(record.mttrMinutes) > this.MTTR_TARGET_MINUTES
+                return this.$createElement('span', {
+                  style: { color: over ? '#f5222d' : '#52c41a', fontWeight: 600 }
+                }, over ? '超标' : '达标')
+              }
+            },
+            actionColumn
+          ]
+        }
+        return [
+          indexColumn,
+          {
+            title: '设备编号',
+            checked: true,
+            dataIndex: 'sbNo'
+          },
+          {
+            title: '设备名称',
+            checked: true,
+            dataIndex: 'sbName'
+          },
+          {
+            title: '设备等级',
+            checked: true,
+            dataIndex: 'level',
+            customRender: (text) => this.sbLevelMap[text] || '-'
+          },
+          {
+            title: '设备类别',
+            checked: true,
+            dataIndex: 'sbTypeName'
+          },
+          {
+            title: '故障次数',
+            checked: true,
+            dataIndex: 'faultNum'
+          },
+          {
+            title: 'MTBF(小时)',
+            checked: true,
+            dataIndex: 'mtbfHours',
+            customRender: (text) => this.BaseTool.Object.isBlank(text) ? '-' : Number(text).toFixed(2)
+          },
+          actionColumn
+        ]
+      }
       const commonColumns = [
         {
           title: '维修次数',
@@ -208,14 +412,7 @@ export default {
           dataIndex: 'totalDealHours',
           customRender: hourRender
         },
-        {
-          title: '操作',
-          checked: true,
-          key: 'action',
-          width: '180px',
-          align: 'center',
-          scopedSlots: { customRender: 'action' }
-        }
+        actionColumn
       ]
       if (this.activeTab === 'user') {
         return [
@@ -228,7 +425,7 @@ export default {
           ...commonColumns
         ]
       }
-      const sbColumns = [
+      return [
         indexColumn,
         {
           title: '设备编号',
@@ -247,13 +444,14 @@ export default {
         },
         ...commonColumns
       ]
-      // 月度统计不展示总停机修复时长
-      if (this.activeTab === 'month') {
-        return sbColumns.filter(c => c.dataIndex !== 'totalDealHours')
-      }
-      return sbColumns
     }
   },
+  created () {
+    this.sbLevelMap = this.DictCache.getLabelByValueMapByType(this.DictCache.TYPE.SBINFO_LEVEL)
+    fetchSbTypeTree().then(res => {
+      this.sbTypeTreeData = res.data || []
+    })
+  },
   mounted () {
     this.$nextTick(() => {
       this.loadChartData()
@@ -262,11 +460,12 @@ export default {
   beforeDestroy () {
     this.chart && this.chart.destroy()
     this.chart = null
+    this.destroyMonthCharts()
   },
   methods: {
     loadChartData () {
       this.chartLoading = true
-      const fetchFn = this.activeTab === 'user' ? getWorkHourByUser : (this.activeTab === 'month' ? getWorkHourByMonth : getWorkHourBySb)
+      const fetchFn = this.activeTab === 'user' ? getWorkHourByUser : getWorkHourBySb
       const parameter = {
         pageNum: 1,
         pageSize: 500,
@@ -284,6 +483,10 @@ export default {
         })
     },
     doRenderChart () {
+      // 已切到月度页签时,异步回调不再渲染 user/sb 图表
+      if (this.isMonth) {
+        return
+      }
       let data = []
       if (this.activeTab === 'user') {
         data = this.chartRows.map(r => ({
@@ -348,26 +551,158 @@ export default {
         this.chart.render()
       })
     },
+    /**
+     * 月度页签:按当前页签(MTTR / MTBF)只拉取对应统计数据
+     */
+    loadMonthData () {
+      this.monthLoading = true
+      const query = this.buildQuery()
+      const fetch = this.monthMetric === 'mttr'
+        ? getMttrMonth(query).then(res => {
+          this.mttrRows = (res && res.data) || []
+        })
+        : getMtbfReport(query).then(res => {
+          const mtbf = (res && res.data) || {}
+          this.mtbfSbRows = mtbf.sbList || []
+          this.mtbfMonthRows = mtbf.monthList || []
+        })
+      fetch
+        .then(() => {
+          this.$refs.table.refresh(true)
+          this.$nextTick(() => {
+            this.renderMonthCharts()
+          })
+        })
+        .finally(() => {
+          this.monthLoading = false
+        })
+    },
+    renderMonthCharts () {
+      this.destroyMonthCharts()
+      // 已切离月度页签时不再渲染
+      if (!this.isMonth) {
+        return
+      }
+      if (this.monthMetric === 'mttr') {
+        // MTTR 月度趋势折线 + 目标值横线(会议确认候选值 90 分钟)
+        const data = this.mttrRows.map(r => ({
+          month: this.formatMonthLabel(r.month),
+          mttrMinutes: Number(r.mttrMinutes || 0)
+        }))
+        this.monthChart = new Chart({
+          container: 'monthTrendChartContainer',
+          autoFit: true,
+          height: 360
+        })
+        this.monthChart.data(data)
+        this.monthChart.scale({
+          mttrMinutes: { alias: 'MTTR(分钟)', nice: true, min: 0 }
+        })
+        this.monthChart.axis('mttrMinutes', { title: { offset: 40 } })
+        this.monthChart.line().position('month*mttrMinutes').color('#1890ff')
+        this.monthChart.point().position('month*mttrMinutes').color('#1890ff')
+        this.monthChart.annotation().line({
+          start: ['min', this.MTTR_TARGET_MINUTES],
+          end: ['max', this.MTTR_TARGET_MINUTES],
+          style: { stroke: '#f5222d', lineDash: [4, 4], lineWidth: 1 },
+          text: {
+            content: '目标值 ' + this.MTTR_TARGET_MINUTES + ' 分钟',
+            position: 'end',
+            style: { fill: '#f5222d', fontSize: 12, textAlign: 'end' }
+          }
+        })
+        this.monthChart.tooltip({ shared: true, showCrosshairs: true })
+        this.monthChart.render()
+      } else {
+        // MTBF:仅按设备柱状图(MTBF 升序,故障不足2次无 MTBF 值的设备不展示;前20/全部可切换)
+        let sbData = this.mtbfSbRows
+          .filter(r => !this.BaseTool.Object.isBlank(r.mtbfHours))
+          .map(r => ({
+            name: (r.sbName || '-') + '(' + (r.sbNo || '-') + ')',
+            mtbfHours: Number(r.mtbfHours)
+          }))
+        if (this.mtbfChartTop === 'top20' && sbData.length > 20) {
+          sbData = sbData.slice(0, 20)
+        }
+        if (sbData.length) {
+          this.$nextTick(() => {
+            this.monthSbChart = new Chart({
+              container: 'monthMtbfSbChartContainer',
+              autoFit: true,
+              height: 360
+            })
+            this.monthSbChart.data(sbData)
+            this.monthSbChart.scale({
+              mtbfHours: { alias: 'MTBF(小时)', nice: true, min: 0 }
+            })
+            // 纵轴标题(与 MTTR 趋势图一致,显示 scale alias)
+            this.monthSbChart.axis('mtbfHours', { title: { offset: 40 } })
+            this.monthSbChart.axis('name', { label: { autoRotate: true, autoHide: false } })
+            this.monthSbChart.interval().position('name*mtbfHours').color('#722ed1')
+            this.monthSbChart.tooltip({ shared: true, showCrosshairs: false })
+            this.monthSbChart.option('scrollbar', { type: 'horizontal' })
+            this.monthSbChart.render()
+          })
+        }
+      }
+    },
+    destroyMonthCharts () {
+      this.monthChart && this.monthChart.destroy()
+      this.monthChart = null
+      this.monthSbChart && this.monthSbChart.destroy()
+      this.monthSbChart = null
+    },
+    // 销毁所有图表实例(user/sb 柱状图 + 月度趋势/设备图),切换页签时调用防止 canvas 残留
+    destroyAllCharts () {
+      this.chart && this.chart.destroy()
+      this.chart = null
+      this.destroyMonthCharts()
+    },
+    formatMonthLabel (month) {
+      if (!month) {
+        return '-'
+      }
+      // 跨年时间段保留年份(如 2025-11),避免不同年份同月标签重复导致图表数据点被合并
+      if (this.mttrStartMonth && this.mttrEndMonth && !this.mttrStartMonth.isSame(this.mttrEndMonth, 'year')) {
+        return month
+      }
+      return parseInt(month.split('-')[1], 10) + '月'
+    },
+    /**
+     * yyyy-MM -> [当月第一天 00:00:00, 当月最后一天 23:59:59]
+     */
+    monthRange (month) {
+      const m = moment(month + '-01')
+      return [m.clone().startOf('month').format('YYYY-MM-DD HH:mm:ss'), m.clone().endOf('month').format('YYYY-MM-DD HH:mm:ss')]
+    },
     buildQuery () {
       const query = {}
       if (this.activeTab === 'user') {
         if (this.BaseTool.String.isNotBlank(this.queryParam.repairUserName)) {
           query.repairUserName = this.queryParam.repairUserName
         }
-      } else if (this.activeTab === 'month') {
-        // 月度统计:后端按 yearMonth(YYYY-MM) 转当月起止时间
-        if (this.queryParam.yearMonth) {
-          query.yearMonth = this.queryParam.yearMonth.format('YYYY-MM')
+      } else if (this.isMonth) {
+        // 月度MTTR/MTBF:统计时间段 + 设备等级/类别/关键字筛选
+        if (this.mttrStartMonth) {
+          query.mttrStartMonth = this.mttrStartMonth.format('YYYY-MM')
         }
-        if (this.BaseTool.String.isNotBlank(this.queryParam.keyword)) {
-          query.keyword = this.queryParam.keyword
+        if (this.mttrEndMonth) {
+          query.mttrEndMonth = this.mttrEndMonth.format('YYYY-MM')
+        }
+        if (this.queryParam.levelList && this.queryParam.levelList.length) {
+          query.levelList = this.queryParam.levelList
+        }
+        if (this.queryParam.typeIdList && this.queryParam.typeIdList.length) {
+          query.typeIdList = this.queryParam.typeIdList
         }
-      } else {
         if (this.BaseTool.String.isNotBlank(this.queryParam.keyword)) {
           query.keyword = this.queryParam.keyword
         }
+        return query
+      } else if (this.BaseTool.String.isNotBlank(this.queryParam.keyword)) {
+        query.keyword = this.queryParam.keyword
       }
-      if (this.activeTab !== 'month' && this.dateRange && this.dateRange.length === 2) {
+      if (this.dateRange && this.dateRange.length === 2) {
         query.searchRepairStartTime = this.dateRange[0].format('YYYY-MM-DD') + ' 00:00:00'
         query.searchRepairEndTime = this.dateRange[1].format('YYYY-MM-DD') + ' 23:59:59'
       }
@@ -376,66 +711,163 @@ export default {
     handleTabChange () {
       // 切换维度后图表聚合维度恢复按设备
       this.chartDim = 'sb'
+      // 切换页签先销毁全部图表实例,避免上个页签的 canvas 残留叠加到新页签
+      this.destroyAllCharts()
       this.$nextTick(() => {
-        this.$refs.table.refresh(true)
-        this.loadChartData()
+        if (this.isMonth) {
+          this.loadMonthData()
+        } else {
+          this.$refs.table.refresh(true)
+          this.loadChartData()
+        }
       })
     },
     handleSearch () {
+      if (this.isMonth) {
+        if (!this.validateMonthRange()) {
+          return
+        }
+        this.loadMonthData()
+        return
+      }
       this.$refs.table.refresh(true)
       this.loadChartData()
     },
+    // 月度页签统计时间段校验,非法时提示并返回 false
+    validateMonthRange () {
+      if (!this.mttrStartMonth || !this.mttrEndMonth) {
+        this.$message.error('请选择统计开始月份和结束月份')
+        return false
+      }
+      if (this.mttrStartMonth.isAfter(this.mttrEndMonth, 'month')) {
+        this.$message.error('开始月份不能晚于结束月份')
+        return false
+      }
+      // 与后端 MTTR_MAX_MONTH_SPAN 保持一致,提前拦截避免大区间慢查询
+      if (this.mttrEndMonth.diff(this.mttrStartMonth, 'month') + 1 > MTTR_MAX_MONTH_SPAN) {
+        this.$message.error('统计时间段跨度不能超过' + MTTR_MAX_MONTH_SPAN + '个月')
+        return false
+      }
+      return true
+    },
     resetSearchForm () {
       this.queryParam = {
         repairUserName: null,
         keyword: null,
-        yearMonth: moment()
+        levelList: [],
+        typeIdList: []
       }
+      // 月度页签统计时间段恢复本年 1-12 月
+      this.mttrStartMonth = moment().startOf('year')
+      this.mttrEndMonth = moment().month(11).startOf('month')
       // 重置后仍默认统计本月
       this.dateRange = [moment().startOf('month'), moment().endOf('month')]
-      this.$refs.table.refresh(true)
-      this.loadChartData()
+      this.handleSearch()
     },
     doExport () {
       const parameter = this.buildQuery()
-      const exportFn = this.activeTab === 'user' ? exportWorkHourUser : (this.activeTab === 'month' ? exportWorkHourMonth : exportWorkHourSb)
+      // 当前页签图表截图(PNG dataURL)随查询条件一并提交,后端嵌入 Excel 的“统计图表”sheet
+      const chartBase64 = this.getChartBase64()
+      if (chartBase64) {
+        parameter.chartBase64 = chartBase64
+      }
+      if (this.isMonth) {
+        // 未点查询直接导出时同样校验时间段,避免下载到后端报错的 JSON 文件
+        if (!this.validateMonthRange()) {
+          return
+        }
+        const monthExportFn = this.monthMetric === 'mttr' ? exportMttrMonth : exportMtbfReport
+        monthExportFn(parameter).then(file => {
+          this.BaseTool.UPLOAD.downLoadExportExcel(file)
+        })
+        return
+      }
+      const exportFn = this.activeTab === 'user' ? exportWorkHourUser : exportWorkHourSb
       exportFn(parameter).then(file => {
         this.BaseTool.UPLOAD.downLoadExportExcel(file)
       })
     },
+    // 取当前页签已渲染图表的 PNG base64;图表未渲染(无数据)时返回 null,仅导出数据
+    getChartBase64 () {
+      // G2 4.0.15 的 Chart 没有 toDataURL,改为直接取画布容器内的原生 <canvas> 截图
+      let containerId
+      if (this.isMonth) {
+        containerId = this.monthMetric === 'mttr' ? 'monthTrendChartContainer' : 'monthMtbfSbChartContainer'
+      } else {
+        containerId = this.chartContainerId
+      }
+      const container = document.getElementById(containerId)
+      const canvas = container && container.querySelector('canvas')
+      if (!canvas) {
+        return null
+      }
+      try {
+        return canvas.toDataURL('image/png')
+      } catch (e) {
+        // 截图失败不阻断导出,降级为仅导出数据
+        return null
+      }
+    },
     handleDetail (record) {
       const modal = this.$refs.detailModal
+      if (this.isMonth) {
+        if (this.monthMetric === 'mttr') {
+          // MTTR 月度明细:该月全部非计划已完成工单,叠加当前设备筛选条件
+          const range = this.monthRange(record.month)
+          modal.base({
+            type: 'detail',
+            record: {},
+            title: 'MTTR明细 - ' + record.month,
+            category: CATEGORY_NOT_PLAN,
+            extraQuery: this.buildMonthFilter(),
+            searchRepairStartTime: range[0],
+            searchRepairEndTime: range[1]
+          })
+        } else {
+          // MTBF 明细:该设备全部非计划已完成工单(按报修时间排序,可核对故障间隔)
+          modal.base({
+            type: 'sb',
+            record: record,
+            title: 'MTBF明细 - ' + (record.sbName || record.sbNo || ''),
+            category: CATEGORY_NOT_PLAN
+          })
+        }
+        return
+      }
       const query = this.buildQuery()
       modal.base({
         type: this.activeTab,
         record: record,
-        yearMonth: query.yearMonth,
         searchRepairStartTime: query.searchRepairStartTime,
         searchRepairEndTime: query.searchRepairEndTime
       })
     },
     doExportDetail (record) {
       const parameter = this.buildDetailQuery(record)
-      const exportFn = this.activeTab === 'month' ? exportWorkHourMonthDetail : exportWorkHourDetail
-      exportFn(parameter).then(file => {
+      exportWorkHourDetail(parameter).then(file => {
         this.BaseTool.UPLOAD.downLoadExportExcel(file)
       })
     },
     buildDetailQuery (record) {
       const parameter = {}
+      if (this.isMonth) {
+        parameter.category = CATEGORY_NOT_PLAN
+        if (this.monthMetric === 'mttr') {
+          const range = this.monthRange(record.month)
+          parameter.searchRepairStartTime = range[0]
+          parameter.searchRepairEndTime = range[1]
+          Object.assign(parameter, this.buildMonthFilter())
+        } else {
+          parameter.sbId = record.sbId
+        }
+        return parameter
+      }
       if (this.activeTab === 'user') {
         parameter.repairUserId = record.repairUserId
       } else {
         parameter.sbId = record.sbId
       }
       const timeQuery = this.buildQuery()
-      if (this.activeTab === 'month') {
-        // 月度明细导出:传 yearMonth(YYYY-MM),后端转当月起止时间
-        if (timeQuery.yearMonth) {
-          parameter.yearMonth = timeQuery.yearMonth
-        }
-        return parameter
-      }
       if (timeQuery.searchRepairStartTime) {
         parameter.searchRepairStartTime = timeQuery.searchRepairStartTime
       }
@@ -443,6 +875,20 @@ export default {
         parameter.searchRepairEndTime = timeQuery.searchRepairEndTime
       }
       return parameter
+    },
+    // 月度页签的设备筛选条件(与统计口径保持一致)
+    buildMonthFilter () {
+      const filter = {}
+      if (this.queryParam.levelList && this.queryParam.levelList.length) {
+        filter.levelList = this.queryParam.levelList
+      }
+      if (this.queryParam.typeIdList && this.queryParam.typeIdList.length) {
+        filter.typeIdList = this.queryParam.typeIdList
+      }
+      if (this.BaseTool.String.isNotBlank(this.queryParam.keyword)) {
+        filter.keyword = this.queryParam.keyword
+      }
+      return filter
     }
   }
 }

+ 16 - 2
src/views/report/repairworkhour/modules/DetailModal.vue

@@ -42,7 +42,7 @@ export default {
       loading: false,
       modalTitle: null,
       visible: false,
-      // 统计维度:user-按维修人,sb-按设备
+      // 统计维度:user-按维修人,sb-按设备,detail-按时间范围(MTTR月度明细)
       type: 'user',
       // 查询参数
       queryParam: {},
@@ -139,9 +139,23 @@ export default {
         this.queryParam.repairUserId = record.repairUserId
         this.modalTitle = '维修工单明细 - ' + (record.repairUserName || '')
       } else {
-        this.queryParam.sbId = record.sbId
+        if (record.sbId) {
+          this.queryParam.sbId = record.sbId
+        }
         this.modalTitle = '维修工单明细 - ' + (record.sbName || record.sbNo || '')
       }
+      // 外部传入标题优先(MTTR/MTBF 明细等场景)
+      if (params.title) {
+        this.modalTitle = params.title
+      }
+      // 报修类型过滤(MTTR/MTBF 明细仅看非计划性维修)
+      if (params.category !== undefined && params.category !== null) {
+        this.queryParam.category = params.category
+      }
+      // 附加筛选条件(设备类别/大类/关键字,与统计口径保持一致)
+      if (params.extraQuery) {
+        Object.assign(this.queryParam, params.extraQuery)
+      }
       // 月度统计:传 yearMonth(YYYY-MM),后端转当月起止时间
       if (params.yearMonth) {
         this.queryParam.yearMonth = params.yearMonth