瀏覽代碼

Merge branch 'qhse_person' of shuzhihua/pms-iot-vue into master

yanghao 1 周之前
父節點
當前提交
531f3c04e6

+ 1 - 1
.env.local

@@ -3,7 +3,7 @@ NODE_ENV=development
 
 VITE_DEV=true
 
-# 请求路径  http://192.168.188.200:48080  https://iot.deepoil.cc  http://172.26.0.56:48080
+# 请求路径  http://192.168.188.200:48080  https://iot.deepoil.cc:5443  http://172.26.0.56:48080
 VITE_BASE_URL='https://iot.deepoil.cc:5443'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务

+ 4 - 0
src/api/pms/qhse/index.ts

@@ -601,6 +601,10 @@ export const InspectDeviceCertApi = {
   // 导出设备证书 Excel
   exportInspectDeviceCert: async (params) => {
     return await request.download({ url: `/rq/qhse-device-cert/export-excel`, params })
+  },
+  // 统计
+  getInspectDeviceCertStat: async (id) => {
+    return await request.get({ url: `/rq/qhse-device-cert/stat?deptId=${id}` })
   }
 }
 

+ 14 - 0
src/views/pms/device/index.vue

@@ -809,6 +809,7 @@ const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 const { push } = useRouter() // 路由跳转
 const deptList = ref<Tree[]>([]) // 树形结构
+const route = useRoute()
 const rootDeptId = 156
 const deptId = useUserStore().getUser.deptId || rootDeptId
 
@@ -940,6 +941,10 @@ const getDashboardData = async () => {
 }
 
 /** 处理部门被点击 */
