Browse Source

危险源上报、人员信息和审计工作页面修改

starrymoonlight 3 weeks ago
parent
commit
b13d5954e9

+ 15 - 0
src/api/riskctrl/dictData/index.ts

@@ -0,0 +1,15 @@
+import { DictOptionVO } from '@/api/riskctrl/dictData/types';
+import { getDicts } from '@/api/system/dict/data';
+
+/**
+ * 根据字典类型查询选项
+ */
+export const getDictOptions = async (dictType: string): Promise<DictOptionVO[]> => {
+  const res: any = await getDicts(dictType);
+  const list = res?.data || res || [];
+
+  return list.map((item: any) => ({
+    label: item.dictLabel,
+    value: item.dictLabel
+  }));
+};

+ 4 - 0
src/api/riskctrl/dictData/types.ts

@@ -0,0 +1,4 @@
+export interface DictOptionVO {
+  label: string;
+  value: string;
+}

+ 952 - 243
src/views/riskctrl/auditPersonChange/index.vue

@@ -1,140 +1,166 @@
 <template>
-  <div class="p-2">
+  <div class="audit-page">
+    <div class="page-header">
+      <div>
+        <div class="page-title">审计工作管理</div>
+        <div class="page-desc">部门重点人员及关键岗位人员离职、调任信息,审计重点工作上传</div>
+      </div>
+    </div>
+
     <!-- 顶部统计卡片 -->
-    <el-row :gutter="16" class="mb-[10px]">
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
+    <el-row :gutter="16" class="stat-row">
+      <el-col :xs="24" :sm="12" :md="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-inner">
             <div>
-              <p class="text-gray-500 text-sm">人员变动总数</p>
-              <p class="text-2xl font-bold text-blue-500">{{ statsData.totalChanges }}</p>
-              <p class="text-xs mt-1">
-                <span :class="statsData.totalTrend >= 0 ? 'text-green-500' : 'text-red-500'">
-                  {{ statsData.totalTrend >= 0 ? '↑' : '↓' }} 较上月 {{ Math.abs(statsData.totalTrend) }}
-                </span>
-              </p>
+              <div class="stat-label">人员变动总数</div>
+              <div class="stat-value">{{ statsData.totalChanges }}</div>
+              <div class="stat-sub">
+                <span :class="trendClass(statsData.totalTrend)"> {{ trendArrow(statsData.totalTrend) }} {{ trendText(statsData.totalTrend) }} </span>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-blue">
+              <el-icon><UserFilled /></el-icon>
             </div>
-            <el-icon :size="40" color="#409EFF"><UserFilled /></el-icon>
           </div>
         </el-card>
       </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
+
+      <el-col :xs="24" :sm="12" :md="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-inner">
             <div>
-              <p class="text-gray-500 text-sm">待审计离职</p>
-              <p class="text-2xl font-bold text-orange-500">{{ statsData.pendingAudit }}</p>
-              <p class="text-xs mt-1">
-                <span :class="statsData.pendingTrend >= 0 ? 'text-green-500' : 'text-red-500'">
-                  {{ statsData.pendingTrend >= 0 ? '↑' : '↓' }} 较上月 {{ Math.abs(statsData.pendingTrend) }}
+              <div class="stat-label">待审计离职</div>
+              <div class="stat-value stat-value-orange">{{ statsData.pendingAudit }}</div>
+              <div class="stat-sub">
+                <span :class="trendClass(statsData.pendingTrend)">
+                  {{ trendArrow(statsData.pendingTrend) }} {{ trendText(statsData.pendingTrend) }}
                 </span>
-              </p>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-orange">
+              <el-icon><Clock /></el-icon>
             </div>
-            <el-icon :size="40" color="#E6A23C"><Clock /></el-icon>
           </div>
         </el-card>
       </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
+
+      <el-col :xs="24" :sm="12" :md="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-inner">
             <div>
-              <p class="text-gray-500 text-sm">已完成调任</p>
-              <p class="text-2xl font-bold text-green-500">{{ statsData.completedTransfer }}</p>
-              <p class="text-xs mt-1">
-                <span :class="statsData.transferTrend >= 0 ? 'text-green-500' : 'text-red-500'">
-                  {{ statsData.transferTrend >= 0 ? '↑' : '↓' }} 较上月 {{ Math.abs(statsData.transferTrend) }}
+              <div class="stat-label">已完成调任</div>
+              <div class="stat-value stat-value-green">{{ statsData.completedTransfer }}</div>
+              <div class="stat-sub">
+                <span :class="trendClass(statsData.transferTrend)">
+                  {{ trendArrow(statsData.transferTrend) }} {{ trendText(statsData.transferTrend) }}
                 </span>
-              </p>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-green">
+              <el-icon><CircleCheckFilled /></el-icon>
             </div>
-            <el-icon :size="40" color="#67C23A"><CircleCheckFilled /></el-icon>
           </div>
         </el-card>
       </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
+
+      <el-col :xs="24" :sm="12" :md="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-inner">
             <div>
-              <p class="text-gray-500 text-sm">审计文件总数</p>
-              <p class="text-2xl font-bold text-purple-500">{{ statsData.totalFiles }}</p>
-              <p class="text-xs mt-1">
-                <span :class="statsData.fileTrend >= 0 ? 'text-green-500' : 'text-red-500'">
-                  {{ statsData.fileTrend >= 0 ? '↑' : '↓' }} 较上月 {{ Math.abs(statsData.fileTrend) }}
-                </span>
-              </p>
+              <div class="stat-label">审计文件总数</div>
+              <div class="stat-value stat-value-purple">{{ statsData.totalFiles }}</div>
+              <div class="stat-sub">
+                <span :class="trendClass(statsData.fileTrend)"> {{ trendArrow(statsData.fileTrend) }} {{ trendText(statsData.fileTrend) }} </span>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-purple">
+              <el-icon><FolderOpened /></el-icon>
             </div>
-            <el-icon :size="40" color="#8B5CF6"><FolderOpened /></el-icon>
           </div>
         </el-card>
       </el-col>
     </el-row>
 
-    <transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
-      <div v-show="showSearch" class="mb-[10px]">
-        <el-card shadow="hover">
-          <el-form ref="queryFormRef" :model="queryParams" :inline="true">
-            <el-form-item label="变动类型" prop="changeType">
-              <el-select v-model="queryParams.changeType" placeholder="类型" clearable>
-                <el-option label="调任" value="调任" />
-                <el-option label="离职" value="离职" />
-              </el-select>
-            </el-form-item>
-            <el-form-item label="部门" prop="department">
-              <el-input v-model="queryParams.department" placeholder="部门" clearable @keyup.enter="handleQuery" />
-            </el-form-item>
-            <el-form-item label="关键词" prop="employeeName">
-              <el-input v-model="queryParams.employeeName" placeholder="搜索姓名" clearable @keyup.enter="handleQuery" />
-            </el-form-item>
-            <el-form-item>
-              <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
-              <el-button icon="Refresh" @click="resetQuery">重置</el-button>
-            </el-form-item>
-          </el-form>
-        </el-card>
-      </div>
-    </transition>
+    <!-- 筛选区 -->
+    <el-card shadow="hover" class="search-card">
+      <el-form ref="queryFormRef" :model="queryParams" :inline="true" class="search-form">
+        <el-form-item label="变动类型" prop="changeType">
+          <el-select v-model="queryParams.changeType" placeholder="全部类型" clearable class="search-select">
+            <el-option v-for="dict in auditChangeTypeOptions" :key="dict.value" :label="dict.label" :value="dict.value" />
+          </el-select>
+        </el-form-item>
 
-    <el-card shadow="hover">
+        <el-form-item label="部门" prop="departmentId">
+          <el-select v-model="queryParams.departmentId" placeholder="全部部门" clearable class="search-select">
+            <el-option v-for="dept in deptOptions" :key="dept.value" :label="dept.label" :value="dept.value" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="关键词" prop="keyword">
+          <el-input v-model="queryParams.keyword" placeholder="搜索姓名/岗位" clearable class="search-keyword" @keyup.enter="handleQuery">
+            <template #prefix>
+              <el-icon><Search /></el-icon>
+            </template>
+          </el-input>
+        </el-form-item>
+
+        <div class="search-actions">
+          <el-button @click="resetQuery">重置</el-button>
+          <el-button type="primary" @click="handleQuery">搜索</el-button>
+          <el-button type="primary" class="primary-btn" @click="handleAdd">
+            <el-icon><Plus /></el-icon>
+            新增人员变动
+          </el-button>
+          <el-button type="primary" class="primary-btn" @click="uploadFileOpen = true">
+            <el-icon><Upload /></el-icon>
+            上传审计文件
+          </el-button>
+          <el-button type="primary" class="primary-btn" @click="handleExport">
+            <el-icon><Download /></el-icon>
+            导出
+          </el-button>
+        </div>
+      </el-form>
+    </el-card>
+
+    <!-- 列表 -->
+    <el-card shadow="hover" class="table-card">
       <template #header>
-        <el-row :gutter="10">
-          <el-col :span="1.5">
-            <el-button v-has-permi="['riskctrl:auditPersonChange:add']" type="primary" plain icon="Plus" @click="handleAdd()">新增人员变动</el-button>
-          </el-col>
-          <el-col :span="1.5">
-            <el-button v-has-permi="['riskctrl:auditPersonChange:upload']" type="warning" plain icon="Upload" @click="uploadFileOpen = true">上传审计文件</el-button>
-          </el-col>
-          <el-col :span="1.5">
-            <el-button v-has-permi="['riskctrl:auditPersonChange:remove']" type="danger" plain :disabled="multiple" icon="Delete" @click="handleDelete()">删除</el-button>
-          </el-col>
-          <right-toolbar v-model:show-search="showSearch" :columns="columns" :search="true" @query-table="getList"></right-toolbar>
-        </el-row>
+        <div class="card-header">
+          <span>人员变动信息列表</span>
+        </div>
       </template>
 
       <el-table v-loading="loading" border :data="changeList" @selection-change="handleSelectionChange">
         <el-table-column type="selection" width="50" align="center" />
