772 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="water-disaster">
<!-- 基本信息 -->
<PanelItem title="基本信息" v-if="!isContinue">
<van-form>
<!-- 路况类别 -->
<BasePicker v-model="formData.roadConditionType" :options="options['waterRoadConditionType']" label="路况类别" placeholder="请选择" />
<!-- 是否阻断 (event.isBlocked) -->
<BasePicker v-model="formData.event.isBlocked" :options="options['yesOrNoBool']" label="是否阻断" placeholder="请选择" />
<!-- 抢险进度 (event.repairProgress) -->
<BasePicker v-model="formData.event.repairProgress" :options="options['repairProgress']" label="抢险进度" placeholder="请选择" />
<!-- 水毁处数 (event.damageCount) -->
<van-field v-model="formData.event.damageCount" label="水毁处数" placeholder="请填写" type="number" />
<!-- 阻断里程 (event.blockedMileage) -->
<van-field v-model="formData.event.blockedMileage" label="阻断里程" placeholder="请填写" type="digit">
<template #button>
<span class="field-unit">公里</span>
</template>
</van-field>
<!-- 发生时间 (顶层 occurTime) -->
<BaseDatePicker v-model="formData.occurTime" label="发生时间" placeholder="请选择时间" :columnsType="['year', 'month', 'day', 'hour', 'minute']" />
<div class="calibrate-time-btn" @click="calibrateTime">
<van-icon name="replay" />
<span>校准时间</span>
</div>
<!-- 线路编号 (顶层 routeNo) -->
<RoadRoutesPicker v-model="formData.routeNo" label="线路编号" placeholder="请线路" @change="handleRouteNoChange" />
<!-- 起点桩号 (event.startStakeNo) -->
<van-field v-model="formData.event.startStakeNo" label="起点桩号(K)" placeholder="请填写" />
<!-- 止点桩号 (event.endStakeNo) -->
<van-field v-model="formData.event.endStakeNo" label="止点桩号(K)" placeholder="请填写" />
<van-field v-model="formData.event.longitude" label="经度" placeholder="请填写" />
<van-field v-model="formData.event.latitude" label="纬度" placeholder="请填写" />
<!-- 路况位置 (occurLocation) -->
<van-field v-model="formData.occurLocation" label="路况位置" placeholder="请填写" />
<!-- 阻断点小地名 (event.blockedPointName) -->
<van-field v-model="formData.event.blockedPointName" label="阻断点小地名" placeholder="请填写" />
</van-form>
</PanelItem>
<!-- 处置情况 (report) -->
<PanelItem title="处置情况">
<div class="disposal-measures">
<span class="measures-label">处置措施</span>
<div class="measures-options">
<van-row gutter="10">
<van-col v-for="(item, index) in options['disposalMeasures']" :span="24 / options['disposalMeasures'].length" :key="index">
<van-button block plain :type="item.value === formData.report.disposalMeasures ? 'primary' : 'default'" @click="formData.report.disposalMeasures = item.value">
{{ item.label }}
</van-button>
</van-col>
</van-row>
</div>
</div>
<!-- 预计恢复时间 (report.expectRecoverTime) -->
<BaseDatePicker v-model="formData.report.expectRecoverTime" label="预计恢复时间" placeholder="请选择时间" :min-date="minDate" :max-date="maxDate" type="datetime" />
<!-- 实际恢复时间 (report.actualRecoverTime) -->
<BaseDatePicker v-model="formData.report.actualRecoverTime" label="实际恢复时间" placeholder="请选择时间" :min-date="minDate" :max-date="maxDate" type="datetime" />
</PanelItem>
<!-- 人员车辆 (report) -->
<PanelItem title="人员车辆">
<van-form>
<van-field v-model="formData.report.injuredCount" label="受伤人员" placeholder="请填写" type="number">
<template #button>
<span class="field-unit">人</span>
</template>
</van-field>
<van-field v-model="formData.report.deadCount" label="死亡人员" placeholder="请填写" type="number">
<template #button>
<span class="field-unit">人</span>
</template>
</van-field>
<van-field v-model="formData.report.strandedPersonCount" label="滞留人员" placeholder="请填写" type="number">
<template #button>
<span class="field-unit">人</span>
</template>
</van-field>
<van-field v-model="formData.report.damagedVehicleCount" label="损坏车辆" placeholder="请填写" type="number">
<template #button>
<span class="field-unit">辆</span>
</template>
</van-field>
<van-field v-model="formData.report.strandedVehicleCount" label="滞留车辆" placeholder="请填写" type="number">
<template #button>
<span class="field-unit">辆</span>
</template>
</van-field>
</van-form>
</PanelItem>
<!-- 灾毁损失 (lossList) -->
<PanelItem title="灾毁损失">
<LossList v-model="formData.lossList" />
<van-field v-model="formData.report.remark" label="处理情况" placeholder="请填写(选填)" />
<van-field v-model="formData.report.totalLossAmount" label="损失总金额" placeholder="请填写(选填)" type="digit">
<template #button>
<span class="field-unit">万元</span>
</template>
</van-field>
</PanelItem>
<!-- 投入资源 (report) -->
<PanelItem>
<van-field v-model="formData.report.investedMachinery" label="投入机械" placeholder="请填写" type="digit">
<template #button>
<span class="field-unit">台/班</span>
</template>
</van-field>
<van-field v-model="formData.report.investedManpower" label="投入人力" placeholder="请填写" type="number">
<template #button>
<span class="field-unit">人次</span>
</template>
</van-field>
<van-field v-model="formData.report.investedFunds" label="投入资金" placeholder="请填写" type="digit">
<template #button>
<span class="field-unit">万元</span>
</template>
</van-field>
<van-field v-model="formData.report.siteDescription" label="现场描述" placeholder="请填写" type="textarea" rows="2" autosize />
<van-field label="附件" center>
<template #input>
<van-uploader accept="video/*,image/*" :modelValue="getFileList()" :after-read="afterRead" multiple :max-count="7" @delete="removeFile" />
</template>
</van-field>
</PanelItem>
<PanelItem v-if="!isContinue || (isContinue && !detail?.event.needsRecovery)">
<!-- 是否需要恢复重建 (event.needsRecovery) -->
<BasePicker v-model="formData.event.needsRecovery" :options="options['yesOrNoBool']" label="是否需要恢复重建" placeholder="请选择" />
<!-- 恢复重建预估费用 (event.estimatedRecoveryCost) -->
<van-field v-model="formData.event.estimatedRecoveryCost" v-if="formData?.event.needsRecovery" label="恢复重建预估费用" placeholder="请填写" type="digit">
<template #button>
<span class="field-unit">万元</span>
</template>
</van-field>
</PanelItem>
<!-- 提交按钮 -->
<van-button type="primary" class="footer-btn" @click="handleSubmit" :loading="submitting"> 提交 </van-button>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { showToast, showFailToast, showLoadingToast } from 'vant'
import PanelItem from '@/components/PanelItem.vue'
import BasePicker from '@/components/BasePicker.vue'
import BaseDatePicker from '@/components/BaseDatePicker.vue'
import RoadRoutesPicker from '../RoadRoutesPicker.vue'
import LossList from './LossList.vue'
import { useRouter, useRoute } from 'vue-router'
import { request } from '@shared/utils/request'
import { useOptions } from '@shared/composables/useOptions'
const route = useRoute()
const { options } = useOptions()
// 是否为续报
const isContinue = computed(() => route.query.isContinue)
// 表单数据 - 按 Request 接口结构定义,使用 ref 包装
const formData = ref({
// 顶层字段
occurLocation: '', // 发生地点
occurTime: null, // 发生时间
roadConditionType: '', // 路况类别
routeNo: '', // 线路编号
// event 对象
event: {
blockedMileage: '', // 阻断里程
blockedPointName: '', // 阻断点小地名
contactPerson: '', // 联系人
contactPhone: '', // 联系电话
damageCount: '', // 水毁处数
district: '', // 上报区县
endStakeNo: '', // 止点桩号
estimatedRecoveryCost: '', // 恢复重建预估费用
isBlocked: '', // 是否阻断
needsRecovery: '', // 是否需要恢复重建
repairProgress: '', // 抢险进度
reporterUnit: '', // 填报单位
startStakeNo: '' // 起点桩号
},
// report 对象
report: {
actualRecoverTime: '', // 实际恢复时间
damagedVehicleCount: '', // 损坏车辆
deadCount: '', // 死亡人员
disposalMeasures: '', // 处置措施(单个值,不再用逗号分隔)
expectRecoverTime: '', // 预计恢复时间
injuredCount: '', // 受伤人员
investedFunds: '', // 已投资金
investedMachinery: '', // 已投机械
investedManpower: '', // 已投人力
remark: '', // 处理情况/备注
siteDescription: '', // 现场描述
strandedPersonCount: '', // 滞留人员
strandedVehicleCount: '', // 滞留车辆
totalLossAmount: '' // 损失总金额
},
// lossList 数组
lossList: [],
// fileList 数组
fileList: []
})
const getFileList = () => {
const fileList = formData.value.fileList?.map((item) => {
return {
url: item.fileUrl,
name: item.fileName
}
})
return fileList
}
const submitting = ref(false)
// 时间选择器范围
const minDate = new Date(2020, 0, 1)
const maxDate = new Date(2030, 11, 31)
const initFormData = (newVal) => {
if (newVal && Object.keys(newVal).length > 0) {
// 深度合并数据 - 直接替换整个对象
formData.value = {
occurLocation: newVal.occurLocation || '',
occurTime: newVal.occurTime || formatTime(),
roadConditionType: newVal.roadConditionType || '',
routeNo: newVal.routeNo || '',
event: { ...formData.value.event, ...(newVal.event || {}) },
report: { ...formData.value.report, ...(newVal.report || {}) },
lossList: newVal.lossList || [],
fileList: newVal.fileList || []
}
}
}
// 校准时间
const calibrateTime = () => {
const now = new Date()
const formatted = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
formData.value.occurTime = formatted
showToast('时间已校准为当前时间')
}
// 图片上传处理
const afterImageRead = (file) => {
console.log('图片上传:', file)
}
const onOversize = () => {
showFailToast('图片大小不能超过500KB')
}
const afterVideoRead = (file) => {
console.log('视频上传:', file)
}
const onVideoOversize = () => {
showFailToast('视频大小不能超过20MB')
}
const isImageFile = (file) => {
// 根据url后缀判断
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i
if (file.fileUrl && imageExtensions.test(file.fileUrl)) return true
// 根据文件类型判断
if (file.type && file.type.startsWith('image/')) return true
return false
}
const isVideoFile = (file) => {
// 根据url后缀判断
const videoExtensions = /\.(mp4|avi|mov|wmv|flv|mkv)$/i
if (file.fileUrl && videoExtensions.test(file.fileUrl)) return true
}
const parsePointValue = (point) => {
if (!point) {
return { longitude: null, latitude: null }
}
if (Array.isArray(point) && point.length >= 2) {
return {
longitude: point[0] ?? null,
latitude: point[1] ?? null
}
}
if (typeof point === 'string') {
try {
const parsed = JSON.parse(point)
if (Array.isArray(parsed) && parsed.length >= 2) {
return {
longitude: parsed[0] ?? null,
latitude: parsed[1] ?? null
}
}
} catch (_error) {
return { longitude: null, latitude: null }
}
}
return { longitude: null, latitude: null }
}
const handleRouteNoChange = (item) => {
formData.routeNo = item.routeCode
formData.event.startStakeNo = item.startStakeNo
formData.event.endStakeNo = item.endStakeNo
const startPoint = parsePointValue(item.startPoint)
const endPoint = parsePointValue(item.endPoint)
formData.event.startStakeLongitude = startPoint.longitude
formData.event.startStakeLatitude = startPoint.latitude
formData.event.endStakeLongitude = endPoint.longitude
formData.event.endStakeLatitude = endPoint.latitude
}
/**
* 判断图片是否可以上传
* @param {File} file - 图片文件
* @returns {boolean} 是否允许上传
*/
const isValidImage = (file) => {
// 校验文件类型
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
if (!isJpgOrPng) {
showFailToast('只能上传 JPG/PNG 格式的图片!')
return false
}
// 校验文件大小500KB = 500 * 1024 bytes
const isLt500k = file.size / 1024 < 500
if (!isLt500k) {
showFailToast(`图片大小不能超过 500KB当前大小${(file.size / 1024).toFixed(2)}KB`)
return false
}
return true
}
/**
* 判断视频是否可以上传
* @param {File} file - 视频文件
* @returns {boolean} 是否允许上传
*/
const isValidVideo = (file) => {
// 校验文件类型
const allowedTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm']
const isValidType = allowedTypes.includes(file.type)
if (!isValidType) {
showFailToast('请上传有效的视频文件MP4、MOV、AVI、WEBM格式')
return false
}
// 校验文件大小50MB = 50 * 1024 * 1024 bytes
const maxSize = 50 * 1024 * 1024
const isValidSize = file.size <= maxSize
if (!isValidSize) {
showFailToast(`视频大小不能超过 50MB当前大小${(file.size / 1024 / 1024).toFixed(2)}MB`)
return false
}
return true
}
/**
* 统一的上传前校验
* @param {File} file - 上传的文件
* @returns {boolean} 是否允许上传
*/
const checkFile = (file) => {
// 判断是否为图片
if (file.type.startsWith('image/')) {
const imageCount = formData.value.fileList?.filter((item) => item.fileType === 1).length
if (imageCount == 6) {
showFailToast('只能上传六张图片!')
return false
}
return isValidImage(file)
}
// 判断是否为视频
if (file.type.startsWith('video/')) {
const videoCount = formData.value.fileList?.filter((item) => item.fileType === 2).length
if (videoCount == 1) {
showFailToast('只能上传一个视频文件!')
return false
}
return isValidVideo(file)
}
showFailToast('只支持图片和视频文件!')
return false
}
const afterRead = async (options) => {
const file = options.file
if (!checkFile(file)) return
const toast = showLoadingToast({
message: '上传中...',
forbidClick: true,
duration: 0 // 设置为0表示不会自动关闭
})
try {
const uploadFormData = new FormData()
uploadFormData.append('file', file)
const res = await request({
url: '/snow-ops-platform/file/upload',
method: 'post',
data: uploadFormData
})
toast.close()
if (res.code === '00000') {
const name = file.name
const url = res.data
const type = isImageFile({ fileUrl: url }) ? 1 : isVideoFile({ fileUrl: url }) ? 2 : 3
const fileData = {
fileName: name,
fileUrl: url,
fileType: type,
fileSize: file.size
}
if (!formData.value.fileList) formData.value.fileList = []
formData.value.fileList.push(fileData)
} else {
throw new Error(res.message)
}
} catch (error) {
toast.close()
showToast({
type: 'fail',
message: error.message
})
}
}
const removeFile = (file, index) => {
// 删除文件
formData.value.fileList.splice(index, 1)
}
// 简单的空值判断
const isEmpty = (value) => {
return value === null || value === undefined || value === ''
}
const validate = () => {
if (isEmpty(formData.value.roadConditionType)) {
showToast('请选择路况类别')
return false
}
if (isEmpty(formData.value.event?.isBlocked)) {
showToast('请选择是否阻断')
return false
}
if (isEmpty(formData.value.event?.repairProgress)) {
showToast('请选择抢险进度')
return false
}
if (isEmpty(formData.value.report?.disposalMeasures)) {
showToast('请选择处置措施')
return false
}
if (isEmpty(formData.value.event?.damageCount)) {
showToast('请输入水毁处数')
return false
}
if (isEmpty(formData.value.event?.blockedMileage)) {
showToast('请输入阻断里程')
return false
}
if (isEmpty(formData.value.occurTime)) {
showToast('请选择发生时间')
return false
}
if (isEmpty(formData.value.report?.expectRecoverTime)) {
showToast('请输入预计恢复时间')
return false
}
if (isEmpty(formData.value.routeNo)) {
showToast('请输入线路编号')
return false
}
if (isEmpty(formData.value.event?.startStakeNo)) {
showToast('请输入起点桩号')
return false
}
if (isEmpty(formData.value.event?.endStakeNo)) {
showToast('请输入止点桩号')
return false
}
if (isEmpty(formData.value.occurLocation)) {
showToast('请输入路况位置')
return false
}
if (isEmpty(formData.value.event?.blockedPointName)) {
showToast('请输入阻断点小地名')
return false
}
if (isEmpty(formData.value.event?.longitude)) {
showToast('请输入经度')
return false
}
if (isEmpty(formData.value.event?.latitude)) {
showToast('请输入纬度')
return false
}
if (isEmpty(formData.value.event?.needsRecovery)) {
showToast('请选择是否需要恢复重建')
return false
}
if (formData.value.event?.needsRecovery && isEmpty(formData.value.event?.estimatedRecoveryCost)) {
showToast('请输入恢复重建预估费用')
return false
}
return true
}
// 获取表单数据 - 返回 formData.value 的副本
const getFormData = () => {
return { ...formData.value }
}
// 提交表单
const handleSubmit = async () => {
// 验证表单
if (!validate()) return
submitting.value = true
try {
// 获取表单数据
let formData = getFormData()
// 添加事件类型和站点信息
const submitData = {
...formData
// 可以在这里添加站点信息等其他数据
}
const res = await request({
url: '/snow-ops-platform/water-damage/addOrUpdate',
method: 'post',
data: submitData
})
if (res?.code === '00000') {
showSuccessToast('提交成功')
let isRebuilded = false
if (isContinue && detail.value.event.needsRecovery) {
// 如果之前已经进行了项目重建的流程,后续不再进行该流程
isRebuilded = true
}
if (!isRebuilded && submitData.event.needsRecovery) {
router.replace({
name: 'RebuildAdd',
params: {
data: res.data.id
}
})
} else {
// 提交成功后返回列表页
setTimeout(() => {
router.replace('/disasterManagement')
}, 500)
}
} else {
showFailToast(res.message)
}
} catch (error) {
showFailToast('提交失败,请重试')
console.error('提交失败:', error)
} finally {
submitting.value = false
}
}
const detail = ref(null)
// 获取灾毁详情
const getDisasterDetail = async () => {
const id = route.query.id
if (!id) {
return
}
try {
const result = await request({
url: `/snow-ops-platform/water-damage/getById`,
method: 'get',
params: { id }
})
if (result?.data) {
// 接口返回 Data 结构
const data = result.data
detail.value = result.data
const newFormData = {
...data,
lossList: null,
report: {},
fileList: null
}
initFormData(newFormData)
} else {
showToast(result.message || '获取详情失败')
}
} catch (error) {
console.error('获取灾毁详情失败:', error)
showToast('获取详情失败,请稍后重试')
}
}
// 日期格式化
const formatTime = (date = new Date()) => {
const pad = (n) => n.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
onMounted(() => {
formData.value.occurTime = formatTime()
if (route.query.id) {
getDisasterDetail()
} else {
initFormData({})
}
})
// 暴露方法给父组件
defineExpose({
validate,
initFormData,
getFormData
})
</script>
<style lang="scss" scoped>
.water-disaster {
.coordinate-row {
display: flex;
gap: 8px;
.coordinate-field {
flex: 1;
}
}
.calibrate-time-btn,
.calibrate-coord-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #1989fa;
margin: 8px 0 8px 12px;
padding: 4px 8px;
background: #f0f7ff;
border-radius: 20px;
cursor: pointer;
width: fit-content;
}
.field-unit {
color: #969799;
font-size: 14px;
margin-left: 4px;
}
.disposal-measures {
margin-bottom: 16px;
.measures-label {
font-size: 14px;
color: #323233;
display: block;
margin-bottom: 8px;
}
.measures-options {
:deep(.van-radio-group) {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
:deep(.van-radio) {
margin-right: 0;
}
}
}
.attachment-tip {
font-size: 12px;
color: #969799;
margin-bottom: 12px;
}
.upload-area {
display: flex;
gap: 16px;
flex-wrap: wrap;
.upload-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: #f8f9fa;
border: 1px dashed #dcdee0;
border-radius: 8px;
gap: 4px;
font-size: 12px;
color: #969799;
cursor: pointer;
}
}
.video-preview {
margin-top: 12px;
}
:deep(.van-field__label) {
width: 110px;
}
}
.footer-btn {
position: fixed;
bottom: 15px;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 32px);
max-width: 340px;
border-radius: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
z-index: 10;
&:active {
opacity: 0.9;
transform: translateX(-50%) scale(0.98);
}
}
</style>