5d015d74fe31d9b56b45414cc96762c6abdf1dfd..f17c76ce685522441bb5c04fe25bb54838b2eb06
5 天以前 zzw
fix: load only active report data
f17c76 对比 | 目录
5 天以前 zzw
feat: show menu path in header
145fd1 对比 | 目录
5 天以前 zzw
feat: add MDI tabbed workspace
d47868 对比 | 目录
5 天以前 zzw
fix: preserve codenames in table data cells
7839e9 对比 | 目录
19个文件已修改
441 ■■■■■ 已修改文件
HANDOFF.md 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/components/AppHeader.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/layout/AppLayout.vue 224 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/router/index.js 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/utils/sensitive.js 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/acceptance/AcceptanceList.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/completion/CompletionList.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/completion/CompletionReport.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/fault/DispatchPage.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/fault/FaultReport.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/fault/SimpleApproval.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/fault/XcbReview.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/report/ReportPage.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/system/ContractorManage.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/system/EngineeringMajorManage.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/system/MenuPermission.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/system/RoleManage.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/system/TeamMajorManage.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
emergency-repair-web/src/views/system/TreeManage.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -1,7 +1,8 @@
# HANDOFF:临抢修软件需求与第一版实现
> 交接日期:2026-08-25(2026-08-26 晚 / 2026-09-07 / 2026-09-08 / 2026-09-09 核对 / 2026-09-10 / 2026-09-11 / 2026-09-14 / 2026-09-16 / 2026-09-17 / 2026-09-28 新会话准备 / 2026-09-28 专业配置与故障编号自动生成 / 2026-09-29 代码入库公司 Git 更新)| 用途:新开会话时先读本文件,即可无缝接续
> 项目目录:`C:\Users\jcxiong\Documents\Codex\MyProject\EmergencyRepair`
> 交接日期:2026-08-25(2026-08-26 晚 / 2026-09-07 / 2026-09-08 / 2026-09-09 核对 / 2026-09-10 / 2026-09-11 / 2026-09-14 / 2026-09-16 / 2026-09-17 / 2026-09-28 新会话准备 / 2026-09-28 专业配置与故障编号自动生成 / 2026-09-29 代码入库公司 Git 更新 / 2026-10-02 本机环境迁移与前后端启动)| 用途:新开会话时先读本文件,即可无缝接续
> 项目目录(2026-10-02 起为本机 `D:\projectJava\lqx`;行内旧路径 `C:\Users\jcxiong\Documents\Codex\MyProject\EmergencyRepair` 为上一台机器)
> 注意:2026-10-02 本机工具链与旧记录不同,构建、启动与工作方式约定以第 16 节为准。
## 0. 最新交接(2026-09-28 新会话准备,优先阅读)
@@ -660,3 +661,39 @@
2. 决策表 B6–B10 待用户答复(`requirement\工程状态机设计.md` 第 4 节 + `requirement\决策表_待确认项.md` B 组),答完固化为正式设计。
3. AXU15 剩余待确认:字段来源与可改性(待确认清单第 13 条)、完工验收各环节角色与打印输出、修后质量评级与 AXU14 T员评分字段的关系。
4. 后续改动完成后直接推送到公司 Git 仓库 `main` 分支。
## 16. 收尾快照(2026-10-02 周五,本机环境迁移与前后端启动)
**环境迁移(重要,与第 0 节旧记录不同)**
- 代码已在本机重新拉取,工作目录改为 `D:\projectJava\lqx`,不再是 `D:\Codex\MyProject\EmergencyRepair`。
- 仓库:`http://61.183.254.94:3000/r/EmergencyRepair.git`,分支 `main`,本地 HEAD `5d015d7`(docs: add shutdown snapshot for 2026-09-29),与 `origin/main` 一致。
- 克隆时远端 HEAD 指向不存在的 ref,不会自动检出,需手动 `git checkout main`。凭据已存入 Windows 凭据管理器,`.git/config` 中不再有明文密码。
- 本机工具链与旧机器不同,**第 0.9 节和第 8 节里“`apply_patch` 不可用、必须用 Python 读改写、不能用 `Start-Process`”的约定只适用于上一台机器**,本机 `apply_patch` 与 `Start-Process` 均可正常使用。
- JDK:`C:\Program Files\Java\jdk1.8.0_172`。PATH 里的 java 是 JRE 1.8.0_481(没有 javac),构建前必须显式设置 `JAVA_HOME`。
- Maven:`D:\solidDir\apache-maven-3.6.1\bin\mvn.cmd`(不在 PATH,需绝对路径调用)。
- Node/npm:`C:\Program Files\nodejs`(Node v14.8.0 / npm 6.14.7,不在 PATH,需绝对路径调用)。
- 达梦驱动 `dm.jdbc:DmJdbcDriver18:1.0` 在默认本地仓库缺失(只有下载失败标记),已从 `D:\solidDir\repo` 同名构件安装进 `C:\Users\Lenovo\.m2`;属环境准备,未改项目文件。
- DM8:服务 `DmServiceDMSERVER` 运行中,监听 `127.0.0.1:5236`(PID 5692),账号 `SYSDBA/SYSDBA`,schema `LX`。
**本次完成的构建与运行**
- 后端 `mvn -B -DskipTests clean package` 通过,产物 `emergency-repair-server\target\emergency-repair-server-1.0.0-SNAPSHOT.jar`(约 27MB)。
- 前端 `npm install` + `npm run build` 通过,产物 `emergency-repair-web\dist`,仅两个既有包体积告警。
- 后端用 `java -jar target\emergency-repair-server-1.0.0-SNAPSHOT.jar` 后台运行,监听 `8091`(PID 61648);前端用 `node node_modules\@vue\cli-service\bin\vue-cli-service.js serve` 后台运行,监听 `8082`(PID 57052)。日志为项目根的 `backend.log` / `frontend.log`(`*.log` 已被 `.gitignore` 忽略)。
- HTTP 验证:`POST /api/auth/login`(`admin/admin123`)返回 `code=200`;`GET http://localhost:8082/` 返回 200;前端 `/api` 代理到 8091 链路通。登录 `admin / admin123`。
**本次唯一代码改动(已获用户确认)**
- `emergency-repair-web\src\utils\sensitive.js`:敏感词运行时替换改为跳过表格数据单元格。
  - 口径:`<td>` 内的文本与属性保持代称;`<th>` 表头、页面标题、筛选标签、placeholder 照常替换。
  - 实现:新增 `isInsideTableCell()`(`closest('td')`),接入文本遍历、属性替换、`MutationObserver` 字符变化回调,以及 `replaceDom()` 直接收到文本节点的分支。
  - 验证:向运行页面注入探针表格(表头与数据格均写 `X号`),结果为 `<th>县号</th>`、`<td>X号</td>`;`npm run build` 通过。
  - 效率影响:表格场景通常更快;没有表格且高频更新普通文本时,仅增加 `closest('td')` 的少量判断开销。
