gzjc.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. <template>
  2. <div class="app-container gzjc-page">
  3. <!-- 搜索区(若依风格) -->
  4. <div class="filter-panel">
  5. <el-form :inline="true" :model="searchForm" class="filter-form" label-width="80px">
  6. <el-form-item label="事件状态">
  7. <el-select v-model="searchForm.status" placeholder="请选择事件状态" clearable style="width:140px">
  8. <el-option v-for="d in trackStatusOptions" :key="d.value" :label="d.label" :value="d.value" />
  9. </el-select>
  10. </el-form-item>
  11. <el-form-item label="事件类别">
  12. <el-select v-model="searchForm.eventCategory" placeholder="请选择事件类别" clearable filterable style="width:140px">
  13. <el-option v-for="d in dis_event_category" :key="d.value" :label="d.label" :value="d.value" />
  14. </el-select>
  15. </el-form-item>
  16. <el-form-item label="接报时间">
  17. <el-date-picker
  18. v-model="searchForm.dateRange"
  19. type="daterange"
  20. range-separator="至"
  21. start-placeholder="开始日期"
  22. end-placeholder="结束日期"
  23. style="width:240px"
  24. value-format="YYYY-MM-DD"
  25. />
  26. </el-form-item>
  27. <el-form-item>
  28. <el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
  29. <el-button :icon="Refresh" @click="handleReset">重置</el-button>
  30. </el-form-item>
  31. </el-form>
  32. </div>
  33. <!-- 数据表格 -->
  34. <div class="table-panel">
  35. <el-table :data="tableData" border stripe style="width:100%" v-loading="loading">
  36. <el-table-column prop="eventNo" label="事件编号" width="140" />
  37. <el-table-column prop="eventCategory" label="事件类别" width="110">
  38. <template #default="{ row }">{{ dictLabel(dis_event_category, row.eventCategory) }}</template>
  39. </el-table-column>
  40. <el-table-column prop="eventLevel" label="事件等级" width="100">
  41. <template #default="{ row }">
  42. <el-tag v-if="row.eventLevel" :type="levelTagType(row.eventLevel)" size="small">{{ dictLabel(dis_event_level, row.eventLevel) }}</el-tag>
  43. <span v-else>-</span>
  44. </template>
  45. </el-table-column>
  46. <el-table-column prop="location" label="发生地点" min-width="150" show-overflow-tooltip>
  47. <template #default="{ row }">{{ row.location || '-' }}</template>
  48. </el-table-column>
  49. <el-table-column prop="statusName" label="当前状态" width="100">
  50. <template #default="{ row }">
  51. <el-tag :type="statusTagType(row.status)" size="small">{{ row.statusName }}</el-tag>
  52. </template>
  53. </el-table-column>
  54. <el-table-column prop="handler" label="处置人" width="100">
  55. <template #default="{ row }">{{ row.handler || '-' }}</template>
  56. </el-table-column>
  57. <el-table-column prop="lastVisitTime" label="最近回访" width="150">
  58. <template #default="{ row }">{{ row.lastVisitTime || '-' }}</template>
  59. </el-table-column>
  60. <el-table-column prop="nextVisitTime" label="下次回访" min-width="150">
  61. <template #default="{ row }">{{ row.nextVisitTime || '-' }}</template>
  62. </el-table-column>
  63. <el-table-column prop="createTime" label="创建时间" width="150" />
  64. <el-table-column label="操作" width="260" fixed="right">
  65. <template #default="{ row }">
  66. <el-button v-if="row.status === 1" type="primary" plain size="small" @click="openOperation(row, 'start')" v-hasPermi="['dispatch:event:dispose']">开始处置</el-button>
  67. <el-button v-if="row.status === 2" type="warning" plain size="small" @click="openOperation(row, 'track')" v-hasPermi="['dispatch:event:track']">跟踪</el-button>
  68. <el-button v-if="row.status === 2" type="success" plain size="small" @click="handleRelease(row)" v-hasPermi="['dispatch:event:release']">解除</el-button>
  69. <el-button type="info" plain size="small" @click="handleDetail(row)">详情</el-button>
  70. <el-button v-if="row.editable" type="danger" plain size="small" @click="handleDelete(row)" v-hasPermi="['dispatch:event:remove']">删除</el-button>
  71. </template>
  72. </el-table-column>
  73. </el-table>
  74. <div class="pagination-wrap">
  75. <el-pagination
  76. v-model:current-page="pagination.page"
  77. v-model:page-size="pagination.pageSize"
  78. :page-sizes="[10, 20, 50]"
  79. layout="total, sizes, prev, pager, next, jumper"
  80. :total="total"
  81. @size-change="handleSizeChange"
  82. @current-change="handleCurrentChange"
  83. />
  84. </div>
  85. </div>
  86. <!-- 开始处置和跟踪共用备注+附件表单 -->
  87. <el-dialog v-model="operationVisible" :title="operationType === 'start' ? '开始处置' : '事件跟踪'" width="600px" :close-on-click-modal="false">
  88. <el-form :model="operationForm" :rules="operationRules" ref="operationFormRef" label-width="100px">
  89. <el-form-item label="备注" prop="operDesc">
  90. <el-input v-model="operationForm.operDesc" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入备注" />
  91. </el-form-item>
  92. <el-form-item label="附件">
  93. <el-upload
  94. ref="operationUploadRef"
  95. v-model:file-list="operationForm.fileList"
  96. :action="uploadUrl"
  97. :headers="uploadHeaders"
  98. :limit="5"
  99. multiple
  100. accept=".jpg,.jpeg,.png,.gif,.bmp,.webp,.mp4,.mov,.m4v,.webm"
  101. :on-success="handleOperationAttachSuccess"
  102. :on-error="handleAttachError"
  103. :on-exceed="() => ElMessage.warning('附件最多 5 个')"
  104. :before-upload="handleAttachBefore"
  105. >
  106. <el-button type="primary" plain>上传附件</el-button>
  107. <template #tip><div class="el-upload__tip">图片或视频,单个不超过 50MB,最多 5 个</div></template>
  108. </el-upload>
  109. </el-form-item>
  110. </el-form>
  111. <template #footer>
  112. <el-button @click="operationVisible = false">取消</el-button>
  113. <el-button type="primary" :loading="operationSubmitting" @click="confirmOperation">{{ operationType === 'start' ? '开始处置' : '保存跟踪' }}</el-button>
  114. </template>
  115. </el-dialog>
  116. <!-- 解除弹窗 -->
  117. <el-dialog v-model="releaseVisible" title="事件解除" width="600px" :close-on-click-modal="false">
  118. <el-form :model="releaseForm" :rules="releaseRules" ref="releaseFormRef" label-width="100px">
  119. <el-form-item label="事件编号">{{ releaseForm.eventNo }}</el-form-item>
  120. <el-form-item label="事件摘要">
  121. <div class="desc-text">{{ releaseForm.summary || '-' }}</div>
  122. </el-form-item>
  123. <el-form-item label="解除原因" prop="releaseReason">
  124. <el-select v-model="releaseForm.releaseReason" placeholder="请选择" style="width:100%">
  125. <el-option v-for="d in dis_event_release_reason" :key="d.value" :label="d.label" :value="d.value" />
  126. </el-select>
  127. </el-form-item>
  128. <el-form-item label="解除说明" prop="releaseNote">
  129. <el-input v-model="releaseForm.releaseNote" type="textarea" :rows="4" placeholder="请输入解除说明" />
  130. </el-form-item>
  131. <el-form-item label="损失(万元)">
  132. <el-input-number v-model="releaseForm.lossAmount" :min="0" :precision="2" style="width:100%" />
  133. </el-form-item>
  134. <template v-if="isHandledRelease">
  135. <el-form-item label="处置前佐证">
  136. <el-upload
  137. ref="beforeUploadRef"
  138. v-model:file-list="releaseForm.beforeFiles"
  139. :action="uploadUrl"
  140. :headers="uploadHeaders"
  141. :limit="5"
  142. multiple
  143. accept=".jpg,.jpeg,.png,.gif,.bmp,.webp,.mp4,.mov,.m4v,.webm"
  144. :on-success="(res) => handleAttachSuccess(res, 'before')"
  145. :on-error="handleAttachError"
  146. :on-exceed="() => ElMessage.warning('处置前佐证最多 5 个')"
  147. :before-upload="handleAttachBefore"
  148. style="width:100%"
  149. >
  150. <el-button type="primary" size="small">上传处置前照片/视频</el-button>
  151. <template #tip><div class="el-upload__tip">图片或视频,单个不超过 50MB,最多 5 个</div></template>
  152. </el-upload>
  153. </el-form-item>
  154. <el-form-item label="处置后佐证">
  155. <el-upload
  156. ref="afterUploadRef"
  157. v-model:file-list="releaseForm.afterFiles"
  158. :action="uploadUrl"
  159. :headers="uploadHeaders"
  160. :limit="5"
  161. multiple
  162. accept=".jpg,.jpeg,.png,.gif,.bmp,.webp,.mp4,.mov,.m4v,.webm"
  163. :on-success="(res) => handleAttachSuccess(res, 'after')"
  164. :on-error="handleAttachError"
  165. :on-exceed="() => ElMessage.warning('处置后佐证最多 5 个')"
  166. :before-upload="handleAttachBefore"
  167. style="width:100%"
  168. >
  169. <el-button type="primary" size="small">上传处置后照片/视频</el-button>
  170. <template #tip><div class="el-upload__tip">图片或视频,单个不超过 50MB,最多 5 个</div></template>
  171. </el-upload>
  172. </el-form-item>
  173. </template>
  174. </el-form>
  175. <template #footer>
  176. <el-button @click="releaseVisible = false">取消</el-button>
  177. <el-button type="primary" @click="confirmRelease">确认</el-button>
  178. </template>
  179. </el-dialog>
  180. <EventDetailDrawer ref="eventDetailRef" />
  181. </div>
  182. </template>
  183. <script setup name="Gzjc">
  184. import { ref, nextTick, computed, onMounted } from 'vue'
  185. import { ElMessage, ElMessageBox } from 'element-plus'
  186. import { useDict } from '@/utils/dict'
  187. import { getToken } from '@/utils/auth'
  188. import {
  189. getEventPage, deleteEventInfo, startDisposeEvent, trackEvent,
  190. releaseEventInfo, getEventStatusOptions
  191. } from '@/api/dispatch'
  192. import { Search, Refresh } from '@element-plus/icons-vue'
  193. import EventDetailDrawer from './EventDetailDrawer.vue'
  194. // ==================== 字典 ====================
  195. const { dis_event_category, dis_event_level, dis_event_trend, dis_event_release_reason, dis_yes_no } = useDict(
  196. 'dis_event_category', 'dis_event_level', 'dis_event_trend', 'dis_event_release_reason', 'dis_yes_no'
  197. )
  198. function dictLabel(list, value) {
  199. if (value === null || value === undefined || value === '') return '-'
  200. const hit = (list || []).find(item => String(item.value) === String(value))
  201. return hit ? hit.label : value
  202. }
  203. // 本页覆盖「已核实(1) 待开始处置 / 处置中(2) 待跟踪解除 / 已解除(3) 终态查看」
  204. const trackStatusOptions = ref([])
  205. async function loadStatusOptions() {
  206. try {
  207. const res = await getEventStatusOptions()
  208. trackStatusOptions.value = (res.data || []).filter(item => [1, 2, 3].includes(item.value))
  209. } catch {
  210. trackStatusOptions.value = []
  211. }
  212. }
  213. // 状态码 -> 标签样式(0 待核实 1 已核实 2 处置中 3 已解除 4 不实事件)
  214. const STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'primary', 3: 'warning', 4: 'info' }
  215. function statusTagType(status) {
  216. return STATUS_TAG[status] || 'info'
  217. }
  218. // 等级字典值 -> 标签样式(1 Ⅰ级紧急 2 Ⅱ级较大 3 Ⅲ级一般)
  219. const LEVEL_TAG = { 1: 'danger', 2: 'warning', 3: 'info' }
  220. function levelTagType(level) {
  221. return LEVEL_TAG[level] || 'info'
  222. }
  223. // ==================== 数据状态 ====================
  224. const tableData = ref([])
  225. const total = ref(0)
  226. const loading = ref(false)
  227. const searchForm = ref({ status: '', eventCategory: '', dateRange: null })
  228. const pagination = ref({ page: 1, pageSize: 10 })
  229. // 下拉清空后可能是 '' / null / undefined,统一判定,避免拼出空查询条件
  230. const hasValue = (val) => val !== '' && val !== null && val !== undefined
  231. // ==================== 加载数据 ====================
  232. async function loadData() {
  233. loading.value = true
  234. try {
  235. const params = { statusList: [1, 2, 3], orderAsc: true }
  236. if (hasValue(searchForm.value.status)) params.statusList = [searchForm.value.status]
  237. if (searchForm.value.eventCategory) params.eventCategory = searchForm.value.eventCategory
  238. if (searchForm.value.dateRange && searchForm.value.dateRange.length === 2) {
  239. params.startDate = searchForm.value.dateRange[0]
  240. params.endDate = searchForm.value.dateRange[1]
  241. }
  242. const res = await getEventPage(pagination.value.page, pagination.value.pageSize, params)
  243. tableData.value = res.data?.rows || res.data?.records || res.data || []
  244. total.value = res.data?.total || 0
  245. } catch (error) {
  246. ElMessage.error('加载数据失败:' + (error.message || '未知错误'))
  247. } finally {
  248. loading.value = false
  249. }
  250. }
  251. onMounted(() => {
  252. loadStatusOptions()
  253. loadData()
  254. })
  255. // ==================== 搜索筛选 ====================
  256. function handleSearch() {
  257. pagination.value.page = 1
  258. loadData()
  259. }
  260. function handleReset() {
  261. searchForm.value = { status: '', eventCategory: '', dateRange: null }
  262. pagination.value.page = 1
  263. loadData()
  264. }
  265. // ==================== 分页 ====================
  266. function handleSizeChange(val) {
  267. pagination.value.pageSize = val
  268. pagination.value.page = 1
  269. loadData()
  270. }
  271. function handleCurrentChange(val) {
  272. pagination.value.page = val
  273. loadData()
  274. }
  275. // ==================== 开始处置 / 跟踪 ====================
  276. const operationVisible = ref(false)
  277. const operationFormRef = ref(null)
  278. const operationUploadRef = ref(null)
  279. const currentRow = ref(null)
  280. const operationType = ref('start')
  281. const operationSubmitting = ref(false)
  282. const operationForm = ref({ operDesc: '', fileList: [], attachUrls: [] })
  283. const operationRules = {
  284. operDesc: [{ required: true, message: '请输入备注', trigger: 'blur' }]
  285. }
  286. function openOperation(row, type) {
  287. currentRow.value = { ...row }
  288. operationType.value = type
  289. operationForm.value = { operDesc: '', fileList: [], attachUrls: [] }
  290. operationVisible.value = true
  291. nextTick(() => operationFormRef.value?.clearValidate())
  292. }
  293. function handleOperationAttachSuccess(res) {
  294. if (res.code === 200 && res.url) {
  295. operationForm.value.attachUrls.push(res.relativePath || res.url)
  296. } else {
  297. ElMessage.error(res.msg || '上传失败')
  298. }
  299. }
  300. async function confirmOperation() {
  301. try {
  302. await operationFormRef.value.validate()
  303. } catch {
  304. return
  305. }
  306. operationSubmitting.value = true
  307. try {
  308. const payload = {
  309. eventId: currentRow.value.id,
  310. operDesc: operationForm.value.operDesc,
  311. attachUrl: operationForm.value.attachUrls.join(',')
  312. }
  313. if (operationType.value === 'start') {
  314. await startDisposeEvent(payload)
  315. ElMessage.success('已开始处置')
  316. } else {
  317. await trackEvent(payload)
  318. ElMessage.success('跟踪记录保存成功')
  319. }
  320. operationVisible.value = false
  321. operationUploadRef.value?.clearFiles()
  322. loadData()
  323. } catch (error) {
  324. ElMessage.error('操作失败:' + (error.message || '未知错误'))
  325. } finally {
  326. operationSubmitting.value = false
  327. }
  328. }
  329. // ==================== 解除 ====================
  330. const releaseVisible = ref(false)
  331. const releaseFormRef = ref(null)
  332. const currentReleaseRow = ref(null)
  333. const releaseForm = ref({ eventNo: '', summary: '', releaseReason: '', releaseNote: '', lossAmount: undefined, beforeFiles: [], afterFiles: [], beforeUrls: [], afterUrls: [] })
  334. const releaseRules = {
  335. releaseReason: [{ required: true, message: '请选择解除原因', trigger: 'change' }],
  336. releaseNote: [{ required: true, message: '请输入解除说明', trigger: 'blur' }]
  337. }
  338. // ==================== 解除佐证附件(处置前 / 处置后) ====================
  339. // 解除原因为「事件已处置完成」(字典值 '1') 时展示上传控件;图片/视频各最多 5 个、单个 ≤50MB
  340. // 佐证附件统一存 MinIO(图片/视频直链可预览)
  341. const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/minio/upload?dir=dispatch_release'
  342. const uploadHeaders = { Authorization: 'Bearer ' + getToken() }
  343. const beforeUploadRef = ref(null)
  344. const afterUploadRef = ref(null)
  345. const ALLOWED_ATTACH_EXT = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp4', 'mov', 'm4v', 'webm']
  346. const isHandledRelease = computed(() => dictLabel(dis_event_release_reason.value, releaseForm.value.releaseReason) === '事件已处置完成')
  347. function handleAttachBefore(file) {
  348. const ext = String(file.name).split('.').pop().toLowerCase()
  349. if (!ALLOWED_ATTACH_EXT.includes(ext)) {
  350. ElMessage.error('仅支持图片(jpg/png/gif 等)或视频(mp4/mov 等)格式')
  351. return false
  352. }
  353. if (file.size / 1024 / 1024 >= 50) {
  354. ElMessage.error('文件大小不能超过 50MB')
  355. return false
  356. }
  357. return true
  358. }
  359. function handleAttachSuccess(res, slot) {
  360. if (res.code === 200 && res.url) {
  361. // 库内只存相对路径(迁移环境不用改数据),展示/下载由后端拼 MinIO 地址
  362. releaseForm.value[slot + 'Urls'].push(res.relativePath || res.url)
  363. } else {
  364. ElMessage.error(res.msg || '上传失败')
  365. }
  366. }
  367. function handleAttachError() {
  368. ElMessage.error('文件上传失败')
  369. }
  370. function handleRelease(row) {
  371. currentReleaseRow.value = row
  372. releaseForm.value = {
  373. eventNo: row.eventNo,
  374. summary: row.description || '',
  375. releaseReason: '',
  376. releaseNote: '',
  377. lossAmount: row.lossAmount !== null && row.lossAmount !== undefined ? Number(row.lossAmount) : undefined,
  378. beforeFiles: [],
  379. afterFiles: [],
  380. beforeUrls: [],
  381. afterUrls: []
  382. }
  383. releaseVisible.value = true
  384. // 清掉上次打开残留的上传文件列表
  385. nextTick(() => {
  386. beforeUploadRef.value?.clearFiles()
  387. afterUploadRef.value?.clearFiles()
  388. })
  389. }
  390. async function confirmRelease() {
  391. try {
  392. await releaseFormRef.value.validate()
  393. } catch {
  394. return
  395. }
  396. try {
  397. await releaseEventInfo(currentReleaseRow.value.id, {
  398. eventId: currentReleaseRow.value.id,
  399. releaseReason: releaseForm.value.releaseReason,
  400. releaseNote: releaseForm.value.releaseNote,
  401. lossAmount: releaseForm.value.lossAmount,
  402. // 处置前/后佐证(多文件英文逗号分隔),仅「事件已处置完成」时采集
  403. beforeUrl: releaseForm.value.beforeUrls.join(','),
  404. afterUrl: releaseForm.value.afterUrls.join(',')
  405. })
  406. ElMessage.success('事件已解除')
  407. releaseVisible.value = false
  408. nextTick(() => {
  409. beforeUploadRef.value?.clearFiles()
  410. afterUploadRef.value?.clearFiles()
  411. })
  412. loadData()
  413. } catch (error) {
  414. ElMessage.error('操作失败:' + (error.message || '未知错误'))
  415. }
  416. }
  417. // ==================== 删除 ====================
  418. async function handleDelete(row) {
  419. try {
  420. await ElMessageBox.confirm('确定要删除该事件吗?', '删除确认', { type: 'warning' })
  421. await deleteEventInfo(row.id)
  422. ElMessage.success('删除成功')
  423. loadData()
  424. } catch (error) {
  425. if (error !== 'cancel' && error !== 'close') {
  426. ElMessage.error('删除失败:' + (error.message || '未知错误'))
  427. }
  428. }
  429. }
  430. // ==================== 详情 ====================
  431. const eventDetailRef = ref(null)
  432. function handleDetail(row) {
  433. eventDetailRef.value?.open(row)
  434. }
  435. </script>
  436. <style scoped>
  437. .gzjc-page {
  438. padding: 16px;
  439. background: #f5f5f5;
  440. min-height: calc(100vh - 84px);
  441. }
  442. .filter-panel {
  443. background: #fff;
  444. border: 1px solid #dcdfe6;
  445. border-radius: 4px;
  446. padding: 12px 16px;
  447. margin-bottom: 12px;
  448. display: flex;
  449. justify-content: space-between;
  450. align-items: center;
  451. flex-wrap: wrap;
  452. gap: 12px;
  453. }
  454. .filter-form {
  455. display: flex;
  456. align-items: center;
  457. gap: 0 12px;
  458. flex-wrap: wrap;
  459. }
  460. /* 若依风格查询栏:内联表单项去默认外边距,间距统一交给 gap */
  461. .filter-form :deep(.el-form-item) {
  462. margin-right: 0;
  463. margin-bottom: 0;
  464. }
  465. .table-panel {
  466. background: #fff;
  467. border: 1px solid #dcdfe6;
  468. border-radius: 4px;
  469. padding: 16px;
  470. }
  471. .pagination-wrap {
  472. margin-top: 16px;
  473. display: flex;
  474. justify-content: flex-end;
  475. }
  476. </style>