yanghao il y a 3 jours
Parent
commit
02c4b4319b

+ 1 - 1
.env.local

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

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

@@ -53,6 +53,34 @@ export const IotMeasureCertApi = {
   }
 }
 
+// 资质证书
+export const IotCertificateApi = {
+  // 获得证书分页
+  getCertificateList: async (params) => {
+    return await request.get({ url: `/rq/qhse-org-cert/page`, params })
+  },
+  // 删除证书
+  deleteCertificate: async (id) => {
+    return await request.delete({ url: `/rq/qhse-org-cert/delete?id=` + id })
+  },
+  // 新增证书
+  createCertificate: async (data) => {
+    return await request.post({ url: `/rq/qhse-org-cert/create`, data })
+  },
+  // 修改证书
+  updateCertificate: async (data) => {
+    return await request.put({ url: `/rq/qhse-org-cert/update`, data })
+  },
+  // 导出证书 Excel
+  exportCertificate: async (params) => {
+    return await request.download({ url: `/rq/qhse-org-cert/export-excel`, params })
+  },
+  //统计
+  getCertificateStatistics: async (id) => {
+    return await request.get({ url: `/rq/qhse-org-cert/stat?deptId=${id}` })
+  }
+}
+
 // 计量器具台账 VO
 export const IotInstrumentApi = {
   // 获得计量器具台账分页

+ 7 - 20
src/views/pms/qhse/certificate.vue

@@ -34,7 +34,7 @@
         @click="handleStatCardClick('warn')">
         <div class="flex items-center gap-2">
           <Icon icon="ep:bell-filled" color="#d97706" />
-          <div class="stats-card__label">60天预警</div>
+          <div class="stats-card__label">60天预警(含过期)</div>
         </div>
         <div class="stats-card__value">
           <CountTo
@@ -88,9 +88,9 @@
       ref="queryFormRef"
       :inline="true">
       <el-form-item label="证书类型" prop="type">
-        <el-select v-model="queryParams.type" placeholder="请选择证书类型" style="width: 120px">
+        <el-select v-model="queryParams.type" placeholder="请选择证书类型" style="width: 150px">
           <el-option label="个人证书" value="personal" />
-          <el-option label="组织证书" value="organization" />
+          <!-- <el-option label="组织证书" value="organization" /> -->
           <el-option label="其他" value="other" />
         </el-select>
       </el-form-item>
@@ -98,9 +98,7 @@
       <el-form-item label="证书类别" prop="classify">
         <el-select v-model="queryParams.classify" placeholder="证书类别" clearable class="!w-150px">
           <el-option
-            v-for="dict in getStrDictOptions(DICT_TYPE.PERSON_CERT).concat(
-              getStrDictOptions(DICT_TYPE.ORG_CERT)
-            )"
+            v-for="dict in getStrDictOptions(DICT_TYPE.PERSON_CERT)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value" />
@@ -292,31 +290,20 @@
           placeholder="请选择证书类型"
           @change="formData.classify = ''">
           <el-option label="个人证书" value="personal" />
-          <el-option label="组织证书" value="organization" />
+
           <el-option label="其他" value="other" />
         </el-select>
       </el-form-item>
 
-      <span class="absolute left-16 text-red" v-if="formData.type !== 'other'">*</span>
+      <span class="absolute left-8 text-red" v-if="formData.type !== 'other'">*</span>
       <el-form-item label="证书类别" prop="classify" v-show="formData.type !== 'other'">
-        <el-select
-          v-if="formData.type === 'personal'"
-          v-model="formData.classify"
-          placeholder="证书类别"
-          clearable>
+        <el-select v-model="formData.classify" placeholder="证书类别" clearable>
           <el-option
             v-for="dict in getStrDictOptions(DICT_TYPE.PERSON_CERT)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value" />
         </el-select>
-        <el-select v-else v-model="formData.classify" placeholder="证书类别" clearable>
-          <el-option
-            v-for="dict in getStrDictOptions(DICT_TYPE.ORG_CERT)"
-            :key="dict.value"
-            :label="dict.label"
-            :value="dict.value" />
-        </el-select>
       </el-form-item>
 
       <el-form-item label="证书名称" prop="certName">

+ 906 - 0
src/views/pms/qhse/credential/index.vue

