index.vue 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952
  1. <template>
  2. <div class="p-2 accident-page">
  3. <!-- 顶部数据卡片 -->
  4. <el-row :gutter="20" class="mb-2">
  5. <el-col :span="6">
  6. <el-card shadow="hover" class="stat-card">
  7. <div class="stat-card-content">
  8. <div class="stat-info">
  9. <div class="stat-title">本月上报事故</div>
  10. <div class="stat-value">{{ stats.monthTotal || 0 }}</div>
  11. <div class="stat-extra" :class="(stats.monthRate || 0) >= 0 ? 'stat-extra-up' : 'stat-extra-down'">
  12. <el-icon v-if="(stats.monthRate || 0) >= 0"><CaretTop /></el-icon>
  13. <el-icon v-else><CaretBottom /></el-icon>
  14. <span>{{ Math.abs(stats.monthRate || 0) }}% 较上月</span>
  15. </div>
  16. </div>
  17. <div class="stat-icon stat-icon-blue">
  18. <el-icon><Document /></el-icon>
  19. </div>
  20. </div>
  21. </el-card>
  22. </el-col>
  23. <el-col :span="6">
  24. <el-card shadow="hover" class="stat-card">
  25. <div class="stat-card-content">
  26. <div class="stat-info">
  27. <div class="stat-title">待处理</div>
  28. <div class="stat-value">{{ stats.investigating || 0 }}</div>
  29. <div class="stat-extra" :class="(stats.investigatingRate || 0) >= 0 ? 'stat-extra-up' : 'stat-extra-down'">
  30. <el-icon v-if="(stats.investigatingRate || 0) >= 0"><CaretTop /></el-icon>
  31. <el-icon v-else><CaretBottom /></el-icon>
  32. <span>{{ Math.abs(stats.investigatingRate || 0) }}% 较上月</span>
  33. </div>
  34. </div>
  35. <div class="stat-icon stat-icon-orange">
  36. <el-icon><Search /></el-icon>
  37. </div>
  38. </div>
  39. </el-card>
  40. </el-col>
  41. <el-col :span="6">
  42. <el-card shadow="hover" class="stat-card">
  43. <div class="stat-card-content">
  44. <div class="stat-info">
  45. <div class="stat-title">归档</div>
  46. <div class="stat-value">{{ stats.finished || 0 }}</div>
  47. <div class="stat-extra" :class="(stats.finishedRate || 0) >= 0 ? 'stat-extra-up' : 'stat-extra-down'">
  48. <el-icon v-if="(stats.finishedRate || 0) >= 0"><CaretTop /></el-icon>
  49. <el-icon v-else><CaretBottom /></el-icon>
  50. <span>{{ Math.abs(stats.finishedRate || 0) }}% 较上月</span>
  51. </div>
  52. </div>
  53. <div class="stat-icon stat-icon-green">
  54. <el-icon><CircleCheck /></el-icon>
  55. </div>
  56. </div>
  57. </el-card>
  58. </el-col>
  59. <el-col :span="6">
  60. <el-card shadow="hover" class="stat-card">
  61. <div class="stat-card-content">
  62. <div class="stat-info">
  63. <div class="stat-title">待考核</div>
  64. <div class="stat-value">{{ stats.pendingAssess || 0 }}</div>
  65. <div class="stat-extra" :class="(stats.pendingAssessRate || 0) >= 0 ? 'stat-extra-up' : 'stat-extra-down'">
  66. <el-icon v-if="(stats.pendingAssessRate || 0) >= 0"><CaretTop /></el-icon>
  67. <el-icon v-else><CaretBottom /></el-icon>
  68. <span>{{ Math.abs(stats.pendingAssessRate || 0) }}% 较上月</span>
  69. </div>
  70. </div>
  71. <div class="stat-icon stat-icon-red">
  72. <el-icon><EditPen /></el-icon>
  73. </div>
  74. </div>
  75. </el-card>
  76. </el-col>
  77. </el-row>
  78. <!-- 搜索区域 -->
  79. <transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
  80. <div v-show="showSearch" class="mb-[10px]">
  81. <el-card shadow="hover">
  82. <el-form ref="queryFormRef" :model="queryParams" :inline="true" label-width="80">
  83. <el-form-item label="关键词" prop="keyword">
  84. <el-input v-model="queryParams.keyword" placeholder="编号/名称" clearable style="width: 200px" @keyup.enter="handleQuery" />
  85. </el-form-item>
  86. <el-form-item label="状态" prop="handleStatus">
  87. <el-select v-model="queryParams.handleStatus" placeholder="所有状态" clearable style="width: 200px">
  88. <el-option v-for="dict in accident_status_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
  89. </el-select>
  90. </el-form-item>
  91. <el-form-item label="等级" prop="accidentLevel">
  92. <el-select v-model="queryParams.accidentLevel" placeholder="事故等级" clearable style="width: 200px">
  93. <el-option v-for="dict in accident_level_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
  94. </el-select>
  95. </el-form-item>
  96. <el-form-item label="类型" prop="accidentType">
  97. <el-select v-model="queryParams.accidentType" placeholder="事故类型" clearable style="width: 200px">
  98. <el-option v-for="dict in accident_type_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
  99. </el-select>
  100. </el-form-item>
  101. <el-form-item>
  102. <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
  103. <el-button icon="Refresh" @click="resetQuery">重置</el-button>
  104. </el-form-item>
  105. </el-form>
  106. </el-card>
  107. </div>
  108. </transition>
  109. <!-- 数据表格区域 -->
  110. <el-card shadow="hover" class="list-card">
  111. <div class="list-toolbar">
  112. <span class="list-total">共 {{ total }} 条记录</span>
  113. <div class="toolbar-actions">
  114. <el-button type="primary" plain icon="Plus" @click="handleAdd">新增上报</el-button>
  115. <right-toolbar v-model:show-search="showSearch" @query-table="getList" style="margin-left: 8px;" />
  116. </div>
  117. </div>
  118. <el-table v-loading="loading" :data="accidentList" border>
  119. <el-table-column label="事故编号" align="center" prop="accidentCode" width="220" :show-overflow-tooltip="true" />
  120. <el-table-column label="事故名称" align="center" prop="accidentTitle" :show-overflow-tooltip="true" />
  121. <el-table-column label="事故时间" align="center" prop="accidentTime" width="170" />
  122. <el-table-column label="事故地点" align="center" prop="accidentLocation" width="160" :show-overflow-tooltip="true" />
  123. <el-table-column label="等级" align="center" prop="accidentLevel" width="100">
  124. <template #default="scope">
  125. <el-tag :type="getLevelTag(scope.row.accidentLevel)">{{ scope.row.accidentLevel }}</el-tag>
  126. </template>
  127. </el-table-column>
  128. <el-table-column label="类型" align="center" prop="accidentType" width="100" />
  129. <el-table-column label="状态" align="center" prop="handleStatus" width="100">
  130. <template #default="scope">
  131. <el-tag :type="getStatusTag(scope.row.handleStatus)">{{ scope.row.handleStatus }}</el-tag>
  132. </template>
  133. </el-table-column>
  134. <el-table-column label="操作" fixed="right" align="center" width="280" class-name="small-padding fixed-width">
  135. <template #default="scope">
  136. <el-button v-hasPermi="['risk:accident:query']" link type="primary" icon="View" @click="handleView(scope.row)">详情</el-button>
  137. <el-button v-if="scope.row.handleStatus === '待处理'" v-hasPermi="['risk:accident:handle']" link type="primary" icon="CircleCheck" @click="openHandleDialog(scope.row)">处理</el-button>
  138. <el-button v-hasPermi="['risk:accident:edit']" link type="primary" icon="Edit" @click="handleUpdate(scope.row)">编辑</el-button>
  139. <el-button v-hasPermi="['risk:accident:remove']" link type="danger" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
  140. </template>
  141. </el-table-column>
  142. </el-table>
  143. <pagination v-show="total > 0" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" :total="total" @pagination="getList" />
  144. </el-card>
  145. <!-- 近12个月事故统计 -->
  146. <el-card shadow="hover" class="mt-2">
  147. <template #header>
  148. <span class="section-title">近12个月事故统计</span>
  149. </template>
  150. <div ref="trendChartRef" class="trend-chart"></div>
  151. </el-card>
  152. <!-- 新增/编辑对话框 -->
  153. <el-dialog v-model="dialog.visible" :title="dialog.title" width="780px" append-to-body @close="closeDialog">
  154. <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
  155. <el-row :gutter="20">
  156. <el-col :span="12">
  157. <el-form-item label="事故时间" prop="accidentTime">
  158. <el-date-picker v-model="form.accidentTime" type="datetime" placeholder="请选择事故时间" value-format="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
  159. </el-form-item>
  160. </el-col>
  161. <el-col :span="12">
  162. <el-form-item label="事故地点" prop="accidentLocation">
  163. <el-input v-model="form.accidentLocation" placeholder="如:XX车间XX工位 / XX园区XX路段" maxlength="200" />
  164. </el-form-item>
  165. </el-col>
  166. </el-row>
  167. <el-row :gutter="20">
  168. <el-col :span="12">
  169. <el-form-item label="事故等级" prop="accidentLevel">
  170. <el-select v-model="form.accidentLevel" placeholder="请选择事故等级" style="width: 100%">
  171. <el-option v-for="dict in accident_level_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
  172. </el-select>
  173. </el-form-item>
  174. </el-col>
  175. <el-col :span="12">
  176. <el-form-item label="事故类型" prop="accidentType">
  177. <el-select v-model="form.accidentType" placeholder="请选择事故类型" style="width: 100%">
  178. <el-option v-for="dict in accident_type_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
  179. </el-select>
  180. </el-form-item>
  181. </el-col>
  182. </el-row>
  183. <el-form-item label="事故名称" prop="accidentTitle">
  184. <el-input v-model="form.accidentTitle" placeholder="如:XX工位机械夹伤事故" maxlength="200" />
  185. </el-form-item>
  186. <el-form-item label="涉及人员" prop="participants">
  187. <el-input v-model="form.participants" placeholder="如:张三(操作工)、李四(班组长)" maxlength="500" />
  188. </el-form-item>
  189. <el-row :gutter="20">
  190. <el-col :span="12">
  191. <el-form-item label="死亡人数" prop="deathCount">
  192. <el-input-number v-model="form.deathCount" :min="0" style="width: 100%" />
  193. </el-form-item>
  194. </el-col>
  195. <el-col :span="12">
  196. <el-form-item label="受伤人数" prop="injuryCount">
  197. <el-input-number v-model="form.injuryCount" :min="0" style="width: 100%" />
  198. </el-form-item>
  199. </el-col>
  200. </el-row>
  201. <el-form-item label="事故经过" prop="accidentDesc">
  202. <el-input v-model="form.accidentDesc" type="textarea" :rows="4" placeholder="请详细描述事故发生经过(包含时间、地点、事件、后果)" maxlength="2000" show-word-limit />
  203. </el-form-item>
  204. <el-form-item label="初步原因" prop="accidentCause">
  205. <el-input v-model="form.accidentCause" type="textarea" :rows="3" placeholder="初步判断的事故原因,如操作违规、设备故障、环境因素" maxlength="1000" show-word-limit />
  206. </el-form-item>
  207. <el-form-item label="上报人" prop="reporterName">
  208. <el-input v-model="form.reporterName" placeholder="上报人姓名(默认当前登录用户)" maxlength="50" />
  209. </el-form-item>
  210. <el-form-item label="现场照片" prop="photoList">
  211. <el-upload v-model:file-list="photoFileList" :http-request="customUpload" list-type="picture-card" :limit="9" accept=".jpg,.png,.jpeg" :on-success="handleUploadSuccess" :on-error="handleUploadError" :on-exceed="handleExceed" :before-upload="beforeUpload">
  212. <el-icon><Plus /></el-icon>
  213. </el-upload>
  214. <div class="el-upload__tip">最多上传9张,支持 jpg/png/jpeg 格式</div>
  215. </el-form-item>
  216. </el-form>
  217. <template #footer>
  218. <div class="dialog-footer">
  219. <el-button type="primary" @click="submitForm">确 定</el-button>
  220. <el-button @click="cancel">取 消</el-button>
  221. </div>
  222. </template>
  223. </el-dialog>
  224. <!-- 查看详情对话框 -->
  225. <el-dialog v-model="viewDialog.visible" title="事故详情" width="800px" append-to-body>
  226. <el-descriptions :column="2" border>
  227. <el-descriptions-item label="事故编号">{{ viewForm.accidentCode }}</el-descriptions-item>
  228. <el-descriptions-item label="事故时间">{{ viewForm.accidentTime }}</el-descriptions-item>
  229. <el-descriptions-item label="事故名称" :span="2">{{ viewForm.accidentTitle }}</el-descriptions-item>
  230. <el-descriptions-item label="事故地点">{{ viewForm.accidentLocation }}</el-descriptions-item>
  231. <el-descriptions-item label="事故等级">
  232. <el-tag :type="getLevelTag(viewForm.accidentLevel)">{{ viewForm.accidentLevel }}</el-tag>
  233. </el-descriptions-item>
  234. <el-descriptions-item label="事故类型">{{ viewForm.accidentType }}</el-descriptions-item>
  235. <el-descriptions-item label="处理状态">
  236. <el-tag :type="getStatusTag(viewForm.handleStatus)">{{ viewForm.handleStatus }}</el-tag>
  237. </el-descriptions-item>
  238. <el-descriptions-item label="上报人">{{ viewForm.reporterName }}</el-descriptions-item>
  239. <el-descriptions-item label="上报时间">{{ viewForm.reportTime }}</el-descriptions-item>
  240. <el-descriptions-item label="涉及人员" :span="2">{{ viewForm.participants }}</el-descriptions-item>
  241. <el-descriptions-item label="死亡人数">{{ viewForm.deathCount }}</el-descriptions-item>
  242. <el-descriptions-item label="受伤人数">{{ viewForm.injuryCount }}</el-descriptions-item>
  243. <el-descriptions-item label="经济损失">{{ viewForm.lossAmount }}</el-descriptions-item>
  244. <el-descriptions-item label="处理人">{{ viewForm.handlerName }}</el-descriptions-item>
  245. <el-descriptions-item label="事故经过" :span="2">{{ viewForm.accidentDesc }}</el-descriptions-item>
  246. <el-descriptions-item label="初步原因" :span="2">{{ viewForm.accidentCause }}</el-descriptions-item>
  247. <el-descriptions-item label="处理措施" :span="2">{{ viewForm.handleMeasures }}</el-descriptions-item>
  248. <el-descriptions-item label="调查结果" :span="2">{{ viewForm.investigationResult }}</el-descriptions-item>
  249. <el-descriptions-item label="改进措施" :span="2">{{ viewForm.improvementMeasures }}</el-descriptions-item>
  250. <el-descriptions-item v-if="viewPhotoList.length > 0" label="现场照片" :span="2">
  251. <el-image v-for="(img, idx) in viewPhotoList" :key="idx" :src="img" :preview-src-list="viewPhotoList" :initial-index="idx" fit="cover" style="width: 80px; height: 80px; margin-right: 8px; border-radius: 4px" />
  252. </el-descriptions-item>
  253. <el-descriptions-item label="备注" :span="2">{{ viewForm.remark }}</el-descriptions-item>
  254. </el-descriptions>
  255. <template #footer>
  256. <el-button @click="viewDialog.visible = false">关 闭</el-button>
  257. </template>
  258. </el-dialog>
  259. <!-- 事故处理弹窗(独立,字段:处理方式/处理意见/责任人/处理期限/附件) -->
  260. <el-dialog
  261. v-model="handleDialog.visible"
  262. :title="handleDialog.title"
  263. width="640px"
  264. append-to-body
  265. :close-on-click-modal="false"
  266. @close="cancelHandle"
  267. >
  268. <el-form ref="handleFormRef" :model="handleForm" :rules="handleRules" label-width="100px">
  269. <el-form-item label="事故编号">
  270. <span class="readonly-text">{{ handleForm.accidentCode }}</span>
  271. </el-form-item>
  272. <el-form-item label="处理方式" prop="handleResult">
  273. <el-select v-model="handleForm.handleResult" placeholder="请选择处理方式" style="width: 100%" clearable>
  274. <el-option v-for="dict in accident_handle_way_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
  275. </el-select>
  276. </el-form-item>
  277. <el-form-item label="处理意见" prop="handleMeasures">
  278. <el-input
  279. v-model="handleForm.handleMeasures"
  280. type="textarea"
  281. :rows="5"
  282. placeholder="请详细填写本次事故处理过程、措施与结论..."
  283. maxlength="2000"
  284. show-word-limit
  285. />
  286. </el-form-item>
  287. <el-form-item label="责任人" prop="handlerName">
  288. <el-input v-model="handleForm.handlerName" placeholder="请输入责任人姓名(默认当前登录用户)" maxlength="50" />
  289. </el-form-item>
  290. <el-form-item label="处理期限" prop="handleDeadline">
  291. <el-date-picker
  292. v-model="handleForm.handleDeadline"
  293. type="date"
  294. placeholder="请选择处理期限"
  295. value-format="YYYY-MM-DD"
  296. style="width: 100%"
  297. />
  298. </el-form-item>
  299. <el-form-item label="附件上传">
  300. <el-upload
  301. v-model:file-list="handleAttachmentFileList"
  302. :http-request="customHandleAttachmentUpload"
  303. list-type="text"
  304. :limit="1"
  305. :on-success="onHandleAttachmentSuccess"
  306. :on-error="onHandleAttachmentError"
  307. :on-exceed="onHandleAttachmentExceed"
  308. :on-remove="onHandleAttachmentRemove"
  309. :before-upload="beforeHandleAttachmentUpload"
  310. >
  311. <el-button type="primary" plain icon="Upload">点击上传</el-button>
  312. <template #tip>
  313. <div class="el-upload__tip">支持任意格式文件,大小不超过 20MB</div>
  314. </template>
  315. </el-upload>
  316. </el-form-item>
  317. </el-form>
  318. <template #footer>
  319. <div class="dialog-footer">
  320. <el-button @click="cancelHandle">取 消</el-button>
  321. <el-button
  322. v-hasPermi="['risk:accident:handle']"
  323. type="primary"
  324. :loading="handleSubmitting"
  325. @click="submitHandle"
  326. >提交处理</el-button>
  327. </div>
  328. </template>
  329. </el-dialog>
  330. </div>
  331. </template>
  332. <script setup name="Accident" lang="ts">
  333. import { ref, reactive, toRefs, onMounted, onBeforeUnmount, nextTick, getCurrentInstance } from 'vue';
  334. import * as echarts from 'echarts';
  335. import {
  336. listAccident,
  337. getAccident,
  338. addAccident,
  339. updateAccident,
  340. delAccident,
  341. getAccidentStats,
  342. getAccidentTrend,
  343. handleAccident
  344. } from '@/api/risk/accident';
  345. import type { RiskAccidentBo, RiskAccidentVo, AccidentStatsVo, AccidentTrendVo } from '@/api/risk/types';
  346. import { FormInstance, UploadFile, UploadRawFile, UploadUserFile } from 'element-plus';
  347. import { UPLOAD_ACTION_URL, getMinioFileUrl, splitFileUrls } from '@/utils/minio';
  348. import { uploadFile } from '@/api/risk/file';
  349. import type { ComponentInternalInstance } from 'vue';
  350. import { CaretTop, CaretBottom, Document, Search, CircleCheck, EditPen } from '@element-plus/icons-vue';
  351. const { proxy } = getCurrentInstance() as ComponentInternalInstance;
  352. interface AccidentForm extends RiskAccidentBo {
  353. photoList?: string[];
  354. }
  355. const accidentList = ref<RiskAccidentVo[]>([]);
  356. const loading = ref(true);
  357. const showSearch = ref(true);
  358. const total = ref(0);
  359. const queryFormRef = ref<FormInstance>();
  360. const formRef = ref<FormInstance>();
  361. const photoFileList = ref<UploadUserFile[]>([]);
  362. const viewPhotoList = ref<string[]>([]);
  363. // 字典
  364. const {
  365. risk_accident_level: accident_level_dict,
  366. risk_accident_type: accident_type_dict,
  367. risk_accident_status: accident_status_dict,
  368. risk_accident_handle_way: accident_handle_way_dict
  369. } = toRefs<any>(
  370. proxy?.useDict(
  371. 'risk_accident_level',
  372. 'risk_accident_type',
  373. 'risk_accident_status',
  374. 'risk_accident_handle_way'
  375. )
  376. );
  377. // 统计
  378. const stats = ref<AccidentStatsVo>({
  379. monthTotal: 0,
  380. lastMonthTotal: 0,
  381. monthRate: 0,
  382. investigating: 0,
  383. lastMonthInvestigating: 0,
  384. investigatingRate: 0,
  385. finished: 0,
  386. lastMonthFinished: 0,
  387. finishedRate: 0,
  388. pendingAssess: 0,
  389. lastMonthPendingAssess: 0,
  390. pendingAssessRate: 0
  391. });
  392. // 趋势
  393. const trendList = ref<AccidentTrendVo[]>([]);
  394. const trendChartRef = ref<HTMLElement>();
  395. let trendChart: echarts.ECharts | null = null;
  396. const data = reactive<any>({
  397. form: {
  398. id: undefined,
  399. accidentCode: '',
  400. accidentTitle: '',
  401. accidentType: '',
  402. accidentLevel: '',
  403. accidentLocation: '',
  404. accidentTime: '',
  405. accidentCause: '',
  406. accidentDesc: '',
  407. deathCount: 0,
  408. injuryCount: 0,
  409. lossAmount: 0,
  410. reporterName: '',
  411. reportTime: '',
  412. handleStatus: '待处理' as any,
  413. participants: '',
  414. photoList: [] as string[]
  415. } as any,
  416. queryParams: {
  417. pageNum: 1,
  418. pageSize: 10,
  419. keyword: '',
  420. handleStatus: '',
  421. accidentLevel: '',
  422. accidentType: ''
  423. },
  424. rules: {
  425. accidentTitle: [{ required: true, message: '事故名称不能为空', trigger: 'blur' }],
  426. accidentTime: [{ required: true, message: '请选择事故时间', trigger: 'change' }],
  427. accidentLocation: [{ required: true, message: '请输入事故地点', trigger: 'blur' }],
  428. accidentLevel: [{ required: true, message: '请选择事故等级', trigger: 'change' }],
  429. accidentType: [{ required: true, message: '请选择事故类型', trigger: 'change' }],
  430. accidentDesc: [{ required: true, message: '请输入事故经过', trigger: 'blur' }],
  431. participants: [{ required: true, message: '请输入涉及人员', trigger: 'blur' }],
  432. deathCount: [{ required: true, message: '请填写死亡人数', trigger: 'change' }],
  433. injuryCount: [{ required: true, message: '请填写受伤人数', trigger: 'change' }],
  434. reporterName: [{ required: true, message: '请输入上报人', trigger: 'blur' }]
  435. }
  436. });
  437. const { queryParams, form, rules } = toRefs(data);
  438. const dialog = reactive<DialogOption>({ visible: false, title: '' });
  439. const viewDialog = reactive({ visible: false });
  440. const viewForm = ref<any>({});
  441. // 事故处理弹窗(独立,不与新增/编辑表单混用)
  442. const handleDialog = reactive({ visible: false, title: '事故处理' });
  443. const handleSubmitting = ref(false);
  444. const handleForm = ref<any>({
  445. id: undefined,
  446. accidentCode: '',
  447. handleResult: '',
  448. handleMeasures: '',
  449. handlerName: '',
  450. handleDeadline: '',
  451. handleAttachmentUrl: '',
  452. handleAttachmentName: ''
  453. });
  454. const handleFormRef = ref<FormInstance>();
  455. const handleAttachmentFileList = ref<UploadUserFile[]>([]);
  456. const handleRules = {
  457. handleResult: [{ required: true, message: '请选择处理方式', trigger: 'change' }],
  458. handleMeasures: [{ required: true, message: '请输入处理意见文案', trigger: 'blur' }],
  459. handlerName: [{ required: true, message: '请输入责任人', trigger: 'blur' }],
  460. handleDeadline: [{ required: true, message: '请选择处理期限', trigger: 'change' }]
  461. };
  462. const getList = async () => {
  463. loading.value = true;
  464. try {
  465. const res = await listAccident(queryParams.value);
  466. accidentList.value = res.rows || [];
  467. total.value = res.total || 0;
  468. } finally {
  469. loading.value = false;
  470. }
  471. };
  472. const loadStats = async () => {
  473. const res = await getAccidentStats();
  474. if (res.data) {
  475. stats.value = res.data;
  476. }
  477. };
  478. const loadTrend = async () => {
  479. const res = await getAccidentTrend();
  480. trendList.value = res.data || [];
  481. await nextTick();
  482. renderTrendChart();
  483. };
  484. const renderTrendChart = () => {
  485. if (!trendChartRef.value) return;
  486. if (!trendChart) {
  487. trendChart = echarts.init(trendChartRef.value);
  488. }
  489. const months = trendList.value.map((it) => it.month?.slice(2) || '');
  490. const reportArr = trendList.value.map((it) => it.reportCount || 0);
  491. const finishedArr = trendList.value.map((it) => it.finishedCount || 0);
  492. trendChart.setOption({
  493. tooltip: { trigger: 'axis' },
  494. legend: {
  495. data: ['上报表数', '已结束'],
  496. top: 0,
  497. icon: 'circle'
  498. },
  499. grid: { left: 40, right: 30, top: 40, bottom: 30 },
  500. xAxis: {
  501. type: 'category',
  502. data: months,
  503. axisLine: { lineStyle: { color: '#e0e0e0' } },
  504. axisLabel: { color: '#666' }
  505. },
  506. yAxis: {
  507. type: 'value',
  508. minInterval: 1,
  509. splitLine: { lineStyle: { type: 'dashed', color: '#eee' } },
  510. axisLabel: { color: '#666' }
  511. },
  512. series: [
  513. {
  514. name: '上报表数',
  515. type: 'bar',
  516. barWidth: '32%',
  517. data: reportArr,
  518. itemStyle: { color: '#409EFF' }
  519. },
  520. {
  521. name: '已结束',
  522. type: 'bar',
  523. barWidth: '32%',
  524. data: finishedArr,
  525. itemStyle: { color: '#67C23A' }
  526. }
  527. ]
  528. });
  529. };
  530. const handleResize = () => {
  531. trendChart?.resize();
  532. };
  533. const loadAll = async () => {
  534. await Promise.all([getList(), loadStats(), loadTrend()]);
  535. };
  536. const handleQuery = () => {
  537. queryParams.value.pageNum = 1;
  538. getList();
  539. };
  540. const resetQuery = () => {
  541. queryFormRef.value?.resetFields();
  542. queryParams.value.pageNum = 1;
  543. queryParams.value.keyword = '';
  544. queryParams.value.handleStatus = '';
  545. queryParams.value.accidentLevel = '';
  546. queryParams.value.accidentType = '';
  547. handleQuery();
  548. };
  549. const handleAdd = () => {
  550. reset();
  551. dialog.visible = true;
  552. dialog.title = '事故上报';
  553. };
  554. const handleUpdate = async (row: RiskAccidentVo) => {
  555. reset();
  556. const res = await getAccident(row.id);
  557. Object.assign(form.value, res.data);
  558. const photoArr = parsePhotoList(form.value.photoList as any);
  559. if (photoArr.length > 0) {
  560. photoFileList.value = photoArr.map((url, idx) => ({
  561. uid: idx,
  562. name: `photo-${idx}`,
  563. status: 'success',
  564. url
  565. }));
  566. }
  567. dialog.visible = true;
  568. dialog.title = '编辑事故';
  569. };
  570. const handleView = async (row: RiskAccidentVo) => {
  571. const res = await getAccident(row.id);
  572. viewForm.value = res.data;
  573. const photoArr = parsePhotoList(viewForm.value.photoList as any);
  574. viewPhotoList.value = photoArr;
  575. viewDialog.visible = true;
  576. };
  577. // ============ 事故处理(独立弹窗) ============
  578. // 打开处理弹窗:重置表单 → 加载事故详情到只读字段 → 显示弹窗
  579. const openHandleDialog = async (row: RiskAccidentVo) => {
  580. if (!row || row.handleStatus !== '待处理') {
  581. proxy?.$modal.msgWarning('只有"待处理"状态的事故才能处理');
  582. return;
  583. }
  584. resetHandleForm();
  585. const res = await getAccident(row.id);
  586. const data: any = res.data || {};
  587. handleForm.value.id = data.id;
  588. handleForm.value.accidentCode = data.accidentCode || '';
  589. // 责任人默认当前登录用户名(由后端 LoginHelper.getUsername() 兜底,前端可空)
  590. handleForm.value.handlerName = data.handlerName || '';
  591. handleDialog.visible = true;
  592. };
  593. // 提交处理(后端强制将状态置为"归档")
  594. const submitHandle = () => {
  595. handleFormRef.value?.validate(async (valid: boolean) => {
  596. if (!valid) return;
  597. handleSubmitting.value = true;
  598. try {
  599. await handleAccident(handleForm.value.id, handleForm.value as RiskAccidentBo);
  600. proxy?.$modal.msgSuccess('处理成功,事故已归档');
  601. handleDialog.visible = false;
  602. await loadAll();
  603. } finally {
  604. handleSubmitting.value = false;
  605. }
  606. });
  607. };
  608. const cancelHandle = () => {
  609. handleDialog.visible = false;
  610. resetHandleForm();
  611. };
  612. const resetHandleForm = () => {
  613. handleForm.value = {
  614. id: undefined,
  615. accidentCode: '',
  616. handleResult: '',
  617. handleMeasures: '',
  618. handlerName: '',
  619. handleDeadline: '',
  620. handleAttachmentUrl: '',
  621. handleAttachmentName: ''
  622. };
  623. handleAttachmentFileList.value = [];
  624. handleFormRef.value?.resetFields();
  625. };
  626. // ============ 处理弹窗附件上传(走 axios baseURL,避免依赖 :action) ============
  627. const customHandleAttachmentUpload = async (options: any) => {
  628. try {
  629. const res: any = await uploadFile(options.file);
  630. if (res && res.code === 200 && res.data) {
  631. options.onSuccess(res, options.file);
  632. } else {
  633. options.onError(new Error(res?.msg || '上传失败'));
  634. }
  635. } catch (err: any) {
  636. options.onError(err);
  637. }
  638. };
  639. const beforeHandleAttachmentUpload = (file: UploadRawFile) => {
  640. const isLt20M = file.size / 1024 / 1024 < 20;
  641. if (!isLt20M) {
  642. proxy?.$modal.msgError('附件大小不能超过 20MB!');
  643. return false;
  644. }
  645. return true;
  646. };
  647. const onHandleAttachmentSuccess = (response: any, uploadFile: UploadFile) => {
  648. const url = response?.fileName || response?.data?.fileName || response?.url || response?.data?.url || '';
  649. uploadFile.url = getMinioFileUrl(url);
  650. handleForm.value.handleAttachmentUrl = uploadFile.url;
  651. handleForm.value.handleAttachmentName = uploadFile.name;
  652. proxy?.$modal.msgSuccess('上传成功');
  653. };
  654. const onHandleAttachmentError = () => {
  655. proxy?.$modal.msgError('附件上传失败,请重试');
  656. };
  657. const onHandleAttachmentExceed = () => {
  658. proxy?.$modal.msgWarning('最多只能上传 1 个附件');
  659. };
  660. const onHandleAttachmentRemove = () => {
  661. handleForm.value.handleAttachmentUrl = '';
  662. handleForm.value.handleAttachmentName = '';
  663. };
  664. const parsePhotoList = (val: any): string[] => {
  665. if (!val) return [];
  666. if (Array.isArray(val)) return val as string[];
  667. if (typeof val === 'string') {
  668. const trimmed = val.trim();
  669. if (!trimmed) return [];
  670. if (trimmed.startsWith('[')) {
  671. try {
  672. const parsed = JSON.parse(trimmed);
  673. return Array.isArray(parsed) ? parsed : [];
  674. } catch {
  675. return splitFileUrls(trimmed);
  676. }
  677. }
  678. return splitFileUrls(trimmed);
  679. }
  680. return [];
  681. };
  682. const handleDelete = async (row: RiskAccidentVo) => {
  683. await proxy?.$modal.confirm('是否确认删除事故编号为 "' + row.accidentCode + '" 的数据项?');
  684. await delAccident(row.id);
  685. proxy?.$modal.msgSuccess('删除成功');
  686. await getList();
  687. };
  688. const submitForm = () => {
  689. formRef.value?.validate(async (valid: boolean) => {
  690. if (valid) {
  691. const uploadedUrls = photoFileList.value
  692. .filter((f: any) => f.status === 'success')
  693. .map((f: any) => f.url || getMinioFileUrl(f.response?.fileName || f.response?.data?.fileName));
  694. form.value.photoList = uploadedUrls.length > 0 ? JSON.stringify(uploadedUrls) : '';
  695. if (form.value.id) {
  696. await updateAccident(form.value as RiskAccidentBo);
  697. proxy?.$modal.msgSuccess('修改成功');
  698. } else {
  699. await addAccident(form.value as RiskAccidentBo);
  700. proxy?.$modal.msgSuccess('上报成功');
  701. }
  702. dialog.visible = false;
  703. await loadAll();
  704. }
  705. });
  706. };
  707. const cancel = () => {
  708. dialog.visible = false;
  709. reset();
  710. };
  711. const reset = () => {
  712. form.value = {
  713. id: undefined,
  714. accidentCode: '',
  715. accidentTitle: '',
  716. accidentType: '',
  717. accidentLevel: '',
  718. accidentLocation: '',
  719. accidentTime: '',
  720. accidentCause: '',
  721. accidentDesc: '',
  722. deathCount: 0,
  723. injuryCount: 0,
  724. lossAmount: 0,
  725. reporterName: '',
  726. reportTime: '',
  727. handleStatus: '待处理',
  728. participants: '',
  729. photoList: []
  730. } as any;
  731. photoFileList.value = [];
  732. formRef.value?.resetFields();
  733. };
  734. const closeDialog = () => {
  735. dialog.visible = false;
  736. reset();
  737. };
  738. const handleUploadSuccess = (response: any, uploadFile: UploadFile) => {
  739. uploadFile.url = getMinioFileUrl(response.fileName || response.data?.fileName);
  740. proxy?.$modal.msgSuccess('上传成功');
  741. };
  742. const handleUploadError = () => {
  743. proxy?.$modal.msgError('上传失败,请重试');
  744. };
  745. const customUpload = async (options: any) => {
  746. try {
  747. const res: any = await uploadFile(options.file);
  748. if (res && res.code === 200 && res.data) {
  749. options.onSuccess(res, options.file);
  750. } else {
  751. options.onError(new Error(res?.msg || '上传失败'));
  752. }
  753. } catch (err: any) {
  754. options.onError(err);
  755. }
  756. };
  757. const beforeUpload = (file: UploadRawFile) => {
  758. const isValidType = ['image/jpeg', 'image/png', 'image/jpg'].includes(file.type);
  759. const isLt10M = file.size / 1024 / 1024 < 10;
  760. if (!isValidType) {
  761. proxy?.$modal.msgError('只能上传 jpg/png/jpeg 格式的图片!');
  762. return false;
  763. }
  764. if (!isLt10M) {
  765. proxy?.$modal.msgError('单张图片大小不能超过 10MB!');
  766. return false;
  767. }
  768. return true;
  769. };
  770. const handleExceed = () => {
  771. proxy?.$modal.msgWarning('最多只能上传9张图片');
  772. };
  773. const getLevelTag = (level: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
  774. const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
  775. 特别重大: 'danger',
  776. 重大: 'danger',
  777. 较大: 'warning',
  778. 一般: 'primary'
  779. };
  780. return (map[level] || 'info') as any;
  781. };
  782. const getStatusTag = (status: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
  783. const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
  784. 待处理: 'danger',
  785. 归档: 'success'
  786. };
  787. return (map[status] || 'info') as any;
  788. };
  789. onMounted(async () => {
  790. await loadAll();
  791. window.addEventListener('resize', handleResize);
  792. });
  793. onBeforeUnmount(() => {
  794. window.removeEventListener('resize', handleResize);
  795. trendChart?.dispose();
  796. trendChart = null;
  797. });
  798. </script>
  799. <style scoped lang="scss">
  800. .accident-page {
  801. padding: 16px;
  802. height: 100%;
  803. overflow-y: auto;
  804. box-sizing: border-box;
  805. }
  806. .stat-card {
  807. border-radius: 8px;
  808. :deep(.el-card__body) { padding: 20px; }
  809. }
  810. .stat-card-content {
  811. display: flex;
  812. align-items: center;
  813. justify-content: space-between;
  814. }
  815. .stat-info { flex: 1; }
  816. .stat-title {
  817. font-size: 13px;
  818. color: #909399;
  819. margin-bottom: 8px;
  820. white-space: nowrap;
  821. }
  822. .stat-value {
  823. font-size: 28px;
  824. font-weight: 700;
  825. color: #303133;
  826. line-height: 1.2;
  827. margin-bottom: 8px;
  828. }
  829. .stat-extra {
  830. font-size: 12px;
  831. color: #909399;
  832. display: flex;
  833. align-items: center;
  834. gap: 2px;
  835. }
  836. .stat-extra-up { color: #f56c6c; }
  837. .stat-extra-down { color: #67c23a; }
  838. .stat-icon {
  839. width: 48px;
  840. height: 48px;
  841. border-radius: 8px;
  842. display: flex;
  843. align-items: center;
  844. justify-content: center;
  845. font-size: 24px;
  846. color: #fff;
  847. }
  848. .stat-icon-blue { background: #ecf5ff; color: #409EFF; }
  849. .stat-icon-orange { background: #fdf6ec; color: #E6A23C; }
  850. .stat-icon-green { background: #f0f9eb; color: #67C23A; }
  851. .stat-icon-red { background: #fef0f0; color: #F56C6C; }
  852. .section-title { font-size: 16px; font-weight: 600; color: #303133; }
  853. .mb-2 { margin-bottom: 8px; }
  854. .mt-2 { margin-top: 8px; }
  855. .trend-chart { width: 100%; height: 360px; }
  856. .el-upload__tip { font-size: 12px; color: #909399; line-height: 1.5; margin-top: 4px; }
  857. .list-toolbar {
  858. display: flex;
  859. justify-content: space-between;
  860. align-items: center;
  861. margin-bottom: 12px;
  862. }
  863. .list-total {
  864. font-size: 13px;
  865. color: #606266;
  866. }
  867. .toolbar-actions {
  868. display: flex;
  869. align-items: center;
  870. }
  871. /* ============ 事故处理弹窗专用样式 ============ */
  872. .readonly-text {
  873. display: inline-block;
  874. width: 100%;
  875. padding: 0 12px;
  876. line-height: 32px;
  877. background: #f5f7fa;
  878. border: 1px solid #e4e7ed;
  879. border-radius: 4px;
  880. color: #606266;
  881. font-size: 14px;
  882. }
  883. .upload-row {
  884. display: flex;
  885. align-items: center;
  886. gap: 12px;
  887. margin-bottom: 4px;
  888. }
  889. .upload-tip {
  890. font-size: 12px;
  891. color: #909399;
  892. line-height: 1.5;
  893. }
  894. </style>