-        <el-table-column v-if="columns[0].visible" key="employeeName" label="员工姓名" align="center" prop="employeeName" width="100" />
-        <el-table-column v-if="columns[1].visible" key="department" label="部门" align="center" prop="department" width="120" />
-        <el-table-column v-if="columns[2].visible" key="oldPosition" label="原岗位" align="center" prop="oldPosition" :show-overflow-tooltip="true" />
-        <el-table-column v-if="columns[3].visible" key="newPosition" label="变动后岗位" align="center" prop="newPosition" :show-overflow-tooltip="true" />
-        <el-table-column v-if="columns[4].visible" key="changeType" label="变动类型" align="center" prop="changeType" width="100">
+        <el-table-column label="姓名" align="center" prop="employeeName" min-width="110" />
+        <el-table-column label="部门" align="center" prop="departmentName" min-width="120" />
+        <el-table-column label="原岗位" align="center" prop="oldPosition" min-width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="变动类型" align="center" prop="changeType" width="110">
+          <template #default="scope">
+            <span class="tag-pill" :class="getChangeTypeClass(scope.row.changeType)">
+              {{ getChangeTypeLabel(scope.row.changeType) }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column label="新岗位" align="center" prop="transferPosition" min-width="140">
           <template #default="scope">
-            <el-tag :type="scope.row.changeType === '离职' ? 'danger' : 'primary'">{{ scope.row.changeType }}</el-tag>
+            <span>{{ scope.row.transferPosition || scope.row.newPosition || '-' }}</span>
           </template>
         </el-table-column>
-        <el-table-column v-if="columns[5].visible" key="changeDate" label="变动日期" align="center" prop="changeDate" width="120" />
-        <el-table-column v-if="columns[6].visible" key="auditStatus" label="审计状态" align="center" prop="auditStatus" width="100">
+        <el-table-column label="变动日期" align="center" prop="changeDate" width="120" />
+        <el-table-column label="审计状态" align="center" prop="auditStatus" width="110">
           <template #default="scope">
-            <el-tag :type="scope.row.auditStatus === '已完成' ? 'success' : 'warning'">{{ scope.row.auditStatus }}</el-tag>
+            <span class="tag-pill" :class="getAuditStatusClass(scope.row.auditStatus)">
+              {{ scope.row.auditStatus }}
+            </span>
           </template>
         </el-table-column>
-        <el-table-column v-if="columns[7].visible" label="创建时间" align="center" prop="createTime" width="160" />
-        <el-table-column label="操作" fixed="right" width="180" class-name="small-padding fixed-width">
+        <el-table-column label="操作" fixed="right" width="160" align="center">
           <template #default="scope">
-            <el-tooltip content="修改" placement="top">
-              <el-button v-has-permi="['riskctrl:auditPersonChange:edit']" link type="primary" icon="Edit" @click="handleUpdate(scope.row)"></el-button>
-            </el-tooltip>
-            <el-tooltip content="删除" placement="top">
-              <el-button v-has-permi="['riskctrl:auditPersonChange:remove']" link type="primary" icon="Delete" @click="handleDelete(scope.row)"></el-button>
-            </el-tooltip>
+            <el-button link type="primary" @click="handleView(scope.row)">查看详情</el-button>
+            <el-button v-has-permi="['riskctrl:auditPersonChange:remove']" link type="danger" @click="handleDelete(scope.row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -142,83 +168,162 @@
       <pagination v-show="total > 0" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" :total="total" @pagination="getList" />
     </el-card>
 
-    <!-- 新增/修改弹窗 -->
-    <el-dialog v-model="open" :title="title" width="700px" append-to-body>
-      <el-form ref="changeFormRef" :model="form" :rules="rules" label-width="110px">
+    <!-- 已上传审计文件 -->
+    <el-card shadow="hover" class="file-card">
+      <template #header>
+        <div class="card-header">
+          <span>已上传审计文件</span>
+        </div>
+      </template>
+
+      <div class="file-grid">
+        <div v-for="item in auditFileList" :key="item.ossId" class="file-item">
+          <div class="file-left">
+            <div class="file-icon" :class="getFileIconClass(item.fileName)">
+              <span>{{ getFileExtLabel(item.fileName) }}</span>
+            </div>
+            <div class="file-meta">
+              <div class="file-name" :title="item.fileName">{{ item.fileName }}</div>
+              <div class="file-desc">大小:{{ formatFileSize(item.fileSize) }} · 上传于:{{ formatDate(item.createTime) }}</div>
+              <div class="file-actions">
+                <el-button link type="primary" @click="download.oss(item.ossId)">下载</el-button>
+                <el-button link type="danger" @click="handleRemoveFile(item)">删除</el-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- 新增/修改/查看弹窗 -->
+    <el-dialog v-model="open" :title="dialogTitle" width="980px" append-to-body :close-on-click-modal="false" destroy-on-close class="person-dialog">
+      <el-form ref="changeFormRef" :model="form" :rules="rules" label-position="top" class="person-form">
+        <el-row :gutter="20">
+          <el-col :span="24">
+            <el-form-item label="员工选择" prop="employeeId" required>
+              <el-select
+                v-model="form.employeeId"
+                placeholder="请选择员工"
+                filterable
+                clearable
+                class="w-full"
+                :disabled="isViewMode"
+                @change="handleEmployeeChange"
+              >
+                <el-option v-for="emp in employeeOptions" :key="emp.employeeId" :label="emp.employeeName" :value="emp.employeeId" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
         <el-row :gutter="20">
           <el-col :span="12">
-            <el-form-item label="员工姓名" prop="employeeName">
-              <el-input v-model="form.employeeName" placeholder="请输入员工姓名" />
+            <el-form-item label="员工编号" prop="employeeCode">
+              <el-input v-model="form.employeeCode" placeholder="自动带出" disabled />
             </el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="所属部门" prop="department">
-              <el-input v-model="form.department" placeholder="自动带出" />
+            <el-form-item label="所属部门" prop="departmentName">
+              <el-input v-model="form.departmentName" placeholder="自动带出" disabled />
             </el-form-item>
           </el-col>
         </el-row>
+
         <el-row :gutter="20">
           <el-col :span="12">
-            <el-form-item label="变动类型" prop="changeType">
-              <el-select v-model="form.changeType" placeholder="请选择" class="w-full" @change="onChangeType">
-                <el-option label="调任" value="调任" />
-                <el-option label="离职" value="离职" />
-              </el-select>
+            <el-form-item label="原岗位" prop="oldPosition">
+              <el-input v-model="form.oldPosition" placeholder="自动带出" :disabled="isViewMode" />
             </el-form-item>
           </el-col>
+
           <el-col :span="12">
-            <el-form-item label="变动日期" prop="changeDate">
-              <el-date-picker v-model="form.changeDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" class="w-full" />
+            <el-form-item label="变动类型" prop="changeType" required>
+              <el-select v-model="form.changeType" placeholder="请选择变动类型" class="w-full" :disabled="isViewMode" @change="onChangeType">
+                <el-option v-for="dict in auditChangeTypeOptions" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
             </el-form-item>
           </el-col>
         </el-row>
-        <el-row :gutter="20">
+
+        <el-row :gutter="20" v-if="isTransferType">
           <el-col :span="12">
-            <el-form-item label="原岗位" prop="oldPosition">
-              <el-input v-model="form.oldPosition" placeholder="请输入原岗位" />
+            <el-form-item label="调任部门" prop="transferDepartmentId" required>
+              <el-select
+                v-model="form.transferDepartmentId"
+                placeholder="请选择调任部门"
+                clearable
+                class="w-full"
+                :disabled="isViewMode"
+                @change="handleTransferDeptChange"
+              >
+                <el-option v-for="dept in deptOptions" :key="dept.value" :label="dept.label" :value="dept.value" />
+              </el-select>
             </el-form-item>
           </el-col>
+
           <el-col :span="12">
-            <el-form-item label="变动后岗位" prop="newPosition">
-              <el-input v-model="form.newPosition" placeholder="离职填写-" :disabled="form.changeType === '离职'" />
+            <el-form-item label="调任岗位" prop="transferPosition" required>
+              <el-input v-model="form.transferPosition" placeholder="请输入调任岗位" :disabled="isViewMode" />
             </el-form-item>
           </el-col>
         </el-row>
-        <el-form-item v-if="form.changeType === '调任'" label="调入部门" prop="newDepartment">
-          <el-input v-model="form.newDepartment" placeholder="请输入调入部门" />
-        </el-form-item>
-        <el-form-item label="备注" prop="remark">
-          <el-input v-model="form.remark" type="textarea" placeholder="请输入备注" />
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item :label="changeDateLabel" prop="changeDate" required>
+              <el-date-picker
+                v-model="form.changeDate"
+                type="date"
+                placeholder="年/月/日"
+                value-format="YYYY-MM-DD"
+                class="w-full"
+                :disabled="isViewMode"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12"></el-col>
+        </el-row>
+
+        <el-form-item label="变动原因" prop="remark">
+          <el-input v-model="form.remark" type="textarea" :rows="4" placeholder="请输入变动原因" :disabled="isViewMode" />
         </el-form-item>
       </el-form>
+
       <template #footer>
         <div class="dialog-footer">
-          <el-button type="primary" @click="submitForm">确 定</el-button>
-          <el-button @click="cancel">取 消</el-button>
+          <el-button v-if="!isViewMode" @click="cancel">取消</el-button>
+          <el-button v-if="isViewMode" type="primary" @click="open = false">关闭</el-button>
+          <el-button v-else type="primary" @click="submitForm">确认提交</el-button>
         </div>
       </template>
     </el-dialog>
 
     <!-- 上传审计文件弹窗 -->
-    <el-dialog v-model="uploadFileOpen" title="上传审计文件" width="550px" append-to-body>
+    <el-dialog v-model="uploadFileOpen" title="上传审计文件" width="620px" append-to-body destroy-on-close class="audit-dialog">
       <el-upload
-        v-model:file-list="auditFileList"
-        action="#"
-        :auto-upload="false"
-        multiple
-        drag
+        v-model:file-list="uploadQueue"
+        :action="uploadUrl"
+        :headers="uploadHeaders"
+        :auto-upload="true"
         :limit="10"
+        :multiple="true"
+        drag
+        accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+        :on-success="handleUploadSuccess"
+        :on-exceed="handleUploadExceed"
+        :before-upload="beforeUpload"
+        :on-remove="handleUploadRemove"
       >
         <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
         <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
         <template #tip>
-          <div class="el-upload__tip">支持 PDF、Word、Excel 等格式,单文件不超过 10MB</div>
+          <div class="el-upload__tip">支持 PDF、Word、Excel、图片等格式,单文件不超过 20MB</div>
         </template>
       </el-upload>
+
       <template #footer>
         <div class="dialog-footer">
-          <el-button type="primary" @click="submitAuditFiles">确 定</el-button>
-          <el-button @click="uploadFileOpen = false">取 消</el-button>
+          <el-button @click="uploadFileOpen = false">关闭</el-button>
         </div>
       </template>
     </el-dialog>
@@ -226,24 +331,61 @@
 </template>
 
 <script setup name="AuditPersonChange" lang="ts">
-import { listAuditPersonChange, getAuditPersonChange, delAuditPersonChange, addAuditPersonChange, updateAuditPersonChange } from '@/api/riskctrl/auditPersonChange';
-import { AuditPersonChangeForm, AuditPersonChangeQuery, AuditPersonChangeVO } from '@/api/riskctrl/auditPersonChange/types';
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref } from 'vue';
+import type { ComponentInternalInstance } from 'vue';
+import { CircleCheckFilled, Clock, Delete, Download, FolderOpened, Plus, Search, Upload, UploadFilled, UserFilled } from '@element-plus/icons-vue';
+import { getToken } from '@/utils/auth';
+import download from '@/plugins/download';
+import { listDept } from '@/api/system/dept';
+import { getDictOptions } from '@/api/riskctrl/dictData';
+import {
+  listAuditPersonChange,
+  getAuditPersonChange,
+  delAuditPersonChange,
+  addAuditPersonChange,
+  updateAuditPersonChange
+} from '@/api/riskctrl/auditPersonChange';
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
-const changeList = ref<AuditPersonChangeVO[]>([]);
-const loading = ref(true);
-const showSearch = ref(true);
-const ids = ref<Array<string | number>>([]);
-const single = ref(true);
-const multiple = ref(true);
-const total = ref(0);
-const title = ref('');
+const queryFormRef = ref<ElFormInstance>();
+const changeFormRef = ref<ElFormInstance>();
+
+const loading = ref(false);
 const open = ref(false);
 const uploadFileOpen = ref(false);
+const total = ref(0);
+const multiple = ref(true);
+const ids = ref<Array<string | number>>([]);
+const changeList = ref<any[]>([]);
+const uploadQueue = ref<any[]>([]);
 const auditFileList = ref<any[]>([]);
+const employeeOptions = ref<any[]>([]);
+const deptOptions = ref<any[]>([]);
+const auditChangeTypeOptions = ref<any[]>([]);
+
+const dialogMode = ref<'add' | 'edit' | 'view'>('add');
+const isViewMode = computed(() => dialogMode.value === 'view');
+const dialogTitle = computed(() => {
+  if (dialogMode.value === 'add') return '新增人员变动';
+  if (dialogMode.value === 'edit') return '修改人员变动';
+  return '查看人员变动';
+});
+
+const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload');
+const uploadHeaders = ref({
+  Authorization: 'Bearer ' + getToken(),
+  clientid: import.meta.env.VITE_APP_CLIENT_ID
+});
+
+const queryParams = reactive<any>({
+  pageNum: 1,
+  pageSize: 10,
+  changeType: undefined,
+  departmentId: undefined,
+  keyword: undefined
+});
 