@@ -0,0 +1,906 @@
+<template>
+  <div
+    class="qhse-page grid grid-cols-[auto_1fr] grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3 gap-x-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    <DeptTreeSelect
+      class="row-span-4"
+      :top-id="rootDeptId"
+      :deptId="deptId"
+      v-model="queryParams.deptId"
+      :init-select="false"
+      :show-title="false"
+      request-api="getSimpleDeptList"
+      @node-click="handleDeptNodeClick" />
+
+    <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">60天预警</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>
+      </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>
+
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 pt-4 flex items-center flex-wrap min-w-0"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true">
+      <el-form-item label="证书类别" prop="classify">
+        <el-select v-model="queryParams.classify" placeholder="证书类别" clearable class="!w-150px">
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.ORG_CERT)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value" />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="是否过期" prop="expired">
+        <el-select
+          v-model="queryParams.expired"
+          placeholder="请选择是否过期"
+          clearable
+          style="width: 150px">
+          <el-option
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value" />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="是否预警" prop="alertWarn">
+        <el-select
+          v-model="queryParams.alertWarn"
+          placeholder="请选择是否预警"
+          clearable
+          style="width: 150px">
+          <el-option
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value" />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item>
+        <el-button @click="handleAdd" type="primary"
+          ><Icon icon="ep:plus" class="mr-5px" />新增</el-button
+        >
+        <el-button @click="handleQuery"
+          ><Icon icon="ep:search" class="mr-5px" /> {{ t('devicePerson.search') }}</el-button
+        >
+        <el-button @click="resetQuery"
+          ><Icon icon="ep:refresh" class="mr-5px" /> {{ t('devicePerson.reset') }}</el-button
+        >
+        <el-button @click="handleExport" type="success" plain :loading="exportLoading"
+          ><Icon icon="ep:download" class="mr-5px" /> 导出</el-button
+        >
+      </el-form-item>
+    </el-form>
+
+    <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-2 pt-3 min-w-0">
+      <div class="flex-1 relative min-h-0">
+        <el-auto-resizer class="absolute">
+          <template #default="{ width, height }">
+            <zm-table
+              :loading="loading"
+              :data="list"
+              :width="width"
+              :height="height"
+              :show-overflow-tooltip="true"
+              :row-style="tableRowStyle"
+              :row-class-name="tableRowClassName">
+              >
+              <zm-table-column :label="t('monitor.serial')" width="70" align="center">
+                <template #default="scope">
+                  {{ scope.$index + 1 }}
+                </template>
+              </zm-table-column>
+
+              <!-- <zm-table-column label="证书类型" align="center" prop="type">
+                <template #default="scope">
+                  {{ getCertificateTypeText(scope.row.type) }}
+                </template>
+              </zm-table-column> -->
+
+              <zm-table-column label="证书类别" align="center" width="150" prop="classify">
+                <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>
+              </zm-table-column>
+              <zm-table-column
+                label="证书名称"
+                align="center"
+                prop="certName"
+                show-overflow-tooltip />
+
+              <zm-table-column label="所在部门" align="center" prop="deptName" />
+
+              <zm-table-column label="颁发机构" align="center" prop="certOrg" />
+
+              <zm-table-column label="证书标准" align="center" prop="certStandard" />
+
+              <zm-table-column label="颁发时间" align="center" prop="certIssue">
+                <template #default="scope">
+                  {{ formatDateCorrectly(scope.row.certIssue) }}
+                </template>
+              </zm-table-column>
+
+              <zm-table-column label="有效期" align="center">
+                <template #default="scope">
+                  {{ formatDateCorrectly(scope.row.certExpire) }}
+                </template>
+              </zm-table-column>
+
+              <zm-table-column label="到期提醒" align="center">
+                <template #default="scope"> {{ scope.row.noticeBefore }}天前提醒 </template>
+              </zm-table-column>
+
+              <zm-table-column label="备注" align="center" prop="remark" />
+              <zm-table-column
+                :label="t('devicePerson.operation')"
+                align="center"
+                fixed="right"
+                min-width="180px"
+                action>
+                <template #default="scope">
+                  <el-button link type="primary" @click="handleEdit(scope.row)"> 编辑 </el-button>
+                  <el-button link type="danger" @click="handleDelete(scope.row.id)">
+                    删除
+                  </el-button>
+                  <el-button
+                    link
+                    type="success"
+                    v-if="scope.row.certPic"
+                    @click="viewFile(scope.row.certPic)">
+                    查看证书
+                  </el-button>
+                </template>
+              </zm-table-column>
+            </zm-table>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="h-8 mt-2 flex items-center justify-end">
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList" />
+      </div>
+    </div>
+
+    <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg p-3 min-w-0">
+      <el-alert title="证书已过期红色预警" type="error" show-icon :closable="false">
+        <template #icon>
+          <Bell />
+        </template>
+      </el-alert>
+
+      <el-alert
+        title="证书60天橙色预警"
+        type="warning"
+        show-icon
+        :closable="false"
+        style="margin-top: 5px">
+        <template #icon>
+          <Bell />
+        </template>
+      </el-alert>
+    </div>
+  </div>
+
+  <!-- 新增/编辑证书对话框 -->
+  <Dialog
+    :title="dialogTitle"
+    v-model="dialogVisible"
+    width="600px"
+    destroy-on-close
+    @close="closeDialog">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="auto"
+      v-loading="formLoading">
+      <el-form-item label="证书类别" prop="classify">
+        <el-select v-model="formData.classify" placeholder="证书类别" clearable>
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.ORG_CERT)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value" />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="证书名称" prop="certName">
+        <el-input v-model="formData.certName" placeholder="请输入证书名称" />
+      </el-form-item>
+
+      <el-form-item label="所在部门" prop="deptId">
+        <el-tree-select
+          clearable
+          v-model="formData.deptId"
+          :data="deptList2"
+          :props="defaultProps"
+          check-strictly
+          node-key="id"
+          filterable
+          placeholder="请选择所在部门"
+          @change="handleDeptChange" />
+      </el-form-item>
+
+      <el-form-item label="颁发机构" prop="certOrg">
+        <el-input v-model="formData.certOrg" placeholder="请输入颁发机构" />
+      </el-form-item>
+
+      <el-form-item label="证书标准" prop="certStandard">
+        <el-input v-model="formData.certStandard" placeholder="如国标、API等" />
+      </el-form-item>
+
+      <el-form-item label="颁发时间" prop="certIssue">
+        <el-date-picker
+          v-model="formData.certIssue"
+          type="date"
+          value-format="x"
+          placeholder="请选择颁发时间"
+          style="width: 100%" />
+      </el-form-item>
+
+      <el-form-item label="有效期" prop="certExpire">
+        <el-date-picker
+          v-model="formData.certExpire"
+          type="date"
+          value-format="x"
+          placeholder="请选择有效期"
+          style="width: 100%" />
+      </el-form-item>
+
+      <el-form-item label="到期前提醒" prop="noticeBefore">
+        <el-input-number
+          v-model="formData.noticeBefore"
+          :min="0"
+          :max="365"
+          placeholder="请输入提前多少天提醒"
+          style="width: 100%" />
+      </el-form-item>
+
+      <el-form-item label="备注" prop="remark">
+        <el-input
+          type="textarea"
+          v-model="formData.remark"
+          :rows="2"
+          placeholder="请输入备注"
+          style="width: 100%" />
+      </el-form-item>
+
+      <el-form-item label="证书附件" prop="certPic">
+        <!-- <UploadImage v-model="formData.certPic" /> -->
+        <UploadFile
+          v-model="formData.certPic"
+          :file-type="['pdf', 'jpg', 'png', 'jpeg', 'bmp']"
+          :file-size="100"
+          class="min-w-80px" />
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="closeDialog">取 消</el-button>
+      <el-button type="primary" @click="submitForm" :loading="submitLoading">确 定</el-button>
+    </template>
+  </Dialog>
+
+  <Dialog v-model="dialogFileView" title="证书附件">
+    <div
+      v-for="(file, index) in fileList"
+      :key="index"
+      class="flex items-center justify-between mt-5">
+      <span class="file-name-text text-ellipsis!">{{ 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>
+
+<script setup lang="ts">
+import { IotCertificateApi } from '@/api/pms/qhse/index'
+import { handleTree } from '@/utils/tree'
+import * as DeptApi from '@/api/system/dept'
+import { ElMessageBox, ElMessage } from 'element-plus'
+const deptList = ref<Tree[]>([]) // 树形结构
+const deptList2 = ref<Tree[]>([]) // 树形结构
+import { formatDate } from '@/utils/formatTime'
+import { DICT_TYPE, getStrDictOptions, getBoolDictOptions } from '@/utils/dict'
+import { defaultProps } from '@/utils/tree'
+import { selectedDeptsEmployee } from '@/api/system/user'
+import { useUserStore } from '@/store/modules/user'
+const userStore = useUserStore()
+
+defineOptions({ name: 'IotQHSECredential' })
+
+const rootDeptId = 156
+const deptId = useUserStore().getUser.deptId || rootDeptId
+
+const loading = ref(true) // 列表的加载中
+const formLoading = ref(false) // 表单加载中
+const submitLoading = ref(false) // 提交按钮加载中
+const exportLoading = ref(false) // 导出按钮加载中
+
+const { t } = useI18n()
+
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+
+  classify: undefined,
+  deptId: '',
+  expired: undefined,
+  alertWarn: undefined
+})
+const queryFormRef = ref(null) // 搜索的表单
+
+// 对话框相关
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const isEdit = ref(false)
+
+// 图片查看对话框
+const dialogFileView = ref(false)
+
+// 表单相关
+const formRef = ref()
+const formData = ref({
+  classify: '', // 证书类别
+  userName: '',
+  certName: '',
+  certOrg: '', // 证书颁发机构
+  certStandard: '', // 证书标准
+  certIssue: '', // 证书颁发时间
+  certExpire: '', // 证书有效期
+  noticeBefore: '', // 到期前提醒
+  certPic: '', // 证书图片上传
+  remark: '', // 备注
+  deptId: '' // 部门id
+})
+
+// 正确格式化日期的函数
+const formatDateCorrectly = (timestamp) => {
+  if (!timestamp) return ''
+
+  // 如果是秒级时间戳,转换为毫秒级
+  let time = Number(timestamp)
+  if (time < 10000000000) {
+    // 小于这个数通常表示秒级时间戳
+    time = time * 1000
+  }
+
+  return formatDate(time).substring(0, 10)
+}
+
+// 表单验证规则
+const formRules = {
+  classify: [{ required: true, message: '证书类别不能为空', trigger: 'blur' }],
+  deptId: [{ required: true, message: '所在部门不能为空', trigger: 'blur' }],
+
+  certOrg: [{ required: true, message: '颁发机构不能为空', trigger: 'blur' }],
+  certIssue: [{ required: true, message: '颁发时间不能为空', trigger: 'blur' }],
+  certExpire: [{ required: true, message: '有效期不能为空', trigger: 'blur' }],
+  certPic: [{ required: true, message: '证书图片不能为空', trigger: 'blur' }]
+}
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await IotCertificateApi.getCertificateList(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleExport = async () => {
+  try {
+    exportLoading.value = true
+    const response = await IotCertificateApi.exportCertificate(queryParams)
+    downloadFile(response)
+    exportLoading.value = false
+  } catch (error) {
+    ElMessage.error('导出失败,请重试')
+    console.error('导出错误:', error)
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 首页处理部门被点击 */
+const handleDeptNodeClick = async (row) => {
+  queryParams.deptId = row.id
+  await getList()
+  await getStatic()
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const handleStatCardClick = (type: 'expired' | 'warn' | 'total' | 'personal') => {
+  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
+  }
+
+  if (type === 'personal') {
+    queryParams.type = 'personal'
+    queryParams.expired = undefined
+    queryParams.alertWarn = undefined
+    getList()
+    return
+  }
+
+  queryParams.type = undefined
+  queryParams.expired = undefined
+  queryParams.alertWarn = undefined
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryParams.deptId = ''
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+
+// 显示新增对话框
+const handleAdd = () => {
+  isEdit.value = false
+  dialogTitle.value = '新增证书'
+  resetForm()
+  dialogVisible.value = true
+}
+
+// 显示编辑对话框
+const handleEdit = (row) => {
+  isEdit.value = true
+  dialogTitle.value = '编辑证书'
+
+  formData.value = {
+    ...row,
+    // 确保日期字段正确处理
+    issueDate: row.issueDate ? ensureMillisecondTimestamp(row.issueDate) : null,
+    validityPeriod: row.validityPeriod ? ensureMillisecondTimestamp(row.validityPeriod) : null,
+    certPic: row.certPic.split(',')
+  }
+
+  dialogVisible.value = true
+}
+
+let fileList = ref([])
+const viewFile = (file) => {
+  fileList.value = file.split(',')
+  dialogFileView.value = true
+}
+
+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 ensureMillisecondTimestamp = (timestamp) => {
+  let time = Number(timestamp)
+  if (time < 10000000000) {
+    // 秒级时间戳转为毫秒级
+    return time * 1000
+  }
+  return time
+}
+
+//删除证书
+const handleDelete = async (id: number) => {
+  ElMessageBox.confirm('确定要删除该证书吗?', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning'
+  })
+    .then(async () => {
+      try {
+        await IotCertificateApi.deleteCertificate(id)
+        ElMessage.success('删除成功')
+        getList()
+      } catch (error) {
+        console.error(error)
+      }
+    })
+    .catch(() => {
+      // 取消操作
+    })
+}
+
+// 重置表单
+const resetForm = () => {
+  formData.value = {
+    type: '', // 证书类型
+    classify: '',
+    certOrg: '', // 证书颁发机构
+    certStandard: '', // 证书标准
+    certIssue: '', // 证书颁发时间
+    certExpire: '', // 证书有效期
+    noticeBefore: '', // 到期前提醒
+    certPic: '', // 证书图片上传
+    remark: '', // 备注
+    deptId: '', // 部门id
+    userId: ''
+  }
+  formRef.value?.clearValidate()
+}
+
+// 关闭对话框
+const closeDialog = () => {
+  dialogVisible.value = false
+  resetForm()
+}
+
+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 ''
+}
+
+// 提交表单
+const submitForm = async () => {
+  if (!formRef.value) return
+
+  try {
+    await formRef.value.validate()
+    submitLoading.value = true
+
+    console.log('提交数据:', formData.value.certPic)
+
+    let certPic: any = null
+    if (isEdit.value) {
+      certPic = formData.value.certPic ? formData.value.certPic.join(',') : ''
+    } else {
+      certPic = formData.value.certPic
+    }
+
+    // 准备提交数据
+    const submitData = {
+      ...formData.value,
+      // 确保日期字段以正确的格式提交
+      certIssue: formData.value.certIssue,
+      certExpire: formData.value.certExpire,
+      certPic // 将图片数组转换为逗号分隔的字符串
+    }
+
+    if (isEdit.value) {
+      // 编辑
+      await IotCertificateApi.updateCertificate(submitData)
+      ElMessage.success('编辑成功')
+    } else {
+      // 新增
+      await IotCertificateApi.createCertificate(submitData)
+      ElMessage.success('新增成功')
+    }
+
+    dialogVisible.value = false
+    getList()
+  } catch (error) {
+    console.error(error)
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+// 下载文件函数
+const downloadFile = (response: any) => {
+  const blob = new Blob([response], {
+    type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8'
+  })
+
+  let fileName = '证书台账.xlsx'
+  const disposition = response.headers ? response.headers['content-disposition'] : ''
+  if (disposition) {
+    const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
+    const matches = filenameRegex.exec(disposition)
+    if (matches != null && matches[1]) {
+      fileName = matches[1].replace(/['"]/g, '')
+    }
+  }
+
+  const url = window.URL.createObjectURL(blob)
+  const link = document.createElement('a')
+  link.href = url
+  link.setAttribute('download', fileName)
+
+  document.body.appendChild(link)
+  link.click()
+
+  document.body.removeChild(link)
+  window.URL.revokeObjectURL(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)
+  }
+}
+
+let userList = ref([])
+const handleDeptChange = async (value) => {
+  const res = await selectedDeptsEmployee({
+    deptIds: value
+  })
+
+  userList.value = res
+  console.log('value>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>', userList.value)
+}
+
+let totalCert = ref(0)
+let expired = ref(0)
+let warn = ref(0)
+let personal = ref(0)
+let organization = ref(0)
+async function getStatic() {
+  if (queryParams.deptId) {
+    const res = await IotCertificateApi.getCertificateStatistics(queryParams.deptId)
+    totalCert.value = res.total
+    expired.value = res.expired
+    warn.value = res.warn
+    personal.value = res.personal
+    organization.value = res.organization
+  } else {
+    const res = await IotCertificateApi.getCertificateStatistics(userStore.user.deptId)
+    totalCert.value = res.total
+    expired.value = res.expired
+    warn.value = res.warn
+    personal.value = res.personal
+    organization.value = res.organization
+  }
+}
+onMounted(async () => {
+  getList()
+  getStatic()
+  deptList.value = handleTree(await DeptApi.getSimpleDeptList())
+  deptList2.value = handleTree(await DeptApi.getSimpleDeptList())
+})
+</script>
+
+<style scoped>
+::deep(.el-tree--highlight-current) {
+  height: 200px !important;
+}
+::deep(.el-transfer-panel__body) {
+  height: 700px !important;
+}
+
+.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;
+}
+
+/* 过期行 - 鼠标悬浮状态 */
+: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>

+ 1 - 1
src/views/pms/qhse/index.vue

@@ -36,7 +36,7 @@
           <el-icon class="stats-card__icon" :size="28">
             <Icon icon="ep:bell-filled" />
           </el-icon>
-          <div class="stats-card__label">90天预警</div>
+          <div class="stats-card__label">90天预警(含过期)</div>
         </div>
         <div class="stats-card__value text-[40px]! pt-10 text-center!">
           <CountTo