**用户本次明确的工作方式要求(务必遵守)**
- 用户问“可不可以……”时只是要调查结论和方案,**不要直接改代码**;只有用户明确要求实施时才动文件。
- 改动前先说明要改什么,口径不明确时先问清楚。
**下次接续**
1. 先确认 `5236 / 8091 / 8082` 是否在监听,不在再按上面的命令重启(本机启动需显式设置 `JAVA_HOME`,Maven / Node 用绝对路径)。
2. `sensitive.js` 改动已获确认并随本次提交推送;`requirement\~$原始需求.docx` 是 Word 锁文件,不要提交。
3. 需求侧待确认项不变:决策表 B6–B10、AXU15 字段来源与可改性、完工验收各环节角色与打印输出、修后质量评级与 AXU14 T员评分字段的关系。
4. 后续改动完成后推送到公司 Git 仓库 `main` 分支。
emergency-repair-web/src/components/AppHeader.vue
@@ -4,6 +4,7 @@
      <repair-logo></repair-logo>
      <span>临抢修管理系统</span>
      <em>网络版</em>
      <span v-if="menuPathText" class="menu-path" :title="menuPathText">{{ menuPathText }}</span>
    </div>
    <div class="header-right">
      <span><i class="el-icon-user-solid"></i> {{ realName }}</span>
