index.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905
  1. <template>
  2. <div class="p-2 personnel-page">
  3. <!-- 页面标题 -->
  4. <div class="page-header">
  5. <h2 class="page-title">人员信息管理</h2>
  6. <p class="page-desc">管理企业所有工作人员基本信息和岗位证书</p>
  7. </div>
  8. <!-- 筛选区域 -->
  9. <el-card shadow="hover" class="filter-card">
  10. <el-form ref="queryFormRef" :model="queryParams" :inline="true" label-width="80">
  11. <el-form-item label="姓名" prop="personnelName">
  12. <el-input
  13. v-model="queryParams.personnelName"
  14. placeholder="请输入姓名搜索"
  15. style="width: 220px"
  16. clearable
  17. @keyup.enter="handleQuery"
  18. />
  19. </el-form-item>
  20. <el-form-item label="人员类型" prop="jobType">
  21. <el-select v-model="queryParams.jobType" placeholder="全部类型" style="width: 200px" @change="handleQuery">
  22. <el-option label="全部类型" value="" />
  23. <el-option
  24. v-for="d in personTypeOptions"
  25. :key="d.value"
  26. :label="d.label"
  27. :value="d.value"
  28. />
  29. </el-select>
  30. </el-form-item>
  31. <el-form-item label="工作门店" prop="storeId">
  32. <el-select
  33. v-model="queryParams.storeId"
  34. placeholder="请选择工作门店"
  35. clearable
  36. filterable
  37. style="width: 220px"
  38. @change="handleQuery"
  39. >
  40. <el-option
  41. v-for="d in storeOptions"
  42. :key="d.value"
  43. :label="d.label"
  44. :value="d.value"
  45. />
  46. </el-select>
  47. </el-form-item>
  48. <el-form-item>
  49. <el-button type="primary" @click="handleQuery">搜索</el-button>
  50. <el-button @click="resetQuery">重置</el-button>
  51. </el-form-item>
  52. </el-form>
  53. </el-card>
  54. <!-- 列表 -->
  55. <el-card shadow="hover" class="list-card">
  56. <div class="list-toolbar">
  57. <span class="list-total">共 {{ total }} 条记录</span>
  58. <el-button type="primary" :icon="Plus" @click="handleAdd">新增人员</el-button>
  59. </div>
  60. <el-table v-loading="loading" :data="personnelList" border stripe>
  61. <el-table-column type="index" label="序号" align="center" width="60" />
  62. <el-table-column label="类型" align="center" prop="jobType" width="100">
  63. <template #default="scope">
  64. <el-tag :type="getJobTypeTag(scope.row.jobType)">
  65. {{ getDictLabel(personTypeOptions, scope.row.jobType) || scope.row.jobType || '-' }}
  66. </el-tag>
  67. </template>
  68. </el-table-column>
  69. <el-table-column label="姓名" align="center" prop="personnelName" width="100" />
  70. <el-table-column label="年龄" align="center" prop="age" width="70" />
  71. <el-table-column label="身份证号" align="center" prop="idCard" width="200">
  72. <template #default="scope">
  73. <span>{{ maskIdCard(scope.row.idCard) }}</span>
  74. </template>
  75. </el-table-column>
  76. <el-table-column label="岗位证书" align="center" min-width="200">
  77. <template #default="scope">
  78. <el-tag
  79. v-for="(cert, idx) in getPersonnelCerts(scope.row.id)"
  80. :key="idx"
  81. type="success"
  82. size="small"
  83. style="margin-right: 4px; margin-bottom: 4px"
  84. >
  85. {{ cert.certName || cert.certType }}
  86. </el-tag>
  87. <span v-if="getPersonnelCerts(scope.row.id).length === 0" class="text-placeholder">暂无证书</span>
  88. </template>
  89. </el-table-column>
  90. <el-table-column label="工作门店" align="center" prop="storeName" :show-overflow-tooltip="true" width="160" />
  91. <el-table-column label="保险状态" align="center" width="110">
  92. <template #default="scope">
  93. <el-tag :type="getInsuranceTag(scope.row)">{{ getInsuranceLabel(scope.row) }}</el-tag>
  94. </template>
  95. </el-table-column>
  96. <el-table-column label="操作" align="center" width="220" fixed="right">
  97. <template #default="scope">
  98. <el-button link type="primary" @click="handleViewCert(scope.row)">查看证书</el-button>
  99. <el-button link type="primary" @click="handleUpdate(scope.row)">编辑</el-button>
  100. <el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
  101. </template>
  102. </el-table-column>
  103. </el-table>
  104. <pagination
  105. v-show="total > 0"
  106. v-model:page="queryParams.pageNum"
  107. v-model:limit="queryParams.pageSize"
  108. :total="total"
  109. @pagination="getList"
  110. />
  111. </el-card>
  112. <!-- 新增/编辑弹窗 -->
  113. <el-dialog
  114. v-model="dialog.visible"
  115. :title="dialog.title"
  116. width="820px"
  117. append-to-body
  118. :close-on-click-modal="false"
  119. @close="closeDialog"
  120. >
  121. <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
  122. <el-row :gutter="20">
  123. <el-col :span="12">
  124. <el-form-item label="人员类型" prop="jobType">
  125. <el-select v-model="form.jobType" placeholder="请选择人员类型" style="width: 100%">
  126. <el-option
  127. v-for="d in personTypeOptions"
  128. :key="d.value"
  129. :label="d.label"
  130. :value="d.value"
  131. />
  132. </el-select>
  133. </el-form-item>
  134. </el-col>
  135. <el-col :span="12">
  136. <el-form-item label="姓名" prop="personnelName">
  137. <el-input v-model="form.personnelName" placeholder="请输入姓名" maxlength="50" />
  138. </el-form-item>
  139. </el-col>
  140. </el-row>
  141. <el-row :gutter="20">
  142. <el-col :span="12">
  143. <el-form-item label="身份证号" prop="idCard">
  144. <el-input
  145. v-model="form.idCard"
  146. placeholder="请输入18位身份证号"
  147. maxlength="18"
  148. @blur="handleIdCardBlur"
  149. />
  150. </el-form-item>
  151. </el-col>
  152. <el-col :span="12">
  153. <el-form-item label="年龄" prop="age">
  154. <el-input-number
  155. v-model="form.age"
  156. :min="0"
  157. :max="150"
  158. placeholder="根据身份证自动计算"
  159. disabled
  160. style="width: 100%"
  161. />
  162. </el-form-item>
  163. </el-col>
  164. </el-row>
  165. <el-row :gutter="20">
  166. <el-col :span="12">
  167. <el-form-item label="工作门店" prop="storeId">
  168. <el-select
  169. v-model="form.storeId"
  170. placeholder="请选择工作门店"
  171. filterable
  172. style="width: 100%"
  173. @change="handleStoreChange"
  174. >
  175. <el-option
  176. v-for="d in storeOptions"
  177. :key="d.value"
  178. :label="d.label"
  179. :value="d.value"
  180. />
  181. </el-select>
  182. </el-form-item>
  183. </el-col>
  184. <el-col :span="12">
  185. <el-form-item label="保险状态" prop="insuranceStatus">
  186. <el-select
  187. v-model="form.insuranceStatus"
  188. placeholder="请选择保险状态"
  189. style="width: 100%"
  190. @change="handleInsuranceStatusChange"
  191. >
  192. <el-option
  193. v-for="d in insuranceStatusOptions"
  194. :key="d.value"
  195. :label="d.label"
  196. :value="d.value"
  197. />
  198. </el-select>
  199. </el-form-item>
  200. </el-col>
  201. </el-row>
  202. <!-- 保险有效期:仅已投保时必填 -->
  203. <el-row v-if="form.insuranceStatus === '已投保'" :gutter="20">
  204. <el-col :span="12">
  205. <el-form-item label="保险有效期开始" prop="insuranceStartDate">
  206. <el-date-picker
  207. v-model="form.insuranceStartDate"
  208. type="datetime"
  209. placeholder="请选择保险有效期开始"
  210. value-format="YYYY-MM-DD HH:mm:ss"
  211. format="YYYY-MM-DD HH:mm"
  212. style="width: 100%"
  213. />
  214. </el-form-item>
  215. </el-col>
  216. <el-col :span="12">
  217. <el-form-item label="保险有效期结束" prop="insuranceExpireDate">
  218. <el-date-picker
  219. v-model="form.insuranceExpireDate"
  220. type="datetime"
  221. placeholder="请选择保险有效期结束"
  222. value-format="YYYY-MM-DD HH:mm:ss"
  223. format="YYYY-MM-DD HH:mm"
  224. style="width: 100%"
  225. />
  226. </el-form-item>
  227. </el-col>
  228. </el-row>
  229. <!-- 岗位证书:动态项 -->
  230. <el-form-item label="岗位证书">
  231. <div class="cert-container">
  232. <div
  233. v-for="(cert, index) in form.certList"
  234. :key="cert.uid || index"
  235. class="cert-item"
  236. >
  237. <el-select
  238. v-model="cert.certType"
  239. placeholder="请选择证书类型"
  240. style="width: 220px"
  241. >
  242. <el-option
  243. v-for="d in certTypeOptions"
  244. :key="d.value"
  245. :label="d.label"
  246. :value="d.value"
  247. />
  248. </el-select>
  249. <el-upload
  250. v-model:file-list="cert.fileList"
  251. :http-request="customCertUpload"
  252. :limit="1"
  253. :accept="'.pdf,.jpg,.png,.jpeg'"
  254. :before-upload="beforeCertUpload"
  255. :on-success="(response, file) => handleCertUploadSuccess(response, file, index)"
  256. :on-error="handleUploadError"
  257. :on-exceed="handleCertExceed"
  258. :on-remove="() => handleCertRemove(index)"
  259. >
  260. <template #trigger>
  261. <div class="cert-upload-trigger">
  262. <el-button type="primary" plain>选择文件</el-button>
  263. <span class="cert-upload-text">{{ cert.fileList.length > 0 ? '已选择 1 个文件' : '未选择任何文件' }}</span>
  264. </div>
  265. </template>
  266. </el-upload>
  267. <el-button link type="danger" @click="handleRemoveCert(index)">删除</el-button>
  268. </div>
  269. <el-button type="primary" plain :icon="Plus" @click="handleAddCert" style="margin-top: 4px">
  270. 添加证书
  271. </el-button>
  272. </div>
  273. </el-form-item>
  274. </el-form>
  275. <template #footer>
  276. <div class="dialog-footer">
  277. <el-button @click="cancel">取消</el-button>
  278. <el-button type="primary" @click="submitForm">保存</el-button>
  279. </div>
  280. </template>
  281. </el-dialog>
  282. <!-- 查看证书弹窗 -->
  283. <el-dialog v-model="certDialog.visible" title="岗位证书" width="640px" append-to-body>
  284. <el-descriptions v-if="currentCerts.length > 0" :column="1" border>
  285. <el-descriptions-item
  286. v-for="(cert, idx) in currentCerts"
  287. :key="idx"
  288. :label="cert.certName || cert.certType"
  289. >
  290. <div v-if="cert.certFile" class="cert-view-box">
  291. <el-icon :size="22" class="cert-view-icon"><Document /></el-icon>
  292. <el-link :href="cert.certFile" target="_blank" type="primary" style="margin-left: 6px">
  293. 查看证书文件
  294. </el-link>
  295. </div>
  296. <span v-else class="text-placeholder">未上传证书文件</span>
  297. </el-descriptions-item>
  298. </el-descriptions>
  299. <div v-else class="empty-tip">该人员暂无岗位证书</div>
  300. <template #footer>
  301. <el-button @click="certDialog.visible = false">关闭</el-button>
  302. </template>
  303. </el-dialog>
  304. </div>
  305. </template>
  306. <script setup name="Personnel" lang="ts">
  307. import { ref, reactive, toRefs, onMounted, getCurrentInstance } from 'vue';
  308. import {
  309. listPersonnel,
  310. getPersonnel,
  311. savePersonnelWithCerts,
  312. delPersonnel
  313. } from '@/api/risk/personnel';
  314. import { listPersonnelCertByPersonnel } from '@/api/risk/personnelCert';
  315. import { uploadFile } from '@/api/risk/file';
  316. import { getDicts } from '@/api/system/dict/data';
  317. import { listDept } from '@/api/system/dept';
  318. import type { DictDataVO } from '@/api/system/dict/data/types';
  319. import type { RiskPersonnelBo, RiskPersonnelVo, RiskPersonnelCertVo } from '@/api/risk/types';
  320. import { Plus, Document } from '@element-plus/icons-vue';
  321. import { ElMessage, FormInstance, UploadFile, UploadRawFile, UploadUserFile } from 'element-plus';
  322. import type { ComponentInternalInstance } from 'vue';
  323. const { proxy } = getCurrentInstance() as ComponentInternalInstance;
  324. interface CertItem {
  325. uid: number;
  326. id?: string | number;
  327. certType: string;
  328. certName?: string;
  329. certFile?: string;
  330. fileList: UploadUserFile[];
  331. }
  332. const personnelList = ref<RiskPersonnelVo[]>([]);
  333. const certMap = ref<Record<string | number, RiskPersonnelCertVo[]>>({});
  334. const loading = ref(true);
  335. const total = ref(0);
  336. const currentCerts = ref<RiskPersonnelCertVo[]>([]);
  337. // 字典
  338. const personTypeOptions = ref<Array<{ label: string; value: string }>>([]);
  339. const certTypeOptions = ref<Array<{ label: string; value: string }>>([]);
  340. const insuranceStatusOptions = ref<Array<{ label: string; value: string }>>([]);
  341. // 门店列表(从 sys_dept 拉 dept_category='门店' 的)
  342. const storeOptions = ref<Array<{ label: string; value: number }>>([]);
  343. const queryFormRef = ref<FormInstance>();
  344. const formRef = ref<FormInstance>();
  345. /** 加载字典 */
  346. const loadDicts = async () => {
  347. try {
  348. const [personType, certType, insuranceStatus] = await Promise.all([
  349. getDicts('person_type'),
  350. getDicts('certificate_type'),
  351. getDicts('insurance_status')
  352. ]);
  353. personTypeOptions.value = mapDictData(personType);
  354. certTypeOptions.value = mapDictData(certType);
  355. insuranceStatusOptions.value = mapDictData(insuranceStatus);
  356. } catch (e) {
  357. console.error('加载字典失败', e);
  358. }
  359. };
  360. const mapDictData = (resOrList: any) => {
  361. // 字典接口被 axios 拦截器自动解包,返回值为数组;兼容部分场景被再包一层 .data 的情况
  362. const list: any[] = Array.isArray(resOrList)
  363. ? resOrList
  364. : Array.isArray(resOrList?.data)
  365. ? resOrList.data
  366. : [];
  367. return list
  368. .filter((d) => d.status === '0' || d.status === undefined)
  369. .sort((a, b) => (a.dictSort || 0) - (b.dictSort || 0))
  370. .map((d) => ({ label: d.dictLabel, value: d.dictValue }));
  371. };
  372. /** 加载门店列表(从 sys_dept 过滤 dept_category='门店') */
  373. const loadStores = async () => {
  374. try {
  375. const res: any = await listDept({});
  376. const list: any[] = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
  377. storeOptions.value = list
  378. .filter((d: any) => d.deptCategory === '门店' && (d.status === '0' || d.status === undefined))
  379. .map((d: any) => ({ label: d.deptName, value: d.deptId }));
  380. } catch (e) {
  381. console.error('加载门店列表失败', e);
  382. storeOptions.value = [];
  383. }
  384. };
  385. const validateIdCard = (_rule: any, value: any, callback: any) => {
  386. if (!value) {
  387. callback(new Error('请输入身份证号'));
  388. return;
  389. }
  390. const reg = /^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/;
  391. if (!reg.test(value)) {
  392. callback(new Error('请输入正确的18位身份证号'));
  393. return;
  394. }
  395. const year = parseInt(value.substring(6, 10), 10);
  396. const month = parseInt(value.substring(10, 12), 10);
  397. const day = parseInt(value.substring(12, 14), 10);
  398. const birthDate = new Date(year, month - 1, day);
  399. if (
  400. birthDate.getFullYear() !== year ||
  401. birthDate.getMonth() !== month - 1 ||
  402. birthDate.getDate() !== day
  403. ) {
  404. callback(new Error('身份证号中的出生日期不合法'));
  405. return;
  406. }
  407. callback();
  408. };
  409. const data = reactive<any>({
  410. form: {
  411. id: undefined,
  412. personnelCode: '',
  413. personnelName: '',
  414. idCard: '',
  415. age: undefined,
  416. storeId: undefined,
  417. storeName: '',
  418. jobType: '',
  419. insuranceStatus: '未投保',
  420. insuranceStartDate: '',
  421. insuranceExpireDate: '',
  422. certList: [] as CertItem[]
  423. } as any,
  424. queryParams: {
  425. pageNum: 1,
  426. pageSize: 10,
  427. personnelName: '',
  428. jobType: '',
  429. storeId: undefined
  430. },
  431. rules: {
  432. jobType: [{ required: true, message: '请选择人员类型', trigger: 'change' }],
  433. personnelName: [{ required: true, message: '姓名不能为空', trigger: 'blur' }],
  434. idCard: [{ required: true, validator: validateIdCard, trigger: 'blur' }],
  435. storeId: [{ required: true, message: '请选择工作门店', trigger: 'change' }],
  436. insuranceStatus: [{ required: true, message: '请选择保险状态', trigger: 'change' }],
  437. insuranceStartDate: [
  438. {
  439. validator: (_rule: any, value: any, callback: any) => {
  440. if (data.form.insuranceStatus === '已投保' && !value) {
  441. callback(new Error('已投保时必须选择保险开始日期'));
  442. } else {
  443. callback();
  444. }
  445. },
  446. trigger: 'change'
  447. }
  448. ],
  449. insuranceExpireDate: [
  450. {
  451. validator: (_rule: any, value: any, callback: any) => {
  452. if (data.form.insuranceStatus === '已投保' && !value) {
  453. callback(new Error('已投保时必须选择保险结束日期'));
  454. } else {
  455. callback();
  456. }
  457. },
  458. trigger: 'change'
  459. }
  460. ]
  461. }
  462. });
  463. const { queryParams, form, rules } = toRefs(data);
  464. const dialog = reactive({
  465. visible: false,
  466. title: '新增人员信息'
  467. });
  468. const certDialog = reactive({
  469. visible: false
  470. });
  471. /** 身份证号计算年龄 */
  472. const calculateAge = (idCard: string): number => {
  473. if (!idCard || idCard.length !== 18) return 0;
  474. const birthYear = parseInt(idCard.substring(6, 10), 10);
  475. const birthMonth = parseInt(idCard.substring(10, 12), 10);
  476. const birthDay = parseInt(idCard.substring(14, 16), 10);
  477. const today = new Date();
  478. let age = today.getFullYear() - birthYear;
  479. if (
  480. today.getMonth() + 1 < birthMonth ||
  481. (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)
  482. ) {
  483. age--;
  484. }
  485. return age;
  486. };
  487. const maskIdCard = (idCard: string): string => {
  488. if (!idCard) return '';
  489. if (idCard.length < 8) return idCard;
  490. return idCard.substring(0, 6) + '********' + idCard.substring(14);
  491. };
  492. const handleIdCardBlur = () => {
  493. if (form.value.idCard && form.value.idCard.length === 18) {
  494. form.value.age = calculateAge(form.value.idCard);
  495. }
  496. };
  497. /** 保险状态切换:切换为未投保时清空有效期 */
  498. const handleInsuranceStatusChange = (val: string) => {
  499. if (val !== '已投保') {
  500. form.value.insuranceStartDate = '';
  501. form.value.insuranceExpireDate = '';
  502. }
  503. };
  504. /** 工作门店选择变化:自动回填 storeName */
  505. const handleStoreChange = (deptId: number | string) => {
  506. const found = storeOptions.value.find((s) => s.value === deptId);
  507. form.value.storeName = found?.label || '';
  508. };
  509. /** 列表中保险状态标签 */
  510. const getInsuranceLabel = (row: any) => {
  511. if (row.insuranceStatus === '未投保') return '未投保';
  512. if (row.insuranceStatus === '已投保') {
  513. if (!row.insuranceExpireDate) return '已投保';
  514. const expire = new Date(row.insuranceExpireDate);
  515. const today = new Date();
  516. const diffDays = Math.floor((expire.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
  517. if (diffDays < 0) return '未投保';
  518. if (diffDays <= 30) return '即将到期';
  519. return '已投保';
  520. }
  521. return row.insuranceStatus || '未投保';
  522. };
  523. const getInsuranceTag = (row: any) => {
  524. const label = getInsuranceLabel(row);
  525. const map: Record<string, string> = {
  526. 已投保: 'success',
  527. 未投保: 'danger',
  528. 即将到期: 'warning'
  529. };
  530. return map[label] || 'info';
  531. };
  532. // 人员类型 tag 颜色(按字典 value 映射,与设计稿一致)
  533. const getJobTypeTag = (type: string) => {
  534. const map: Record<string, string> = {
  535. 防损人员: 'primary', // 蓝色
  536. 施工人员: 'warning', // 橙色
  537. 特岗人员: 'danger', // 红色
  538. 食品相关岗位人员: 'success', // 绿色
  539. 检测员: 'info', // 灰色
  540. 兼职品控人员: 'primary' // 蓝色
  541. };
  542. return map[type] || 'info';
  543. };
  544. // 字典值 -> 中文 label(用于列表渲染)
  545. const getDictLabel = (
  546. opts: Array<{ label: string; value: string }>,
  547. val: string
  548. ) => {
  549. if (!val) return ''
  550. return opts.find((o) => o.value === val)?.label || val
  551. }
  552. const getList = async () => {
  553. loading.value = true;
  554. const res = await listPersonnel(queryParams.value as RiskPersonnelBo);
  555. loading.value = false;
  556. personnelList.value = res.rows || [];
  557. total.value = res.total || 0;
  558. if (personnelList.value.length > 0) {
  559. loadPersonnelCerts(personnelList.value);
  560. }
  561. };
  562. const loadPersonnelCerts = async (list: RiskPersonnelVo[]) => {
  563. const map: Record<string | number, RiskPersonnelCertVo[]> = {};
  564. await Promise.all(
  565. list.map(async (p) => {
  566. try {
  567. const res = await listPersonnelCertByPersonnel(p.id);
  568. map[p.id] = res.data || [];
  569. } catch (e) {
  570. map[p.id] = [];
  571. }
  572. })
  573. );
  574. certMap.value = map;
  575. };
  576. const getPersonnelCerts = (id: string | number): RiskPersonnelCertVo[] => {
  577. return certMap.value[id] || [];
  578. };
  579. const handleQuery = () => {
  580. queryParams.value.pageNum = 1;
  581. getList();
  582. };
  583. const resetQuery = () => {
  584. queryFormRef.value?.resetFields();
  585. queryParams.value.pageNum = 1;
  586. queryParams.value.personnelName = '';
  587. queryParams.value.jobType = '';
  588. queryParams.value.storeId = undefined;
  589. handleQuery();
  590. };
  591. const handleAdd = () => {
  592. reset();
  593. dialog.visible = true;
  594. dialog.title = '新增人员信息';
  595. };
  596. const handleUpdate = async (row: RiskPersonnelVo) => {
  597. reset();
  598. const res = await getPersonnel(row.id);
  599. Object.assign(form.value, res.data);
  600. if (form.value.idCard) {
  601. form.value.age = calculateAge(form.value.idCard);
  602. }
  603. try {
  604. const certRes = await listPersonnelCertByPersonnel(row.id);
  605. const certs = certRes.data || [];
  606. form.value.certList = certs.map((c) => ({
  607. uid: -Math.floor(Math.random() * 100000),
  608. id: c.id,
  609. certType: c.certType,
  610. certName: c.certName,
  611. certFile: c.certFile,
  612. fileList: c.certFile
  613. ? [
  614. {
  615. uid: -Math.floor(Math.random() * 100000),
  616. name: c.certName || c.certType || '证书',
  617. status: 'success',
  618. url: c.certFile
  619. }
  620. ]
  621. : []
  622. }));
  623. } catch (e) {
  624. form.value.certList = [];
  625. }
  626. dialog.visible = true;
  627. dialog.title = '编辑人员信息';
  628. };
  629. const handleViewCert = async (row: RiskPersonnelVo) => {
  630. try {
  631. const res = await listPersonnelCertByPersonnel(row.id);
  632. currentCerts.value = res.data || [];
  633. } catch (e) {
  634. currentCerts.value = [];
  635. }
  636. certDialog.visible = true;
  637. };
  638. const handleDelete = async (row: RiskPersonnelVo) => {
  639. try {
  640. await proxy?.$modal.confirm(`是否确认删除姓名为 "${row.personnelName}" 的人员信息?`);
  641. await delPersonnel(row.id);
  642. proxy?.$modal.msgSuccess('删除成功');
  643. await getList();
  644. } catch (e) {
  645. // 取消
  646. }
  647. };
  648. const handleAddCert = () => {
  649. if (!form.value.certList) {
  650. form.value.certList = [];
  651. }
  652. form.value.certList.push({
  653. uid: Date.now() + Math.floor(Math.random() * 1000),
  654. certType: '',
  655. certFile: '',
  656. fileList: []
  657. });
  658. };
  659. const handleRemoveCert = (index: number) => {
  660. form.value.certList?.splice(index, 1);
  661. };
  662. const handleCertUploadSuccess = (response: any, file: UploadFile, index: number) => {
  663. const url = response.data?.url || response.url;
  664. file.url = url;
  665. if (form.value.certList && form.value.certList[index]) {
  666. form.value.certList[index].certFile = url;
  667. }
  668. ElMessage.success('证书上传成功');
  669. };
  670. const handleCertRemove = (index: number) => {
  671. if (form.value.certList && form.value.certList[index]) {
  672. form.value.certList[index].certFile = '';
  673. form.value.certList[index].fileList = [];
  674. }
  675. };
  676. const handleUploadError = () => {
  677. ElMessage.error('上传失败,请重试');
  678. };
  679. const customCertUpload = async (options: any) => {
  680. try {
  681. const res: any = await uploadFile(options.file);
  682. if (res && res.code === 200 && res.data) {
  683. options.onSuccess(res, options.file);
  684. } else {
  685. options.onError(new Error(res?.msg || '上传失败'));
  686. }
  687. } catch (err: any) {
  688. options.onError(err);
  689. }
  690. };
  691. const beforeCertUpload = (file: UploadRawFile) => {
  692. const validTypes = ['image/jpeg', 'image/png', 'image/jpg', 'application/pdf'];
  693. if (!validTypes.includes(file.type)) {
  694. ElMessage.error('只能上传 PDF/JPG/PNG 格式的文件!');
  695. return false;
  696. }
  697. if (file.size / 1024 / 1024 >= 10) {
  698. ElMessage.error('文件大小不能超过 10MB!');
  699. return false;
  700. }
  701. return true;
  702. };
  703. const handleCertExceed = () => {
  704. ElMessage.warning('每个证书只能上传1个文件');
  705. };
  706. const submitForm = () => {
  707. formRef.value?.validate(async (valid: boolean) => {
  708. if (valid) {
  709. const certBos = (form.value.certList || [])
  710. .filter((c: CertItem) => c.certType)
  711. .map((c: CertItem) => ({
  712. id: c.id,
  713. certType: c.certType,
  714. certName: c.certType,
  715. certFile: c.certFile || '',
  716. certStatus: '有效'
  717. }));
  718. const personnel = {
  719. id: form.value.id,
  720. personnelCode: form.value.personnelCode,
  721. personnelName: form.value.personnelName,
  722. idCard: form.value.idCard,
  723. age: form.value.age,
  724. storeId: form.value.storeId,
  725. storeName: form.value.storeName,
  726. jobType: form.value.jobType,
  727. insuranceStatus: form.value.insuranceStatus,
  728. insuranceStartDate: form.value.insuranceStatus === '已投保' ? form.value.insuranceStartDate : '',
  729. insuranceExpireDate: form.value.insuranceStatus === '已投保' ? form.value.insuranceExpireDate : ''
  730. };
  731. await savePersonnelWithCerts({ personnel, certList: certBos });
  732. proxy?.$modal.msgSuccess(form.value.id ? '修改成功' : '新增成功');
  733. dialog.visible = false;
  734. await getList();
  735. }
  736. });
  737. };
  738. const cancel = () => {
  739. dialog.visible = false;
  740. reset();
  741. };
  742. const reset = () => {
  743. form.value = {
  744. id: undefined,
  745. personnelCode: '',
  746. personnelName: '',
  747. idCard: '',
  748. age: undefined,
  749. storeId: undefined,
  750. storeName: '',
  751. jobType: '',
  752. insuranceStatus: '未投保',
  753. insuranceStartDate: '',
  754. insuranceExpireDate: '',
  755. certList: []
  756. } as any;
  757. formRef.value?.resetFields();
  758. };
  759. const closeDialog = () => {
  760. dialog.visible = false;
  761. reset();
  762. };
  763. onMounted(async () => {
  764. await loadDicts();
  765. await loadStores();
  766. await getList();
  767. });
  768. </script>
  769. <style scoped>
  770. .personnel-page {
  771. padding: 16px;
  772. height: 100%;
  773. overflow-y: auto;
  774. box-sizing: border-box;
  775. }
  776. .page-header {
  777. margin-bottom: 16px;
  778. }
  779. .page-title {
  780. font-size: 20px;
  781. font-weight: 600;
  782. margin: 0;
  783. color: #303133;
  784. }
  785. .page-desc {
  786. font-size: 13px;
  787. color: #909399;
  788. margin: 4px 0 0;
  789. }
  790. .filter-card {
  791. margin-bottom: 12px;
  792. }
  793. .list-card {
  794. margin-bottom: 12px;
  795. }
  796. .list-toolbar {
  797. display: flex;
  798. justify-content: space-between;
  799. align-items: center;
  800. margin-bottom: 12px;
  801. }
  802. .list-total {
  803. font-size: 13px;
  804. color: #606266;
  805. }
  806. .text-placeholder {
  807. color: #c0c4cc;
  808. font-size: 12px;
  809. }
  810. .empty-tip {
  811. text-align: center;
  812. color: #909399;
  813. padding: 40px 0;
  814. font-size: 14px;
  815. }
  816. .cert-container {
  817. width: 100%;
  818. }
  819. .cert-item {
  820. display: flex;
  821. align-items: center;
  822. gap: 12px;
  823. margin-bottom: 12px;
  824. }
  825. .cert-upload-trigger {
  826. display: inline-flex;
  827. align-items: center;
  828. gap: 8px;
  829. }
  830. .cert-upload-text {
  831. font-size: 13px;
  832. color: #606266;
  833. }
  834. :deep(.el-upload) {
  835. display: inline-block;
  836. }
  837. .cert-view-box {
  838. display: inline-flex;
  839. align-items: center;
  840. }
  841. .cert-view-icon {
  842. color: #e6a23c;
  843. }
  844. </style>