Files
threeonecheck_web/components/hazard/HazardFormPanel.vue
2026-06-30 09:47:10 +08:00

1432 lines
33 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>
<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>