-// 统计数据
 const statsData = reactive({
   totalChanges: 0,
   pendingAudit: 0,
@@ -255,150 +397,717 @@ const statsData = reactive({
   fileTrend: 0
 });
 
-const queryFormRef = ref<ElFormInstance>();
-const changeFormRef = ref<ElFormInstance>();
-
-const queryParams = reactive<AuditPersonChangeQuery & { department?: string }>({
-  pageNum: 1,
-  pageSize: 10,
-  employeeName: undefined,
-  department: undefined,
-  changeType: undefined,
-  auditStatus: undefined
-});
-
-const form = reactive<AuditPersonChangeForm & { newDepartment?: string }>({
+const form = reactive<any>({
   changeId: undefined,
+  employeeId: undefined,
   employeeName: '',
-  department: '',
+  employeeCode: '',
+  departmentId: undefined,
+  departmentName: '',
   oldPosition: '',
-  newPosition: '',
   changeType: '',
   changeDate: '',
+  transferDepartmentId: undefined,
+  transferDepartmentName: '',
+  transferPosition: '',
+  auditStatus: '待审计',
   remark: ''
 });
 
-const rules = {
-  employeeName: [{ required: true, message: '员工姓名不能为空', trigger: 'blur' }],
-  changeType: [{ required: true, message: '变动类型不能为空', trigger: 'change' }]
-};
-
-const columns = ref([
-  { key: 0, label: '员工姓名', visible: true },
-  { key: 1, label: '部门', visible: true },
-  { key: 2, label: '原岗位', visible: true },
-  { key: 3, label: '变动后岗位', visible: true },
-  { key: 4, label: '变动类型', visible: true },
-  { key: 5, label: '变动日期', visible: true },
-  { key: 6, label: '审计状态', visible: true },
-  { key: 7, label: '创建时间', visible: true }
-]);
-
-const onChangeType = (val: string) => {
-  if (val === '离职') {
-    form.newPosition = '-';
-  } else {
-    form.newPosition = '';
-  }
-};
-
-const getList = async () => {
-  loading.value = true;
-  const res = await listAuditPersonChange(queryParams);
-  changeList.value = res.rows;
-  total.value = res.total;
-  loading.value = false;
-};
+const changeTypeLabel = computed(() => getDictLabel(auditChangeTypeOptions.value, form.changeType));
+const isTransferType = computed(() => changeTypeLabel.value === '调任');
+const isLeaveType = computed(() => changeTypeLabel.value === '离职');
+const changeDateLabel = computed(() => (isLeaveType.value ? '离职日期' : '变动日期'));
 
-const cancel = () => {
-  open.value = false;
-  reset();
-};
+const rules = computed(() => ({
+  employeeId: [{ required: true, message: '请选择员工', trigger: 'change' }],
+  changeType: [{ required: true, message: '请选择变动类型', trigger: 'change' }],
+  changeDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
+  transferDepartmentId: isTransferType.value ? [{ required: true, message: '请选择调任部门', trigger: 'change' }] : [],
+  transferPosition: isTransferType.value ? [{ required: true, message: '请输入调任岗位', trigger: 'blur' }] : [],
+  remark: [{ required: true, message: '请输入变动原因', trigger: 'blur' }]
+}));
 
-const reset = () => {
+const resetForm = () => {
   form.changeId = undefined;
+  form.employeeId = undefined;
   form.employeeName = '';
-  form.department = '';
+  form.employeeCode = '';
+  form.departmentId = undefined;
+  form.departmentName = '';
   form.oldPosition = '';
-  form.newPosition = '';
   form.changeType = '';
   form.changeDate = '';
-  form.newDepartment = '';
+  form.transferDepartmentId = undefined;
+  form.transferDepartmentName = '';
+  form.transferPosition = '';
+  form.auditStatus = '待审计';
   form.remark = '';
-  nextTick(() => { changeFormRef.value?.resetFields(); });
+  nextTick(() => changeFormRef.value?.clearValidate());
+};
+
+const getList = async () => {
+  loading.value = true;
+  try {
+    const res: any = await listAuditPersonChange(queryParams);
+    changeList.value = res.rows || [];
+    total.value = res.total || 0;
+  } finally {
+    loading.value = false;
+  }
+};
+
+const loadStats = async () => {
+  try {
+    const res: any = await listAuditPersonChange({ pageNum: 1, pageSize: 9999 });
+    const allData = res.rows || [];
+    statsData.totalChanges = allData.length;
+    statsData.pendingAudit = allData.filter(
+      (d: any) => getDictLabel(auditChangeTypeOptions.value, d.changeType) === '离职' && d.auditStatus === '待审计'
+    ).length;
+    statsData.completedTransfer = allData.filter(
+      (d: any) => getDictLabel(auditChangeTypeOptions.value, d.changeType) === '调任' && d.auditStatus === '已完成'
+    ).length;
+    statsData.totalFiles = auditFileList.value.length;
+  } catch (e) {
+    console.error(e);
+  }
 };
 
-const handleQuery = () => { queryParams.pageNum = 1; getList(); };
-const resetQuery = () => { queryFormRef.value?.resetFields(); handleQuery(); };
+const loadDeptOptions = async () => {
+  const res: any = await listDept();
+  const flat: any[] = [];
+
+  const walk = (nodes: any[]) => {
+    (nodes || []).forEach((n) => {
+      flat.push({
+        label: n.label || n.deptName || n.name,
+        value: n.id || n.deptId
+      });
+      if (n.children?.length) walk(n.children);
+    });
+  };
+
+  walk(res.data || []);
+  deptOptions.value = flat;
+};
+
+const loadEmployeeOptions = async () => {
+  // TODO:后续要替换为员工查询接口
+  employeeOptions.value = [
+    { employeeId: 1, employeeName: '张明', employeeCode: 'E1001', departmentId: 10, departmentName: '技术部', oldPosition: '高级开发工程师' },
+    { employeeId: 2, employeeName: '李婷', employeeCode: 'E1002', departmentId: 20, departmentName: '财务部', oldPosition: '财务总监' }
+  ];
+};
+
+const getDicts = async () => {
+  const changeRes = await getDictOptions('audit_change_type');
+  auditChangeTypeOptions.value = changeRes || [];
+};
+
+const handleQuery = async () => {
+  queryParams.pageNum = 1;
+  await getList();
+};
+
+const resetQuery = async () => {
+  queryFormRef.value?.resetFields();
+  queryParams.changeType = undefined;
+  queryParams.departmentId = undefined;
+  queryParams.keyword = undefined;
+  queryParams.pageNum = 1;
+  await getList();
+};
 
-const handleSelectionChange = (selection: AuditPersonChangeVO[]) => {
+const handleSelectionChange = (selection: any[]) => {
   ids.value = selection.map((item) => item.changeId);
-  single.value = selection.length !== 1;
-  multiple.value = !selection.length;
+  multiple.value = selection.length === 0;
 };
 
-const handleAdd = () => { reset(); open.value = true; title.value = '新增人员变动'; };
+const handleEmployeeChange = (employeeId: string | number) => {
+  const emp = employeeOptions.value.find((item) => String(item.employeeId) === String(employeeId));
+  if (!emp) return;
+  form.employeeName = emp.employeeName;
+  form.employeeCode = emp.employeeCode;
+  form.departmentId = emp.departmentId;
+  form.departmentName = emp.departmentName;
+  form.oldPosition = emp.oldPosition;
+};
+
+const handleTransferDeptChange = (deptId: string | number) => {
+  const dept = deptOptions.value.find((item) => String(item.value) === String(deptId));
+  form.transferDepartmentName = dept?.label || '';
+};
 
-const handleUpdate = async (row?: AuditPersonChangeVO) => {
-  reset();
+const onChangeType = () => {
+  if (isTransferType.value) {
+    form.transferDepartmentId = form.transferDepartmentId || undefined;
+    form.transferPosition = form.transferPosition || '';
+  } else {
+    form.transferDepartmentId = undefined;
+    form.transferDepartmentName = '';
+    form.transferPosition = '';
+  }
+};
+
+const handleAdd = () => {
+  dialogMode.value = 'add';
+  resetForm();
+  open.value = true;
+};
+
+const handleView = async (row: any) => {
+  dialogMode.value = 'view';
+  resetForm();
+  const res: any = await getAuditPersonChange(row.changeId);
+  const data = res.data || res || {};
+  Object.assign(form, data);
+  open.value = true;
+};
+
+const handleUpdate = async (row?: any) => {
+  dialogMode.value = 'edit';
+  resetForm();
   const changeId = row?.changeId || ids.value[0];
-  const res = await getAuditPersonChange(changeId);
-  Object.assign(form, res.data);
+  const res: any = await getAuditPersonChange(changeId);
+  Object.assign(form, res.data || res || {});
   open.value = true;
-  title.value = '修改人员变动';
+};
+
+const cancel = () => {
+  open.value = false;
+  resetForm();
 };
 
 const submitForm = () => {
   changeFormRef.value?.validate(async (valid: boolean) => {
-    if (valid) {
-      if (form.changeId !== undefined) {
-        await updateAuditPersonChange(form as AuditPersonChangeForm);
-        proxy?.$modal.msgSuccess('修改成功');
-      } else {
-        await addAuditPersonChange(form as AuditPersonChangeForm);
-        proxy?.$modal.msgSuccess('新增成功');
-      }
-      open.value = false;
-      await getList();
+    if (!valid) return;
+
+    const payload: any = {
+      ...form,
+      changeType: form.changeType,
+      changeDate: form.changeDate,
+      transferDepartmentName: isTransferType.value ? form.transferDepartmentName : '',
+      transferPosition: isTransferType.value ? form.transferPosition : '',
+      transferDepartmentId: isTransferType.value ? form.transferDepartmentId : undefined
+    };
+
+    if (!isTransferType.value) {
+      payload.transferDepartmentId = undefined;
+      payload.transferDepartmentName = '';
+      payload.transferPosition = '';
     }
+
+    if (payload.changeId !== undefined) {
+      await updateAuditPersonChange(payload);
+      proxy?.$modal.msgSuccess('修改成功');
+    } else {
+      await addAuditPersonChange(payload);
+      proxy?.$modal.msgSuccess('新增成功');
+    }
+
+    open.value = false;
+    await loadStats();
+    await getList();
   });
 };
 
-const handleDelete = async (row?: AuditPersonChangeVO) => {
+const handleDelete = async (row?: any) => {
   const changeIds = row?.changeId || ids.value;
   await proxy?.$modal.confirm('是否确认删除该人员变动记录?');
   await delAuditPersonChange(changeIds);
-  await getList();
   proxy?.$modal.msgSuccess('删除成功');
+  await loadStats();
+  await getList();
 };
 
-// 加载统计数据
-const loadStats = async () => {
-  try {
-    const res = await listAuditPersonChange({ pageNum: 1, pageSize: 9999 } as any);
-    const allData = (res as any).rows || [];
-    statsData.totalChanges = allData.length;
-    statsData.pendingAudit = allData.filter((d: any) => d.auditStatus === '待审计').length;
-    statsData.completedTransfer = allData.filter((d: any) => d.changeType === '调任' && d.auditStatus === '已完成').length;
-  } catch (e) {
-    console.error('加载统计数据失败', e);
+const handleExport = () => {
+  // 替换成你后端实际导出接口
+  download.zip('/riskctrl/auditPersonChange/export', '审计工作管理导出.zip');
+};
+
+const beforeUpload = (file: File) => {
+  const allow = [
+    'application/pdf',
+    'application/msword',
+    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    'application/vnd.ms-excel',
+    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    'image/png',
+    'image/jpeg'
+  ];
+  if (!allow.includes(file.type)) {
+    proxy?.$modal.msgError('仅支持 PDF、Word、Excel、图片文件');
+    return false;
   }
+  if (file.size / 1024 / 1024 > 20) {
+    proxy?.$modal.msgError('单文件不能超过 20MB');
+    return false;
+  }
+  return true;
+};
+
+const handleUploadExceed = () => {
+  proxy?.$modal.msgWarning('最多只能上传 10 个文件');
 };
 
-// 提交审计文件
-const submitAuditFiles = () => {
-  if (auditFileList.value.length === 0) {
-    proxy?.$modal.msgWarning('请选择要上传的文件');
+const handleUploadSuccess = async (response: any, file: any) => {
+  if (response?.code !== 200 && response?.data?.ossId == null && response?.data?.url == null) {
+    proxy?.$modal.msgError(response?.msg || '上传失败');
     return;
   }
-  statsData.totalFiles += auditFileList.value.length;
-  proxy?.$modal.msgSuccess(`成功上传 ${auditFileList.value.length} 个审计文件`);
-  uploadFileOpen.value = false;
-  auditFileList.value = [];
+
+  const item = {
+    ossId: response?.data?.ossId || response?.data?.id || response?.ossId || Date.now(),
+    fileName: file?.name || response?.data?.fileName || response?.fileName || '未命名文件',
+    fileSize: file?.size || response?.data?.fileSize || 0,
+    createTime: response?.data?.createTime || new Date().toISOString()
+  };
+
+  auditFileList.value.unshift(item);
+  statsData.totalFiles = auditFileList.value.length;
+  proxy?.$modal.msgSuccess('上传成功');
+};
+
+const handleUploadRemove = () => {};
+
+const handleRemoveFile = async (row: any) => {
+  await proxy?.$modal.confirm(`是否确认删除文件 ${row.fileName}?`);
+  auditFileList.value = auditFileList.value.filter((item) => item.ossId !== row.ossId);
+  statsData.totalFiles = auditFileList.value.length;
+  proxy?.$modal.msgSuccess('删除成功');
+};
+
+const getDictLabel = (options: any[], value: any) => {
+  const item = options.find((d) => String(d.value) === String(value));
+  return item?.label || value || '';
+};
+
+const getChangeTypeLabel = (value: any) => getDictLabel(auditChangeTypeOptions.value, value);
+
+const getChangeTypeClass = (val: any) => {
+  const label = getChangeTypeLabel(val);
+  if (label === '调任') return 'tag-blue';
+  if (label === '离职') return 'tag-red';
+  return 'tag-gray';
+};
+
+const getAuditStatusClass = (val: string) => {
+  if (val === '已完成') return 'tag-green';
+  if (val === '待审计') return 'tag-orange';
+  return 'tag-gray';
+};
+
+const trendClass = (val: number) => {
+  if (val > 0) return 'trend-up';
+  if (val < 0) return 'trend-down';
+  return 'trend-flat';
+};
+
+const trendArrow = (val: number) => (val > 0 ? '↑' : val < 0 ? '↓' : '—');
+const trendText = (val: number) => {
+  if (val > 0) return `较上月 ${Math.abs(val)}%`;
+  if (val < 0) return `较上月 ${Math.abs(val)}%`;
+  return '持平';
+};
+
+const getFileExtLabel = (fileName: string) => {
+  const ext = (fileName || '').split('.').pop()?.toLowerCase() || '';
+  if (ext === 'pdf') return 'PDF';
+  if (ext === 'doc' || ext === 'docx') return 'DOC';
+  if (ext === 'xls' || ext === 'xlsx') return 'XLS';
+  if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') return 'IMG';
+  return 'FILE';
+};
+
+const getFileIconClass = (fileName: string) => {
+  const ext = (fileName || '').split('.').pop()?.toLowerCase() || '';
+  if (ext === 'pdf') return 'file-pdf';
+  if (ext === 'doc' || ext === 'docx') return 'file-doc';
+  if (ext === 'xls' || ext === 'xlsx') return 'file-xls';
+  if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') return 'file-img';
+  return 'file-other';
+};
+
+const formatFileSize = (size: number) => {
+  if (!size) return '-';
+  if (size < 1024) return `${size}B`;
+  if (size < 1024 * 1024) return `${(size / 1024).toFixed(0)}KB`;
+  return `${(size / 1024 / 1024).toFixed(1)}MB`;
 };
 
-onMounted(() => {
-  getList();
-  loadStats();
+const formatDate = (val: string) => {
+  if (!val) return '-';
+  return String(val).slice(0, 10);
+};
+
+onMounted(async () => {
+  await Promise.all([getList(), loadStats(), loadDeptOptions(), loadEmployeeOptions(), getDicts()]);
 });
 </script>
+
+<style scoped lang="scss">
+.audit-page {
+  padding: 16px;
+  background: #f5f7fb;
+  min-height: calc(100vh - 50px);
+}
+
+.page-header {
+  margin-bottom: 16px;
+}
+
+.page-title {
+  font-size: 22px;
+  font-weight: 700;
+  color: #1f2937;
+  margin-bottom: 6px;
+}
+
+.page-desc {
+  font-size: 14px;
+  color: #6b7280;
+}
+
+.stat-row {
+  margin-bottom: 16px;
+}
+
+.stat-card,
+.search-card,
+.table-card,
+.file-card {
+  border-radius: 14px;
+  border: none;
+  overflow: hidden;
+}
+
+.stat-inner {
+  min-height: 88px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.stat-label {
+  font-size: 13px;
+  color: #64748b;
+  margin-bottom: 8px;
+}
+
+.stat-value {
+  font-size: 34px;
+  font-weight: 700;
+  line-height: 1;
+  color: #111827;
+}
+
+.stat-value-orange {
+  color: #f97316;
+}
+
+.stat-value-green {
+  color: #22c55e;
+}
+
+.stat-value-purple {
+  color: #8b5cf6;
+}
+
+.stat-sub {
+  margin-top: 8px;
+  font-size: 12px;
+}
+
+.trend-up {
+  color: #22c55e;
+}
+
+.trend-down {
+  color: #ef4444;
+}
+
+.trend-flat {
+  color: #9ca3af;
+}
+
+.stat-icon {
+  width: 44px;
+  height: 44px;
+  border-radius: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 22px;
+}
+
+.stat-icon-blue {
+  background: #e7efff;
+  color: #2563eb;
+}
+
+.stat-icon-orange {
+  background: #ffedd5;
+  color: #f97316;
+}
+
+.stat-icon-green {
+  background: #dcfce7;
+  color: #16a34a;
+}
+
+.stat-icon-purple {
+  background: #ede9fe;
+  color: #8b5cf6;
+}
+
+.search-card,
+.table-card,
+.file-card {
+  margin-bottom: 16px;
+}
+
+.search-form {
+  width: 100%;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  gap: 10px;
+}
+
+.search-select {
+  width: 140px;
+}
+
+.search-keyword {
+  width: 210px;
+}
+
+.search-actions {
+  margin-left: auto;
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.primary-btn {
+  background: #2563eb;
+  border-color: #2563eb;
+}
+
+.card-header {
+  font-weight: 700;
+  color: #111827;
+}
+
+.tag-pill {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 72px;
+  height: 28px;
+  padding: 0 12px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.tag-blue {
+  background: #dbeafe;
+  color: #2563eb;
+}
+
+.tag-red {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+.tag-green {
+  background: #dcfce7;
+  color: #16a34a;
+}
+
+.tag-orange {
+  background: #ffedd5;
+  color: #ea580c;
+}
+
+.tag-gray {
+  background: #f1f5f9;
+  color: #64748b;
+}
+
+.file-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.file-item {
+  border: 1px solid #e5e7eb;
+  border-radius: 10px;
+  padding: 14px;
+  background: #fff;
+}
+
+.file-left {
+  display: flex;
+  gap: 12px;
+  align-items: flex-start;
+}
+
+.file-icon {
+  width: 42px;
+  height: 42px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 700;
+  font-size: 12px;
+  flex-shrink: 0;
+}
+
+.file-pdf {
+  background: #fee2e2;
+  color: #ef4444;
+}
+
+.file-doc {
+  background: #dbeafe;
+  color: #2563eb;
+}
+
+.file-xls {
+  background: #dcfce7;
+  color: #16a34a;
+}
+
+.file-img {
+  background: #ede9fe;
+  color: #8b5cf6;
+}
+
+.file-other {
+  background: #f1f5f9;
+  color: #64748b;
+}
+
+.file-meta {
+  min-width: 0;
+  flex: 1;
+}
+
+.file-name {
+  font-size: 14px;
+  font-weight: 600;
+  color: #1f2937;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.file-desc {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #64748b;
+}
+
+.file-actions {
+  margin-top: 8px;
+  display: flex;
+  gap: 10px;
+}
+
+.person-dialog {
+  :deep(.el-dialog__header) {
+    margin-right: 0;
+    padding: 16px 20px 10px;
+    border-bottom: 1px solid #eef2f7;
+  }
+
+  :deep(.el-dialog__body) {
+    padding: 18px 20px 12px;
+  }
+
+  :deep(.el-dialog__footer) {
+    padding: 14px 20px 20px;
+    border-top: 1px solid #eef2f7;
+  }
+}
+
+.audit-dialog {
+  :deep(.el-dialog__header) {
+    margin-right: 0;
+    padding: 16px 20px 12px;
+    border-bottom: 1px solid #e5e7eb;
+  }
+
+  :deep(.el-dialog__body) {
+    padding: 18px 20px 10px;
+  }
+
+  :deep(.el-dialog__footer) {
+    padding: 12px 20px 18px;
+    border-top: 1px solid #f1f5f9;
+  }
+}
+
+.person-form {
+  :deep(.el-form-item) {
+    margin-bottom: 18px;
+  }
+
+  :deep(.el-form-item__label) {
+    font-weight: 600;
+    color: #374151;
+    padding-bottom: 8px;
+  }
+
+  :deep(.el-input__wrapper),
+  :deep(.el-select__wrapper),
+  :deep(.el-textarea__inner) {
+    border-radius: 8px;
+  }
+
+  :deep(.el-form-item.is-required > .el-form-item__label:before) {
+    color: #f56c6c;
+  }
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+
+:deep(.el-card__body) {
+  padding: 18px 18px 16px;
+}
+
+:deep(.el-form-item) {
+  margin-bottom: 14px;
+}
+
+@media (max-width: 1200px) {
+  .file-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 768px) {
+  .file-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .search-actions {
+    margin-left: 0;
+    width: 100%;
+  }
+
+  .search-select,
+  .search-keyword {
+    width: 100%;
+  }
+}
+</style>

+ 398 - 280
src/views/riskctrl/dangerSource/index.vue

@@ -1,119 +1,59 @@
 <template>
-  <div class="p-2">
-    <!-- 顶部统计卡片 -->
-    <el-row :gutter="16" class="mb-[10px]">
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
-            <div>
-              <p class="text-gray-500 text-sm">危险源总数</p>
-              <p class="text-2xl font-bold text-blue-500">{{ statsData.total }}</p>
-            </div>
-            <el-icon :size="40" color="#409EFF"><WarningFilled /></el-icon>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
-            <div>
-              <p class="text-gray-500 text-sm">本月新增</p>
-              <p class="text-2xl font-bold text-green-500">{{ statsData.monthNew }}</p>
-            </div>
-            <el-icon :size="40" color="#67C23A"><CirclePlusFilled /></el-icon>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
-            <div>
-              <p class="text-gray-500 text-sm">高风险数</p>
-              <p class="text-2xl font-bold text-red-500">{{ statsData.highRisk }}</p>
-            </div>
-            <el-icon :size="40" color="#F56C6C"><TriangleFilled /></el-icon>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
-            <div>
-              <p class="text-gray-500 text-sm">已管控</p>
-              <p class="text-2xl font-bold text-purple-500">{{ statsData.controlled }}</p>
-            </div>
-            <el-icon :size="40" color="#8B5CF6"><CircleCheckFilled /></el-icon>
-          </div>
-        </el-card>
-      </el-col>
-    </el-row>
-
-    <transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
-      <div v-show="showSearch" class="mb-[10px]">
-        <el-card shadow="hover">
-          <el-form ref="queryFormRef" :model="queryParams" :inline="true">
-            <el-form-item label="危险源名称" prop="sourceName">
-              <el-input v-model="queryParams.sourceName" placeholder="搜索危险源名称" clearable @keyup.enter="handleQuery" />
-            </el-form-item>
-            <el-form-item label="类型" prop="sourceType">
-              <el-select v-model="queryParams.sourceType" placeholder="全部类型" clearable>
-                <el-option label="电气危险源" value="电气危险源" />
-                <el-option label="消防危险源" value="消防危险源" />
-                <el-option label="高空危险源" value="高空危险源" />
-                <el-option label="设备危险源" value="设备危险源" />
-                <el-option label="食品危险源" value="食品危险源" />
-              </el-select>
-            </el-form-item>
-            <el-form-item>
-              <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
-              <el-button icon="Refresh" @click="resetQuery">重置</el-button>
-            </el-form-item>
-          </el-form>
-        </el-card>
+  <div class="danger-source-page">
+    <div class="page-header">
+      <div>
+        <div class="page-title">危险源上报列表</div>
+        <!--        <div class="page-desc">管理危险源基础信息,及时进行风险排查与处理</div>-->
       </div>
-    </transition>
 
-    <el-card shadow="hover">
-      <template #header>
-        <el-row :gutter="10">
-          <el-col :span="1.5">
-            <el-button v-has-permi="['riskctrl:dangerSource:add']" type="primary" plain icon="Plus" @click="handleAdd()">新增</el-button>
-          </el-col>
-          <el-col :span="1.5">
-            <el-button v-has-permi="['riskctrl:dangerSource:edit']" type="success" plain :disabled="single" icon="Edit" @click="handleUpdate()">修改</el-button>
-          </el-col>
-          <el-col :span="1.5">
-            <el-button v-has-permi="['riskctrl:dangerSource:remove']" type="danger" plain :disabled="multiple" icon="Delete" @click="handleDelete()">删除</el-button>
-          </el-col>
-          <right-toolbar v-model:show-search="showSearch" :columns="columns" :search="true" @query-table="getList"></right-toolbar>
-        </el-row>
-      </template>
+      <el-button v-has-permi="['riskctrl:dangerSource:add']" type="primary" icon="Plus" @click="handleAdd"> 新增危险源 </el-button>
+    </div>
 
-      <el-table v-loading="loading" border :data="sourceList" @selection-change="handleSelectionChange">
-        <el-table-column type="selection" width="50" align="center" />
-        <el-table-column v-if="columns[0].visible" key="sourceNo" label="编号" align="center" prop="sourceNo" />
-        <el-table-column v-if="columns[1].visible" key="sourceName" label="危险源名称" align="center" prop="sourceName" :show-overflow-tooltip="true" />
-        <el-table-column v-if="columns[2].visible" key="sourceType" label="类型" align="center" prop="sourceType" />
-        <el-table-column v-if="columns[3].visible" key="location" label="位置" align="center" prop="location" :show-overflow-tooltip="true" />
-        <el-table-column v-if="columns[4].visible" key="possibleHarm" label="可能危害" align="center" prop="possibleHarm" :show-overflow-tooltip="true" />
-        <el-table-column v-if="columns[5].visible" key="preventionMethod" label="防控方式" align="center" prop="preventionMethod" :show-overflow-tooltip="true" />
-        <el-table-column v-if="columns[6].visible" key="reportTime" label="上报时间" align="center" prop="reportTime" width="160" />
-        <el-table-column v-if="columns[7].visible" key="status" label="状态" align="center" prop="status">
-          <template #default="scope">
-            <el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
-              {{ scope.row.status === '0' ? '启用' : '停用' }}
-            </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column v-if="columns[8].visible" label="创建时间" align="center" prop="createTime" width="160" />
-        <el-table-column label="操作" fixed="right" width="180" class-name="small-padding fixed-width">
+    <!-- 搜索区域 -->
+    <el-card shadow="hover" class="search-card">
+      <el-form ref="queryFormRef" :model="queryParams" :inline="true" class="search-form">
+        <el-form-item label="名称" prop="sourceName">
+          <el-input v-model="queryParams.sourceName" placeholder="请输入危险源名称" clearable @keyup.enter="handleQuery" />
+        </el-form-item>
+
+        <el-form-item label="类型" prop="sourceType">
+          <el-select v-model="queryParams.sourceType" placeholder="全部类型" clearable>
+            <el-option label="电气危险源" value="电气危险源" />
+            <el-option label="消防危险源" value="消防危险源" />
+            <el-option label="高空危险源" value="高空危险源" />
+            <el-option label="设备危险源" value="设备危险源" />
+            <el-option label="食品危险源" value="食品危险源" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item class="search-actions">
+          <el-button @click="resetQuery">重置</el-button>
+          <el-button type="primary" @click="handleQuery">搜索</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 列表区域 -->
+    <el-card shadow="hover" class="table-card">
+      <el-table v-loading="loading" border :data="sourceList">
+        <el-table-column label="危险源编号" prop="sourceNo" align="center" width="160" />
+        <el-table-column label="危险源名称" prop="sourceName" align="center" min-width="150" :show-overflow-tooltip="true" />
+        <el-table-column label="类型" prop="sourceType" align="center" width="120" />
+        <el-table-column label="位置" prop="location" align="center" min-width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="可能危害" prop="possibleHarm" align="center" min-width="180" :show-overflow-tooltip="true" />
+        <el-table-column label="防控方式" prop="preventionMethod" align="center" min-width="180" :show-overflow-tooltip="true" />
+        <el-table-column label="上报时间" prop="reportTime" align="center" width="160" />
+        <el-table-column label="操作" fixed="right" width="240" align="center" header-align="center" class-name="operation-col">
           <template #default="scope">
-            <el-tooltip content="修改" placement="top">
-              <el-button v-has-permi="['riskctrl:dangerSource:edit']" link type="primary" icon="Edit" @click="handleUpdate(scope.row)"></el-button>
-            </el-tooltip>
-            <el-tooltip content="删除" placement="top">
-              <el-button v-has-permi="['riskctrl:dangerSource:remove']" link type="primary" icon="Delete" @click="handleDelete(scope.row)"></el-button>
-            </el-tooltip>
+            <div class="operation-actions">
+              <el-button link type="primary" icon="View" @click="handleDetail(scope.row)">详情</el-button>
+              <el-button v-has-permi="['riskctrl:dangerSource:edit']" link type="warning" icon="Edit" @click="handleUpdate(scope.row)">
+                编辑
+              </el-button>
+              <el-button v-has-permi="['riskctrl:dangerSource:remove']" link type="danger" icon="Delete" @click="handleDelete(scope.row)">
+                删除
+              </el-button>
+            </div>
           </template>
         </el-table-column>
       </el-table>
@@ -122,19 +62,43 @@
     </el-card>
 
     <!-- 新增/修改弹窗 -->
-    <el-dialog v-model="open" :title="title" width="750px" append-to-body>
-      <el-form ref="sourceFormRef" :model="form" :rules="rules" label-width="110px">
-        <el-form-item label="危险源编号" prop="sourceNo">
-          <el-input v-model="form.sourceNo" placeholder="自动生成" disabled />
-        </el-form-item>
+    <el-dialog
+      v-model="open"
+      :title="dialogTitle"
+      width="980px"
+      append-to-body
+      :close-on-click-modal="false"
+      destroy-on-close
+      class="danger-source-dialog"
+    >
+      <div class="dialog-banner">
+        <div class="dialog-banner-title">
+          <el-icon><Plus /></el-icon>
+          <span>{{ dialogTitle }}</span>
+        </div>
+        <div class="dialog-banner-desc">请填写危险源详细信息,带 <span class="required-star">*</span> 为必填项</div>
+      </div>
+
+      <div class="form-section-title">危险源信息</div>
+
+      <el-form ref="sourceFormRef" :model="form" :rules="rules" label-position="top" class="source-form">
         <el-row :gutter="20">
           <el-col :span="12">
-            <el-form-item label="危险源名称" prop="sourceName">
+            <el-form-item label="危险源编号" prop="sourceNo">
+              <el-input v-model="form.sourceNo" placeholder="自动生成" disabled />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12">
+            <el-form-item label="危险源名称" prop="sourceName" required>
               <el-input v-model="form.sourceName" placeholder="请输入危险源名称" maxlength="50" show-word-limit />
             </el-form-item>
           </el-col>
+        </el-row>
+
+        <el-row :gutter="20">
           <el-col :span="12">
-            <el-form-item label="类型" prop="sourceType">
+            <el-form-item label="危险源类型" prop="sourceType" required>
               <el-select v-model="form.sourceType" placeholder="请选择危险源类型" class="w-full">
                 <el-option label="电气危险源" value="电气危险源" />
                 <el-option label="消防危险源" value="消防危险源" />
@@ -144,198 +108,165 @@
               </el-select>
             </el-form-item>
           </el-col>
+
+          <el-col :span="12">
+            <el-form-item label="位置" prop="location" required>
+              <el-input v-model="form.location" placeholder="请输入危险源位置" maxlength="100" show-word-limit />
+            </el-form-item>
+          </el-col>
         </el-row>
-        <el-form-item label="位置" prop="location">
-          <el-input v-model="form.location" placeholder="请输入危险源位置(如:XX车间XX工位)" maxlength="100" show-word-limit />
-        </el-form-item>
-        <el-form-item label="可能危害" prop="possibleHarm">
-          <el-input v-model="form.possibleHarm" type="textarea" :rows="3" placeholder="请描述可能造成的危害" maxlength="500" show-word-limit />
-        </el-form-item>
-        <el-form-item label="防控方式" prop="preventionMethod">
-          <el-input v-model="form.preventionMethod" type="textarea" :rows="3" placeholder="请描述防控方式" maxlength="500" show-word-limit />
-        </el-form-item>
-        <el-form-item label="上报时间" prop="reportTime">
-          <el-date-picker v-model="form.reportTime" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" class="w-full" :disabled-date="disabledDate" />
+
+        <el-form-item label="可能危害" prop="possibleHarm" required>
+          <el-input v-model="form.possibleHarm" type="textarea" :rows="4" placeholder="请描述可能造成的危害" maxlength="500" show-word-limit />
         </el-form-item>
-        <el-form-item label="危险源照片">
-          <el-upload
-            v-model:file-list="uploadFileList"
-            action="#"
-            list-type="picture-card"
-            :auto-upload="false"
-            :limit="5"
-            :on-exceed="handleExceed"
-            accept=".jpg,.jpeg,.png"
-          >
-            <el-icon><Plus /></el-icon>
-            <template #file="{ file }">
-              <img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
-              <span class="el-upload-actions">
-                <span class="el-upload-actions__preview" @click="handlePictureCardPreview(file)">
-                  <el-icon><ZoomIn /></el-icon>
-                </span>
-                <span class="el-upload-actions__delete" @click="handleRemoveFile(file)">
-                  <el-icon><Delete /></el-icon>
-                </span>
-              </span>
-            </template>
-          </el-upload>
+
+        <el-form-item label="防控方式" prop="preventionMethod" required>
+          <el-input v-model="form.preventionMethod" type="textarea" :rows="4" placeholder="请描述防控方式" maxlength="500" show-word-limit />
         </el-form-item>
-        <el-form-item label="备注" prop="remark">
-          <el-input v-model="form.remark" type="textarea" placeholder="请输入备注" />
+
+        <el-form-item label="上报时间" prop="reportTime" required>
+          <el-date-picker
+            v-model="form.reportTime"
+            type="date"
+            placeholder="年/月/日"
+            value-format="YYYY-MM-DD"
+            class="w-full"
+            :disabled-date="disabledDate"
+          />
         </el-form-item>
       </el-form>
+
       <template #footer>
-        <div class="dialog-footer">
-          <el-button type="primary" @click="submitForm">确 定</el-button>
-          <el-button @click="cancel">取 消</el-button>
+        <div class="dialog-footer dialog-footer-center">
+          <el-button :icon="Close" @click="cancel">取消</el-button>
+          <el-button type="primary" :icon="Check" @click="submitForm">提交</el-button>
         </div>
       </template>
     </el-dialog>
 
-    <!-- 图片预览 -->
-    <el-dialog v-model="dialogVisible" width="500px" append-to-body>
-      <img :src="previewUrl" style="width: 100%" />
+    <!-- 详情弹窗 -->
+    <el-dialog
+      v-model="detailOpen"
+      title="危险源详情"
+      width="980px"
+      append-to-body
+      :close-on-click-modal="false"
+      destroy-on-close
+      class="danger-source-dialog"
+    >
+      <el-descriptions :column="2" border class="detail-desc">
+        <el-descriptions-item label="危险源编号">{{ detailData.sourceNo || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="危险源名称">{{ detailData.sourceName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="危险源类型">{{ detailData.sourceType || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="位置">{{ detailData.location || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="可能危害" :span="2">{{ detailData.possibleHarm || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="防控方式" :span="2">{{ detailData.preventionMethod || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="上报时间">{{ detailData.reportTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="创建时间">{{ detailData.createTime || '-' }}</el-descriptions-item>
+      </el-descriptions>
+
+      <template #footer>
+        <div class="dialog-footer dialog-footer-center">
+          <el-button type="primary" @click="detailOpen = false">关闭</el-button>
+        </div>
+      </template>
     </el-dialog>
   </div>
 </template>
 
 <script setup name="DangerSource" lang="ts">
-import { ref, reactive, onMounted, nextTick, getCurrentInstance } from 'vue';
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref } from 'vue';
 import type { ComponentInternalInstance } from 'vue';
-import type { ElFormInstance, UploadFile, UploadFiles, UploadRawFile } from 'element-plus';
-import { genFileId } from 'element-plus';
+import type { FormInstance } from 'element-plus';
+import { Check, Close, Delete, Edit, Plus, View } from '@element-plus/icons-vue';
 import { listDangerSource, getDangerSource, delDangerSource, addDangerSource, updateDangerSource } from '@/api/riskctrl/dangerSource';
-import { DangerSourceForm, DangerSourceQuery, DangerSourceVO } from '@/api/riskctrl/dangerSource/types';
+import type { DangerSourceForm, DangerSourceQuery, DangerSourceVO } from '@/api/riskctrl/dangerSource/types';
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
 const sourceList = ref<DangerSourceVO[]>([]);
 const loading = ref(true);
-const showSearch = ref(true);
-const ids = ref<Array<string | number>>([]);
-const single = ref(true);
-const multiple = ref(true);
 const total = ref(0);
-const title = ref('');
 const open = ref(false);
+const detailOpen = ref(false);
 
-// 图片相关
-const uploadFileList = ref<UploadFile[]>([]);
-const dialogVisible = ref(false);
-const previewUrl = ref('');
-
-// 统计数据
-const statsData = reactive({
-  total: 0,
-  monthNew: 0,
-  highRisk: 0,
-  controlled: 0
-});
+const dialogMode = ref<'add' | 'edit'>('add');
+const dialogTitle = computed(() => (dialogMode.value === 'add' ? '新增危险源信息' : '编辑危险源信息'));
 
-const queryFormRef = ref<ElFormInstance>();
-const sourceFormRef = ref<ElFormInstance>();
+const queryFormRef = ref<FormInstance>();
+const sourceFormRef = ref<FormInstance>();
+
+const detailData = ref<Partial<DangerSourceVO>>({});
 
 const queryParams = reactive<DangerSourceQuery>({
   pageNum: 1,
   pageSize: 10,
   sourceNo: undefined,
   sourceName: undefined,
-  sourceType: undefined,
-  status: undefined
+  sourceType: undefined
 });
 
-const form = reactive<DangerSourceForm>({
+const form = reactive<any>({
   sourceId: undefined,
-  sourceNo: '自动生成',
+  sourceNo: '',
   sourceName: '',
   sourceType: '',
   location: '',
   possibleHarm: '',
   preventionMethod: '',
   reportTime: '',
-  status: '0',
-  remark: ''
+  status: '0'
 });
 
+const unwrap = <T,>(res: any): T => (res?.data ?? res) as T;
+
+const validateTrimRequired = (message: string, max: number) => {
+  return (_rule: any, value: string, callback: any) => {
+    const text = (value || '').trim();
+    if (!text) {
+      callback(new Error(message));
+      return;
+    }
+    if (text.length > max) {
+      callback(new Error(`长度不能超过 ${max} 个字符`));
+      return;
+    }
+    callback();
+  };
+};
+
 const rules = {
-  sourceName: [{ required: true, message: '请填写危险源名称', trigger: 'blur' }],
+  sourceName: [{ validator: validateTrimRequired('请填写危险源名称', 50), trigger: 'blur' }],
   sourceType: [{ required: true, message: '请选择危险源类型', trigger: 'change' }],
-  location: [{ required: true, message: '请填写危险源位置', trigger: 'blur' }],
-  possibleHarm: [{ required: true, message: '请描述可能危害', trigger: 'blur' }],
-  preventionMethod: [{ required: true, message: '请描述防控方式', trigger: 'blur' }],
+  location: [{ validator: validateTrimRequired('请填写危险源位置', 100), trigger: 'blur' }],
+  possibleHarm: [{ validator: validateTrimRequired('请描述可能造成的危害', 500), trigger: 'blur' }],
+  preventionMethod: [{ validator: validateTrimRequired('请描述防控方式', 500), trigger: 'blur' }],
   reportTime: [{ required: true, message: '请选择上报日期', trigger: 'change' }]
 };
 
-const columns = ref([
-  { key: 0, label: '编号', visible: true },
-  { key: 1, label: '危险源名称', visible: true },
-  { key: 2, label: '类型', visible: true },
-  { key: 3, label: '位置', visible: true },
-  { key: 4, label: '可能危害', visible: true },
-  { key: 5, label: '防控方式', visible: true },
-  { key: 6, label: '上报时间', visible: true },
-  { key: 7, label: '状态', visible: true },
-  { key: 8, label: '创建时间', visible: true }
-]);
-
-// 禁用未来日期
 const disabledDate = (time: Date) => {
   return time.getTime() > Date.now();
 };
 
-// 图片预览
-const handlePictureCardPreview = (file: UploadFile) => {
-  previewUrl.value = file.url as string;
-  dialogVisible.value = true;
-};
-
-// 删除文件
-const handleRemoveFile = (file: UploadFile) => {
-  const index = uploadFileList.value.findIndex(f => f.uid === file.uid);
-  if (index > -1) {
-    uploadFileList.value.splice(index, 1);
-  }
-};
-
-// 超出文件数限制
-const handleExceed = (files: File[], uploadFiles: UploadFiles) => {
-  proxy?.$modal.msgWarning('最多只能上传5张照片');
-};
-
-// 加载统计数据
-const loadStats = async () => {
-  try {
-    const res = await listDangerSource({ pageNum: 1, pageSize: 9999 } as any);
-    const allData = (res as any).rows || [];
-    statsData.total = allData.length;
-    // 本月新增
-    const now = new Date();
-    const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0];
-    statsData.monthNew = allData.filter((d: any) => d.createTime && d.createTime >= monthStart).length;
-    statsData.highRisk = allData.filter((d: any) => d.riskLevel === '高风险').length;
-    statsData.controlled = allData.filter((d: any) => d.status === '0').length;
-  } catch (e) {
-    console.error('加载统计数据失败', e);
-  }
-};
-
 const getList = async () => {
   loading.value = true;
-  const res = await listDangerSource(queryParams);
-  sourceList.value = res.rows;
-  total.value = res.total;
-  loading.value = false;
-};
-
-const cancel = () => {
-  open.value = false;
-  reset();
+  try {
+    const res = await listDangerSource(queryParams as any);
+    const data = unwrap<{ rows?: DangerSourceVO[]; total?: number }>(res);
+    sourceList.value = data.rows || [];
+    total.value = data.total || 0;
+  } catch (error) {
+    console.error('加载危险源列表失败', error);
+    sourceList.value = [];
+    total.value = 0;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const reset = () => {
   form.sourceId = undefined;
-  form.sourceNo = '自动生成';
+  form.sourceNo = '';
   form.sourceName = '';
   form.sourceType = '';
   form.location = '';
@@ -343,10 +274,8 @@ const reset = () => {
   form.preventionMethod = '';
   form.reportTime = '';
   form.status = '0';
-  form.remark = '';
-  uploadFileList.value = [];
   nextTick(() => {
-    sourceFormRef.value?.resetFields();
+    sourceFormRef.value?.clearValidate();
   });
 };
 
@@ -357,56 +286,245 @@ const handleQuery = () => {
 
 const resetQuery = () => {
   queryFormRef.value?.resetFields();
-  handleQuery();
-};
-
-const handleSelectionChange = (selection: DangerSourceVO[]) => {
-  ids.value = selection.map((item) => item.sourceId);
-  single.value = selection.length !== 1;
-  multiple.value = !selection.length;
+  queryParams.pageNum = 1;
+  queryParams.sourceNo = undefined;
+  queryParams.sourceName = undefined;
+  queryParams.sourceType = undefined;
+  getList();
 };
 
 const handleAdd = () => {
+  dialogMode.value = 'add';
   reset();
+  form.sourceNo = '';
   open.value = true;
-  title.value = '新增危险源';
 };
 
-const handleUpdate = async (row?: DangerSourceVO) => {
+const handleDetail = async (row: DangerSourceVO) => {
+  try {
+    const res = await getDangerSource(row.sourceId);
+    const data = unwrap<DangerSourceVO>(res);
+    detailData.value = data;
+    detailOpen.value = true;
+  } catch (error) {
+    console.error('获取危险源详情失败', error);
+  }
+};
+
+const handleUpdate = async (row: DangerSourceVO) => {
+  dialogMode.value = 'edit';
   reset();
-  const sourceId = row?.sourceId || ids.value[0];
-  const res = await getDangerSource(sourceId);
-  Object.assign(form, res.data);
-  open.value = true;
-  title.value = '修改危险源';
+
+  try {
+    const res = await getDangerSource(row.sourceId);
+    const data = unwrap<DangerSourceVO>(res);
+    Object.assign(form, data);
+    open.value = true;
+  } catch (error) {
+    console.error('获取危险源详情失败', error);
+  }
 };
 
 const submitForm = () => {
   sourceFormRef.value?.validate(async (valid: boolean) => {
-    if (valid) {
-      if (form.sourceId !== undefined) {
-        await updateDangerSource(form);
+    if (!valid) return;
+
+    try {
+      const payload: any = {
+        ...form,
+        sourceNo: form.sourceNo === '自动生成' ? '' : form.sourceNo
+      };
+
+      if (dialogMode.value === 'edit' && payload.sourceId !== undefined) {
+        await updateDangerSource(payload);
         proxy?.$modal.msgSuccess('修改成功');
       } else {
-        await addDangerSource(form);
+        await addDangerSource(payload);
         proxy?.$modal.msgSuccess('新增成功');
       }
+
       open.value = false;
       await getList();
+    } catch (error) {
+      console.error('提交失败', error);
     }
   });
 };
 
-const handleDelete = async (row?: DangerSourceVO) => {
-  const sourceIds = row?.sourceId || ids.value;
-  await proxy?.$modal.confirm('是否确认删除编号为"' + sourceIds + '"的数据项?');
-  await delDangerSource(sourceIds);
-  await getList();
-  proxy?.$modal.msgSuccess('删除成功');
+const cancel = () => {
+  open.value = false;
+  reset();
+};
+
+const handleDelete = async (row: DangerSourceVO) => {
+  await proxy?.$modal.confirm(`是否确认删除编号为"${row.sourceNo}"的数据项?`);
+  try {
+    await delDangerSource(row.sourceId);
+    proxy?.$modal.msgSuccess('删除成功');
+    await getList();
+  } catch (error) {
+    console.error('删除失败', error);
+  }
 };
 
 onMounted(() => {
   getList();
-  loadStats();
 });
 </script>
+
+<style scoped lang="scss">
+.danger-source-page {
+  padding: 16px;
+  background: #f5f7fb;
+  min-height: calc(100vh - 50px);
+}
+
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 14px;
+}
+
+.page-title {
+  font-size: 22px;
+  font-weight: 700;
+  color: #1f2937;
+  margin-bottom: 6px;
+}
+
+.page-desc {
+  font-size: 14px;
+  color: #6b7280;
+}
+
+.search-card,
+.table-card {
+  border-radius: 14px;
+  border: none;
+  margin-bottom: 14px;
+}
+
+.search-form {
+  width: 100%;
+  display: flex;
+  align-items: flex-end;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+
+.search-actions {
+  margin-left: auto;
+  margin-top: 0;
+}
+
+.operation-actions {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 14px;
+}
+
+.danger-source-dialog {
+  :deep(.el-dialog__header) {
+    margin-right: 0;
+    padding: 16px 20px 10px;
+    border-bottom: 1px solid #eef2f7;
+  }
+
+  :deep(.el-dialog__body) {
+    padding: 18px 20px 12px;
+  }
+
+  :deep(.el-dialog__footer) {
+    padding: 14px 20px 20px;
+    border-top: 1px solid #eef2f7;
+  }
+}
+
+.dialog-banner {
+  margin-bottom: 16px;
+}
+
+.dialog-banner-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #2563eb;
+  font-size: 18px;
+  font-weight: 700;
+}
+
+.dialog-banner-title .el-icon {
+  font-size: 18px;
+}
+
+.dialog-banner-desc {
+  margin-top: 6px;
+  color: #64748b;
+  font-size: 13px;
+}
+
+.form-section-title {
+  font-size: 15px;
+  font-weight: 700;
+  color: #1f2937;
+  padding: 10px 0 12px;
+  border-bottom: 1px solid #eef2f7;
+  margin-bottom: 18px;
+}
+
+.source-form {
+  :deep(.el-form-item) {
+    margin-bottom: 18px;
+  }
+
+  :deep(.el-form-item__label) {
+    font-weight: 600;
+    color: #374151;
+    padding-bottom: 8px;
+  }
+
+  :deep(.el-input__wrapper),
+  :deep(.el-select__wrapper),
+  :deep(.el-textarea__inner) {
+    border-radius: 8px;
+  }
+}
+
+.dialog-footer {
+  display: flex;
+  gap: 12px;
+}
+
+.dialog-footer-center {
+  justify-content: center;
+}
+
+.detail-desc {
+  margin-bottom: 10px;
+}
+
+:deep(.operation-col .cell) {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+:deep(.el-button.is-link) {
+  padding-left: 0;
+  padding-right: 0;
+}
+
+.required-star {
+  color: #f56c6c;
+  font-weight: 700;
+}
+
+/* 让 required 星号更稳定地显示为红色 */
+.source-form {
+  :deep(.el-form-item.is-required > .el-form-item__label:before) {
+    color: #f56c6c;
+  }
+}
+</style>

+ 1 - 1
src/views/riskctrl/hazard/index.vue

@@ -231,7 +231,7 @@
             <div v-if="photoViewList.length" class="photo-view-list">
               <div v-for="(item, index) in photoViewList" :key="index" class="photo-view-item">
                 <el-image :src="item" fit="cover" class="photo-thumb" :preview-src-list="photoViewList" preview-teleported />
-<!--                <div class="photo-path">{{ item }}</div>-->
+                <!--                <div class="photo-path">{{ item }}</div>-->
               </div>
             </div>
             <div v-else class="view-text">-</div>

+ 636 - 299
src/views/riskctrl/personInfo/index.vue

@@ -1,70 +1,33 @@
 <template>
-  <div class="p-2">
-    <!-- 顶部统计卡片 -->
-    <el-row :gutter="16" class="mb-[10px]">
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
-            <div>
-              <p class="text-gray-500 text-sm">人员总数</p>
-              <p class="text-2xl font-bold text-blue-500">{{ statsData.total }}</p>
-            </div>
-            <el-icon :size="40" color="#409EFF"><UserFilled /></el-icon>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
-            <div>
-              <p class="text-gray-500 text-sm">已投保</p>
-              <p class="text-2xl font-bold text-green-500">{{ statsData.insured }}</p>
-            </div>
-            <el-icon :size="40" color="#67C23A"><CircleCheckFilled /></el-icon>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
-            <div>
-              <p class="text-gray-500 text-sm">未投保</p>
-              <p class="text-2xl font-bold text-red-500">{{ statsData.uninsured }}</p>
-            </div>
-            <el-icon :size="40" color="#F56C6C"><CircleCloseFilled /></el-icon>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <div class="flex items-center justify-between">
-            <div>
-              <p class="text-gray-500 text-sm">保险即将到期</p>
-              <p class="text-2xl font-bold text-orange-500">{{ statsData.expiringSoon }}</p>
-            </div>
-            <el-icon :size="40" color="#E6A23C"><WarningFilled /></el-icon>
-          </div>
-        </el-card>
-      </el-col>
-    </el-row>
-
-    <transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
-      <div v-show="showSearch" class="mb-[10px]">
-        <el-card shadow="hover">
-          <el-form ref="queryFormRef" :model="queryParams" :inline="true">
+  <div class="person-page">
+    <div class="page-header">
+      <div>
+        <div class="page-title">人员信息管理</div>
+        <div class="page-desc">管理企业所有工作人员基本信息和资质证书</div>
+      </div>
+
+      <el-button v-has-permi="['riskctrl:personInfo:add']" type="primary" icon="Plus" @click="handleAdd"> 新增人员 </el-button>
+    </div>
+
+    <!-- 搜索区域 -->
+    <el-card shadow="hover" class="search-card">
+      <el-form ref="queryFormRef" :model="queryParams" label-position="top" class="search-form">
+        <el-row :gutter="12">
+          <el-col :xs="24" :sm="12" :md="8">
             <el-form-item label="姓名" prop="personName">
-              <el-input v-model="queryParams.personName" placeholder="请输入姓名" clearable @keyup.enter="handleQuery" />
+              <el-input v-model="queryParams.personName" placeholder="请输入姓名搜索" clearable @keyup.enter="handleQuery" />
             </el-form-item>
+          </el-col>
+
+          <el-col :xs="24" :sm="12" :md="8">
             <el-form-item label="人员类型" prop="personType">
-              <el-select v-model="queryParams.personType" placeholder="请选择" clearable>
-                <el-option
-                  v-for="dict in person_type_options"
-                  :key="dict.value"
-                  :label="dict.label"
-                  :value="dict.value"
-                />
+              <el-select v-model="queryParams.personType" placeholder="全部类型" clearable class="w-full">
+                <el-option v-for="dict in person_type_options" :key="dict.value" :label="dict.label" :value="dict.value" />
               </el-select>
             </el-form-item>
+          </el-col>
+
+          <el-col :xs="24" :sm="12" :md="8">
             <el-form-item label="工作门店" prop="workStoreId">
               <el-tree-select
                 v-model="queryParams.workStoreId"
@@ -74,83 +37,61 @@
                 placeholder="请选择门店"
                 check-strictly
                 clearable
+                class="w-full"
               />
             </el-form-item>
-            <el-form-item label="保险状态" prop="insuranceStatus">
-              <el-select v-model="queryParams.insuranceStatus" placeholder="请选择" clearable>
-                <el-option label="已投保" value="1" />
-                <el-option label="未投保" value="0" />
-              </el-select>
-            </el-form-item>
-            <el-form-item>
-              <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
-              <el-button icon="Refresh" @click="resetQuery">重置</el-button>
-            </el-form-item>
-          </el-form>
-        </el-card>
-      </div>
-    </transition>
-
-    <el-card shadow="hover">
-      <template #header>
-        <el-row :gutter="10">
-          <el-col :span="1.5">
-            <el-button v-has-permi="['riskctrl:personInfo:add']" type="primary" plain icon="Plus" @click="handleAdd()">新增</el-button>
-          </el-col>
-          <el-col :span="1.5">
-            <el-button v-has-permi="['riskctrl:personInfo:edit']" type="success" plain :disabled="single" icon="Edit" @click="handleUpdate()">修改</el-button>
-          </el-col>
-          <el-col :span="1.5">
-            <el-button v-has-permi="['riskctrl:personInfo:remove']" type="danger" plain :disabled="multiple" icon="Delete" @click="handleDelete()">删除</el-button>
           </el-col>
-          <right-toolbar v-model:show-search="showSearch" :columns="columns" :search="true" @query-table="getList"></right-toolbar>
         </el-row>
-      </template>
 
-      <el-table v-loading="loading" border :data="personList" @selection-change="handleSelectionChange">
-        <el-table-column type="selection" width="50" align="center" />
-        <el-table-column v-if="columns[0].visible" label="人员类型" align="center" prop="personType" width="100">
+        <div class="search-actions">
+          <el-button @click="resetQuery">重置</el-button>
+          <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
+        </div>
+      </el-form>
+    </el-card>
+
+    <!-- 列表工具栏 -->
+    <div class="list-toolbar">
+      <div class="list-total">共 {{ total }} 条记录</div>
+      <el-button v-has-permi="['riskctrl:personInfo:add']" type="primary" icon="Plus" @click="handleAdd"> 新增人员 </el-button>
+    </div>
+
+    <!-- 列表 -->
+    <el-card shadow="hover" class="table-card">
+      <el-table v-loading="loading" border :data="personList">
+        <el-table-column label="人员类型" align="center" prop="personType" width="110">
           <template #default="scope">
             <el-tag :type="getPersonTypeType(scope.row.personType)">{{ scope.row.personType }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column v-if="columns[1].visible" label="姓名" align="center" prop="personName" />
-        <el-table-column v-if="columns[2].visible" label="年龄" align="center" prop="age" width="80" />
-        <el-table-column v-if="columns[3].visible" label="身份证号" align="center" prop="idCard" width="180">
+
+        <el-table-column label="姓名" align="center" prop="personName" width="120" />
+        <el-table-column label="年龄" align="center" prop="age" width="80" />
+        <el-table-column label="身份证号" align="center" prop="idCard" width="180">
           <template #default="scope">
             <span>{{ maskIdCard(scope.row.idCard) }}</span>
           </template>
         </el-table-column>
-        <el-table-column v-if="columns[4].visible" label="工作门店" align="center" prop="workStoreName" />
-        <el-table-column v-if="columns[5].visible" label="保险状态" align="center" prop="insuranceStatus" width="100">
-          <template #default="scope">
-            <el-tag :type="getInsuranceType(scope.row)">{{ getInsuranceLabel(scope.row) }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column v-if="columns[6].visible" label="保险有效期" align="center" prop="insuranceExpireDate" width="120">
+
+        <el-table-column label="岗位证书" align="center" prop="certificates" min-width="150" :show-overflow-tooltip="true">
           <template #default="scope">
-            <span>{{ scope.row.insuranceExpireDate ? scope.row.insuranceExpireDate.substring(0, 10) : '-' }}</span>
+            <span>{{ getCertNames(scope.row.certificates) }}</span>
           </template>
         </el-table-column>
-        <el-table-column v-if="columns[7].visible" label="岗位证书" align="center" width="120">
+
+        <el-table-column label="工作门店" align="center" prop="workStoreName" min-width="140" :show-overflow-tooltip="true" />
+
+        <el-table-column label="保险状态" align="center" prop="insuranceStatus" width="110">
           <template #default="scope">
-            <el-button link type="primary" @click="viewCertificates(scope.row)">
-              {{ getCertCount(scope.row.certificates) }}个证书
-            </el-button>
+            <el-tag :type="getInsuranceType(scope.row)">{{ getInsuranceLabel(scope.row) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column v-if="columns[8].visible" label="创建时间" align="center" prop="createTime" width="160" />
-        <el-table-column label="操作" fixed="right" width="200" class-name="small-padding fixed-width">
+
+        <el-table-column label="操作" fixed="right" width="220" class-name="small-padding fixed-width">
           <template #default="scope">
-            <el-tooltip content="修改" placement="top">
-              <el-button v-has-permi="['riskctrl:personInfo:edit']" link type="primary" icon="Edit" @click="handleUpdate(scope.row)"></el-button>
-            </el-tooltip>
-            <el-tooltip content="查看证书" placement="top">
-              <el-button link type="primary" icon="Document" @click="viewCertificates(scope.row)"></el-button>
-            </el-tooltip>
-            <el-tooltip content="删除" placement="top">
-              <el-button v-has-permi="['riskctrl:personInfo:remove']" link type="primary" icon="Delete" @click="handleDelete(scope.row)"></el-button>
-            </el-tooltip>
+            <el-button link type="primary" @click="viewCertificates(scope.row)">查看证书</el-button>
+            <el-button v-has-permi="['riskctrl:personInfo:edit']" link type="warning" @click="handleUpdate(scope.row)">编辑</el-button>
+            <el-button v-has-permi="['riskctrl:personInfo:remove']" link type="danger" @click="handleDelete(scope.row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -159,108 +100,146 @@
     </el-card>
 
     <!-- 新增/修改弹窗 -->
-    <el-dialog v-model="open" :title="title" width="700px" append-to-body>
-      <el-form ref="personFormRef" :model="form" :rules="rules" label-width="100px">
+    <el-dialog v-model="open" :title="dialogTitle" width="980px" append-to-body :close-on-click-modal="false" destroy-on-close class="person-dialog">
+      <!--      <div class="dialog-banner">-->
+      <!--        <div class="dialog-banner-title">-->
+      <!--          <span class="dialog-banner-icon">+</span>-->
+      <!--          <span>{{ dialogTitle }}</span>-->
+      <!--        </div>-->
+      <!--        <div class="dialog-banner-desc">请填写人员详细信息,带 <span class="required-star">*</span> 为必填项</div>-->
+      <!--      </div>-->
+
+      <!--      <div class="section-title">人员信息</div>-->
+
+      <el-form ref="personFormRef" :model="form" :rules="rules" label-position="top" class="person-form">
         <el-row :gutter="20">
           <el-col :span="12">
-            <el-form-item label="人员类型" prop="personType">
-              <el-select v-model="form.personType" placeholder="请选择" class="w-full">
-                <el-option
-                  v-for="dict in person_type_options"
-                  :key="dict.value"
-                  :label="dict.label"
-                  :value="dict.value"
-                />
+            <el-form-item label="人员类型" prop="personType" required>
+              <el-select v-model="form.personType" placeholder="请选择人员类型" class="w-full">
+                <el-option v-for="dict in person_type_options" :key="dict.value" :label="dict.label" :value="dict.value" />
               </el-select>
             </el-form-item>
           </el-col>
+
           <el-col :span="12">
-            <el-form-item label="姓名" prop="personName">
+            <el-form-item label="姓名" prop="personName" required>
               <el-input v-model="form.personName" placeholder="请输入姓名" />
             </el-form-item>
           </el-col>
         </el-row>
+
         <el-row :gutter="20">
           <el-col :span="12">
-            <el-form-item label="身份证号" prop="idCard">
+            <el-form-item label="身份证号" prop="idCard" required>
               <el-input v-model="form.idCard" placeholder="请输入身份证号" maxlength="18" @blur="calcAge" />
             </el-form-item>
           </el-col>
+
           <el-col :span="12">
             <el-form-item label="年龄">
               <el-input v-model="form.age" placeholder="自动计算" disabled />
             </el-form-item>
           </el-col>
         </el-row>
+
         <el-row :gutter="20">
           <el-col :span="12">
-            <el-form-item label="工作门店" prop="workStoreId">
+            <el-form-item label="工作门店" prop="workStoreId" required>
               <el-tree-select
                 v-model="form.workStoreId"
                 :data="deptOptions"
                 :props="{ value: 'id', label: 'label', children: 'children' }"
                 value-key="id"
-                placeholder="请选择门店"
+                placeholder="请选择工作门店"
                 check-strictly
                 clearable
                 class="w-full"
               />
             </el-form-item>
           </el-col>
+
           <el-col :span="12">
-            <el-form-item label="保险状态" prop="insuranceStatus">
-              <el-select v-model="form.insuranceStatus" placeholder="请选择" class="w-full">
+            <el-form-item label="保险状态" prop="insuranceStatus" required>
+              <el-select v-model="form.insuranceStatus" placeholder="请选择保险状态" class="w-full">
                 <el-option label="已投保" value="1" />
                 <el-option label="未投保" value="0" />
               </el-select>
             </el-form-item>
           </el-col>
         </el-row>
-        <el-form-item v-if="form.insuranceStatus === '1'" label="保险有效期" prop="insuranceExpireDate">
-          <el-date-picker v-model="form.insuranceExpireDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" class="w-full" />
-        </el-form-item>
-        <el-form-item label="岗位证书">
-          <div class="w-full">
-            <div v-for="(cert, index) in certificateList" :key="index" class="flex items-center gap-2 mb-2">
-              <el-select v-model="cert.certType" placeholder="证书类型" style="width: 180px">
-                <el-option
-                  v-for="dict in certificate_type_options"
-                  :key="dict.value"
-                  :label="dict.label"
-                  :value="dict.value"
-                />
-              </el-select>
-              <el-upload
-                :action="uploadAction"
-                :headers="uploadHeaders"
-                :show-file-list="false"
-                :on-success="(res: any) => handleCertUploadSuccess(res, index)"
-                accept=".jpg,.png,.pdf"
-              >
-                <el-button type="primary" link>
-                  {{ cert.certUrl ? '重新上传' : '上传证书' }}
-                </el-button>
-              </el-upload>
-              <el-tag v-if="cert.certUrl" type="success" size="small">已上传</el-tag>
-              <el-button type="danger" link icon="Delete" @click="removeCertificate(index)"></el-button>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="保险有效期开始" prop="insuranceStartDate" :required="form.insuranceStatus === '1'">
+              <el-date-picker
+                v-model="form.insuranceStartDate"
+                type="date"
+                placeholder="年/月/日"
+                value-format="YYYY-MM-DD"
+                class="w-full"
+                :disabled-date="disabledDate"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12">
+            <el-form-item label="保险有效期结束" prop="insuranceEndDate" :required="form.insuranceStatus === '1'">
+              <el-date-picker
+                v-model="form.insuranceEndDate"
+                type="date"
+                placeholder="年/月/日"
+                value-format="YYYY-MM-DD"
+                class="w-full"
+                :disabled-date="disabledDate"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-form-item label="岗位证书" required>
+          <div class="certificate-section">
+            <div v-for="(cert, index) in certificateList" :key="index" class="certificate-row">
+              <el-row :gutter="12" align="middle">
+                <el-col :span="8">
+                  <el-select v-model="cert.certType" placeholder="请选择证书类型" class="w-full">
+                    <el-option v-for="dict in certificate_type_options" :key="dict.value" :label="dict.label" :value="dict.value" />
+                  </el-select>
+                </el-col>
+
+                <el-col :span="14">
+                  <div class="cert-file-group">
+                    <el-input :model-value="cert.certName || ''" placeholder="请选择文件" readonly class="cert-file-input" />
+                    <el-upload
+                      :action="uploadAction"
+                      :headers="uploadHeaders"
+                      :show-file-list="false"
+                      :accept="'.jpg,.jpeg,.png,.pdf'"
+                      :before-upload="beforeCertUpload"
+                      :on-success="(res: any, file: any) => handleCertUploadSuccess(res, file, index)"
+                    >
+                      <el-button type="primary">选择文件</el-button>
+                    </el-upload>
+                  </div>
+                </el-col>
+
+                <el-col :span="2">
+                  <el-button link type="danger" icon="Delete" @click="removeCertificate(index)" />
+                </el-col>
+              </el-row>
+            </div>
+
+            <div class="add-cert-box" @click="addCertificate">
+              <span class="add-cert-plus">+</span>
+              <span>添加证书</span>
             </div>
-            <el-button type="primary" link icon="Plus" @click="addCertificate">添加证书</el-button>
           </div>
         </el-form-item>
-        <el-form-item label="状态" prop="status">
-          <el-radio-group v-model="form.status">
-            <el-radio label="0">正常</el-radio>
-            <el-radio label="1">停用</el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-form-item label="备注" prop="remark">
-          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
-        </el-form-item>
       </el-form>
+
       <template #footer>
         <div class="dialog-footer">
-          <el-button type="primary" @click="submitForm">确 定</el-button>
-          <el-button @click="cancel">取 消</el-button>
+          <el-button @click="cancel">取消</el-button>
+          <el-button type="primary" @click="submitForm">保存</el-button>
         </div>
       </template>
     </el-dialog>
@@ -270,7 +249,13 @@
       <div v-if="currentCerts.length > 0">
         <div v-for="(cert, index) in currentCerts" :key="index" class="cert-item mb-4 p-4 border rounded">
           <p class="font-bold mb-2">证书类型:{{ cert.certTypeName || cert.certType }}</p>
-          <el-image v-if="isImage(cert.certUrl)" :src="cert.certUrl" style="max-width: 100%; max-height: 300px;" fit="contain" :preview-src-list="[cert.certUrl]" />
+          <el-image
+            v-if="isImage(cert.certUrl)"
+            :src="cert.certUrl"
+            style="max-width: 100%; max-height: 300px"
+            fit="contain"
+            :preview-src-list="[cert.certUrl]"
+          />
           <div v-else class="text-center py-4">
             <el-icon :size="48" color="#409EFF"><Document /></el-icon>
             <p class="mt-2">
@@ -285,44 +270,65 @@
 </template>
 
 <script setup name="PersonInfo" lang="ts">
-import { listPersonInfo, getPersonInfo, delPersonInfo, addPersonInfo, updatePersonInfo, getPersonStats } from '@/api/riskctrl/personInfo';
-import { PersonInfoForm, PersonInfoQuery, PersonInfoVO, Certificate } from '@/api/riskctrl/personInfo/types';
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from 'vue';
+import type { ComponentInternalInstance } from 'vue';
+import type { FormInstance } from 'element-plus';
+import { Document, Delete, Plus } from '@element-plus/icons-vue';
+import { listPersonInfo, getPersonInfo, delPersonInfo, addPersonInfo, updatePersonInfo } from '@/api/riskctrl/personInfo';
+import type { PersonInfoForm, PersonInfoQuery, PersonInfoVO } from '@/api/riskctrl/personInfo/types';
 import { listDept } from '@/api/system/dept';
 import { getToken } from '@/utils/auth';
+import { getDictOptions } from '@/api/riskctrl/dictData';
+
+interface CertificateItem {
+  certType: string;
+  certTypeName?: string;
+  certUrl: string;
+  certName?: string;
+}
+
+type TagType = 'success' | 'warning' | 'danger' | 'info' | 'primary';
+
+type PersonForm = PersonInfoForm & {
+  insuranceStartDate?: string;
+  insuranceEndDate?: string;
+  certificates?: string;
+  workStoreName?: string;
+};
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
 const personList = ref<PersonInfoVO[]>([]);
 const loading = ref(true);
-const showSearch = ref(true);
-const ids = ref<Array<string | number>>([]);
-const single = ref(true);
-const multiple = ref(true);
 const total = ref(0);
-const title = ref('');
 const open = ref(false);
 const certOpen = ref(false);
 
-const queryFormRef = ref<ElFormInstance>();
-const personFormRef = ref<ElFormInstance>();
+const queryFormRef = ref<FormInstance>();
+const personFormRef = ref<FormInstance>();
+
+const deptOptions = ref<any[]>([]);
+const person_type_options = ref<any[]>([]);
+const certificate_type_options = ref<any[]>([]);
 
-const statsData = reactive({
-  total: 0,
-  insured: 0,
-  uninsured: 0,
-  expiringSoon: 0
+const uploadAction = ref(import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload');
+const uploadHeaders = ref({
+  Authorization: 'Bearer ' + getToken(),
+  clientid: import.meta.env.VITE_APP_CLIENT_ID
 });
 
+const dialogMode = ref<'add' | 'edit'>('add');
+const dialogTitle = computed(() => (dialogMode.value === 'add' ? '新增人员信息' : '编辑人员信息'));
+
 const queryParams = reactive<PersonInfoQuery>({
   pageNum: 1,
   pageSize: 10,
   personName: undefined,
   personType: undefined,
-  workStoreId: undefined,
-  insuranceStatus: undefined
+  workStoreId: undefined
 });
 
-const form = reactive<PersonInfoForm>({
+const form = reactive<PersonForm>({
   personId: undefined,
   personType: '',
   personName: '',
@@ -331,74 +337,83 @@ const form = reactive<PersonInfoForm>({
   workStoreId: undefined,
   workStoreName: '',
   insuranceStatus: '0',
-  insuranceExpireDate: undefined,
+  insuranceStartDate: '',
+  insuranceEndDate: '',
   certificates: '[]',
   status: '0',
   remark: ''
 });
 
-const certificateList = ref<Certificate[]>([]);
-const currentCerts = ref<Certificate[]>([]);
-
-const deptOptions = ref<any[]>([]);
-const person_type_options = ref<any[]>([]);
-const certificate_type_options = ref<any[]>([]);
-
-const uploadAction = ref(import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload');
-const uploadHeaders = ref({ Authorization: 'Bearer ' + getToken(), clientid: import.meta.env.VITE_APP_CLIENT_ID });
+const certificateList = ref<CertificateItem[]>([]);
+const currentCerts = ref<CertificateItem[]>([]);
 
-const rules = {
+const rules = computed(() => ({
   personType: [{ required: true, message: '人员类型不能为空', trigger: 'change' }],
   personName: [{ required: true, message: '姓名不能为空', trigger: 'blur' }],
   idCard: [
     { required: true, message: '身份证号不能为空', trigger: 'blur' },
-    { pattern: /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/, message: '身份证号格式不正确', trigger: 'blur' }
+    {
+      pattern: /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/,
+      message: '身份证号格式不正确',
+      trigger: 'blur'
+    }
   ],
-  insuranceStatus: [{ required: true, message: '保险状态不能为空', trigger: 'change' }]
+  workStoreId: [{ required: true, message: '工作门店不能为空', trigger: 'change' }],
+  insuranceStatus: [{ required: true, message: '保险状态不能为空', trigger: 'change' }],
+  insuranceStartDate: form.insuranceStatus === '1' ? [{ required: true, message: '请选择保险有效期开始', trigger: 'change' }] : [],
+  insuranceEndDate:
+    form.insuranceStatus === '1'
+      ? [
+          { required: true, message: '请选择保险有效期结束', trigger: 'change' },
+          {
+            validator: (_rule: any, value: string, callback: any) => {
+              if (value && form.insuranceStartDate && value < form.insuranceStartDate) {
+                callback(new Error('保险有效期结束不能早于开始日期'));
+              } else {
+                callback();
+              }
+            },
+            trigger: 'change'
+          }
+        ]
+      : []
+}));
+
+const disabledDate = (time: Date) => {
+  return time.getTime() > Date.now();
 };
 
-const columns = ref([
-  { key: 0, label: '人员类型', visible: true },
-  { key: 1, label: '姓名', visible: true },
-  { key: 2, label: '年龄', visible: true },
-  { key: 3, label: '身份证号', visible: true },
-  { key: 4, label: '工作门店', visible: true },
-  { key: 5, label: '保险状态', visible: true },
-  { key: 6, label: '保险有效期', visible: true },
-  { key: 7, label: '岗位证书', visible: true },
-  { key: 8, label: '创建时间', visible: true }
-]);
-
-const loadStats = async () => {
-  try {
-    const res = await getPersonStats();
-    Object.assign(statsData, res.data);
-  } catch { /* ignore */ }
-};
+const createEmptyCertificate = (): CertificateItem => ({
+  certType: '',
+  certUrl: '',
+  certName: ''
+});
 
 const getList = async () => {
   loading.value = true;
-  const res = await listPersonInfo(queryParams);
-  personList.value = res.rows;
-  total.value = res.total;
-  loading.value = false;
+  try {
+    const res: any = await listPersonInfo(queryParams as any);
+    personList.value = res.rows || [];
+    total.value = res.total || 0;
+  } catch (error) {
+    console.error('加载人员列表失败', error);
+    personList.value = [];
+    total.value = 0;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const getDeptTree = async () => {
-  const res = await listDept();
-  deptOptions.value = res.data;
+  const res: any = await listDept();
+  deptOptions.value = res.data || [];
 };
 
 const getDicts = async () => {
-  const ptRes = await proxy?.getDicts('person_type');
-  person_type_options.value = ptRes.data || [];
-  const ctRes = await proxy?.getDicts('certificate_type');
-  certificate_type_options.value = ctRes.data || [];
-};
+  const [personRes, certRes] = await Promise.all([getDictOptions('person_type'), getDictOptions('certificate_type')]);
 
-const cancel = () => {
-  open.value = false;
-  reset();
+  person_type_options.value = personRes;
+  certificate_type_options.value = certRes;
 };
 
 const reset = () => {
@@ -410,16 +425,27 @@ const reset = () => {
   form.workStoreId = undefined;
   form.workStoreName = '';
   form.insuranceStatus = '0';
-  form.insuranceExpireDate = undefined;
+  form.insuranceStartDate = '';
+  form.insuranceEndDate = '';
   form.certificates = '[]';
   form.status = '0';
   form.remark = '';
-  certificateList.value = [];
+  certificateList.value = [createEmptyCertificate()];
   nextTick(() => {
-    personFormRef.value?.resetFields();
+    personFormRef.value?.clearValidate();
   });
 };
 
+watch(
+  () => form.insuranceStatus,
+  (val) => {
+    if (val === '0') {
+      form.insuranceStartDate = '';
+      form.insuranceEndDate = '';
+    }
+  }
+);
+
 const handleQuery = () => {
   queryParams.pageNum = 1;
   getList();
@@ -427,96 +453,164 @@ const handleQuery = () => {
 
 const resetQuery = () => {
   queryFormRef.value?.resetFields();
-  handleQuery();
-};
-
-const handleSelectionChange = (selection: PersonInfoVO[]) => {
-  ids.value = selection.map((item) => item.personId);
-  single.value = selection.length !== 1;
-  multiple.value = !selection.length;
+  queryParams.pageNum = 1;
+  queryParams.personName = undefined;
+  queryParams.personType = undefined;
+  queryParams.workStoreId = undefined;
+  getList();
 };
 
 const handleAdd = () => {
+  dialogMode.value = 'add';
   reset();
   open.value = true;
-  title.value = '新增人员';
 };
 
-const handleUpdate = async (row?: PersonInfoVO) => {
+const handleUpdate = async (row: PersonInfoVO) => {
+  dialogMode.value = 'edit';
   reset();
-  const personId = row?.personId || ids.value[0];
-  const res = await getPersonInfo(personId);
-  Object.assign(form, res.data);
+
   try {
-    certificateList.value = JSON.parse(form.certificates || '[]');
-  } catch {
-    certificateList.value = [];
+    const res: any = await getPersonInfo(row.personId);
+    const data: any = res.data || res;
+    Object.assign(form, data);
+
+    form.insuranceStartDate = data.insuranceStartDate || '';
+    form.insuranceEndDate = data.insuranceEndDate || data.insuranceExpireDate || '';
+
+    try {
+      const parsed = typeof data.certificates === 'string' ? JSON.parse(data.certificates || '[]') : data.certificates || [];
+      certificateList.value = Array.isArray(parsed) && parsed.length > 0 ? parsed : [createEmptyCertificate()];
+    } catch {
+      certificateList.value = [createEmptyCertificate()];
+    }
+
+    open.value = true;
+  } catch (error) {
+    console.error('获取人员详情失败', error);
   }
-  open.value = true;
-  title.value = '修改人员';
 };
 
 const submitForm = () => {
   personFormRef.value?.validate(async (valid: boolean) => {
-    if (valid) {
-      form.certificates = JSON.stringify(certificateList.value);
-      if (form.workStoreId && deptOptions.value.length > 0) {
-        const dept = findDeptById(deptOptions.value, String(form.workStoreId));
-        if (dept) form.workStoreName = dept.label;
+    if (!valid) return;
+
+    const certValid = certificateList.value.length > 0 && certificateList.value.every((item) => item.certType && item.certUrl);
+    if (!certValid) {
+      proxy?.$modal.msgError('请完善岗位证书信息');
+      return;
+    }
+
+    try {
+      const dept = form.workStoreId ? findDeptById(deptOptions.value, String(form.workStoreId)) : null;
+      form.workStoreName = dept?.label || '';
+
+      const payload: any = {
+        ...form,
+        insuranceExpireDate: form.insuranceEndDate || '',
+        certificates: JSON.stringify(
+          certificateList.value.map((item) => ({
+            certType: item.certType,
+            certTypeName: getCertTypeLabel(item.certType),
+            certUrl: item.certUrl,
+            certName: item.certName || getFileNameFromUrl(item.certUrl)
+          }))
+        )
+      };
+
+      if (payload.insuranceStatus === '0') {
+        payload.insuranceStartDate = '';
+        payload.insuranceEndDate = '';
+        payload.insuranceExpireDate = '';
       }
-      if (form.personId !== undefined) {
-        await updatePersonInfo(form);
+
+      if (payload.personId !== undefined) {
+        await updatePersonInfo(payload);
         proxy?.$modal.msgSuccess('修改成功');
       } else {
-        await addPersonInfo(form);
+        await addPersonInfo(payload);
         proxy?.$modal.msgSuccess('新增成功');
       }
+
       open.value = false;
       await getList();
-      await loadStats();
+    } catch (error) {
+      console.error('提交失败', error);
     }
   });
 };
 
-const handleDelete = async (row?: PersonInfoVO) => {
-  const personIds = row?.personId || ids.value;
-  await proxy?.$modal.confirm('是否确认删除姓名为"' + (row?.personName || '') + '"的数据项?');
-  await delPersonInfo(personIds);
-  await getList();
-  await loadStats();
-  proxy?.$modal.msgSuccess('删除成功');
+const cancel = () => {
+  open.value = false;
+  reset();
+};
+
+const handleDelete = async (row: PersonInfoVO) => {
+  await proxy?.$modal.confirm('是否确认删除姓名为"' + (row.personName || '') + '"的数据项?');
+  try {
+    await delPersonInfo(row.personId);
+    proxy?.$modal.msgSuccess('删除成功');
+    await getList();
+  } catch (error) {
+    console.error('删除失败', error);
+  }
 };
 
 const calcAge = () => {
   if (!form.idCard || form.idCard.length !== 18) return;
-  const year = parseInt(form.idCard.substring(6, 10));
+  const year = parseInt(form.idCard.substring(6, 10), 10);
   const currentYear = new Date().getFullYear();
   form.age = currentYear - year;
 };
 
 const addCertificate = () => {
-  certificateList.value.push({ certType: '', certUrl: '' });
+  certificateList.value.push(createEmptyCertificate());
 };
 
 const removeCertificate = (index: number) => {
   certificateList.value.splice(index, 1);
+  if (certificateList.value.length === 0) {
+    certificateList.value.push(createEmptyCertificate());
+  }
+};
+
+const getUploadUrl = (res: any) => {
+  return res?.data?.url || res?.url || res?.fileName || '';
+};
+
+const beforeCertUpload = (file: File) => {
+  const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png'];
+  if (!allowedTypes.includes(file.type)) {
+    proxy?.$modal.msgError('仅支持 PDF、JPG、PNG 格式的文件');
+    return false;
+  }
+  const isLt10M = file.size / 1024 / 1024 < 10;
+  if (!isLt10M) {
+    proxy?.$modal.msgError('文件大小不能超过 10MB');
+    return false;
+  }
+  return true;
 };
 
-const handleCertUploadSuccess = (res: any, index: number) => {
-  if (res.code === 200) {
-    certificateList.value[index].certUrl = res.url || res.fileName;
-    proxy?.$modal.msgSuccess('上传成功');
-  } else {
-    proxy?.$modal.msgError(res.msg || '上传失败');
+const handleCertUploadSuccess = (res: any, file: any, index: number) => {
+  const url = getUploadUrl(res);
+  if (!url) {
+    proxy?.$modal.msgError(res?.msg || '上传失败');
+    return;
   }
+
+  if (!certificateList.value[index]) return;
+  certificateList.value[index].certUrl = url;
+  certificateList.value[index].certName = file?.name || getFileNameFromUrl(url);
+  proxy?.$modal.msgSuccess('上传成功');
 };
 
 const viewCertificates = (row: PersonInfoVO) => {
   try {
-    currentCerts.value = JSON.parse(row.certificates || '[]');
+    const parsed = typeof row.certificates === 'string' ? JSON.parse(row.certificates || '[]') : row.certificates || [];
+    currentCerts.value = Array.isArray(parsed) ? parsed : [];
     currentCerts.value.forEach((cert: any) => {
-      const dict = certificate_type_options.value.find((d: any) => d.value === cert.certType);
-      if (dict) cert.certTypeName = dict.label;
+      cert.certTypeName = getCertTypeLabel(cert.certType);
     });
   } catch {
     currentCerts.value = [];
@@ -536,13 +630,35 @@ const getCertCount = (certs: string) => {
   }
 };
 
+const getCertNames = (certs: string) => {
+  try {
+    const list = JSON.parse(certs || '[]');
+    if (!Array.isArray(list) || !list.length) return '-';
+    return list
+      .map((item: any) => getCertTypeLabel(item.certType) || item.certType || '')
+      .filter(Boolean)
+      .join('、');
+  } catch {
+    return '-';
+  }
+};
+
+const getFileNameFromUrl = (url: string) => {
+  if (!url) return '未知文件';
+  const parts = url.split('/');
+  return parts[parts.length - 1] || '未知文件';
+};
+
+const getCertTypeLabel = (type: string) => {
+  const dict = certificate_type_options.value.find((d: any) => d.value === type);
+  return dict?.label || type || '';
+};
+
 const maskIdCard = (idCard: string) => {
-  if (!idCard || idCard.length < 8) return idCard;
+  if (!idCard || idCard.length < 8) return idCard || '-';
   return idCard.substring(0, 4) + '**********' + idCard.substring(idCard.length - 4);
 };
 
-type TagType = 'success' | 'warning' | 'danger' | 'info' | 'primary';
-
 const getPersonTypeType = (type: string): TagType => {
   const map: Record<string, TagType> = {
     '防损人员': 'danger',
@@ -552,26 +668,29 @@ const getPersonTypeType = (type: string): TagType => {
   return map[type] || 'info';
 };
 
-const getInsuranceType = (row: PersonInfoVO): TagType => {
-  if (row.insuranceStatus === '0') return 'danger';
-  if (isExpiringSoon(row.insuranceExpireDate)) return 'warning';
-  return 'success';
-};
-
-const getInsuranceLabel = (row: PersonInfoVO): string => {
-  if (row.insuranceStatus === '0') return '未投保';
-  if (isExpiringSoon(row.insuranceExpireDate)) return '即将到期';
-  return '已投保';
-};
-
 const isExpiringSoon = (dateStr?: string) => {
   if (!dateStr) return false;
   const expire = new Date(dateStr);
+  if (Number.isNaN(expire.getTime())) return false;
   const now = new Date();
   const diff = expire.getTime() - now.getTime();
   return diff > 0 && diff <= 30 * 24 * 60 * 60 * 1000;
 };
 
+const getInsuranceType = (row: any): TagType => {
+  if (row.insuranceStatus === '0') return 'danger';
+  const endDate = row.insuranceEndDate || row.insuranceExpireDate;
+  if (isExpiringSoon(endDate)) return 'warning';
+  return 'success';
+};
+
+const getInsuranceLabel = (row: any): string => {
+  if (row.insuranceStatus === '0') return '未投保';
+  const endDate = row.insuranceEndDate || row.insuranceExpireDate;
+  if (isExpiringSoon(endDate)) return '即将到期';
+  return '已投保';
+};
+
 const findDeptById = (depts: any[], id: string): any => {
   for (const dept of depts) {
     if (String(dept.id) === id) return dept;
@@ -585,8 +704,226 @@ const findDeptById = (depts: any[], id: string): any => {
 
 onMounted(() => {
   getList();
-  loadStats();
   getDeptTree();
   getDicts();
 });
 </script>
+
+<style scoped lang="scss">
+.person-page {
+  padding: 16px;
+  background: #f5f7fb;
+  min-height: calc(100vh - 50px);
+}
+
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 14px;
+}
+
+.page-title {
+  font-size: 22px;
+  font-weight: 700;
+  color: #1f2937;
+  margin-bottom: 6px;
+}
+
+.page-desc {
+  font-size: 14px;
+  color: #6b7280;
+}
+
+.search-card,
+.table-card {
+  border-radius: 14px;
+  border: none;
+  margin-bottom: 14px;
+}
+
+.search-form {
+  width: 100%;
+}
+
+.search-actions {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 6px;
+}
+
+.list-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin: 6px 0 10px;
+}
+
+.list-total {
+  color: #64748b;
+  font-size: 14px;
+}
+
+.person-dialog {
+  :deep(.el-dialog__header) {
+    margin-right: 0;
+    padding: 16px 20px 10px;
+    border-bottom: 1px solid #eef2f7;
+  }
+
+  :deep(.el-dialog__body) {
+    padding: 18px 20px 12px;
+  }
+
+  :deep(.el-dialog__footer) {
+    padding: 14px 20px 20px;
+    border-top: 1px solid #eef2f7;
+  }
+}
+
+.dialog-banner {
+  margin-bottom: 16px;
+}
+
+.dialog-banner-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #2563eb;
+  font-size: 18px;
+  font-weight: 700;
+}
+
+.dialog-banner-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 18px;
+  height: 18px;
+  font-size: 18px;
+  line-height: 1;
+}
+
+.dialog-banner-desc {
+  margin-top: 6px;
+  color: #64748b;
+  font-size: 13px;
+}
+
+.required-star {
+  color: #f56c6c;
+  font-weight: 700;
+}
+
+.section-title {
+  font-size: 15px;
+  font-weight: 700;
+  color: #1f2937;
+  padding: 10px 0 12px;
+  border-bottom: 1px solid #eef2f7;
+  margin-bottom: 18px;
+}
+
+.person-form {
+  :deep(.el-form-item) {
+    margin-bottom: 18px;
+  }
+
+  :deep(.el-form-item__label) {
+    font-weight: 600;
+    color: #374151;
+    padding-bottom: 8px;
+  }
+
+  :deep(.el-input__wrapper),
+  :deep(.el-select__wrapper),
+  :deep(.el-textarea__inner) {
+    border-radius: 8px;
+  }
+
+  :deep(.el-form-item.is-required > .el-form-item__label:before) {
+    color: #f56c6c;
+  }
+}
+
+.certificate-section {
+  width: 100%;
+}
+
+.certificate-row {
+  padding: 12px 0;
+  border-bottom: 1px dashed #e5e7eb;
+}
+
+.certificate-row:last-child {
+  border-bottom: none;
+}
+
+.cert-file-group {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+}
+
+.cert-file-input {
+  flex: 1;
+  min-width: 0;
+}
+
+.cert-file-group :deep(.el-input__wrapper) {
+  border-radius: 8px;
+}
+
+.cert-file-group .el-upload {
+  flex-shrink: 0;
+}
+
+.add-cert-box {
+  margin-top: 12px;
+  width: 30%;
+  min-width: 220px;
+  height: 44px;
+  border: 1px dashed #cbd5e1;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  color: #2563eb;
+  background: #f8fbff;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.add-cert-box:hover {
+  border-color: #93c5fd;
+  background: #eff6ff;
+}
+
+.add-cert-plus {
+  font-size: 20px;
+  font-weight: 700;
+  line-height: 1;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+
+.cert-item {
+  background: #fff;
+}
+
+:deep(.el-button.is-link) {
+  padding-left: 0;
+  padding-right: 0;
+}
+
+:deep(.el-input-group__append) {
+  padding: 0;
+  background: #fff;
+}
+</style>