index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739
  1. <template>
  2. <div class="ledger-import-page">
  3. <el-card class="page-header-card">
  4. <div class="page-title">台账一键导入</div>
  5. <div class="page-desc">支持12类台账Excel批量上传,请按模板格式准备文件后上传</div>
  6. </el-card>
  7. <!-- 一键全量导入 -->
  8. <el-card class="combined-import-card" shadow="never">
  9. <div class="combined-inner">
  10. <div class="combined-info">
  11. <el-icon class="combined-icon">
  12. <Files />
  13. </el-icon>
  14. <div>
  15. <div class="combined-title">一键全量导入</div>
  16. <div class="combined-desc">上传「旅检三部"三三"数字管理平台.xlsx」,系统将自动识别20个Sheet并分别导入对应台账表</div>
  17. </div>
  18. </div>
  19. <div class="combined-btns">
  20. <el-button v-hasPermi="['ledger:sync:all']" type="primary" size="large" :loading="syncing"
  21. class="combined-btn" @click="handleSync">
  22. 同步台账
  23. </el-button>
  24. <el-upload :action="''" :auto-upload="false" :show-file-list="false" :on-change="handleCombinedChange"
  25. accept=".xlsx,.xls">
  26. <el-button type="primary" size="large" :loading="combinedLoading" class="combined-btn">
  27. <el-icon>
  28. <Upload />
  29. </el-icon> 选择文件并全量导入
  30. </el-button>
  31. </el-upload>
  32. <el-button size="large" class="combined-tpl-btn" @click="downloadCombinedTemplate">
  33. <el-icon>
  34. <Download />
  35. </el-icon> 下载合并模板
  36. </el-button>
  37. </div>
  38. </div>
  39. <div v-if="combinedResult && combinedResult.length" class="combined-result">
  40. <div class="result-title">导入结果:</div>
  41. <el-tag v-for="item in combinedResult" :key="item.sheetName"
  42. :type="item.sheetResult.includes('失败') || item.sheetResult.includes('错误') ? 'danger' : 'success'" class="result-tag">
  43. {{ item.sheetName }}:{{ item.sheetResult }}
  44. </el-tag>
  45. </div>
  46. </el-card>
  47. <!-- 按时间范围清理台账数据 -->
  48. <el-card class="clear-card" shadow="never">
  49. <div class="clear-inner">
  50. <div class="clear-info">
  51. <el-icon class="clear-icon">
  52. <DeleteFilled />
  53. </el-icon>
  54. <div>
  55. <div class="clear-title">清理台账数据</div>
  56. <div class="clear-desc">按导入时间范围删除全部20张台账表数据,同步清除台账来源的配分事项(手动录入不受影响)</div>
  57. </div>
  58. </div>
  59. <div class="clear-actions">
  60. <el-date-picker v-model="clearBeginDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期"
  61. style="width: 150px" />
  62. <span style="margin: 0 6px; color: #909399;">至</span>
  63. <el-date-picker v-model="clearEndDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期"
  64. :disabled-date="(d) => clearBeginDate && d <= new Date(new Date(clearBeginDate).getTime() - 24 * 60 * 60 * 1000)" style="width: 150px" />
  65. <el-button type="danger" :loading="clearLoading" :disabled="!clearBeginDate || !clearEndDate"
  66. @click="handleClear">
  67. <el-icon>
  68. <Delete />
  69. </el-icon> 清理
  70. </el-button>
  71. </div>
  72. </div>
  73. <div v-if="clearResult" class="clear-result">
  74. <div class="result-title">清理结果(按导入时间 {{ clearBeginDate }} ~ {{ clearEndDate }}):</div>
  75. <div class="result-table">
  76. <span v-for="(count, name) in clearResult" :key="name" class="result-item">
  77. <el-tag :type="count > 0 ? 'warning' : 'info'" size="small">{{ name }}:{{ count }} 条</el-tag>
  78. </span>
  79. </div>
  80. </div>
  81. </el-card>
  82. <el-divider content-position="left">或按类型单独导入</el-divider>
  83. <div class="import-grid">
  84. <div v-for="item in importItems" :key="item.key" class="import-card">
  85. <el-card shadow="hover" :class="['ledger-card', item.status]">
  86. <div class="card-header">
  87. <el-icon class="card-icon">
  88. <component :is="item.icon" />
  89. </el-icon>
  90. <div class="card-title">{{ item.title }}</div>
  91. </div>
  92. <div class="card-desc">{{ item.desc }}</div>
  93. <div class="card-actions">
  94. <el-upload ref="uploadRefs" :action="''" :auto-upload="false" :show-file-list="false"
  95. :before-upload="(file) => beforeUpload(file, item)" :on-change="(file) => handleFileChange(file, item)"
  96. accept=".xlsx,.xls">
  97. <el-button type="primary" size="small" :loading="item.loading">
  98. <el-icon>
  99. <Upload />
  100. </el-icon> 选择文件上传
  101. </el-button>
  102. </el-upload>
  103. <el-button size="small" text @click="downloadTemplate(item)">
  104. <el-icon>
  105. <Download />
  106. </el-icon> 下载模板
  107. </el-button>
  108. </div>
  109. <div v-if="item.lastResult" class="last-result" :class="item.lastResult.success ? 'success' : 'error'">
  110. <el-icon>
  111. <component :is="item.lastResult.success ? 'CircleCheck' : 'CircleClose'" />
  112. </el-icon>
  113. {{ item.lastResult.msg }}
  114. </div>
  115. </el-card>
  116. </div>
  117. </div>
  118. </div>
  119. </template>
  120. <script setup>
  121. import { ref, reactive } from 'vue'
  122. import { ElMessage, ElMessageBox } from 'element-plus'
  123. import { Upload, Download, Document, DocumentChecked, Warning, Trophy, UserFilled, Ticket, DataAnalysis, Histogram, Medal, Memo, Money, Calendar, Flag, Files, Reading, Management, FirstAidKit, House, Bell, Delete, DeleteFilled } from '@element-plus/icons-vue'
  124. import { importCombinedLedger, clearLedgerByTimeRange, downloadLedgerTemplate } from '@/api/ledger/index'
  125. import { syncLedgerAll } from '@/api/score/index'
  126. import {
  127. importSupervisionProblem,
  128. importPatrolInspection,
  129. importRealtimeInterception,
  130. importServicePatrol,
  131. importComplaint,
  132. importSecurityTest,
  133. importChannelPassRate,
  134. importUnsafeEvent,
  135. importSeizureStats,
  136. importTerminalBonus,
  137. importExamScore,
  138. importRewardApproval,
  139. importLeaveSpecial,
  140. importDailyTraining,
  141. importLeaderDuty,
  142. importHealthSoldier,
  143. importDormFireSafety,
  144. importTrainingIssue,
  145. importQualificationLevel
  146. } from '@/api/ledger/index'
  147. defineOptions({ name: 'LedgerImport' })
  148. // ── 一键全量导入 ──────────────────────────────────────
  149. const combinedLoading = ref(false)
  150. const combinedResult = ref([])
  151. // ── 台账同步 ──────────────────────────────────────────
  152. const syncing = ref(false)
  153. async function handleSync() {
  154. syncing.value = true
  155. try {
  156. const r = await syncLedgerAll()
  157. const d = r.data || {}
  158. ElMessage.success(`同步完成:新增 ${d.inserted ?? 0} 条,跳过 ${d.skipped ?? 0} 条`)
  159. } catch (e) {
  160. ElMessage.error('同步失败,请查看后端日志')
  161. } finally {
  162. syncing.value = false
  163. }
  164. }
  165. // ── 台账数据清理 ──────────────────────────────────────
  166. const clearBeginDate = ref('')
  167. const clearEndDate = ref('')
  168. const clearLoading = ref(false)
  169. const clearResult = ref(null)
  170. async function handleClear() {
  171. if (!clearBeginDate.value || !clearEndDate.value) {
  172. ElMessage.warning('请先选择清理时间范围')
  173. return
  174. }
  175. if (clearBeginDate.value > clearEndDate.value) {
  176. ElMessage.warning('开始日期不能晚于结束日期')
  177. return
  178. }
  179. const beginTime = clearBeginDate.value
  180. const endTime = clearEndDate.value
  181. try {
  182. await ElMessageBox.confirm(
  183. `确认删除导入时间在 ${beginTime} ~ ${endTime} 之间的全部台账数据?\n此操作不可恢复,请谨慎操作!`,
  184. '危险操作确认',
  185. { type: 'warning', confirmButtonText: '确认清理', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
  186. )
  187. } catch {
  188. return
  189. }
  190. clearLoading.value = true
  191. clearResult.value = null
  192. try {
  193. const res = await clearLedgerByTimeRange({ beginTime, endTime })
  194. if (res.code === 200) {
  195. clearResult.value = res.data || {}
  196. ElMessage.success(res.msg || '清理完成')
  197. } else {
  198. ElMessage.error(res.msg || '清理失败')
  199. }
  200. } catch (e) {
  201. ElMessage.error('清理失败,请查看后端日志')
  202. } finally {
  203. clearLoading.value = false
  204. }
  205. }
  206. async function handleCombinedChange(uploadFile) {
  207. const file = uploadFile.raw
  208. if (!file) return
  209. if (!beforeUpload(file)) return
  210. combinedLoading.value = true
  211. combinedResult.value = null
  212. const formData = new FormData()
  213. formData.append('file', file)
  214. try {
  215. const res = await importCombinedLedger(formData)
  216. if (res.code === 200) {
  217. combinedResult.value = res.data || []
  218. ElMessage.success('全量导入完成,请查看各Sheet结果')
  219. } else {
  220. ElMessage.error(res.msg || '全量导入失败')
  221. }
  222. } catch (e) {
  223. ElMessage.error('全量导入失败,请查看后端日志')
  224. } finally {
  225. combinedLoading.value = false
  226. }
  227. }
  228. // ── 单类型导入 ──────────────────────────────────────
  229. const importItems = reactive([
  230. {
  231. key: 'supervisionProblem',
  232. title: '部门监察问题记录',
  233. desc: '记录监察问题、扣加分及评分维度',
  234. icon: 'Warning',
  235. api: importSupervisionProblem,
  236. loading: false,
  237. lastResult: null
  238. },
  239. // {
  240. // key: 'patrolInspection',
  241. // title: '队室三级质控巡查记录',
  242. // desc: '队室层级巡查问题记录',
  243. // icon: 'DocumentChecked',
  244. // api: importPatrolInspection,
  245. // loading: false,
  246. // lastResult: null
  247. // },
  248. {
  249. key: 'realtimeInterception',
  250. title: '实时质控拦截记录',
  251. desc: '实时拦截物品及旅客记录',
  252. icon: 'Ticket',
  253. api: importRealtimeInterception,
  254. loading: false,
  255. lastResult: null
  256. },
  257. {
  258. key: 'servicePatrol',
  259. title: '服务巡查记录',
  260. desc: '服务质量巡查问题记录',
  261. icon: 'UserFilled',
  262. api: importServicePatrol,
  263. loading: false,
  264. lastResult: null
  265. },
  266. {
  267. key: 'complaint',
  268. title: '投诉情况记录',
  269. desc: '旅客投诉及处理结果',
  270. icon: 'Memo',
  271. api: importComplaint,
  272. loading: false,
  273. lastResult: null
  274. },
  275. {
  276. key: 'securityTest',
  277. title: '安保测试记录(部门)',
  278. desc: '安保测试项目及结果',
  279. icon: 'Flag',
  280. api: importSecurityTest,
  281. loading: false,
  282. lastResult: null
  283. },
  284. {
  285. key: 'channelPassRate',
  286. title: '通道过检率',
  287. desc: '各通道过检人数及过检率',
  288. icon: 'DataAnalysis',
  289. api: importChannelPassRate,
  290. loading: false,
  291. lastResult: null
  292. },
  293. {
  294. key: 'unsafeEvent',
  295. title: '不安全事件',
  296. desc: '安全事件记录及处理',
  297. icon: 'Document',
  298. api: importUnsafeEvent,
  299. loading: false,
  300. lastResult: null
  301. },
  302. {
  303. key: 'leaveSpecial',
  304. title: '请、休假记录表(特殊)',
  305. desc: '请、休假记录表(特殊)',
  306. icon: 'Calendar',
  307. api: importLeaveSpecial,
  308. loading: false,
  309. lastResult: null
  310. },
  311. {
  312. key: 'seizureStats',
  313. title: '2026查获违规品统计',
  314. desc: '查获违规品统计数据',
  315. icon: 'Histogram',
  316. api: importSeizureStats,
  317. loading: false,
  318. lastResult: null
  319. },
  320. {
  321. key: 'terminalBonus',
  322. title: '航站楼加分',
  323. desc: '航站楼相关加分记录',
  324. icon: 'Trophy',
  325. api: importTerminalBonus,
  326. loading: false,
  327. lastResult: null
  328. },
  329. {
  330. key: 'examScore',
  331. title: '成绩收集',
  332. desc: '考试成绩汇总导入',
  333. icon: 'Medal',
  334. api: importExamScore,
  335. loading: false,
  336. lastResult: null
  337. },
  338. {
  339. key: 'rewardApproval',
  340. title: '小额奖励审批单',
  341. desc: '小额奖励审批流程记录',
  342. icon: 'Money',
  343. api: importRewardApproval,
  344. loading: false,
  345. lastResult: null
  346. },
  347. {
  348. key: 'qualificationLevel',
  349. title: '职业资格证书获取时间',
  350. desc: '职业资格证书获取时间流程记录',
  351. icon: 'Medal',
  352. api: importQualificationLevel,
  353. loading: false,
  354. lastResult: null
  355. },
  356. // {
  357. // key: 'dailyTraining',
  358. // title: '日常培训记录',
  359. // desc: '月度培训任务及完成情况记录',
  360. // icon: 'Reading',
  361. // api: importDailyTraining,
  362. // loading: false,
  363. // lastResult: null
  364. // },
  365. // {
  366. // key: 'leaderDuty',
  367. // title: '组长履职情况记录',
  368. // desc: '队室组长本班点评及问题处置',
  369. // icon: 'Management',
  370. // api: importLeaderDuty,
  371. // loading: false,
  372. // lastResult: null
  373. // },
  374. // {
  375. // key: 'healthSoldier',
  376. // title: '健康锐兵',
  377. // desc: '员工身心健康状况台账记录',
  378. // icon: 'FirstAidKit',
  379. // api: importHealthSoldier,
  380. // loading: false,
  381. // lastResult: null
  382. // },
  383. // {
  384. // key: 'dormFireSafety',
  385. // title: '宿舍消防安全专项自查',
  386. // desc: '宿舍消防隐患自查情况记录',
  387. // icon: 'House',
  388. // api: importDormFireSafety,
  389. // loading: false,
  390. // lastResult: null
  391. // },
  392. // {
  393. // key: 'trainingIssue',
  394. // title: '培训台账问题通报',
  395. // desc: '培训台账发现问题及整改通报',
  396. // icon: 'Bell',
  397. // api: importTrainingIssue,
  398. // loading: false,
  399. // lastResult: null
  400. // }
  401. ])
  402. function beforeUpload(file) {
  403. const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  404. || file.type === 'application/vnd.ms-excel'
  405. || file.name.endsWith('.xlsx')
  406. || file.name.endsWith('.xls')
  407. if (!isExcel) {
  408. ElMessage.error('只支持上传 .xlsx / .xls 格式文件')
  409. return false
  410. }
  411. if (file.size > 10 * 1024 * 1024) {
  412. ElMessage.error('文件大小不能超过 10MB')
  413. return false
  414. }
  415. return true
  416. }
  417. async function handleFileChange(uploadFile, item) {
  418. const file = uploadFile.raw
  419. if (!file) return
  420. if (!beforeUpload(file)) return
  421. item.loading = true
  422. item.lastResult = null
  423. const formData = new FormData()
  424. formData.append('file', file)
  425. try {
  426. const res = await item.api(formData)
  427. if (res.code === 200) {
  428. item.lastResult = { success: true, msg: res.msg || '导入成功' }
  429. ElMessage.success(item.title + ' - ' + (res.msg || '导入成功'))
  430. } else {
  431. item.lastResult = { success: false, msg: res.msg || '导入失败' }
  432. ElMessage.error(item.title + ' - ' + (res.msg || '导入失败'))
  433. }
  434. } catch (e) {
  435. item.lastResult = { success: false, msg: '网络异常,导入失败' }
  436. ElMessage.error(item.title + ' - 网络异常')
  437. } finally {
  438. item.loading = false
  439. }
  440. }
  441. async function downloadTemplate(item) {
  442. try {
  443. const res = await downloadLedgerTemplate({ type: item.key })
  444. triggerDownload(res, item.title + '_导入模板.xlsx')
  445. } catch {
  446. ElMessage.error('模板下载失败')
  447. }
  448. }
  449. async function downloadCombinedTemplate() {
  450. try {
  451. const res = await downloadLedgerTemplate({})
  452. triggerDownload(res, '旅检三部三三数字管理平台_模板.xlsx')
  453. } catch {
  454. ElMessage.error('合并模板下载失败')
  455. }
  456. }
  457. function triggerDownload(blob, fileName) {
  458. const url = URL.createObjectURL(new Blob([blob], {
  459. type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  460. }))
  461. const a = document.createElement('a')
  462. a.href = url
  463. a.download = fileName
  464. document.body.appendChild(a)
  465. a.click()
  466. document.body.removeChild(a)
  467. URL.revokeObjectURL(url)
  468. }
  469. </script>
  470. <style lang="scss" scoped>
  471. .ledger-import-page {
  472. padding: 20px;
  473. background: #f5f7fa;
  474. min-height: 100vh;
  475. }
  476. .page-header-card {
  477. margin-bottom: 20px;
  478. .page-title {
  479. font-size: 20px;
  480. font-weight: 600;
  481. color: #303133;
  482. margin-bottom: 6px;
  483. }
  484. .page-desc {
  485. font-size: 13px;
  486. color: #909399;
  487. }
  488. }
  489. .combined-import-card {
  490. margin-bottom: 16px;
  491. .combined-inner {
  492. display: flex;
  493. align-items: center;
  494. justify-content: space-between;
  495. gap: 16px;
  496. flex-wrap: wrap;
  497. }
  498. .combined-info {
  499. display: flex;
  500. align-items: flex-start;
  501. gap: 12px;
  502. }
  503. .combined-icon {
  504. font-size: 36px;
  505. color: #409eff;
  506. flex-shrink: 0;
  507. margin-top: 2px;
  508. }
  509. .combined-title {
  510. font-size: 17px;
  511. font-weight: 600;
  512. color: #303133;
  513. margin-bottom: 4px;
  514. }
  515. .combined-desc {
  516. font-size: 13px;
  517. color: #606266;
  518. max-width: 580px;
  519. }
  520. .combined-btns {
  521. display: flex;
  522. gap: 10px;
  523. align-items: center;
  524. flex-wrap: wrap;
  525. }
  526. .combined-btn {
  527. min-width: 180px;
  528. height: 44px;
  529. font-size: 15px;
  530. }
  531. .combined-tpl-btn {
  532. height: 44px;
  533. font-size: 14px;
  534. }
  535. .sync-btn {
  536. border-color: #409eff;
  537. color: #409eff;
  538. }
  539. .combined-result {
  540. margin-top: 14px;
  541. padding-top: 14px;
  542. border-top: 1px dashed #dcdfe6;
  543. overflow-x: auto;
  544. .result-title {
  545. font-size: 13px;
  546. color: #606266;
  547. margin-bottom: 8px;
  548. font-weight: 500;
  549. }
  550. .result-tag {
  551. margin-right: 8px;
  552. margin-bottom: 6px;
  553. }
  554. }
  555. }
  556. .clear-card {
  557. margin-bottom: 16px;
  558. border: 1px solid #fde2e2;
  559. background: #fff8f8;
  560. .clear-inner {
  561. display: flex;
  562. align-items: center;
  563. justify-content: space-between;
  564. gap: 16px;
  565. flex-wrap: wrap;
  566. }
  567. .clear-info {
  568. display: flex;
  569. align-items: flex-start;
  570. gap: 12px;
  571. }
  572. .clear-icon {
  573. font-size: 36px;
  574. color: #f56c6c;
  575. flex-shrink: 0;
  576. margin-top: 2px;
  577. }
  578. .clear-title {
  579. font-size: 17px;
  580. font-weight: 600;
  581. color: #f56c6c;
  582. margin-bottom: 4px;
  583. }
  584. .clear-desc {
  585. font-size: 13px;
  586. color: #909399;
  587. max-width: 520px;
  588. }
  589. .clear-actions {
  590. display: flex;
  591. gap: 10px;
  592. align-items: center;
  593. flex-wrap: wrap;
  594. }
  595. .clear-result {
  596. margin-top: 14px;
  597. padding-top: 14px;
  598. border-top: 1px dashed #fde2e2;
  599. .result-title {
  600. font-size: 13px;
  601. color: #606266;
  602. margin-bottom: 8px;
  603. font-weight: 500;
  604. }
  605. .result-table {
  606. display: flex;
  607. flex-wrap: wrap;
  608. gap: 6px;
  609. }
  610. }
  611. }
  612. .import-grid {
  613. display: grid;
  614. grid-template-columns: repeat(2, 1fr);
  615. gap: 16px;
  616. }
  617. .ledger-card {
  618. height: 100%;
  619. :deep(.el-card__body) {
  620. padding: 16px;
  621. }
  622. .card-header {
  623. display: flex;
  624. align-items: center;
  625. gap: 8px;
  626. margin-bottom: 8px;
  627. }
  628. .card-icon {
  629. font-size: 20px;
  630. color: #409eff;
  631. flex-shrink: 0;
  632. }
  633. .card-title {
  634. font-size: 15px;
  635. font-weight: 600;
  636. color: #303133;
  637. line-height: 1.3;
  638. }
  639. .card-desc {
  640. font-size: 12px;
  641. color: #909399;
  642. margin-bottom: 12px;
  643. min-height: 32px;
  644. }
  645. .card-actions {
  646. display: flex;
  647. gap: 8px;
  648. align-items: center;
  649. flex-wrap: wrap;
  650. }
  651. .last-result {
  652. margin-top: 10px;
  653. font-size: 12px;
  654. display: flex;
  655. align-items: center;
  656. gap: 4px;
  657. padding: 4px 8px;
  658. border-radius: 4px;
  659. &.success {
  660. background: #f0f9eb;
  661. color: #67c23a;
  662. }
  663. &.error {
  664. background: #fef0f0;
  665. color: #f56c6c;
  666. }
  667. }
  668. }
  669. @media (max-width: 768px) {
  670. .import-grid {
  671. grid-template-columns: 1fr;
  672. }
  673. }
  674. </style>