@@ -18,7 +19,13 @@
export default {
  name: 'AppHeader',
  components: { RepairLogo },
  props: {
    menuPath: { type: Array, default: () => [] }
  },
  computed: {
    menuPathText() {
      return this.menuPath.join(' / ')
    },
    realName() {
      return sessionStorage.getItem('realName') || '系统管理员'
    }
@@ -36,6 +43,7 @@
<style scoped>
.app-header {
  gap: 20px;
  display: flex;
  align-items: center;
  justify-content: space-between;
@@ -46,10 +54,12 @@
  box-shadow: 0 1px 4px rgba(18, 41, 63, 0.24);
  flex-shrink: 0;
}
.brand { display: flex; align-items: center; gap: 9px; font-size: 18px; font-weight: 600; }
.brand { display: flex; flex: 1; align-items: center; min-width: 0; gap: 9px; font-size: 18px; font-weight: 600; }
.brand .repair-logo { color: #7fc4ff; font-size: 25px; transform: translateY(2px); }
.brand em { padding: 1px 6px; border: 1px solid rgba(255,255,255,.35); border-radius: 3px; font-size: 11px; font-style: normal; font-weight: 400; opacity: .85; }
.header-right { display: flex; align-items: center; gap: 14px; font-size: 14px; }
.brand > .repair-logo, .brand > span:not(.menu-path), .brand > em { flex: 0 0 auto; }
.menu-path { flex: 0 1 auto; min-width: 0; padding-left: 14px; border-left: 1px solid rgba(255,255,255,.28); overflow: hidden; color: #d8e9f7; font-size: 13px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
.header-right { display: flex; flex: 0 0 auto; align-items: center; gap: 14px; font-size: 14px; }
.header-right .el-button { color: #d8e9f7; }
.header-right .el-button:hover { color: #fff; }
</style>
emergency-repair-web/src/layout/AppLayout.vue
@@ -1,6 +1,6 @@
<template>
  <el-container class="layout">
    <app-header></app-header>
    <app-header :menu-path="activeMenuPath"></app-header>
    <el-container class="layout-body">
      <el-aside width="228px" class="layout-aside">
        <div class="aside-caption">业务导航</div>
@@ -9,9 +9,42 @@
        </el-menu>
      </el-aside>
      <el-main class="layout-main">
        <transition name="fade" mode="out-in">
          <router-view></router-view>
        </transition>
        <div class="mdi-tabbar">
          <div ref="tabScroll" class="mdi-tabs-scroll">
            <div class="mdi-tabs">
              <div
                v-for="doc in openDocs"
                :key="doc.fullPath"
                class="mdi-tab"
                :class="{ 'is-active': isActiveDoc(doc) }"
                :title="doc.title"
                @click="activateDoc(doc)"
              >
                <i :class="doc.icon"></i>
                <span class="mdi-tab-title">{{ doc.title }}</span>
                <i v-if="doc.pinned" class="el-icon-lock mdi-tab-pin"></i>
                <i v-else class="el-icon-close mdi-tab-close" title="关闭" @click.stop="closeDoc(doc)"></i>
              </div>
            </div>
          </div>
          <div class="mdi-actions">
            <el-dropdown trigger="click" @command="handleCommand">
              <button class="mdi-actions-button" type="button" title="文档操作"><i class="el-icon-more"></i></button>
              <el-dropdown-menu slot="dropdown">
                <el-dropdown-item command="closeCurrent" :disabled="!activeDoc || activeDoc.pinned">关闭当前</el-dropdown-item>
                <el-dropdown-item command="closeOthers" :disabled="!canCloseOthers">关闭其他</el-dropdown-item>
                <el-dropdown-item command="closeAll" :disabled="openDocs.length <= 1">关闭全部</el-dropdown-item>
              </el-dropdown-menu>
            </el-dropdown>
          </div>
        </div>
        <div ref="workspace" class="mdi-workspace" @scroll="rememberScroll">
          <router-view v-slot="{ Component, route }">
            <keep-alive :include="cachedNames" :max="20">
              <component :is="Component" :key="viewKey(route)" />
            </keep-alive>
          </router-view>
        </div>
      </el-main>
    </el-container>
  </el-container>
@@ -22,11 +55,57 @@
import AppHeader from '@/components/AppHeader.vue'
import MenuNode from '@/components/MenuNode.vue'
const HOME_DOC = {
  fullPath: '/dashboard',
  title: '首页门户',
  icon: 'el-icon-s-home',
  cacheName: 'Dashboard',
  pinned: true,
  version: 1
}
const REPORT_TITLES = {
  overview: '装备故障总体分析',
  'top-equipment': '故障高发设备Top',
  'by-team': '按T统计',
  contractors: '厂家维修情况',
  trend: '年度趋势'
}
export default {
  name: 'AppLayout',
  components: { AppHeader, MenuNode },
  data() {
    return { menus: [] }
    return {
      menus: [],
      openDocs: [],
      currentFullPath: '',
      cacheVersions: {},
      scrollTops: {}
    }
  },
  computed: {
    activeDoc() {
      return this.openDocs.find(doc => doc.fullPath === this.currentFullPath) || null
    },
    cachedNames() {
      return [...new Set(this.openDocs.map(doc => doc.cacheName).filter(Boolean))]
    },
    canCloseOthers() {
      const keepCount = this.activeDoc && this.activeDoc.pinned ? 1 : 2
      return this.openDocs.length > keepCount
    },
    activeMenuPath() {
      const path = this.findMenuPath(this.menus, this.$route.path)
      if (path.length) return path
      return this.$route.meta.title ? [this.$route.meta.title] : []
    }
  },
  watch: {
    '$route': {
      immediate: true,
      handler(route) { this.syncRoute(route) }
    }
  },
  created() {
    this.loadMenus()
@@ -36,6 +115,121 @@
      api.get('/system/menu/nav').then(res => {
        this.menus = (res.data || []).filter(item => item.visible !== 0)
      }).catch(() => {})
    },
    syncRoute(route) {
      if (!route || route.path === '/login') return
      if (this.currentFullPath && this.$refs.workspace) {
        this.scrollTops[this.currentFullPath] = this.$refs.workspace.scrollTop
      }
      this.ensureHomeDoc()
      const fullPath = route.fullPath
      let doc = this.openDocs.find(item => item.fullPath === fullPath)
      if (!doc) {
        doc = {
          fullPath,
          title: this.resolveTitle(route),
          icon: this.resolveIcon(route.path),
          cacheName: route.meta.cacheName || route.path,
          pinned: false,
          version: (this.cacheVersions[fullPath] || 0) + 1
        }
        this.openDocs.push(doc)
      } else {
        doc.title = this.resolveTitle(route)
        doc.icon = this.resolveIcon(route.path)
        doc.cacheName = route.meta.cacheName || doc.cacheName
      }
      this.currentFullPath = fullPath
      this.$nextTick(() => {
        this.restoreScroll()
        this.scrollActiveTab()
      })
    },
    ensureHomeDoc() {
      if (!this.openDocs.some(doc => doc.pinned)) {
        this.openDocs.unshift({ ...HOME_DOC })
      }
    },
    findMenuPath(items, targetPath, trail = []) {
      for (const item of items || []) {
        const nextTrail = [...trail, item.name]
        if (item.url === targetPath) return nextTrail
        const childPath = this.findMenuPath(item.children, targetPath, nextTrail)
        if (childPath.length) return childPath
      }
      return []
    },
    resolveTitle(route) {
      if (route.path.startsWith('/reports/')) {
        return REPORT_TITLES[route.params.type] || route.meta.title || '统计报表'
      }
      return route.meta.title || route.name || route.path
    },
    resolveIcon(path) {
      if (path === '/dashboard') return 'el-icon-s-home'
      if (path === '/fault/report') return 'el-icon-edit-outline'
      if (path.includes('/approval/') || path === '/fault/review') return 'el-icon-s-check'
      if (path === '/fault/dispatch') return 'el-icon-s-promotion'
      if (path.startsWith('/fault/completion') || path === '/completion/report') return 'el-icon-finished'
      if (path.startsWith('/acceptance/')) return 'el-icon-circle-check'
      if (path.startsWith('/reports/')) return 'el-icon-data-analysis'
      if (path.startsWith('/system/')) return 'el-icon-setting'
      return 'el-icon-document'
    },
    isActiveDoc(doc) { return doc.fullPath === this.currentFullPath },
    viewKey(route) {
      const doc = this.openDocs.find(item => item.fullPath === route.fullPath)
      return route.fullPath + '#' + (doc ? doc.version : 0)
    },
    activateDoc(doc) {
      if (!this.isActiveDoc(doc)) this.$router.push(doc.fullPath).catch(() => {})
    },
    closeDoc(doc) {
      if (!doc || doc.pinned) return
      const index = this.openDocs.findIndex(item => item.fullPath === doc.fullPath)
      if (index < 0) return
      const wasActive = this.isActiveDoc(doc)
      this.cacheVersions[doc.fullPath] = doc.version + 1
      this.openDocs.splice(index, 1)
      if (wasActive) {
        const target = this.openDocs[index - 1] || this.openDocs[index] || this.openDocs[0]
        this.$router.push(target.fullPath).catch(() => {})
      } else {
        this.$nextTick(this.scrollActiveTab)
      }
    },
    handleCommand(command) {
      if (command === 'closeCurrent') this.closeDoc(this.activeDoc)
      if (command === 'closeOthers') this.closeOtherDocs()
      if (command === 'closeAll') this.closeAllDocs()
    },
    closeOtherDocs() {
      if (!this.activeDoc) return
      const keepPath = this.activeDoc.fullPath
      this.openDocs.filter(doc => !doc.pinned && doc.fullPath !== keepPath).forEach(doc => {
        this.cacheVersions[doc.fullPath] = doc.version + 1
      })
      this.openDocs = this.openDocs.filter(doc => doc.pinned || doc.fullPath === keepPath)
      this.$nextTick(this.scrollActiveTab)
    },
    closeAllDocs() {
      this.openDocs.filter(doc => !doc.pinned).forEach(doc => {
        this.cacheVersions[doc.fullPath] = doc.version + 1
      })
      this.openDocs = this.openDocs.filter(doc => doc.pinned)
      this.$router.push('/dashboard').catch(() => {})
    },
    rememberScroll() {
      if (this.currentFullPath && this.$refs.workspace) {
        this.scrollTops[this.currentFullPath] = this.$refs.workspace.scrollTop
      }
    },
    restoreScroll() {
      if (this.$refs.workspace) this.$refs.workspace.scrollTop = this.scrollTops[this.currentFullPath] || 0
    },
    scrollActiveTab() {
      const active = this.$refs.tabScroll && this.$refs.tabScroll.querySelector('.mdi-tab.is-active')
      if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' })
    }
  }
}
@@ -46,7 +240,7 @@
.layout-body { flex: 1; min-height: 0; }
.layout-aside { border-right: 1px solid #d9e2eb; background: #f5f8fb; overflow-x: hidden; overflow-y: auto; }
.aside-caption { padding: 14px 18px 8px; color: #8090a2; font-size: 12px; letter-spacing: .08em; }
.layout-main { min-width: 0; padding: 0; overflow: auto; background: #eef2f6; }
.layout-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: 0; overflow: hidden; background: #eef2f6; }
.el-menu { border-right: 0; }
.layout-aside ::v-deep .el-menu-item,
.layout-aside ::v-deep .el-submenu__title {
@@ -65,6 +259,20 @@
  overflow-wrap: anywhere;
  word-break: break-word;
}
.fade-enter-active, .fade-leave-active { transition: opacity .12s ease; }
.fade-enter, .fade-leave-to { opacity: 0; }
.mdi-tabbar { display: flex; flex: 0 0 40px; align-items: stretch; height: 40px; padding-top: 5px; border-bottom: 1px solid #cfdae5; background: #e3eaf1; }
.mdi-tabs-scroll { flex: 1; min-width: 0; overflow-x: auto; overflow-y: hidden; }
.mdi-tabs { display: inline-flex; align-items: flex-end; min-width: 100%; height: 100%; padding: 0 4px; }
.mdi-tab { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; max-width: 220px; height: 34px; margin-right: 2px; padding: 0 9px; border: 1px solid transparent; border-bottom: 0; border-radius: 4px 4px 0 0; color: #5c6f82; background: rgba(255, 255, 255, .42); cursor: pointer; font-size: 13px; user-select: none; }
.mdi-tab:hover { color: #245275; background: rgba(255, 255, 255, .72); }
.mdi-tab.is-active { height: 35px; color: #1f3348; background: #eef2f6; border-color: #cfdae5; box-shadow: inset 0 2px 0 #2c7fb8; }
.mdi-tab > i { flex: 0 0 auto; }
.mdi-tab-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mdi-tab-close, .mdi-tab-pin { width: 16px; color: #8191a2; text-align: center; }
.mdi-tab-close { border-radius: 50%; font-size: 12px; }
.mdi-tab-close:hover { color: #fff; background: #d9534f; }
.mdi-tab-pin { font-size: 12px; }
.mdi-actions { display: flex; flex: 0 0 auto; align-items: center; padding: 0 6px; border-left: 1px solid rgba(125, 147, 169, .22); }
.mdi-actions-button { width: 30px; height: 28px; padding: 0; border: 0; border-radius: 3px; color: #587087; background: transparent; cursor: pointer; font-size: 17px; }
.mdi-actions-button:hover { color: #1f5f91; background: rgba(255, 255, 255, .68); }
.mdi-workspace { position: relative; flex: 1; min-height: 0; overflow: auto; background: #eef2f6; }
</style>
emergency-repair-web/src/router/index.js
@@ -11,30 +11,30 @@
    path: '/',
    component: AppLayout,
    children: [
      { path: 'dashboard', component: () => import('@/views/Dashboard.vue') },
      { path: 'fault/report', component: () => import('@/views/fault/FaultReport.vue') },
      { path: 'fault/review', component: () => import('@/views/fault/XcbReview.vue') },
      { path: 'fault/approval/cmb', component: () => import('@/views/fault/SimpleApproval.vue'), meta: { stage: 'CMB' } },
      { path: 'fault/approval/jcc-assistant', component: () => import('@/views/fault/SimpleApproval.vue'), meta: { stage: 'JCC_ASSISTANT' } },
      { path: 'fault/approval/jcc-director', component: () => import('@/views/fault/SimpleApproval.vue'), meta: { stage: 'JCC_DIRECTOR' } },
      { path: 'fault/dispatch', component: () => import('@/views/fault/DispatchPage.vue') },
      { path: 'fault/completion', component: () => import('@/views/completion/CompletionList.vue') },
      { path: 'completion/report', component: () => import('@/views/completion/CompletionReport.vue') },
      { path: 'acceptance/application', component: () => import('@/views/acceptance/AcceptanceList.vue'), meta: { stage: 'APPLICATION' } },
      { path: 'acceptance/inspector', component: () => import('@/views/acceptance/AcceptanceList.vue'), meta: { stage: 'INSPECTOR' } },
      { path: 'acceptance/leader', component: () => import('@/views/acceptance/AcceptanceList.vue'), meta: { stage: 'LEADER' } },
      { path: 'acceptance/print', component: () => import('@/views/acceptance/AcceptanceList.vue'), meta: { stage: 'PRINT' } },
      { path: 'acceptance/rating', component: () => import('@/views/acceptance/AcceptanceList.vue'), meta: { stage: 'RATING' } },
      { path: 'reports/:type', component: () => import('@/views/report/ReportPage.vue') },
      { path: 'system/contractors', component: () => import('@/views/system/ContractorManage.vue') },
      { path: 'system/product-tree', component: () => import('@/views/system/TreeManage.vue'), meta: { treeType: 'product' } },
      { path: 'system/base-equipment', component: () => import('@/views/system/TreeManage.vue'), meta: { treeType: 'base' } },
      { path: 'system/engineering-majors', component: () => import('@/views/system/EngineeringMajorManage.vue') },
      { path: 'system/team-majors', component: () => import('@/views/system/TeamMajorManage.vue') },
      { path: 'system/roles', component: () => import('@/views/system/RoleManage.vue') },
      { path: 'system/menus', component: () => import('@/views/system/MenuManage.vue') },
      { path: 'system/menu-permissions', component: () => import('@/views/system/MenuPermission.vue') },
      { path: 'system/three-member', component: () => import('@/views/system/ThreeMember.vue') }
      { path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '首页门户', cacheName: 'Dashboard' } },
      { path: 'fault/report', component: () => import('@/views/fault/FaultReport.vue'), meta: { title: '故障上报', cacheName: 'FaultReport' } },
      { path: 'fault/review', component: () => import('@/views/fault/XcbReview.vue'), meta: { title: 'XCB助理审核', cacheName: 'XcbReview' } },
      { path: 'fault/approval/cmb', component: () => import('@/views/fault/SimpleApproval.vue'), props: { stage: 'CMB' }, meta: { title: 'CMB领导审批', stage: 'CMB', cacheName: 'SimpleApproval' } },
      { path: 'fault/approval/jcc-assistant', component: () => import('@/views/fault/SimpleApproval.vue'), props: { stage: 'JCC_ASSISTANT' }, meta: { title: 'JCC助理审批', stage: 'JCC_ASSISTANT', cacheName: 'SimpleApproval' } },
      { path: 'fault/approval/jcc-director', component: () => import('@/views/fault/SimpleApproval.vue'), props: { stage: 'JCC_DIRECTOR' }, meta: { title: 'JCC处长审批', stage: 'JCC_DIRECTOR', cacheName: 'SimpleApproval' } },
      { path: 'fault/dispatch', component: () => import('@/views/fault/DispatchPage.vue'), meta: { title: 'XCB助理派单', cacheName: 'DispatchPage' } },
      { path: 'fault/completion', component: () => import('@/views/completion/CompletionList.vue'), meta: { title: '完工情况处理', cacheName: 'CompletionList' } },
      { path: 'completion/report', component: () => import('@/views/completion/CompletionReport.vue'), props: route => ({ workOrderId: route.query.workOrderId || null }), meta: { title: '完工信息上报', cacheName: 'CompletionReport' } },
      { path: 'acceptance/application', component: () => import('@/views/acceptance/AcceptanceList.vue'), props: { stage: 'APPLICATION' }, meta: { title: '验收申请', stage: 'APPLICATION', cacheName: 'AcceptanceList' } },
      { path: 'acceptance/inspector', component: () => import('@/views/acceptance/AcceptanceList.vue'), props: { stage: 'INSPECTOR' }, meta: { title: '质检员验收', stage: 'INSPECTOR', cacheName: 'AcceptanceList' } },
      { path: 'acceptance/leader', component: () => import('@/views/acceptance/AcceptanceList.vue'), props: { stage: 'LEADER' }, meta: { title: '质检负责人验收', stage: 'LEADER', cacheName: 'AcceptanceList' } },
      { path: 'acceptance/print', component: () => import('@/views/acceptance/AcceptanceList.vue'), props: { stage: 'PRINT' }, meta: { title: '完工验收打印输出', stage: 'PRINT', cacheName: 'AcceptanceList' } },
      { path: 'acceptance/rating', component: () => import('@/views/acceptance/AcceptanceList.vue'), props: { stage: 'RATING' }, meta: { title: '修后质量评级', stage: 'RATING', cacheName: 'AcceptanceList' } },
      { path: 'reports/:type', component: () => import('@/views/report/ReportPage.vue'), props: route => ({ type: route.params.type }), meta: { title: '统计报表', cacheName: 'ReportPage' } },
      { path: 'system/contractors', component: () => import('@/views/system/ContractorManage.vue'), meta: { title: '承修单位管理', cacheName: 'ContractorManage' } },
      { path: 'system/product-tree', component: () => import('@/views/system/TreeManage.vue'), props: { treeType: 'product' }, meta: { title: '产品结构树管理', treeType: 'product', cacheName: 'TreeManage' } },
      { path: 'system/base-equipment', component: () => import('@/views/system/TreeManage.vue'), props: { treeType: 'base' }, meta: { title: '设备基础库管理', treeType: 'base', cacheName: 'TreeManage' } },
      { path: 'system/engineering-majors', component: () => import('@/views/system/EngineeringMajorManage.vue'), meta: { title: '工程专业配置', cacheName: 'EngineeringMajorManage' } },
      { path: 'system/team-majors', component: () => import('@/views/system/TeamMajorManage.vue'), meta: { title: 'T队专业配置', cacheName: 'TeamMajorManage' } },
      { path: 'system/roles', component: () => import('@/views/system/RoleManage.vue'), meta: { title: '角色管理', cacheName: 'RoleManage' } },
      { path: 'system/menus', component: () => import('@/views/system/MenuManage.vue'), meta: { title: '菜单管理', cacheName: 'MenuManage' } },
      { path: 'system/menu-permissions', component: () => import('@/views/system/MenuPermission.vue'), meta: { title: '菜单权限', cacheName: 'MenuPermission' } },
      { path: 'system/three-member', component: () => import('@/views/system/ThreeMember.vue'), meta: { title: '三员管理', cacheName: 'ThreeMember' } }
    ]
  }
]
emergency-repair-web/src/utils/sensitive.js
@@ -12,6 +12,16 @@
    !(realName.startsWith('[') && realName.endsWith(']'))
}
// Table body cells render stored data, so they keep the codenames from the
// source instead of being rewritten like headers, labels and placeholders.
function isInsideTableCell(node) {
  const element = node && node.nodeType === 1 ? node : node && node.parentElement
  if (!element || !element.closest) {
    return false
  }
  return !!element.closest('td')
}
function replaceAsciiCodename(value, codename, realName) {
  const pattern = new RegExp('(^|[^A-Za-z0-9])' + codename + '(?=$|[^A-Za-z0-9])', 'g')
  return value.replace(pattern, function (match, prefix) {
@@ -57,11 +67,16 @@
    return
  }
  if (root.nodeType === 1) {
    attributes.forEach(function (attribute) {
      replaceAttribute(root, attribute)
    })
    if (!isInsideTableCell(root)) {
      attributes.forEach(function (attribute) {
        replaceAttribute(root, attribute)
      })
    }
  }
  root.querySelectorAll('[placeholder], [title], [aria-label]').forEach(function (element) {
    if (isInsideTableCell(element)) {
      return
    }
    attributes.forEach(function (attribute) {
      replaceAttribute(element, attribute)
    })
@@ -74,7 +89,9 @@
  }
  if (root.nodeType === 3) {
    replaceTextNode(root)
    if (!isInsideTableCell(root)) {
      replaceTextNode(root)
    }
    return
  }
@@ -85,9 +102,10 @@
    {
      acceptNode: function (node) {
        const parent = node.parentElement
        return parent && !SKIPPED_TAGS[parent.tagName]
          ? NodeFilter.FILTER_ACCEPT
          : NodeFilter.FILTER_REJECT
        if (!parent || SKIPPED_TAGS[parent.tagName] || isInsideTableCell(parent)) {
          return NodeFilter.FILTER_REJECT
        }
        return NodeFilter.FILTER_ACCEPT
      }
    }
  )
@@ -107,7 +125,9 @@
  observer = new MutationObserver(function (mutations) {
    mutations.forEach(function (mutation) {
      if (mutation.type === 'characterData') {
        replaceTextNode(mutation.target)
        if (!isInsideTableCell(mutation.target)) {
          replaceTextNode(mutation.target)
        }
        return
      }
      mutation.addedNodes.forEach(function (node) {
emergency-repair-web/src/views/acceptance/AcceptanceList.vue
@@ -16,7 +16,7 @@
    </div>
    <div class="table-panel">
      <el-table :data="filteredRows" border stripe height="calc(100vh - 230px)">
      <el-table :data="filteredRows" border stripe height="calc(100vh - 270px)">
        <el-table-column prop="workOrderNo" label="派工单编号" width="155" fixed></el-table-column>
        <el-table-column prop="status" label="验收状态" width="155" fixed>
          <template slot-scope="{ row }"><el-tag size="mini" :type="acceptanceMeta(row.status).type">{{ acceptanceMeta(row.status).text }}</el-tag></template>
@@ -83,6 +83,9 @@
export default {
  name: 'AcceptanceList',
  props: {
    stage: { type: String, required: true }
  },
  data() {
    return {
      rows: [],
@@ -95,7 +98,6 @@
    }
  },
  computed: {
    stage() { return this.$route.meta.stage },
    pageTitle() {
      return { APPLICATION: '验收申请', INSPECTOR: '质检员验收', LEADER: '质检负责人验收', PRINT: '完工验收打印输出', RATING: '修后质量评级' }[this.stage]
    },
@@ -125,7 +127,7 @@
    }
  },
  watch: {
    '$route.path': 'onRouteChange'
    stage: 'onRouteChange'
  },
  created() { this.load() },
  methods: {
emergency-repair-web/src/views/completion/CompletionList.vue
@@ -27,7 +27,7 @@
    </div>
    <div class="table-panel">
      <el-table :data="rows" border stripe height="calc(100vh - 285px)" highlight-current-row @current-change="selectedRow = $event">
      <el-table :data="rows" border stripe height="calc(100vh - 325px)" highlight-current-row @current-change="selectedRow = $event">
        <el-table-column width="46" align="center">
          <template slot-scope="{ row }"><el-radio v-model="selectedId" :label="row.id" @change="selectRow(row)"><span></span></el-radio></template>
        </el-table-column>
emergency-repair-web/src/views/completion/CompletionReport.vue
@@ -90,6 +90,9 @@
import api from '@/api'
export default {
  name: 'CompletionReport',
  props: {
    workOrderId: { type: [String, Number], default: null }
  },
  data() {
    return {
      workOrder: null,
@@ -102,7 +105,7 @@
  },
  computed: {
    routeWorkOrderId() {
      const value = this.$route.query.workOrderId
      const value = this.workOrderId
      return value ? Number(value) : null
    }
  },
emergency-repair-web/src/views/fault/DispatchPage.vue
@@ -19,7 +19,7 @@
    <el-alert :closable="false" type="info" show-icon title="一个故障有多个承修单位时,将分别生成派工单;编号从“故障编号/01”开始依次编号。" style="margin-bottom:12px"></el-alert>
    <div class="table-panel">
      <el-table :data="rows" border stripe height="calc(100vh - 285px)" @selection-change="selection = $event">
      <el-table :data="rows" border stripe height="calc(100vh - 325px)" @selection-change="selection = $event">
        <el-table-column type="selection" width="46"></el-table-column>
        <el-table-column prop="faultCode" label="故障编号" width="180" fixed></el-table-column>
        <el-table-column prop="xNo" label="X号" width="64"></el-table-column>
emergency-repair-web/src/views/fault/FaultReport.vue
@@ -56,7 +56,7 @@
    </div>
    <div class="table-panel">
      <el-table :data="rows" border stripe height="calc(100vh - 355px)" :header-cell-style="{ textAlign: 'center' }" @selection-change="selection = $event">
      <el-table :data="rows" border stripe height="calc(100vh - 395px)" :header-cell-style="{ textAlign: 'center' }" @selection-change="selection = $event">
        <el-table-column type="selection" width="46" :selectable="row => canEdit(row)"></el-table-column>
        <el-table-column prop="taskStatus" label="任务状态" width="145" fixed>
          <template slot-scope="{ row }"><el-tag size="mini" :type="taskMeta(row.taskStatus).type">{{ taskMeta(row.taskStatus).text }}</el-tag></template>
emergency-repair-web/src/views/fault/SimpleApproval.vue
@@ -17,7 +17,7 @@
    </div>
    <div class="table-panel">
      <el-table :data="rows" border stripe height="calc(100vh - 230px)" :header-cell-style="{ textAlign: 'center' }" @selection-change="selection = $event">
      <el-table :data="rows" border stripe height="calc(100vh - 270px)" :header-cell-style="{ textAlign: 'center' }" @selection-change="selection = $event">
        <el-table-column type="selection" width="46"></el-table-column>
        <el-table-column prop="taskStatus" label="任务状态" width="145" fixed>
          <template slot-scope="{ row }"><el-tag size="mini" :type="taskMeta(row.taskStatus).type">{{ taskMeta(row.taskStatus).text }}</el-tag></template>
@@ -81,11 +81,13 @@
export default {
  name: 'SimpleApproval',
  props: {
    stage: { type: String, required: true }
  },
  data() {
    return { rows: [], selection: [], keyword: '', dialogVisible: false, saving: false, form: { result: 'PASS', opinion: '' } }
  },
  computed: {
    stage() { return this.$route.meta.stage },
    pageTitle() {
      return { CMB: 'CMB领导审批', JCC_ASSISTANT: 'JCC助理审批', JCC_DIRECTOR: 'JCC处长审批' }[this.stage]
    },
emergency-repair-web/src/views/fault/XcbReview.vue
@@ -61,7 +61,7 @@
    </div>
    <div class="table-panel">
      <el-table :data="rows" border stripe height="calc(100vh - 355px)" :header-cell-style="{ textAlign: 'center' }" @selection-change="selection = $event">
      <el-table :data="rows" border stripe height="calc(100vh - 395px)" :header-cell-style="{ textAlign: 'center' }" @selection-change="selection = $event">
        <el-table-column type="selection" width="46" :selectable="canProcess"></el-table-column>
        <el-table-column prop="taskStatus" label="任务状态" width="145" fixed>
          <template slot-scope="{ row }"><el-tag size="mini" :type="taskMeta(row.taskStatus).type">{{ taskMeta(row.taskStatus).text }}</el-tag></template>
emergency-repair-web/src/views/report/ReportPage.vue
@@ -38,11 +38,13 @@
export default {
  name: 'ReportPage',
  props: {
    type: { type: String, required: true }
  },
  data() {
    return { stats: {}, rows: [], chart: null }
    return { stats: {}, rows: [], chart: null, loadSeq: 0 }
  },
  computed: {
    type() { return this.$route.params.type },
    title() {
      return {
        overview: '装备故障总体分析',
@@ -93,7 +95,7 @@
    }
  },
  watch: {
    '$route.params.type': 'load'
    type: 'load'
  },
  mounted() {
    this.load()
@@ -105,14 +107,25 @@
  },
  methods: {
    load() {
      const requests = {
        overview: api.get('/reports/overview').then(res => { this.stats = res.data || {}; this.rows = this.stats.chart || [] }),
        'top-equipment': api.get('/reports/top-equipment').then(res => { this.rows = res.data || []; this.stats = {} }),
        'by-team': api.get('/reports/by-team').then(res => { this.rows = res.data || []; this.stats = {} }),
        contractors: api.get('/reports/contractors').then(res => { this.rows = res.data || []; this.stats = {} }),
        trend: api.get('/reports/yearly-trend').then(res => { this.rows = res.data || []; this.stats = {} })
      const loaders = {
        overview: () => api.get('/reports/overview').then(res => ({
          stats: res.data || {},
          rows: (res.data && res.data.chart) || []
        })),
        'top-equipment': () => api.get('/reports/top-equipment').then(res => ({ stats: {}, rows: res.data || [] })),
        'by-team': () => api.get('/reports/by-team').then(res => ({ stats: {}, rows: res.data || [] })),
        contractors: () => api.get('/reports/contractors').then(res => ({ stats: {}, rows: res.data || [] })),
        trend: () => api.get('/reports/yearly-trend').then(res => ({ stats: {}, rows: res.data || [] }))
      }
      ;(requests[this.type] || Promise.resolve()).then(() => this.$nextTick(this.renderChart))
      const seq = ++this.loadSeq
      const loader = loaders[this.type]
      const request = loader ? loader() : Promise.resolve({ stats: {}, rows: [] })
      request.then(result => {
        if (seq !== this.loadSeq) return
        this.stats = result.stats
        this.rows = result.rows
        this.$nextTick(this.renderChart)
      }).catch(() => {})
    },
    renderChart() {
      if (!this.$refs.chart) return
emergency-repair-web/src/views/system/ContractorManage.vue
@@ -15,7 +15,7 @@
    </div>
    <div class="table-panel">
      <el-table :data="filteredRows" border stripe height="calc(100vh - 205px)">
      <el-table :data="filteredRows" border stripe height="calc(100vh - 245px)">
        <el-table-column prop="sortNo" label="排序" width="80" align="center"></el-table-column>
        <el-table-column label="常用" width="80" align="center">
          <template slot-scope="{ $index }"><el-tag v-if="$index < 7" size="mini" type="success">前7</el-tag><span v-else>-</span></template>
emergency-repair-web/src/views/system/EngineeringMajorManage.vue
@@ -15,7 +15,7 @@
    </div>
    <div class="table-panel">
      <el-table :data="filteredRows" border stripe height="calc(100vh - 205px)">
      <el-table :data="filteredRows" border stripe height="calc(100vh - 245px)">
        <el-table-column type="index" label="序号" width="70" align="center"></el-table-column>
        <el-table-column prop="name" label="名称" min-width="240"></el-table-column>
        <el-table-column label="操作" width="140" fixed="right">
emergency-repair-web/src/views/system/MenuPermission.vue
@@ -77,7 +77,7 @@
</script>
<style scoped>
.permission-grid { display: grid; grid-template-columns: 1fr 1fr 320px; gap: 12px; }
.permission-panel { min-height: calc(100vh - 180px); padding: 14px; border: 1px solid #dce4ec; border-radius: 4px; background: #fff; overflow: auto; }
.permission-panel { min-height: calc(100vh - 220px); padding: 14px; border: 1px solid #dce4ec; border-radius: 4px; background: #fff; overflow: auto; }
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.panel-head h3 { margin: 0; color: #31465d; font-size: 15px; }
.intersection-list { display: flex; flex-wrap: wrap; gap: 6px; }
emergency-repair-web/src/views/system/RoleManage.vue
@@ -5,7 +5,7 @@
      <el-button type="primary" icon="el-icon-plus" @click="openCreate">新增角色</el-button>
    </div>
    <div class="table-panel">
      <el-table :data="rows" border stripe height="calc(100vh - 150px)">
      <el-table :data="rows" border stripe height="calc(100vh - 190px)">
        <el-table-column prop="sortNo" label="排序" width="80" align="center"></el-table-column>
        <el-table-column prop="name" label="角色名称" min-width="180"></el-table-column>
        <el-table-column prop="code" label="角色编码" width="170"></el-table-column>
emergency-repair-web/src/views/system/TeamMajorManage.vue
@@ -15,7 +15,7 @@
    </div>
    <div class="table-panel">
      <el-table :data="filteredRows" border stripe height="calc(100vh - 205px)">
      <el-table :data="filteredRows" border stripe height="calc(100vh - 245px)">
        <el-table-column type="index" label="序号" width="70" align="center"></el-table-column>
        <el-table-column prop="name" label="名称" min-width="240"></el-table-column>
        <el-table-column prop="category" label="分类" width="120" align="center">
emergency-repair-web/src/views/system/TreeManage.vue
@@ -73,18 +73,20 @@
import api from '@/api'
export default {
  name: 'TreeManage',
  props: {
    treeType: { type: String, required: true }
  },
  data() {
    return { tree: [], keyword: '', form: {}, saving: false, baseDevices: [] }
  },
  computed: {
    treeType() { return this.$route.meta.treeType },
    isProduct() { return this.treeType === 'product' },
    maxLevel() { return this.isProduct ? 5 : 3 },
    levelNames() { return this.isProduct ? ['型号', 'X号', '一级系统', '二级系统', '设备'] : ['一级系统', '二级系统', '设备'] }
  },
  watch: {
    keyword(value) { this.$refs.tree && this.$refs.tree.filter(value) },
    '$route.meta.treeType'() { this.form = {}; this.load() }
    treeType() { this.form = {}; this.load() }
  },
  created() { this.load() },
  methods: {
@@ -147,7 +149,7 @@
</script>
<style scoped>
.tree-layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 12px; }
.tree-panel, .detail-panel { min-height: calc(100vh - 140px); padding: 14px; border: 1px solid #dce4ec; border-radius: 4px; background: #fff; }
.tree-panel, .detail-panel { min-height: calc(100vh - 180px); padding: 14px; border: 1px solid #dce4ec; border-radius: 4px; background: #fff; }
.tree-node { display: flex; align-items: center; gap: 7px; }
.tree-node small { color: #95a2b0; }
.level-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 50px; height: 20px; padding: 0 5px; border-radius: 3px; color: #2f658f; background: #e9f3fb; font-size: 11px; }