+const syncRouteCodeQuery = () => {
+  queryParams.yfClass = route.query.code ? String(route.query.code) : undefined
+}
+
 const handleDeptNodeClick = async (row) => {
   queryParams.deptId = row.id
   await Promise.all([getList(), getDashboardData()])
@@ -1027,6 +1032,7 @@ const handleExport = async () => {
 
 /** 初始化 **/
 onMounted(async () => {
+  syncRouteCodeQuery()
   productClassifyList.value = handleTree(
     await ProductClassifyApi.IotProductClassifyApi.getSimpleProductClassifyList()
   )
@@ -1044,6 +1050,14 @@ onMounted(async () => {
 
   deptList.value = handleTree(await DeptApi.getSimpleDeptList())
 })
+
+watch(
+  () => route.query.code,
+  async () => {
+    syncRouteCodeQuery()
+    await Promise.all([getList(), getDashboardData()])
+  }
+)
 </script>
 <style scoped lang="scss">
 @media (width <= 1440px) {

+ 255 - 91
src/views/pms/qhse/deviceCert/index.vue

@@ -11,99 +11,145 @@
       request-api="getSimpleDeptList"
       @node-click="handleDeptNodeClick" />
 
-    <div class="mb-1">
-      <el-form
-        :model="queryParams"
-        ref="queryFormRef"
-        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 py-2 pt-4 flex items-center flex-wrap min-w-0">
-        <div class="flex items-center gap-4 flex-wrap">
-          <el-form-item label="设备名称" prop="deviceName">
-            <el-input
-              v-model="queryParams.deviceName"
-              placeholder="请输入设备名称"
-              clearable
-              class="!w-150px" />
-          </el-form-item>
-
-          <el-form-item label="检测类型" prop="certType">
-            <el-select
-              v-model="queryParams.certType"
-              placeholder="请选择检测类型"
-              clearable
-              style="width: 150px">
-              <el-option
-                v-for="dict in getStrDictOptions(DICT_TYPE.QHSE_DEVICE_CERT_TYPE)"
-                :key="dict.value"
-                :label="dict.label"
-                :value="dict.value" />
-            </el-select>
-          </el-form-item>
-          <el-form-item label="序列号" prop="certNo">
-            <el-input
-              v-model="queryParams.certNo"
-              placeholder="请输入序列号"
-              clearable
-              class="!w-150px" />
-          </el-form-item>
-          <el-form-item label="检测日期" prop="certTime">
-            <el-date-picker
-              v-model="queryParams.certTime"
-              value-format="YYYY-MM-DD HH:mm:ss"
-              type="daterange"
-              start-placeholder="开始日期"
-              end-placeholder="结束日期"
-              :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
-              class="!w-200px" />
-          </el-form-item>
-          <el-form-item label="检测单位" prop="certOrg">
-            <el-input
-              v-model="queryParams.certOrg"
-              placeholder="请输入检测单位"
-              clearable
-              @keyup.enter="handleQuery"
-              class="!w-150px" />
-          </el-form-item>
-          <el-form-item label="有效期" prop="certExpire">
-            <el-date-picker
-              v-model="queryParams.certExpire"
-              value-format="YYYY-MM-DD HH:mm:ss"
-              type="daterange"
-              start-placeholder="开始日期"
-              end-placeholder="结束日期"
-              :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
-              class="!w-200px" />
-          </el-form-item>
-
-          <el-form-item>
-            <el-button @click="handleQuery" v-hasPermi="['rq:qhse-device-cert:query']">
-              <Icon icon="ep:search" class="mr-5px" /> 搜索
-            </el-button>
-            <el-button @click="resetQuery">
-              <Icon icon="ep:refresh" class="mr-5px" /> 重置
-            </el-button>
-            <el-button
-              v-hasPermi="['rq:qhse-device-cert:create']"
-              type="primary"
-              plain
-              @click="openForm('create')">
-              <Icon icon="ep:plus" class="mr-5px" /> 新增
-            </el-button>
-            <el-button
-              v-hasPermi="['rq:qhse-device-cert:export']"
-              type="success"
-              plain
-              @click="handleExport"
-              :loading="exportLoading">
-              <Icon icon="ep:download" class="mr-5px" /> 导出
-            </el-button>
-          </el-form-item>
+    <div class="stats-cards">
+      <div
+        class="stats-card stats-card--expired stats-card--clickable"
+        @click="handleStatCardClick('expired')">
+        <div class="flex items-center gap-2">
+          <Icon icon="ep:info-filled" color="#de3b3b" />
+          <div class="stats-card__label">已过期</div>
+        </div>
+
+        <div class="stats-card__value">
+          <CountTo
+            :duration="2600"
+            :end-val="expired"
+            :start-val="0"
+            class="stats-card__value text-[40px]! pt-10 text-center! text-[#e35656]!" />
+        </div>
+      </div>
+      <div
+        class="stats-card stats-card--warn stats-card--clickable"
+        @click="handleStatCardClick('warn')">
+        <div class="flex items-center gap-2">
+          <Icon icon="ep:bell-filled" color="#d97706" />
+          <div class="stats-card__label">90天预警(含过期)</div>
+        </div>
+        <div class="stats-card__value">
+          <CountTo
+            :duration="2600"
+            :end-val="warn"
+            :start-val="0"
+            class="stats-card__value text-[40px]! pt-10 text-center! text-[#d97706]!" />
         </div>
-      </el-form>
+      </div>
+      <div
+        class="stats-card stats-card--total stats-card--clickable"
+        @click="handleStatCardClick('total')">
+        <div class="flex items-center gap-2">
+          <Icon icon="eos-icons:counting" color="#2563eb" />
+          <div class="stats-card__label">证书总数</div>
+        </div>
+
+        <div class="stats-card__value">
+          <CountTo
+            :duration="2600"
+            :end-val="totalCert"
+            :start-val="0"
+            class="stats-card__value text-[40px]! pt-10 text-center! text-[#2563eb]!" />
+        </div>
+      </div>
     </div>
 
-    <div class="min-w-0"></div>
+    <el-form
+      :model="queryParams"
+      ref="queryFormRef"
+      class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 py-2 pt-4 flex items-center flex-wrap min-w-0">
+      <div class="flex items-center gap-2 flex-wrap">
+        <el-form-item label="设备名称" prop="deviceName">
+          <el-input
+            v-model="queryParams.deviceName"
+            placeholder="请输入设备名称"
+            clearable
+            class="!w-150px" />
+        </el-form-item>
+
+        <el-form-item label="检测类型" prop="certType">
+          <el-select
+            v-model="queryParams.certType"
+            placeholder="请选择检测类型"
+            clearable
+            style="width: 150px">
+            <el-option
+              v-for="dict in getStrDictOptions(DICT_TYPE.QHSE_DEVICE_CERT_TYPE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="序列号" prop="certNo">
+          <el-input
+            v-model="queryParams.certNo"
+            placeholder="请输入序列号"
+            clearable
+            class="!w-150px" />
+        </el-form-item>
+        <el-form-item label="检测日期" prop="certTime">
+          <el-date-picker
+            v-model="queryParams.certTime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            type="daterange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+            class="!w-200px" />
+        </el-form-item>
+        <el-form-item label="检测单位" prop="certOrg">
+          <el-input
+            v-model="queryParams.certOrg"
+            placeholder="请输入检测单位"
+            clearable
+            @keyup.enter="handleQuery"
+            class="!w-150px" />
+        </el-form-item>
+        <el-form-item label="有效期" prop="certExpire">
+          <el-date-picker
+            v-model="queryParams.certExpire"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            type="daterange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+            class="!w-200px" />
+        </el-form-item>
+
+        <el-form-item>
+          <el-button @click="handleQuery" v-hasPermi="['rq:qhse-device-cert:query']">
+            <Icon icon="ep:search" class="mr-5px" /> 搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" class="mr-5px" /> 重置
+          </el-button>
+          <el-button
+            v-hasPermi="['rq:qhse-device-cert:create']"
+            type="primary"
+            plain
+            @click="openForm('create')">
+            <Icon icon="ep:plus" class="mr-5px" /> 新增
+          </el-button>
+          <el-button
+            v-hasPermi="['rq:qhse-device-cert:export']"
+            type="success"
+            plain
+            @click="handleExport"
+            :loading="exportLoading">
+            <Icon icon="ep:download" class="mr-5px" /> 导出
+          </el-button>
+        </el-form-item>
+      </div>
+    </el-form>
 
-    <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-2 pt-4 min-w-0">
+    <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col mt-1 p-2 pt-4 min-w-0">
       <div class="flex-1 relative min-h-0">
         <el-auto-resizer class="absolute">
           <template #default="{ width, height }">
@@ -184,7 +230,7 @@
     </div>
 
     <div
-      class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg p-3 min-w-0 flex gap-4 justify-between">
+      class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg p-3 mt-1 min-w-0 flex gap-4 justify-between">
       <div
         class="bg-[#fef0f0] text-[#f57979] px-3 flex-1 flex items-center justify-center text-center py-2 text-sm rounded-md"
         ><el-icon class="stats-card__icon" :size="28"> <Icon icon="ep:info-filled" /> </el-icon
@@ -238,7 +284,7 @@ import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 
 /** 应检设备证书 列表 */
 defineOptions({ name: 'QHSEDeviceCert' })
-// const userStore = useUserStore()
+const userStore = useUserStore()
 const rootDeptId = 156
 const deptId = useUserStore().getUser.deptId || rootDeptId
 
@@ -303,12 +349,38 @@ const handleQuery = () => {
   getList()
 }
 
+const handleStatCardClick = (type: 'expired' | 'warn' | 'total') => {
+  queryParams.pageNo = 1
+
+  if (type === 'expired') {
+    queryParams.expired = true
+    queryParams.alertWarn = undefined
+    queryParams.type = undefined
+    getList()
+    return
+  }
+
+  if (type === 'warn') {
+    queryParams.alertWarn = true
+    queryParams.expired = undefined
+    queryParams.type = undefined
+    getList()
+    return
+  }
+
+  queryParams.type = undefined
+  queryParams.expired = undefined
+  queryParams.alertWarn = undefined
+  getList()
+}
+
 const selectedDept = ref<{ id: number; name: string }>()
 
 const handleDeptNodeClick = async (row) => {
   selectedDept.value = { id: row.id, name: row.name }
   queryParams.deptId = row.id
   await getList()
+  await getStatic()
 }
 
 const formatDateCorrectly = (timestamp) => {
@@ -422,9 +494,28 @@ const handleDownload = async (url) => {
     console.error('下载失败:', error)
   }
 }
+
+let totalCert = ref(0)
+let expired = ref(0)
+let warn = ref(0)
+
+async function getStatic() {
+  if (queryParams.deptId) {
+    const res = await InspectDeviceCertApi.getInspectDeviceCertStat(queryParams.deptId)
+    totalCert.value = res.total
+    expired.value = res.expired
+    warn.value = res.warn
+  } else {
+    const res = await InspectDeviceCertApi.getInspectDeviceCertStat(userStore.user.deptId)
+    totalCert.value = res.total
+    expired.value = res.expired
+    warn.value = res.warn
+  }
+}
 /** 初始化 **/
 onMounted(() => {
   getList()
+  getStatic()
 })
 </script>
 
@@ -438,6 +529,79 @@ onMounted(() => {
   color: var(--el-text-color-primary);
 }
 
+.stats-cards {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 12px;
+  margin-bottom: 5px;
+}
+
+.stats-card {
+  padding: 14px 16px;
+  background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
+  border: 1px solid #e4ecf7;
+  border-radius: 10px;
+  box-shadow: 0 4px 12px rgb(31 91 184 / 8%);
+}
+
+.stats-card--clickable {
+  cursor: pointer;
+  transition:
+    transform 0.18s ease,
+    box-shadow 0.18s ease,
+    border-color 0.18s ease;
+}
+
+.stats-card--clickable:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 10px 24px rgb(31 91 184 / 14%);
+}
+
+.stats-card__label {
+  font-size: 14px;
+  font-weight: 600;
+  color: #6b7280;
+  line-height: 1.4;
+}
+
+.stats-card__value {
+  margin-top: 8px;
+  font-size: 28px;
+  font-weight: 700;
+  line-height: 1;
+  color: #1f5bb8;
+}
+
+.stats-card--expired {
+  background: linear-gradient(180deg, #fff4f4 0%, #ffe8e8 100%);
+  border-color: #ffcfcf;
+}
+
+.stats-card--expired .stats-card__value {
+  color: #de3b3b;
+}
+
+.stats-card--warn {
+  background: linear-gradient(180deg, #fff8ef 0%, #ffeed9 100%);
+  border-color: #ffd7a1;
+}
+
+.stats-card--warn .stats-card__value {
+  color: #d97706;
+}
+
+.stats-card--total .stats-card__value {
+  color: #2563eb;
+}
+
+.stats-card--personal .stats-card__value {
+  color: #16a34a;
+}
+
+.stats-card--organization .stats-card__value {
+  color: #7c3aed;
+}
+
 /* 过期行的红色背景 - 基础状态 */
 :deep(.el-table__body tr.expired-row > td.el-table__cell) {
   background-color: #ffe6e6 !important;

+ 175 - 64
src/views/pms/qhse/kanban/index.vue

@@ -17,7 +17,13 @@ import {
   Search,
   Refresh
 } from '@element-plus/icons-vue'
-import { kanbanApi, IotDangerApi, IotInstrumentApi, IotMeasureCertApi } from '@/api/pms/qhse/index'
+import {
+  kanbanApi,
+  IotDangerApi,
+  IotInstrumentApi,
+  IotMeasureCertApi,
+  IotCertificateApi
+} from '@/api/pms/qhse/index'
 import { useUserStore } from '@/store/modules/user'
 import {
   ANIMATION,
@@ -871,6 +877,26 @@ function destroySocChart() {
   socChart = null
 }
 
+const tableRowStyle = ({ row }) => {
+  if (row.expired) {
+    return { backgroundColor: '#ffe6e6' }
+  }
+  if (row.alertWarn) {
+    return { backgroundColor: '#e19f1a' }
+  }
+  return {}
+}
+
+const tableRowClassName = ({ row }) => {
+  if (row.expired) {
+    return 'expired-row'
+  }
+  if (row.alertWarn) {
+    return 'alert-warn-row'
+  }
+  return ''
+}
+
 function formatPercent(numerator: number, denominator: number) {
   if (!denominator || Number.isNaN(denominator)) {
     return '0%'
@@ -915,9 +941,7 @@ const certTotal = ref(0)
 const certQueryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  type: undefined as string | undefined,
-  classify: undefined as string | undefined,
-  userName: '',
+
   expired: undefined as string | undefined,
   alertWarn: undefined as string | undefined
 })
@@ -931,22 +955,59 @@ function formatDateCorrectly(timestamp: any) {
   return formatDate(time).substring(0, 10)
 }
 
-function getCertificateTypeText(type: string) {
-  const map: Record<string, string> = {
-    personal: '个人证书',
-    organization: '组织证书',
-    other: '其他'
+let dialogFileView = ref(false)
+let fileList = ref([])
+const viewFile = (file) => {
+  fileList.value = file.split(',')
+  dialogFileView.value = true
+  // window.open(file)
+}
+
+const viewFileInfo = (file) => {
+  window.open(
+    'http://doc.deepoil.cc:8012/onlinePreview?url=' + encodeURIComponent(Base64.encode(file))
+  )
+}
+
+const extractFileName = (url: string): string => {
+  try {
+    // 移除查询参数和哈希
+    const cleanUrl = url.split('?')[0].split('#')[0]
+    // 获取最后一个斜杠后的内容
+    const parts = cleanUrl.split('/')
+    const fileName = parts[parts.length - 1]
+    // URL 解码
+    return decodeURIComponent(fileName) || url
+  } catch {
+    // 如果解析失败,返回原始 URL
+    return url
+  }
+}
+
+const handleDownload = async (url) => {
+  try {
+    const response = await fetch(url)
+    const blob = await response.blob()
+    const downloadUrl = window.URL.createObjectURL(blob)
+
+    const link = document.createElement('a')
+    link.href = downloadUrl
+    link.download = url.split('/').pop() // 自动获取文件名‌:ml-citation{ref="3" data="citationList"}
+    link.click()
+
+    URL.revokeObjectURL(downloadUrl)
+  } catch (error) {
+    console.error('下载失败:', error)
   }
-  return map[type] || type
 }
 
 async function getCertificateBoardList() {
   certLoading.value = true
   try {
-    const data = await IotMeasureCertApi.getIotMeasureCertPage({
-      ...certQueryParams,
-      deptId: userStore.user.deptId,
-      type: 'organization'
+    const data = await IotCertificateApi.getCertificateList({
+      ...certQueryParams
+      // deptId: userStore.user.deptId
+      // type: 'organization'
     })
     certTableData.value = data.list || []
     certTotal.value = data.total || 0
@@ -963,9 +1024,7 @@ function handleCertificateQuery() {
 function resetCertificateQuery() {
   certQueryParams.pageNo = 1
   certQueryParams.pageSize = 10
-  certQueryParams.type = undefined
-  certQueryParams.classify = undefined
-  certQueryParams.userName = ''
+
   certQueryParams.expired = undefined
   certQueryParams.alertWarn = undefined
   getCertificateBoardList()
@@ -1252,31 +1311,6 @@ onUnmounted(() => {
         <div v-else class="certificate-board">
           <section class="panel certificate-filter-panel">
             <div class="certificate-filters">
-              <div class="certificate-filter-item">
-                <span class="certificate-filter-item__label">证书类别</span>
-                <el-select
-                  v-model="certQueryParams.classify"
-                  clearable
-                  placeholder="证书类别"
-                  class="certificate-filter-item__control">
-                  <el-option
-                    v-for="dict in getStrDictOptions(DICT_TYPE.PERSON_CERT).concat(
-                      getStrDictOptions(DICT_TYPE.ORG_CERT)
-                    )"
-                    :key="dict.value"
-                    :label="dict.label"
-                    :value="dict.value" />
-                </el-select>
-              </div>
-
-              <div class="certificate-filter-item">
-                <span class="certificate-filter-item__label">所属人</span>
-                <el-input
-                  v-model="certQueryParams.userName"
-                  placeholder="输入所属人"
-                  class="certificate-filter-item__control" />
-              </div>
-
               <div class="certificate-filter-item">
                 <span class="certificate-filter-item__label">是否过期</span>
                 <el-select
@@ -1320,6 +1354,8 @@ onUnmounted(() => {
             <el-table
               :loading="certLoading"
               :data="certTableData"
+              :row-style="tableRowStyle"
+              :row-class-name="tableRowClassName"
               height="100vh"
               :header-cell-style="{
                 height: '42px'
@@ -1331,41 +1367,40 @@ onUnmounted(() => {
                 </template>
               </el-table-column>
 
-              <el-table-column label="证书类型" align="center" min-width="120">
+              <el-table-column label="证书类别" align="center" width="150" prop="classify">
                 <template #default="scope">
-                  {{ getCertificateTypeText(scope.row.type) }}
+                  <dict-tag :type="DICT_TYPE.ORG_CERT" :value="scope.row.classify" />
                 </template>
               </el-table-column>
-
-              <el-table-column label="证书类别" align="center" min-width="140">
-                <template #default="scope">
-                  <dict-tag
-                    v-if="scope.row.type === 'organization'"
-                    :type="DICT_TYPE.ORG_CERT"
-                    :value="scope.row.classify" />
-                  <dict-tag v-else :type="DICT_TYPE.PERSON_CERT" :value="scope.row.classify" />
-                </template>
-              </el-table-column>
-
-              <el-table-column label="证书名称" prop="certName" align="center" min-width="160" />
-              <el-table-column label="所属人" prop="userName" align="center" min-width="100" />
-              <el-table-column label="所在部门" prop="deptName" align="center" min-width="140" />
-              <el-table-column label="颁发机构" prop="certOrg" align="center" min-width="220" />
               <el-table-column
-                label="证书标准"
-                prop="certStandard"
+                label="证书名称"
                 align="center"
-                min-width="140" />
-              <el-table-column label="颁发时间" align="center" min-width="120">
+                prop="certName"
+                show-overflow-tooltip />
+
+              <el-table-column label="所在部门" align="center" prop="deptName" />
+
+              <el-table-column label="颁发机构" align="center" prop="certOrg" />
+
+              <el-table-column label="证书标准" align="center" prop="certStandard" />
+
+              <el-table-column label="颁发时间" align="center" prop="certIssue">
                 <template #default="scope">
                   {{ formatDateCorrectly(scope.row.certIssue) }}
                 </template>
               </el-table-column>
-              <el-table-column label="有效期" align="center" min-width="120">
+
+              <el-table-column label="有效期" align="center">
                 <template #default="scope">
                   {{ formatDateCorrectly(scope.row.certExpire) }}
                 </template>
               </el-table-column>
+
+              <el-table-column label="到期提醒" align="center">
+                <template #default="scope"> {{ scope.row.noticeBefore }}天前提醒 </template>
+              </el-table-column>
+
+              <el-table-column label="备注" align="center" prop="remark" />
             </el-table>
 
             <div class="certificate-pagination">
@@ -1375,11 +1410,49 @@ onUnmounted(() => {
                 v-model:limit="certQueryParams.pageSize"
                 @pagination="getCertificateBoardList" />
             </div>
+
+            <div class="shadow rounded-lg p-3 min-w-0 flex gap-4 justify-between">
+              <div
+                class="bg-[#fef0f0] text-[#f57979] px-3 flex-1 flex items-center justify-center text-center py-2 text-sm rounded-md"
+                ><el-icon class="stats-card__icon" :size="28">
+                  <Icon icon="ep:info-filled" /> </el-icon
+                >证书已过期红色预警</div
+              >
+              <div
+                class="bg-[#fdf6ec] flex-1 text-[#e6a23c] text-center flex items-center justify-center px-3 py-2 text-sm rounded-md"
+                ><el-icon class="stats-card__icon" :size="28">
+                  <Icon icon="ep:bell-filled" /> </el-icon
+                >证书60天橙色预警</div
+              >
+            </div>
           </section>
         </div>
       </div>
     </div>
   </div>
+
+  <Dialog v-model="dialogFileView" title="附件" width="500">
+    <div
+      v-for="(file, index) in fileList"
+      :key="index"
+      class="flex items-center justify-between mt-5">
+      <span class="file-name-text">{{ extractFileName(file) }}</span>
+      <div>
+        <el-button link type="primary" @click="viewFileInfo(file)">
+          <Icon icon="ep:view" class="mr-2px" />查看</el-button
+        >
+        <el-button link type="primary" @click="handleDownload(file)">
+          <Icon icon="ep:download" class="mr-2px" />下载</el-button
+        >
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer mt-10">
+        <el-button type="primary" @click="dialogFileView = false"> 确认 </el-button>
+      </div>
+    </template>
+  </Dialog>
 </template>
 
 <style lang="scss" scoped>
@@ -1922,4 +1995,42 @@ onUnmounted(() => {
     line-height: 14px !important;
   }
 }
+
+/* 过期行的红色背景 - 基础状态 */
+:deep(.el-table__body tr.expired-row > td.el-table__cell) {
+  background-color: #ffe6e6 !important;
+}
+
+/* 过期行 - 鼠标悬浮状态 */
+:deep(.el-table__body tr.expired-row:hover > td.el-table__cell) {
+  background-color: #ffcccc !important;
+}
+
+/* 确保斑马纹不影响过期行 */
+:deep(.el-table__body tr.expired-row.el-table__row--striped > td.el-table__cell) {
+  background-color: #ffe6e6 !important;
+}
+
+:deep(.el-table__body tr.expired-row.el-table__row--striped:hover > td.el-table__cell) {
+  background-color: #ffcccc !important;
+}
+
+/* 预警行的橙色背景 - 基础状态 */
+:deep(.el-table__body tr.alert-warn-row > td.el-table__cell) {
+  background-color: #fff1df !important;
+}
+
+/* 预警行 - 鼠标悬浮状态 */
+:deep(.el-table__body tr.alert-warn-row:hover > td.el-table__cell) {
+  background-color: #ffe2bf !important;
+}
+
+/* 确保斑马纹不影响预警行 */
+:deep(.el-table__body tr.alert-warn-row.el-table__row--striped > td.el-table__cell) {
+  background-color: #fff1df !important;
+}
+
+:deep(.el-table__body tr.alert-warn-row.el-table__row--striped:hover > td.el-table__cell) {
+  background-color: #ffe2bf !important;
+}
 </style>

+ 22 - 9
src/views/pms/qhse/ptw/index.vue

@@ -360,7 +360,7 @@
           <template #default="scope">
             <el-radio
               :model-value="selectedPerson"
-              :label="scope.row.userName"
+              :label="scope.row.id"
               @change="handleRadioChange(scope.row)">
               &nbsp;
             </el-radio>
@@ -463,7 +463,7 @@
           <template #default="scope">
             <el-radio
               :model-value="selectedJSA"
-              :label="scope.row.jsaNo"
+              :label="getJsaSelectionKey(scope.row)"
               @change="handleRadioChange2(scope.row)">
               &nbsp;
             </el-radio>
@@ -861,7 +861,8 @@ onMounted(async () => {
 
 let personDialogVisible = ref(false)
 let certList = ref([])
-const selectedPerson = ref<string | undefined>(undefined)
+const selectedPerson = ref<number | undefined>(undefined)
+const selectedPersonRow = ref<any>()
 
 const getCertificateTypeText = (type: string) => {
   const map: Record<string, string> = {
@@ -907,20 +908,21 @@ const confirmSelectMeasure = () => {
 
   // 将选中的仪器信息填入表单
   if (personType.value == 'workPerson') {
-    formData.value.workPerson = selectedPerson.value
+    formData.value.workPerson = selectedPersonRow.value?.userName || ''
   }
 
   if (personType.value == 'guardian') {
-    formData.value.guardian = selectedPerson.value
+    formData.value.guardian = selectedPersonRow.value?.userName || ''
   }
 
   if (personType.value == 'workDuty') {
-    formData.value.workDuty = selectedPerson.value
+    formData.value.workDuty = selectedPersonRow.value?.userName || ''
   }
 
   // 关闭选择仪器对话框
   personDialogVisible.value = false
   selectedPerson.value = undefined
+  selectedPersonRow.value = undefined
 }
 
 let personDialog = ref('')
@@ -933,12 +935,15 @@ const selectworkPerson = (type) => {
   } else if (type == 'workDuty') {
     personDialog.value = '选择作业负责人'
   }
+  selectedPerson.value = undefined
+  selectedPersonRow.value = undefined
   personDialogVisible.value = true
   getCertList()
 }
 
 const handleRadioChange = (row: any) => {
-  selectedPerson.value = row.userName
+  selectedPerson.value = row.id
+  selectedPersonRow.value = row
   // selectedMeasure.value = row
 }
 
@@ -972,6 +977,9 @@ const getJSAList = async () => {
 // 选择JSA序号
 const selectJSA = () => {
   dialogJSA.value = true
+  selectedJSA.value = undefined
+  selectedJSARow.value = undefined
+  jsaId.value = 0
   getJSAList()
 }
 
@@ -989,6 +997,8 @@ const resetQuery3 = () => {
 }
 
 let selectedJSA = ref<string | undefined>(undefined)
+let selectedJSARow = ref<any>()
+const getJsaSelectionKey = (row: any) => `${row?.jsaXh || ''}__${row?.jsaNo || ''}`
 const confirmSelectJSA = () => {
   if (!selectedJSA.value) {
     ElMessage.warning('请先选择一个JSA')
@@ -996,17 +1006,20 @@ const confirmSelectJSA = () => {
   }
 
   // 将选中的仪器信息填入表单
-  formData.value.ptwNo = selectedJSA.value
+  formData.value.ptwNo = selectedJSARow.value?.jsaNo || ''
   formData.value.jsaId = jsaId.value
 
   // 关闭选择仪器对话框
   dialogJSA.value = false
   selectedJSA.value = undefined
+  selectedJSARow.value = undefined
+  jsaId.value = 0
 }
 let jsaId = ref(0)
 const handleRadioChange2 = (row: any) => {
   console.log('row', row)
-  selectedJSA.value = row.jsaNo
+  selectedJSA.value = getJsaSelectionKey(row)
+  selectedJSARow.value = row
   jsaId.value = row.id
 }
 </script>

+ 23 - 1
src/views/pms/stat/rdkb/rd-device-category.vue

@@ -7,14 +7,17 @@ interface DeviceCategoryItem {
   name: string
   count: number
   type: string
+  code?: string | number
 }
 
 interface DeviceCategoryResponseItem {
   className?: string
   count?: number | string
   type?: string
+  code?: string | number
 }
 
+const { push } = useRouter()
 const chartRef = ref<HTMLDivElement>()
 const loading = ref(false)
 const categoryData = ref<DeviceCategoryItem[]>([])
@@ -150,11 +153,13 @@ function getChartOption(): echarts.EChartsOption {
         type: 'bar',
         data: categoryData.value.map((item) => ({
           value: item.count,
+          code: item.code,
           itemStyle: {
             color: item.type === 'top' ? TOP_BAR_COLOR : DEFAULT_BAR_COLOR
           }
         })),
         barWidth: layout.barWidth,
+        cursor: 'pointer',
         showBackground: true,
         backgroundStyle: {
           color: 'rgba(31, 91, 184, 0.08)',
@@ -193,9 +198,25 @@ function initChart() {
   chart = echarts.init(chartRef.value, undefined, {
     renderer: CHART_RENDERER
   })
+  bindChartEvents()
   renderChart()
 }
 
+function bindChartEvents() {
+  chart?.off('click')
+  chart?.on('click', (params) => {
+    const row = categoryData.value[params.dataIndex ?? -1]
+    if (!row?.code) return
+
+    push({
+      name: 'IotDevicePms',
+      query: {
+        code: String(row.code)
+      }
+    })
+  })
+}
+
 function renderChart() {
   chart?.setOption(getChartOption(), true)
 }
@@ -220,7 +241,8 @@ async function getDeviceCategory() {
           res.map((item: DeviceCategoryResponseItem) => ({
             name: item.className || '未知',
             count: Number(item.count ?? 0),
-            type: item.type || ''
+            type: item.type || '',
+            code: item.code
           }))
         ).slice(0, 10)
       : []