1432 lines
33 KiB
Vue
1432 lines
33 KiB
Vue
<template>
|
||
<view :class="pageMode ? 'hazard-form-page' : 'popup-content'">
|
||
<view v-if="!pageMode" class="popup-header">
|
||
<view class="popup-title text-bold">{{ title }}</view>
|
||
<view class="popup-close" @click="handleClose">×</view>
|
||
</view>
|
||
<scroll-view :class="pageMode ? 'page-body' : 'popup-body'" scroll-y :style="bodyStyle">
|
||
<view
|
||
v-if="showDraftBanner"
|
||
class="draft-banner"
|
||
>
|
||
<view class="draft-banner-left">
|
||
<text class="cuIcon-info draft-banner-icon"></text>
|
||
<text>已自动恢复您上次未提交的内容</text>
|
||
</view>
|
||
<text class="draft-banner-action" @click="emit('clear-draft')">清空草稿</text>
|
||
</view>
|
||
|
||
<view class="flex margin-bottom">
|
||
<view class="text-gray">检查形式</view>
|
||
<view class="text-red">*</view>
|
||
</view>
|
||
<scroll-view class="hazard-choose-scroll" scroll-x :show-scrollbar="false">
|
||
<view class="hazard-choose-row">
|
||
<view
|
||
v-for="(item, index) in SOURCE_OPTIONS"
|
||
:key="item.id"
|
||
class="hazard-choose-tag hazard-choose-tag--source"
|
||
:class="{ 'hazard-choose-tag--active': formData.source === index }"
|
||
@tap.stop="onChooseSelect('source', index)"
|
||
>
|
||
<text class="hazard-choose-tag__text">{{ item.title }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="flex margin-bottom margin-top">
|
||
<view class="text-gray">隐患来源</view>
|
||
<view class="text-red">*</view>
|
||
</view>
|
||
<view class="select-trigger" @click="openHazardSourcePicker">
|
||
<view class="select-value" :class="{ placeholder: !formData.hazardSourceName }">
|
||
{{ formData.hazardSourceName || '请选择隐患来源' }}
|
||
</view>
|
||
<text class="cuIcon-unfold"></text>
|
||
</view>
|
||
|
||
<view class="flex margin-bottom margin-top">
|
||
<view class="text-gray">隐患图片</view>
|
||
<view class="text-red">*</view>
|
||
</view>
|
||
<up-upload
|
||
:fileList="fileList"
|
||
@afterRead="afterRead"
|
||
@delete="deletePic"
|
||
name="1"
|
||
multiple
|
||
imageMode="aspectFill"
|
||
:maxCount="10"
|
||
></up-upload>
|
||
<canvas
|
||
:canvas-id="canvasId"
|
||
:width="canvasWidth"
|
||
:height="canvasHeight"
|
||
:style="{
|
||
width: canvasWidth + 'px',
|
||
height: canvasHeight + 'px',
|
||
position: 'fixed',
|
||
left: '-9999px',
|
||
top: '-9999px'
|
||
}"
|
||
></canvas>
|
||
<view class="text-gray text-sm">必填:请上传现场照片作为隐患证据</view>
|
||
<view class="ai-btn-wrapper margin-top">
|
||
<button class="ai-analyze-btn" :loading="aiAnalyzing" :disabled="aiAnalyzing" @click="handleAiAnalyze">
|
||
<text v-if="!aiAnalyzing" class="cuIcon-magic ai-btn-icon"></text>
|
||
{{ aiAnalyzing ? 'AI识别中...' : 'AI 识别隐患' }}
|
||
</button>
|
||
</view>
|
||
|
||
<view class="flex margin-bottom margin-top">
|
||
<view class="text-gray">隐患标题</view>
|
||
<view class="text-red">*</view>
|
||
</view>
|
||
<up-input placeholder="请输入内容" border="surround" v-model="formData.title"></up-input>
|
||
<view class="text-sm text-gray margin-top-xs">请用简洁的语言概括隐患要点</view>
|
||
|
||
<view class="flex margin-bottom margin-top">
|
||
<view class="text-gray">隐患等级</view>
|
||
<view class="text-red">*</view>
|
||
</view>
|
||
<scroll-view class="hazard-choose-scroll" scroll-x :show-scrollbar="false">
|
||
<view class="hazard-choose-row">
|
||
<view
|
||
v-for="(item, index) in LEVEL_OPTIONS"
|
||
:key="item.id"
|
||
class="hazard-choose-tag hazard-choose-tag--level"
|
||
:class="{ 'hazard-choose-tag--active': formData.level === index }"
|
||
@tap.stop="onChooseSelect('level', index)"
|
||
>
|
||
<text class="hazard-choose-tag__text">{{ item.title }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="flex margin-bottom margin-top">
|
||
<view class="text-gray">隐患位置</view>
|
||
<view class="text-red">*</view>
|
||
</view>
|
||
<view class="address-box">
|
||
<up-input
|
||
:key="'address-' + formRenderKey"
|
||
class="address-input-wrapper"
|
||
v-model="address"
|
||
placeholder="请输入地址"
|
||
border="surround"
|
||
></up-input>
|
||
<button class="btn-address bg-blue" @tap.stop="chooseLocation">选择地址</button>
|
||
</view>
|
||
<view class="text-gray text-sm margin-top-xs">如:办公楼3层东侧消防通道、生产车间A区设备旁等,或点击"选择地址"按钮在地图上选择</view>
|
||
|
||
<view class="flex margin-bottom margin-top">
|
||
<view class="text-gray">法律依据</view>
|
||
</view>
|
||
<view class="select-trigger" @click="openLawPopup">
|
||
<view class="select-value" :class="{ placeholder: !formData.regulationName }">
|
||
{{ formData.regulationName || '请选择法律依据' }}
|
||
</view>
|
||
<text class="cuIcon-unfold"></text>
|
||
</view>
|
||
|
||
<view class="flex margin-bottom margin-top">
|
||
<view class="text-gray">隐患区域</view>
|
||
</view>
|
||
<view class="select-trigger" @click="openAreaPicker">
|
||
<view class="select-value" :class="{ placeholder: !areaName }">
|
||
{{ areaName || '请选择隐患区域' }}
|
||
</view>
|
||
<text class="cuIcon-unfold"></text>
|
||
</view>
|
||
|
||
<view class="flex margin-bottom margin-top">
|
||
<view class="text-gray">隐患描述</view>
|
||
<view class="text-red">*</view>
|
||
</view>
|
||
<up-textarea
|
||
:key="'description-' + formRenderKey"
|
||
v-model="formData.description"
|
||
placeholder="请输入内容"
|
||
></up-textarea>
|
||
<view class="text-gray text-sm margin-top-xs">请详细说明隐患现状、潜在风险及影响范围</view>
|
||
|
||
<view class="text-gray margin-bottom margin-top">隐患标签</view>
|
||
<view class="hazard-choose-wrap">
|
||
<view
|
||
v-for="(item, index) in tagOptions"
|
||
:key="item.id"
|
||
class="hazard-choose-tag hazard-choose-tag--tag"
|
||
:class="{ 'hazard-choose-tag--active': formData.tagIndex === index }"
|
||
@tap.stop="onChooseSelect('tagIndex', index)"
|
||
>
|
||
<text class="hazard-choose-tag__text">{{ item.title }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="flex margin-bottom margin-top">
|
||
<view class="text-gray">下一步流程</view>
|
||
<view class="text-red">*</view>
|
||
</view>
|
||
<view class="static-field">隐患交办</view>
|
||
|
||
<view class="flex margin-bottom margin-top">
|
||
<view class="text-gray">下一步处理人</view>
|
||
<view class="text-red">*</view>
|
||
</view>
|
||
<view class="static-field">部门、企业管理员、企业成员</view>
|
||
</scroll-view>
|
||
<view :class="pageMode ? 'page-footer' : 'popup-footer'">
|
||
<button class="btn-cancel" @click="handleClose">取消</button>
|
||
<button class="btn-confirm bg-blue" :loading="submitting" @click="handleConfirm">提交</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 选择法规弹出框 -->
|
||
<u-popup :show="showLawPopup" mode="center" round="20" :safe-area-inset-bottom="false" @close="showLawPopup = false">
|
||
<view class="law-popup">
|
||
<view class="popup-header">
|
||
<view class="popup-title">选择法律依据</view>
|
||
<view class="popup-close" @click="showLawPopup = false">×</view>
|
||
</view>
|
||
|
||
<view class="search-box">
|
||
<text class="cuIcon-search search-icon"></text>
|
||
<input class="search-input" v-model="lawKeyword" placeholder="请输入关键词搜索" @confirm="searchRegulation" />
|
||
<text class="search-btn" @click="searchRegulation">搜索</text>
|
||
</view>
|
||
|
||
<scroll-view class="law-list" scroll-y @scrolltolower="loadMoreLaw">
|
||
<view v-if="lawLoading && lawList.length === 0" class="loading-tip">加载中...</view>
|
||
<view v-else-if="!lawLoading && lawList.length === 0" class="empty-tip">暂无数据</view>
|
||
<template v-else>
|
||
<view
|
||
class="law-item"
|
||
:class="{ 'law-item-active': selectedLawId === item.id }"
|
||
v-for="item in lawList"
|
||
:key="item.id"
|
||
@click="selectLaw(item)"
|
||
>
|
||
<view class="law-title">{{ item.depict }}</view>
|
||
<view class="law-basis text-gray">{{ item.legalBasis }}</view>
|
||
</view>
|
||
<view v-if="lawLoading" class="loading-tip">加载中...</view>
|
||
</template>
|
||
</scroll-view>
|
||
|
||
<view class="law-popup-footer">
|
||
<button class="btn-cancel" @click="showLawPopup = false">取消</button>
|
||
<button class="btn-confirm bg-blue" @click="confirmLaw">确定</button>
|
||
</view>
|
||
</view>
|
||
</u-popup>
|
||
|
||
<!-- 隐患来源选择弹窗 -->
|
||
<u-popup :show="showHazardSourcePicker" mode="bottom" round="20" @close="showHazardSourcePicker = false">
|
||
<view class="picker-popup">
|
||
<view class="picker-header">
|
||
<view class="picker-cancel" @click="showHazardSourcePicker = false">取消</view>
|
||
<view class="picker-title">选择隐患来源</view>
|
||
<view class="picker-confirm" @click="confirmHazardSourceSelect">确定</view>
|
||
</view>
|
||
<scroll-view class="picker-body" scroll-y>
|
||
<view
|
||
v-for="item in hazardSourceList"
|
||
:key="item.code"
|
||
class="picker-item"
|
||
:class="{ 'picker-item-active': tempHazardSourceCode === item.code }"
|
||
@click="tempHazardSourceCode = item.code"
|
||
>
|
||
<text>{{ item.name }}</text>
|
||
<text v-if="tempHazardSourceCode === item.code" class="cuIcon-check text-blue"></text>
|
||
</view>
|
||
<view v-if="hazardSourceList.length === 0" class="text-gray text-center padding">暂无隐患来源数据</view>
|
||
</scroll-view>
|
||
</view>
|
||
</u-popup>
|
||
|
||
<!-- 区域选择弹窗 -->
|
||
<u-popup :show="showAreaPicker" mode="bottom" round="20" @close="showAreaPicker = false">
|
||
<view class="picker-popup">
|
||
<view class="picker-header">
|
||
<view class="picker-cancel" @click="showAreaPicker = false">取消</view>
|
||
<view class="picker-title">选择隐患区域</view>
|
||
<view class="picker-confirm" @click="confirmAreaSelect">确定</view>
|
||
</view>
|
||
<scroll-view class="picker-body" scroll-y>
|
||
<view
|
||
v-for="item in areaList"
|
||
:key="item.id"
|
||
class="picker-item"
|
||
:class="{ 'picker-item-active': tempAreaId === item.id }"
|
||
@click="tempAreaId = item.id"
|
||
>
|
||
<view class="flex align-center">
|
||
<view class="area-color-dot" :style="{ backgroundColor: item.color }"></view>
|
||
<text>{{ item.name }}</text>
|
||
</view>
|
||
<text v-if="tempAreaId === item.id" class="cuIcon-check text-blue"></text>
|
||
</view>
|
||
<view v-if="areaList.length === 0" class="text-gray text-center padding">暂无区域数据</view>
|
||
</scroll-view>
|
||
</view>
|
||
</u-popup>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, watch, getCurrentInstance, nextTick } from 'vue';
|
||
import { addHiddenDanger, getHiddenDangerLabelList, getHazardSourceList, getRegulationList, analyzeHazardImage } from '@/request/api.js';
|
||
import { getAreaList } from '@/request/three_one_api/area.js';
|
||
import { createUploadListHandlers } from '@/utils/upload.js';
|
||
import {
|
||
LEVEL_OPTIONS,
|
||
SOURCE_OPTIONS,
|
||
AI_LEVEL_MAP,
|
||
createEmptyHazardPayload,
|
||
buildHiddenDangerParams
|
||
} from './hazardForm.js';
|
||
|
||
const props = defineProps({
|
||
pageMode: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
show: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
title: {
|
||
type: String,
|
||
default: '填写隐患信息'
|
||
},
|
||
mode: {
|
||
type: String,
|
||
default: 'collect'
|
||
},
|
||
modelValue: {
|
||
type: Object,
|
||
default: null
|
||
},
|
||
extraParams: {
|
||
type: Object,
|
||
default: () => ({})
|
||
},
|
||
showDraftBanner: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
bodyHeight: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
canvasId: {
|
||
type: String,
|
||
default: 'hazardFormWatermarkCanvas'
|
||
}
|
||
});
|
||
|
||
const emit = defineEmits(['update:show', 'update:modelValue', 'success', 'confirm', 'clear-draft', 'close', 'draft-change']);
|
||
|
||
const popupVisible = computed({
|
||
get: () => props.pageMode ? true : props.show,
|
||
set: (value) => {
|
||
if (!props.pageMode) {
|
||
emit('update:show', value);
|
||
}
|
||
}
|
||
});
|
||
|
||
const isApiSuccess = (res) => Number(res?.code) === 0 || Number(res?.code) === 200;
|
||
|
||
const bodyStyle = computed(() => {
|
||
if (props.bodyHeight) {
|
||
return { height: props.bodyHeight };
|
||
}
|
||
if (props.pageMode) {
|
||
return { maxHeight: 'none' };
|
||
}
|
||
return {};
|
||
});
|
||
|
||
const canvasWidth = ref(300);
|
||
const canvasHeight = ref(300);
|
||
const submitting = ref(false);
|
||
const aiAnalyzing = ref(false);
|
||
const syncingFromParent = ref(false);
|
||
|
||
const formData = reactive({
|
||
title: '',
|
||
level: 0,
|
||
source: 0,
|
||
hazardSourceCode: null,
|
||
hazardSourceName: '',
|
||
description: '',
|
||
tagIndex: 0,
|
||
tagId: null,
|
||
regulationId: null,
|
||
regulationName: ''
|
||
});
|
||
const address = ref('');
|
||
const lng = ref(0);
|
||
const lat = ref(0);
|
||
const areaId = ref('');
|
||
const areaName = ref('');
|
||
const fileList = ref([]);
|
||
const tagOptions = ref([]);
|
||
|
||
const showLawPopup = ref(false);
|
||
const lawKeyword = ref('');
|
||
const selectedLawId = ref(null);
|
||
const selectedLawName = ref('');
|
||
const lawList = ref([]);
|
||
const lawLoading = ref(false);
|
||
const lawPageNum = ref(1);
|
||
const lawPageSize = ref(10);
|
||
const hasMoreLaw = ref(true);
|
||
|
||
const showHazardSourcePicker = ref(false);
|
||
const hazardSourceList = ref([]);
|
||
const tempHazardSourceCode = ref(null);
|
||
|
||
const showAreaPicker = ref(false);
|
||
const areaList = ref([]);
|
||
const tempAreaId = ref('');
|
||
const choosingLocation = ref(false);
|
||
const suppressModelEmit = ref(false);
|
||
const formRenderKey = ref(0);
|
||
|
||
const LOCATION_PICKER_DRAFT_KEY = 'hazard_form_location_picker_draft';
|
||
|
||
const uploadInstance = getCurrentInstance();
|
||
const { afterRead, deletePic } = createUploadListHandlers(fileList, {
|
||
watermark: {
|
||
canvasId: props.canvasId,
|
||
canvasWidthRef: canvasWidth,
|
||
canvasHeightRef: canvasHeight,
|
||
instance: uploadInstance
|
||
}
|
||
});
|
||
|
||
function getPayload() {
|
||
syncTagIdFromIndex();
|
||
return {
|
||
formData: {
|
||
title: formData.title,
|
||
level: formData.level,
|
||
source: formData.source,
|
||
hazardSourceCode: formData.hazardSourceCode,
|
||
hazardSourceName: formData.hazardSourceName,
|
||
description: formData.description,
|
||
tagIndex: formData.tagIndex,
|
||
tagId: formData.tagId,
|
||
regulationId: formData.regulationId,
|
||
regulationName: formData.regulationName
|
||
},
|
||
address: address.value,
|
||
lng: lng.value,
|
||
lat: lat.value,
|
||
areaId: areaId.value,
|
||
areaName: areaName.value,
|
||
fileList: fileList.value,
|
||
tagOptions: tagOptions.value
|
||
};
|
||
}
|
||
|
||
function applyPayload(payload) {
|
||
if (!payload) return;
|
||
syncingFromParent.value = true;
|
||
const data = payload.formData || {};
|
||
formData.title = data.title || '';
|
||
formData.level = data.level ?? 0;
|
||
formData.source = data.source ?? 0;
|
||
formData.hazardSourceCode = data.hazardSourceCode ?? null;
|
||
formData.hazardSourceName = data.hazardSourceName || '';
|
||
syncHazardSourceNameFromCode();
|
||
formData.description = data.description || '';
|
||
formData.tagIndex = data.tagIndex ?? 0;
|
||
formData.tagId = data.tagId ?? null;
|
||
formData.regulationId = data.regulationId ?? null;
|
||
formData.regulationName = data.regulationName || '';
|
||
address.value = payload.address || '';
|
||
lng.value = payload.lng || 0;
|
||
lat.value = payload.lat || 0;
|
||
areaId.value = payload.areaId || '';
|
||
areaName.value = payload.areaName || '';
|
||
fileList.value = payload.fileList ? [...payload.fileList] : [];
|
||
selectedLawId.value = data.regulationId ?? null;
|
||
selectedLawName.value = data.regulationName || '';
|
||
syncingFromParent.value = false;
|
||
formRenderKey.value++;
|
||
}
|
||
|
||
function hasPayloadContent(payload) {
|
||
if (!payload) return false;
|
||
const data = payload.formData || {};
|
||
const files = payload.fileList || [];
|
||
return !!(
|
||
data.title ||
|
||
data.description ||
|
||
data.regulationName ||
|
||
payload.address ||
|
||
(files.length > 0)
|
||
);
|
||
}
|
||
|
||
function getLocationPickerDraftKey() {
|
||
return props.extraParams?.locationDraftKey || LOCATION_PICKER_DRAFT_KEY;
|
||
}
|
||
|
||
function saveLocationPickerDraft() {
|
||
const snapshot = getPayload();
|
||
try {
|
||
uni.setStorageSync(getLocationPickerDraftKey(), snapshot);
|
||
} catch (error) {
|
||
console.error('保存选点草稿失败:', error);
|
||
}
|
||
return snapshot;
|
||
}
|
||
|
||
function loadLocationPickerDraft() {
|
||
try {
|
||
const draft = uni.getStorageSync(getLocationPickerDraftKey());
|
||
return draft && typeof draft === 'object' ? draft : null;
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function clearLocationPickerDraft() {
|
||
try {
|
||
uni.removeStorageSync(getLocationPickerDraftKey());
|
||
} catch (error) {
|
||
console.error('清除选点草稿失败:', error);
|
||
}
|
||
}
|
||
|
||
function restoreAfterLocationPicker(patch = {}) {
|
||
const draft = loadLocationPickerDraft();
|
||
if (!draft) return false;
|
||
|
||
applyPayload({
|
||
...draft,
|
||
...patch,
|
||
formData: {
|
||
...(draft.formData || {}),
|
||
...(patch.formData || {})
|
||
},
|
||
fileList: patch.fileList ?? draft.fileList ?? []
|
||
});
|
||
clearLocationPickerDraft();
|
||
return true;
|
||
}
|
||
|
||
function reopenPopupAfterLocation() {
|
||
nextTick(() => {
|
||
choosingLocation.value = false;
|
||
if (!props.pageMode) {
|
||
popupVisible.value = true;
|
||
}
|
||
emitModelValue();
|
||
});
|
||
}
|
||
|
||
function resetForm() {
|
||
applyPayload(createEmptyHazardPayload());
|
||
selectedLawId.value = null;
|
||
selectedLawName.value = '';
|
||
tempAreaId.value = '';
|
||
clearLocationPickerDraft();
|
||
}
|
||
|
||
function syncTagIdFromIndex() {
|
||
const selectedTag = tagOptions.value[formData.tagIndex];
|
||
formData.tagId = selectedTag?.id ?? formData.tagId ?? null;
|
||
}
|
||
|
||
function onChooseSelect(field, index) {
|
||
formData[field] = Number(index);
|
||
if (field === 'tagIndex') {
|
||
syncTagIdFromIndex();
|
||
}
|
||
emitModelValue();
|
||
}
|
||
|
||
function emitModelValue() {
|
||
if (suppressModelEmit.value || syncingFromParent.value) return;
|
||
if (props.pageMode && props.mode === 'direct') {
|
||
emit('draft-change', getPayload());
|
||
return;
|
||
}
|
||
if (props.modelValue === null) return;
|
||
// collect / direct 弹窗模式不与父组件 v-model 双向同步
|
||
if (props.mode === 'collect' || props.mode === 'direct') return;
|
||
emit('update:modelValue', getPayload());
|
||
}
|
||
|
||
function closePopup() {
|
||
if (props.pageMode) return;
|
||
popupVisible.value = false;
|
||
}
|
||
|
||
function handleClose() {
|
||
if (props.pageMode) {
|
||
uni.navigateBack();
|
||
return;
|
||
}
|
||
closePopup();
|
||
emit('close');
|
||
}
|
||
|
||
function validateForm() {
|
||
if (fileList.value.length === 0) {
|
||
uni.showToast({ title: '请上传隐患图片', icon: 'none' });
|
||
return false;
|
||
}
|
||
if (!formData.title) {
|
||
uni.showToast({ title: '请输入隐患标题', icon: 'none' });
|
||
return false;
|
||
}
|
||
if (formData.hazardSourceCode == null) {
|
||
uni.showToast({ title: '请选择隐患来源', icon: 'none' });
|
||
return false;
|
||
}
|
||
if (!address.value) {
|
||
uni.showToast({ title: '请输入隐患位置', icon: 'none' });
|
||
return false;
|
||
}
|
||
if (!formData.description) {
|
||
uni.showToast({ title: '请输入隐患描述', icon: 'none' });
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function handleConfirm() {
|
||
if (!validateForm()) return;
|
||
|
||
const payload = getPayload();
|
||
|
||
if (props.mode === 'collect') {
|
||
// 先回写数据再关弹窗,避免父组件在 show=false 时提前清空索引导致 confirm 丢失
|
||
if (props.modelValue !== null) {
|
||
emit('update:modelValue', payload);
|
||
}
|
||
emit('confirm', payload);
|
||
closePopup();
|
||
uni.showToast({ title: '隐患信息已暂存', icon: 'success' });
|
||
return;
|
||
}
|
||
|
||
submitting.value = true;
|
||
try {
|
||
const params = buildHiddenDangerParams(payload, props.extraParams);
|
||
const res = await addHiddenDanger(params);
|
||
if (isApiSuccess(res)) {
|
||
suppressModelEmit.value = true;
|
||
if (!props.pageMode) {
|
||
closePopup();
|
||
}
|
||
emit('success', { ...payload, hazardId: res.data });
|
||
await nextTick();
|
||
resetForm();
|
||
uni.showToast({ title: res.msg || '新增成功', icon: 'success' });
|
||
if (props.pageMode) {
|
||
setTimeout(() => {
|
||
uni.navigateBack();
|
||
}, 1500);
|
||
}
|
||
} else {
|
||
uni.showToast({ title: res.msg || '新增失败', icon: 'none' });
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
uni.showToast({ title: '请求失败', icon: 'none' });
|
||
} finally {
|
||
suppressModelEmit.value = false;
|
||
submitting.value = false;
|
||
}
|
||
}
|
||
|
||
function openLawPopup() {
|
||
showLawPopup.value = true;
|
||
if (lawList.value.length === 0) {
|
||
fetchRegulationList();
|
||
}
|
||
}
|
||
|
||
async function fetchRegulationList(isLoadMore = false) {
|
||
if (lawLoading.value) return;
|
||
lawLoading.value = true;
|
||
try {
|
||
const params = {
|
||
pageNum: lawPageNum.value,
|
||
pageSize: lawPageSize.value,
|
||
status: 1
|
||
};
|
||
if (lawKeyword.value?.trim()) {
|
||
params.keyword = lawKeyword.value.trim();
|
||
}
|
||
const res = await getRegulationList(params);
|
||
if (res.code === 0) {
|
||
const records = res.data.records || res.data || [];
|
||
lawList.value = isLoadMore ? [...lawList.value, ...records] : records;
|
||
const total = res.data.total || 0;
|
||
hasMoreLaw.value = lawList.value.length < total;
|
||
}
|
||
} catch (error) {
|
||
console.error('获取法规列表失败:', error);
|
||
} finally {
|
||
lawLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function searchRegulation() {
|
||
lawPageNum.value = 1;
|
||
lawList.value = [];
|
||
hasMoreLaw.value = true;
|
||
fetchRegulationList();
|
||
}
|
||
|
||
function loadMoreLaw() {
|
||
if (!hasMoreLaw.value || lawLoading.value) return;
|
||
lawPageNum.value++;
|
||
fetchRegulationList(true);
|
||
}
|
||
|
||
function selectLaw(item) {
|
||
selectedLawId.value = item.id;
|
||
selectedLawName.value = item.depict || item.keyword || '';
|
||
}
|
||
|
||
function confirmLaw() {
|
||
if (selectedLawId.value) {
|
||
formData.regulationId = selectedLawId.value;
|
||
formData.regulationName = selectedLawName.value;
|
||
}
|
||
showLawPopup.value = false;
|
||
emitModelValue();
|
||
}
|
||
|
||
function syncHazardSourceNameFromCode() {
|
||
if (formData.hazardSourceCode == null) return;
|
||
const selected = hazardSourceList.value.find((item) => item.code === formData.hazardSourceCode);
|
||
if (selected) {
|
||
formData.hazardSourceName = selected.name;
|
||
}
|
||
}
|
||
|
||
function openHazardSourcePicker() {
|
||
tempHazardSourceCode.value = formData.hazardSourceCode;
|
||
showHazardSourcePicker.value = true;
|
||
}
|
||
|
||
function confirmHazardSourceSelect() {
|
||
if (tempHazardSourceCode.value != null) {
|
||
const selected = hazardSourceList.value.find((item) => item.code === tempHazardSourceCode.value);
|
||
if (selected) {
|
||
formData.hazardSourceCode = selected.code;
|
||
formData.hazardSourceName = selected.name;
|
||
}
|
||
}
|
||
showHazardSourcePicker.value = false;
|
||
emitModelValue();
|
||
}
|
||
|
||
async function fetchHazardSourceList() {
|
||
try {
|
||
const res = await getHazardSourceList();
|
||
if (isApiSuccess(res)) {
|
||
const list = Array.isArray(res.data) ? res.data : [];
|
||
hazardSourceList.value = list
|
||
.map((item) => ({
|
||
code: item.code,
|
||
name: item.name || ''
|
||
}))
|
||
.filter((item) => item.code != null && item.name);
|
||
syncHazardSourceNameFromCode();
|
||
}
|
||
} catch (error) {
|
||
console.error('获取隐患来源列表失败:', error);
|
||
}
|
||
}
|
||
|
||
function openAreaPicker() {
|
||
tempAreaId.value = areaId.value;
|
||
showAreaPicker.value = true;
|
||
}
|
||
|
||
async function fetchAreaList() {
|
||
try {
|
||
const res = await getAreaList();
|
||
if (res.code === 0 && res.data?.records) {
|
||
areaList.value = res.data.records;
|
||
}
|
||
} catch (error) {
|
||
console.error('获取区域列表失败:', error);
|
||
}
|
||
}
|
||
|
||
function confirmAreaSelect() {
|
||
if (tempAreaId.value) {
|
||
areaId.value = tempAreaId.value;
|
||
const selected = areaList.value.find((item) => item.id === tempAreaId.value);
|
||
areaName.value = selected ? selected.name : '';
|
||
}
|
||
showAreaPicker.value = false;
|
||
emitModelValue();
|
||
}
|
||
|
||
function chooseLocation() {
|
||
choosingLocation.value = true;
|
||
saveLocationPickerDraft();
|
||
if (!props.pageMode) {
|
||
closePopup();
|
||
setTimeout(() => {
|
||
uni.getLocation({
|
||
type: 'gcj02',
|
||
success: (locationRes) => {
|
||
openChooseLocation(locationRes.latitude, locationRes.longitude);
|
||
},
|
||
fail: () => {
|
||
openChooseLocation();
|
||
}
|
||
});
|
||
}, 300);
|
||
return;
|
||
}
|
||
uni.getLocation({
|
||
type: 'gcj02',
|
||
success: (locationRes) => {
|
||
openChooseLocation(locationRes.latitude, locationRes.longitude);
|
||
},
|
||
fail: () => {
|
||
openChooseLocation();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openChooseLocation(latitude, longitude) {
|
||
const options = {};
|
||
if (latitude != null && longitude != null) {
|
||
options.latitude = latitude;
|
||
options.longitude = longitude;
|
||
}
|
||
uni.chooseLocation({
|
||
...options,
|
||
success: (res) => {
|
||
restoreAfterLocationPicker({
|
||
address: res.address + (res.name ? `(${res.name})` : ''),
|
||
lng: res.longitude,
|
||
lat: res.latitude
|
||
});
|
||
reopenPopupAfterLocation();
|
||
},
|
||
fail: (err) => {
|
||
restoreAfterLocationPicker();
|
||
reopenPopupAfterLocation();
|
||
if (err.errMsg && err.errMsg.indexOf('cancel') === -1) {
|
||
uni.showToast({ title: '选择位置失败', icon: 'none' });
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleAiAnalyze() {
|
||
const imageFiles = fileList.value.filter((f) => {
|
||
return f.status === 'success' && f.url.toLowerCase().match(/\.(jpg|jpeg|png|gif|bmp|webp)$/);
|
||
});
|
||
if (imageFiles.length === 0) {
|
||
uni.showToast({ title: '请先上传隐患图片', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
aiAnalyzing.value = true;
|
||
try {
|
||
const analyzeRes = await analyzeHazardImage({ imageUrl: imageFiles[0].url });
|
||
if (analyzeRes.code === 0 && analyzeRes.data) {
|
||
const aiData = analyzeRes.data;
|
||
if (aiData.title) formData.title = aiData.title;
|
||
if (aiData.description) formData.description = aiData.description;
|
||
if (aiData.level) {
|
||
const levelIndex = AI_LEVEL_MAP[aiData.level];
|
||
if (levelIndex !== undefined) {
|
||
formData.level = levelIndex;
|
||
}
|
||
}
|
||
emitModelValue();
|
||
uni.showToast({ title: 'AI分析完成,已自动填充', icon: 'success', duration: 2000 });
|
||
} else {
|
||
uni.showToast({ title: analyzeRes.msg || 'AI分析失败', icon: 'none' });
|
||
}
|
||
} catch (error) {
|
||
console.error('AI分析接口调用失败:', error);
|
||
uni.showToast({ title: 'AI分析失败,请重试', icon: 'none' });
|
||
} finally {
|
||
aiAnalyzing.value = false;
|
||
}
|
||
}
|
||
|
||
async function fetchTagOptions() {
|
||
try {
|
||
const res = await getHiddenDangerLabelList();
|
||
if (res.code === 0) {
|
||
tagOptions.value = res.data.map((item) => ({
|
||
id: item.id,
|
||
title: item.name
|
||
}));
|
||
syncTagIdFromIndex();
|
||
}
|
||
} catch (error) {
|
||
console.error('获取标签列表失败:', error);
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(val) => {
|
||
// direct 页面模式由页面草稿恢复,不与父组件 v-model 双向同步
|
||
if (props.mode === 'direct' && props.pageMode) return;
|
||
if (props.mode === 'direct') return;
|
||
if (hasPayloadContent(val)) {
|
||
applyPayload(val);
|
||
}
|
||
},
|
||
{ deep: true, immediate: true }
|
||
);
|
||
|
||
watch(
|
||
() => ({
|
||
title: formData.title,
|
||
level: formData.level,
|
||
source: formData.source,
|
||
hazardSourceCode: formData.hazardSourceCode,
|
||
hazardSourceName: formData.hazardSourceName,
|
||
description: formData.description,
|
||
tagIndex: formData.tagIndex,
|
||
regulationId: formData.regulationId,
|
||
regulationName: formData.regulationName,
|
||
address: address.value,
|
||
areaId: areaId.value,
|
||
areaName: areaName.value,
|
||
fileListLength: fileList.value.length
|
||
}),
|
||
() => {
|
||
if (!props.pageMode || syncingFromParent.value) return;
|
||
emit('draft-change', getPayload());
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
watch(
|
||
() => props.show,
|
||
(val) => {
|
||
if (props.pageMode) return;
|
||
if (val && hazardSourceList.value.length === 0) {
|
||
fetchHazardSourceList();
|
||
}
|
||
if (val && areaList.value.length === 0) {
|
||
fetchAreaList();
|
||
}
|
||
if (!val) return;
|
||
|
||
// 地图选点返回时由选点回调恢复,避免空 modelValue 覆盖已填内容
|
||
if (choosingLocation.value) {
|
||
return;
|
||
}
|
||
|
||
const locationDraft = loadLocationPickerDraft();
|
||
if (locationDraft) {
|
||
applyPayload(locationDraft);
|
||
clearLocationPickerDraft();
|
||
return;
|
||
}
|
||
|
||
if (hasPayloadContent(props.modelValue)) {
|
||
applyPayload(props.modelValue);
|
||
}
|
||
}
|
||
);
|
||
|
||
fetchTagOptions();
|
||
fetchHazardSourceList();
|
||
fetchAreaList();
|
||
|
||
defineExpose({
|
||
getPayload,
|
||
applyPayload,
|
||
resetForm,
|
||
validateForm
|
||
});
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.hazard-form-page {
|
||
min-height: 100vh;
|
||
background: #ebf2fc;
|
||
padding: 24rpx;
|
||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.page-body {
|
||
padding: 30rpx;
|
||
max-height: none;
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.page-footer {
|
||
display: flex;
|
||
gap: 24rpx;
|
||
margin-top: 32rpx;
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
|
||
button {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
border-radius: 44rpx;
|
||
font-size: 30rpx;
|
||
margin: 0;
|
||
padding: 0;
|
||
|
||
&::after {
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
.btn-cancel {
|
||
background: #fff;
|
||
color: #2667e9;
|
||
border: 2rpx solid #2667e9;
|
||
}
|
||
|
||
.btn-confirm {
|
||
color: #fff;
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
.popup-content {
|
||
width: 600rpx;
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.popup-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 30rpx;
|
||
border-bottom: 1rpx solid #eee;
|
||
|
||
.popup-title {
|
||
font-size: 32rpx;
|
||
color: #333;
|
||
}
|
||
|
||
.popup-close {
|
||
font-size: 40rpx;
|
||
color: #999;
|
||
line-height: 1;
|
||
}
|
||
}
|
||
|
||
.popup-body {
|
||
padding: 30rpx;
|
||
max-height: 900rpx;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.draft-banner {
|
||
font-size: 24rpx;
|
||
background-color: #fff7eb;
|
||
border: 1rpx solid #ffe4cc;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20rpx;
|
||
padding: 16rpx 20rpx;
|
||
border-radius: 8rpx;
|
||
color: #fa8c16;
|
||
}
|
||
|
||
.draft-banner-left {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.draft-banner-icon {
|
||
margin-right: 10rpx;
|
||
}
|
||
|
||
.draft-banner-action {
|
||
cursor: pointer;
|
||
padding: 0 10rpx;
|
||
color: #2667e9;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.hazard-choose-scroll {
|
||
width: 100%;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.hazard-choose-row {
|
||
display: inline-flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
padding-right: 20rpx;
|
||
}
|
||
|
||
.hazard-choose-wrap {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.hazard-choose-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
border: 1rpx solid #dcdfe6;
|
||
border-radius: 8rpx;
|
||
background: #fff;
|
||
flex-shrink: 0;
|
||
margin-right: 12rpx;
|
||
|
||
&--source {
|
||
width: 152rpx;
|
||
height: 64rpx;
|
||
}
|
||
|
||
&--level {
|
||
width: 183rpx;
|
||
height: 72rpx;
|
||
}
|
||
|
||
&--tag {
|
||
min-width: 120rpx;
|
||
height: 64rpx;
|
||
padding: 0 24rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
&--active {
|
||
background: #2667e9;
|
||
border-color: #2667e9;
|
||
|
||
.hazard-choose-tag__text {
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
&__text {
|
||
font-size: 24rpx;
|
||
line-height: 1.2;
|
||
color: #606266;
|
||
text-align: center;
|
||
}
|
||
}
|
||
|
||
.ai-btn-wrapper {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.ai-analyze-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 72rpx;
|
||
padding: 0 32rpx;
|
||
font-size: 28rpx;
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #4facfe 0%, #2668ea 100%);
|
||
border-radius: 36rpx;
|
||
border: none;
|
||
|
||
&::after {
|
||
border: none;
|
||
}
|
||
|
||
.ai-btn-icon {
|
||
margin-right: 8rpx;
|
||
font-size: 30rpx;
|
||
}
|
||
|
||
&[disabled] {
|
||
opacity: 0.7;
|
||
}
|
||
}
|
||
|
||
.popup-footer {
|
||
display: flex;
|
||
border-top: 1rpx solid #eee;
|
||
|
||
button {
|
||
flex: 1;
|
||
height: 90rpx;
|
||
line-height: 90rpx;
|
||
border-radius: 0;
|
||
font-size: 30rpx;
|
||
|
||
&::after {
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
.btn-cancel {
|
||
background: #fff;
|
||
color: #666;
|
||
}
|
||
|
||
.btn-confirm {
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.address-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
|
||
.address-input-wrapper {
|
||
flex: 1;
|
||
}
|
||
|
||
.btn-address {
|
||
flex-shrink: 0;
|
||
height: 70rpx;
|
||
line-height: 70rpx;
|
||
padding: 0 30rpx;
|
||
font-size: 26rpx;
|
||
border-radius: 8rpx;
|
||
color: #fff;
|
||
|
||
&::after {
|
||
border: none;
|
||
}
|
||
}
|
||
}
|
||
|
||
.select-trigger {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: #fff;
|
||
border: 1rpx solid #dcdfe6;
|
||
border-radius: 8rpx;
|
||
padding: 20rpx 24rpx;
|
||
|
||
.select-value {
|
||
flex: 1;
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
|
||
&.placeholder {
|
||
color: #c0c4cc;
|
||
}
|
||
}
|
||
}
|
||
|
||
.static-field {
|
||
background: #fff;
|
||
border: 1rpx solid #dcdfe6;
|
||
border-radius: 8rpx;
|
||
padding: 20rpx 24rpx;
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.picker-popup {
|
||
background: #fff;
|
||
|
||
.picker-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 30rpx;
|
||
border-bottom: 1rpx solid #eee;
|
||
|
||
.picker-cancel {
|
||
font-size: 28rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.picker-title {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.picker-confirm {
|
||
font-size: 28rpx;
|
||
color: #2667e9;
|
||
}
|
||
}
|
||
|
||
.picker-body {
|
||
max-height: 600rpx;
|
||
padding: 0 30rpx;
|
||
}
|
||
|
||
.picker-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 30rpx 0;
|
||
border-bottom: 1rpx solid #f5f5f5;
|
||
font-size: 30rpx;
|
||
color: #333;
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
&.picker-item-active {
|
||
color: #2667e9;
|
||
}
|
||
}
|
||
}
|
||
|
||
.area-color-dot {
|
||
width: 24rpx;
|
||
height: 24rpx;
|
||
border-radius: 50%;
|
||
margin-right: 16rpx;
|
||
}
|
||
|
||
.law-popup {
|
||
width: 600rpx;
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
overflow: hidden;
|
||
max-height: 80vh;
|
||
|
||
.popup-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 30rpx;
|
||
border-bottom: 1rpx solid #eee;
|
||
|
||
.popup-title {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.popup-close {
|
||
font-size: 40rpx;
|
||
color: #999;
|
||
}
|
||
}
|
||
}
|
||
|
||
.search-box {
|
||
display: flex;
|
||
align-items: center;
|
||
background: #f5f5f5;
|
||
border-radius: 40rpx;
|
||
padding: 16rpx 24rpx;
|
||
margin: 20rpx 30rpx;
|
||
|
||
.search-icon {
|
||
font-size: 28rpx;
|
||
color: #999;
|
||
margin-right: 12rpx;
|
||
}
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
font-size: 28rpx;
|
||
background: transparent;
|
||
border: none;
|
||
}
|
||
|
||
.search-btn {
|
||
color: #2667e9;
|
||
font-size: 26rpx;
|
||
margin-left: 16rpx;
|
||
}
|
||
}
|
||
|
||
.loading-tip,
|
||
.empty-tip {
|
||
text-align: center;
|
||
padding: 40rpx;
|
||
color: #999;
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.law-list {
|
||
max-height: 400rpx;
|
||
padding: 0 30rpx;
|
||
}
|
||
|
||
.law-item {
|
||
padding: 24rpx;
|
||
border: 2rpx solid #e5e5e5;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
|
||
.law-title {
|
||
line-height: 1.5;
|
||
margin-bottom: 8rpx;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.law-basis {
|
||
font-size: 24rpx;
|
||
line-height: 1.4;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
}
|
||
|
||
.law-item-active {
|
||
border-color: #2667e9;
|
||
background: #f0f6ff;
|
||
}
|
||
|
||
.law-popup-footer {
|
||
display: flex;
|
||
padding: 30rpx;
|
||
border-top: 1rpx solid #eee;
|
||
gap: 20rpx;
|
||
|
||
button {
|
||
flex: 1;
|
||
height: 80rpx;
|
||
line-height: 80rpx;
|
||
font-size: 28rpx;
|
||
border-radius: 40rpx;
|
||
|
||
&::after {
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
.btn-cancel {
|
||
background: #f5f5f5;
|
||
color: #666;
|
||
}
|
||
|
||
.btn-confirm {
|
||
background: #2667e9;
|
||
color: #fff;
|
||
}
|
||
}
|
||
</style>
|