Browse Source

feat:修改病虫害的识别逻辑和地图勾画逻辑

wangsisi 2 weeks ago
parent
commit
640128e8d9

+ 4 - 0
src/components/popup/ImagePreviewPopup.vue

@@ -47,6 +47,9 @@
                     </el-icon>
                 </div>
             </div>
+            <div v-if="$slots.footer" class="image-preview-popup__footer" @click.stop>
+                <slot name="footer" />
+            </div>
         </div>
     </popup>
 </template>
@@ -133,6 +136,7 @@ function handleClose() {
 .image-preview-popup {
     width: 100% !important;
     max-width: 100% !important;
+    background: transparent;
 
     &__content {
         width: 100%;

+ 209 - 156
src/components/popup/UploadProgressPopup.vue

@@ -2,17 +2,41 @@
     <popup :show="show" round :close-on-click-overlay="false" class="upload-progress-popup"
         @update:show="emit('update:show', $event)">
         <slot name="header"></slot>
-        <div class="upload-box" v-loading="loading" element-loading-:text="t('上传中...')">
+        <div class="upload-box">
             <div class="box-header">
                 <div class="upload-title">
                     <span>{{ t('上传照片') }}</span>
                     <span v-if="!uploadRequired" class="optional">{{ t('(可选)') }}</span>
                 </div>
             </div>
-            <upload ref="uploadRef" :maxCount="10" :initImgArr="props.initImgArr" :enableIdentifyStatus="true"
-                :before-read="beforeReadUpload" :after-read="afterReadUpload" @handleUpload="onUploadChange"
-                @clickPreview="onClickPreview">
-            </upload>
+            <uploader class="popup-uploader" :class="{ 'popup-uploader-identify': enableIdentify }" v-model="fileList"
+                multiple :max-count="10" :after-read="afterReadUpload"
+                :preview-full-image="!enableIdentify" @delete="onDeleteUpload" @click-preview="openIdentifyPreview">
+                <template v-if="enableIdentify" #preview-cover="previewItem">
+                    <div v-if="previewItem?.identifyStatus" class="identify-preview-cover"
+                        :class="{ 'is-done': previewItem.identifyStatus === 'done' }">
+                        <div class="identify-status-bar"
+                            @click.stop="previewItem.identifyStatus === 'done' && openIdentifyPreview(previewItem)">
+                            <template v-if="previewItem.identifyStatus === 'identifying'">
+                                <span class="identify-status-text">{{ t('正在识别中..') }}</span>
+                            </template>
+                            <template v-else-if="previewItem.identifyStatus === 'done'">
+                                <el-icon class="identify-done-icon" :size="14">
+                                    <CircleCheck />
+                                </el-icon>
+                                <span class="identify-status-text">{{ t('识别完成') }}</span>
+                                <el-icon class="identify-arrow-icon" :size="12">
+                                    <ArrowRight />
+                                </el-icon>
+                            </template>
+                            <template v-else-if="previewItem.identifyStatus === 'failed'">
+                                <span class="identify-status-text">{{ t('识别失败') }}</span>
+                            </template>
+                        </div>
+                    </div>
+                </template>
+                <img class="plus" src="@/assets/img/home/plus.png" alt="">
+            </uploader>
         </div>
         <slot name="footer"></slot>
         <div class="upload-action-btns">
@@ -21,33 +45,29 @@
         </div>
     </popup>
 
-    <popup v-model:show="showIdentifyPreview" class="identify-result-popup" teleport="body" z-index="3000">
-        <div class="identify-preview-wrap">
-            <img class="identify-preview-img" :src="previewImageUrl" alt="" />
+    <ImagePreviewPopup v-if="enableIdentify" v-model:show="showIdentifyPreview" :images="identifyPreviewImages">
+        <template #footer>
             <div class="identify-result-panel" v-if="previewResult">
                 <div class="result-header">
                     <span class="accent-bar"></span>
                     <span class="result-name">{{ t('病虫名称:') }}{{ getIdentifyField(previewResult, ['disease_name', 'name', 'pest_name']) }}</span>
                 </div>
-                <div class="result-tabs">
-                    <span v-for="(tab, index) in resultTabs" :key="tab.key"
-                        class="result-tab" :class="{ active: activeResultTab === index }"
-                        @click="activeResultTab = index">{{ tab.label }}</span>
-                </div>
-                <div class="result-content">{{ currentTabContent }}</div>
+                <div class="result-content">{{ getIdentifyField(previewResult, ['kepu', 'harm', 'harm_desc', 'phenotype', 'phenotypic_characteristics']) }}</div>
             </div>
-        </div>
-    </popup>
+        </template>
+    </ImagePreviewPopup>
 </template>
 
 <script setup>
 import { useI18n } from "@/i18n";
 const { t } = useI18n();
 import { ref, computed, watch } from 'vue';
-import { Popup } from 'vant';
+import { Popup, Uploader } from 'vant';
 import { ElMessage } from 'element-plus';
-import upload from '@/components/upload.vue';
+import { CircleCheck, ArrowRight } from '@element-plus/icons-vue';
+import ImagePreviewPopup from '@/components/popup/ImagePreviewPopup.vue';
 import UploadFile from "@/utils/upliadFile";
+import 'vant/lib/uploader/style';
 import { getFileExt } from "@/utils/util";
 import { base_img_url2 } from '@/api/config';
 
@@ -56,10 +76,6 @@ const props = defineProps({
         type: Boolean,
         default: false,
     },
-    popupImageUploadLoading: {
-        type: Boolean,
-        default: false,
-    },
     initImgArr: {
         type: Array,
         default: () => [],
@@ -73,37 +89,29 @@ const props = defineProps({
         type: Boolean,
         default: true,
     },
+    /** 是否开启上传后 AI 病虫识别(物候上传等场景关闭) */
+    enableIdentify: {
+        type: Boolean,
+        default: false,
+    },
 });
 
 const emit = defineEmits(['update:show', 'cancel', 'confirm', 'reset']);
 
-const uploadRef = ref(null);
+const fileList = ref([]);
 const popupInnerImgArr = ref([]);
 /** 用户已上传/删除过图片后,以 popupInnerImgArr 为准,不再回退 props.initImgArr */
 const imgTouched = ref(false);
-const popupInnerLoading = ref(false);
 const uploadFileObj = new UploadFile();
 const miniUserId = localStorage.getItem("MINI_USER_ID");
 
-const loading = computed(() => props.popupImageUploadLoading || popupInnerLoading.value);
-
 const showIdentifyPreview = ref(false);
 const previewImageUrl = ref('');
 const previewResult = ref(null);
-const activeResultTab = ref(0);
-
-const resultTabs = computed(() => [
-    { key: 'name', label: t('病虫的名称'), fields: ['disease_name', 'name', 'pest_name'] },
-    { key: 'phenotype', label: t('病虫的表型'), fields: ['phenotype', 'phenotypic_characteristics'] },
-    { key: 'harm', label: t('危害'), fields: ['harm', 'damage', 'kepu', 'harm_desc'] },
-    { key: 'peak', label: t('高发时期'), fields: ['peak_period', 'high_incidence_period', 'outbreak_period'] },
-]);
-
-const currentTabContent = computed(() => {
-    const tab = resultTabs.value[activeResultTab.value];
-    if (!tab || !previewResult.value) return '-';
-    return getIdentifyField(previewResult.value, tab.fields);
-});
+
+const identifyPreviewImages = computed(() => (
+    previewImageUrl.value ? [previewImageUrl.value] : []
+));
 
 function getIdentifyField(result, fields) {
     for (const field of fields) {
@@ -122,51 +130,50 @@ function getConfirmImgArr() {
     return [...(props.initImgArr || [])];
 }
 
-/** 与 upload 组件内 imgArr 同步(含删除),用于确认提交等 */
-function onUploadChange({ imgArr }) {
+function onDeleteUpload(_file, detail) {
     imgTouched.value = true;
-    popupInnerImgArr.value = [...(imgArr || [])];
+    popupInnerImgArr.value.splice(detail.index, 1);
 }
 
-const beforeReadUpload = () => {
-    popupInnerLoading.value = false;
-    return true;
-};
-
 const afterReadUpload = async (data) => {
     if (!Array.isArray(data)) {
         data = [data];
     }
-    popupInnerLoading.value = true;
-    try {
-        for (const file of data) {
-            const fileVal = file.file;
-            file.status = "uploading";
-            file.message = "上传中...";
-            const ext = getFileExt(fileVal.name);
-            const key = `birdseye-look-mini/${miniUserId}/${new Date().getTime()}.${ext}`;
-            const resFilename = await uploadFileObj.put(key, fileVal);
-            if (resFilename) {
-                file.status = "done";
-                file.message = "";
-                file.resFilename = resFilename;
-                file.url = base_img_url2 + resFilename;
-                imgTouched.value = true;
-                popupInnerImgArr.value.push(resFilename);
+    for (const file of data) {
+        const fileVal = file.file;
+        file.status = "uploading";
+        file.message = "上传中...";
+        const ext = getFileExt(fileVal.name);
+        const key = `birdseye-look-mini/${miniUserId}/${new Date().getTime()}.${ext}`;
+        const resFilename = await uploadFileObj.put(key, fileVal);
+        if (resFilename) {
+            file.status = "done";
+            file.message = "";
+            file.resFilename = resFilename;
+            file.url = base_img_url2 + resFilename;
+            imgTouched.value = true;
+            popupInnerImgArr.value.push(resFilename);
+            if (props.enableIdentify) {
                 file.identifyStatus = 'identifying';
                 identifySingleImage(file, resFilename);
-            } else {
-                file.status = "failed";
-                file.message = "上传失败";
-                ElMessage.error("图片上传失败,请稍后再试!");
             }
+        } else {
+            file.status = "failed";
+            file.message = "上传失败";
+            ElMessage.error("图片上传失败,请稍后再试!");
         }
-    } finally {
-        popupInnerLoading.value = false;
-        uploadRef.value?.syncImgArr?.(popupInnerImgArr.value);
     }
 };
 
+function patchFileItem(resFilename, patch) {
+    let index = fileList.value.findIndex((item) => item.resFilename === resFilename);
+    if (index === -1) {
+        index = fileList.value.findIndex((item) => item.url?.includes(resFilename));
+    }
+    if (index === -1) return;
+    Object.assign(fileList.value[index], patch);
+}
+
 async function identifySingleImage(file, resFilename) {
     let farmData = {};
     try {
@@ -181,23 +188,26 @@ async function identifySingleImage(file, resFilename) {
     try {
         const res = await VE_API.record.batchPestIdentify(params);
         if (res.code === 200 && res.results?.length) {
-            file.identifyStatus = 'done';
-            file.identifyResult = res.results[0];
+            const patch = { identifyStatus: 'done', identifyResult: res.results[0] };
+            Object.assign(file, patch);
+            patchFileItem(resFilename, patch);
         } else {
-            file.identifyStatus = 'failed';
+            const patch = { identifyStatus: 'failed' };
+            Object.assign(file, patch);
+            patchFileItem(resFilename, patch);
             ElMessage.error(t('AI识别失败,请稍后再试!'));
         }
     } catch {
-        file.identifyStatus = 'failed';
+        const patch = { identifyStatus: 'failed' };
+        Object.assign(file, patch);
+        patchFileItem(resFilename, patch);
         ElMessage.error(t('AI识别失败,请稍后再试!'));
     }
 }
 
-function onClickPreview(file) {
-    if (file.identifyStatus !== 'done' || !file.identifyResult) return;
-    previewImageUrl.value = file.url || base_img_url2 + file.resFilename;
-    previewResult.value = file.identifyResult;
-    activeResultTab.value = 0;
+function openIdentifyPreview(file) {
+    previewImageUrl.value = file.url || base_img_url2 + (file.resFilename || '');
+    previewResult.value = file.identifyResult || null;
     showIdentifyPreview.value = true;
 }
 
@@ -211,17 +221,31 @@ function handleConfirm() {
 }
 
 function uploadReset() {
+    fileList.value = [];
     popupInnerImgArr.value = [];
     imgTouched.value = false;
-    popupInnerLoading.value = false;
     showIdentifyPreview.value = false;
     previewImageUrl.value = '';
     previewResult.value = null;
-    activeResultTab.value = 0;
-    uploadRef.value?.uploadReset?.();
 }
 
 watch(
+    () => props.initImgArr,
+    (val) => {
+        if (imgTouched.value || !val?.length) {
+            return;
+        }
+        fileList.value = val.map((item) => ({
+            url: base_img_url2 + item,
+            isImage: true,
+            resFilename: item,
+        }));
+        popupInnerImgArr.value = [...val];
+    },
+    { immediate: true, deep: true },
+);
+
+watch(
     () => props.show,
     (val) => {
         if (val) {
@@ -262,6 +286,80 @@ defineExpose({
                 }
             }
         }
+
+        .popup-uploader {
+            .plus {
+                width: calc((100vw - 68px) / 4);
+                height: calc((100vw - 68px) / 4);
+            }
+
+            ::v-deep {
+                .van-uploader__wrapper {
+                    --van-uploader-size: 76.7px;
+                    --van-padding-xs: 6px;
+                }
+            }
+
+            &.popup-uploader-identify {
+                ::v-deep {
+                    .van-uploader__preview-cover {
+                        position: absolute;
+                        left: 0;
+                        right: 0;
+                        top: 0;
+                        bottom: 0;
+                        height: 100%;
+                        pointer-events: none;
+                    }
+
+                    .van-uploader__preview-delete {
+                        z-index: 3;
+                    }
+                }
+            }
+        }
+
+        .identify-preview-cover {
+            display: flex;
+            flex-direction: column;
+            justify-content: flex-end;
+            width: 100%;
+            height: 100%;
+            position: relative;
+        }
+
+        .identify-status-bar {
+            pointer-events: auto;
+            display: flex;
+            align-items: center;
+            justify-content: flex-start;
+            gap: 4px;
+            width: 100%;
+            padding: 4px 6px;
+            box-sizing: border-box;
+            background: rgba(0, 0, 0, 0.55);
+            color: #fff;
+            font-size: 11px;
+            text-align: center;
+
+
+            .identify-status-text {
+                flex: 1;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                white-space: nowrap;
+            }
+
+            .identify-done-icon {
+                color: #52c41a;
+                flex-shrink: 0;
+            }
+
+            .identify-arrow-icon {
+                flex-shrink: 0;
+                margin-left: auto;
+            }
+        }
     }
 
     .upload-action-btns {
@@ -291,83 +389,38 @@ defineExpose({
     }
 }
 
-.identify-result-popup {
-    width: 100%;
-    max-width: 100%;
-    border-radius: 0;
-    background: none;
+.identify-result-panel {
+    padding: 8px 10px;
+    background: rgba(0, 0, 0, 0.4);
+    border-radius: 6px;
+    box-sizing: border-box;
+    margin: 18px 12px;
 
-    .identify-preview-wrap {
-        position: relative;
-        width: 100%;
-
-        .identify-preview-img {
-            display: block;
-            width: 100%;
-            max-height: 75vh;
-            object-fit: contain;
-            background: #000;
+    .result-header {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        margin-bottom: 7px;
+
+        .accent-bar {
+            width: 4px;
+            height: 15px;
+            border-radius: 2px;
+            background: #FFD786;
+            flex-shrink: 0;
         }
 
-        .identify-result-panel {
-            position: absolute;
-            left: 12px;
-            right: 12px;
-            bottom: 12px;
-            padding: 10px 12px;
-            background: rgba(61, 61, 61, 0.92);
-            border-radius: 8px;
-            backdrop-filter: blur(4px);
-
-            .result-header {
-                display: flex;
-                align-items: center;
-                gap: 6px;
-                margin-bottom: 8px;
-
-                .accent-bar {
-                    width: 3px;
-                    height: 14px;
-                    border-radius: 2px;
-                    background: #f0d09c;
-                    flex-shrink: 0;
-                }
-
-                .result-name {
-                    color: #f0d09c;
-                    font-size: 14px;
-                    font-weight: 500;
-                    line-height: 20px;
-                }
-            }
-
-            .result-tabs {
-                display: flex;
-                flex-wrap: wrap;
-                gap: 8px 12px;
-                margin-bottom: 8px;
-
-                .result-tab {
-                    color: rgba(255, 255, 255, 0.75);
-                    font-size: 12px;
-                    line-height: 18px;
-                    cursor: pointer;
-
-                    &.active {
-                        color: #fff;
-                        font-weight: 500;
-                    }
-                }
-            }
-
-            .result-content {
-                color: #fff;
-                font-size: 13px;
-                line-height: 20px;
-                white-space: pre-wrap;
-                word-break: break-all;
-            }
+        .result-name {
+            color: #FFD786;
+            font-size: 18px;
+            font-weight: 500;
+            line-height: 22px;
         }
     }
+
+    .result-content {
+        color: #ffffff;
+        font-size: 13px;
+    }
 }
 </style>

+ 5 - 87
src/components/upload.vue

@@ -11,25 +11,9 @@
     </div>
     <div class="upload-content">
       <img v-if="exampleImg" @click="showExample(null)" class="example" src="@/assets/img/home/example-4.png" alt="" />
-      <uploader class="uploader" :class="{ 'uploader-list': exampleImg, 'uploader-identify': enableIdentifyStatus }"
-        v-model="fileList" :multiple="props.maxCount > 1" :max-count="props.maxCount" :before-read="onBeforeRead"
-        :after-read="onAfterRead" :preview-full-image="!enableIdentifyStatus" @delete="deleteImg"
-        @click-preview="onClickPreview">
-        <template v-if="enableIdentifyStatus" #preview-cover="previewItem">
-          <div v-if="previewItem?.identifyStatus" class="identify-status-bar">
-            <template v-if="previewItem.identifyStatus === 'identifying'">
-              <span class="identify-status-text">{{ $t('正在识别中..') }}</span>
-            </template>
-            <template v-else-if="previewItem.identifyStatus === 'done'">
-              <el-icon class="identify-done-icon" :size="14"><CircleCheck /></el-icon>
-              <span class="identify-status-text">{{ $t('识别完成') }}</span>
-              <el-icon class="identify-arrow-icon" :size="12"><ArrowRight /></el-icon>
-            </template>
-            <template v-else-if="previewItem.identifyStatus === 'failed'">
-              <span class="identify-status-text">{{ $t('识别失败') }}</span>
-            </template>
-          </div>
-        </template>
+      <uploader class="uploader" :class="{ 'uploader-list': exampleImg }" v-model="fileList"
+        :multiple="props.maxCount > 1" :max-count="props.maxCount" :before-read="onBeforeRead" :after-read="onAfterRead"
+        @delete="deleteImg">
         <template v-if="exampleImg">
           <slot v-if="!fileList.length"></slot>
           <img class="plus" v-else src="@/assets/img/home/plus.png" alt="">
@@ -59,7 +43,6 @@ import eventBus from "@/api/eventBus";
 import { base_img_url2 } from "@/api/config";
 import { getFileExt } from "@/utils/util";
 import { ElMessage } from "element-plus";
-import { CircleCheck, ArrowRight } from "@element-plus/icons-vue";
 import UploadFile from "@/utils/upliadFile";
 import 'vant/lib/uploader/style';
 import { useStore } from "vuex";
@@ -99,18 +82,13 @@ const props = defineProps({
     type: Function,
     default: null,
   },
-  /** 上传后展示 AI 识别状态条 */
-  enableIdentifyStatus: {
-    type: Boolean,
-    default: false,
-  },
 })
 
 
 const store = useStore();
 const miniUserId = store.state.home.miniUserId;
 
-const emit = defineEmits(['handleUpload', 'clickPreview'])
+const emit = defineEmits(['handleUpload'])
 
 //上传照片
 const fileList = ref([]);
@@ -139,10 +117,6 @@ const onAfterRead = async (files) => {
   return defaultAfterRead(files);
 };
 
-const onClickPreview = (file, detail) => {
-  emit('clickPreview', file, detail);
-};
-
 const defaultAfterRead = async (files) => {
   if (!Array.isArray(files)) {
     files = [files];
@@ -223,16 +197,9 @@ function uploadReset() {
   imgArr.value = []
 }
 
-function syncImgArr(arr) {
-  imgArr.value = [...(arr || [])]
-  fileArr.value = imgArr.value.map((item) =>
-    props.fullPath ? base_img_url2 + item : item,
-  )
-}
 
 defineExpose({
-  uploadReset,
-  syncImgArr,
+  uploadReset
 })
 
 onMounted(() => {
@@ -311,24 +278,6 @@ onMounted(() => {
         --van-padding-xs: 6px;
       }
     }
-
-    &.uploader-identify {
-      ::v-deep {
-        .van-uploader__preview {
-          overflow: hidden;
-          border-radius: 8px;
-        }
-
-        .van-uploader__preview-cover {
-          position: absolute;
-          left: 0;
-          right: 0;
-          bottom: 0;
-          top: auto;
-          height: auto;
-        }
-      }
-    }
   }
 
   .uploader-list {
@@ -355,37 +304,6 @@ onMounted(() => {
     top: 0;
     left: 0;
   }
-
-  .identify-status-bar {
-    display: flex;
-    align-items: center;
-    justify-content: flex-start;
-    gap: 4px;
-    width: 100%;
-    padding: 4px 6px;
-    box-sizing: border-box;
-    background: rgba(0, 0, 0, 0.55);
-    color: #fff;
-    font-size: 11px;
-    line-height: 16px;
-
-    .identify-status-text {
-      flex: 1;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-
-    .identify-done-icon {
-      color: #52c41a;
-      flex-shrink: 0;
-    }
-
-    .identify-arrow-icon {
-      flex-shrink: 0;
-      margin-left: auto;
-    }
-  }
 }
 
 .example-popup {

+ 2 - 2
src/i18n/recordDetails-messages.js

@@ -43,7 +43,7 @@ export const recordDetailsZh = {
     partitionBannerDesc:
         "如果区域长势不同,会降低病虫害防治功效,建议根据长势拆分区域,进行分区精细管理,达到减药减肥的目的",
     partitionManage: "分区管理",
-    abnormalCount: "有多少植株出现了异常?",
+    abnormalCount: "每 20 棵树中,有几棵出现了 病虫异常?",
     inputPlaceholder: "请输入",
     ratioRequired: "请输入占比",
     patrolDesc: "巡园描述",
@@ -135,7 +135,7 @@ export const recordDetailsEn = {
     partitionBannerDesc:
         "If growth varies by area, pest control is less effective. Split zones by vigor for precision management to reduce pesticide and fertilizer use.",
     partitionManage: "Zone management",
-    abnormalCount: "What % of plants show abnormality?",
+    abnormalCount: "Out of every 20 trees, how many show pest/disease abnormality?",
     inputPlaceholder: "Enter",
     ratioRequired: "Please enter the ratio",
     patrolDesc: "Patrol notes",

+ 4 - 7
src/views/old_mini/recordDetails/index.vue

@@ -151,7 +151,7 @@
         <ImagePreviewPopup v-model:show="showPhenotypeImagePreview" :images="phenotypePreviewImages" />
 
         <!-- 物候 上传弹窗 -->
-        <UploadProgressPopup ref="phenologyUploadPopupRef" v-model:show="showPhenologyUploadPopup"
+        <UploadProgressPopup ref="phenologyUploadPopupRef" v-model:show="showPhenologyUploadPopup" :enable-identify="false"
             @cancel="handleCancelPhenologyUploadPopup" :upload-required="false" @confirm="handleConfirmPhenologyUpload">
             <template #header>
                 <div class="upload-progress-title">
@@ -162,8 +162,8 @@
         </UploadProgressPopup>
 
         <!-- 病虫害 上传弹窗 -->
-        <UploadProgressPopup ref="pestUploadPopupRef" v-model:show="showPestUploadPopup"
-            :popup-image-upload-loading="pestPopupImageUploadLoading" :init-img-arr="pestInitImgArr"
+        <UploadProgressPopup ref="pestUploadPopupRef" v-model:show="showPestUploadPopup" :enable-identify="true"
+            :init-img-arr="pestInitImgArr"
             :confirm-text="t('recordDetails.confirmUpload')" :upload-required="false"
             @reset="handlePestUploadPopupReset" @cancel="handleCancelPestUploadPopup"
             @confirm="handleConfirmPestUpload">
@@ -176,7 +176,7 @@
                         <el-input v-model="formData.ratio" type="number" size="large"
                             :placeholder="t('recordDetails.inputPlaceholder')">
                             <template #suffix>
-                                %
+                                
                             </template>
                         </el-input>
                     </div>
@@ -525,11 +525,8 @@ const pestInitImgArr = ref([]);
 const phenologyUploadPopupRef = ref(null);
 const pestUploadPopupRef = ref(null);
 const phenologyTrackTimelineRef = ref(null);
-const pestPopupImageUploadLoading = ref(false);
-
 const handlePestUploadPopupReset = () => {
     pestInitImgArr.value = [];
-    pestPopupImageUploadLoading.value = false;
 };
 
 const curStage = ref({});

+ 97 - 7
src/views/old_mini/recordDetails/map/mapManage.js

@@ -131,9 +131,10 @@ class MapManage {
   }
 
   initMap(location, target, options = {}) {
-    const { editable = true, constrainedDrawing = false } = options;
+    const { editable = true, constrainedDrawing = false, onDrawOutsideBoundary } = options;
     this.editable = editable;
     this.constrainedDrawing = constrainedDrawing;
+    this.onDrawOutsideBoundary = typeof onDrawOutsideBoundary === "function" ? onDrawOutsideBoundary : null;
     this.constrainedDrawingReady = false;
     this.boundaryGeometry = null;
     let level = 16;
@@ -155,16 +156,103 @@ class MapManage {
     }
   }
 
+  isCoordinateInsideBoundary(coordinate) {
+    if (!this.boundaryGeometry || !coordinate || !this.kmap) return false;
+    try {
+      return this.boundaryGeometry.intersectsCoordinate(coordinate);
+    } catch {
+      return false;
+    }
+  }
+
+  notifyDrawOutsideBoundary() {
+    this.onDrawOutsideBoundary?.();
+  }
+
+  getLastCoordinateFromGeometry(geometry) {
+    if (!geometry || typeof geometry.getType !== "function") return null;
+    const type = geometry.getType();
+    if (type === "Point") return geometry.getCoordinates();
+    if (type === "LineString") {
+      const coords = geometry.getCoordinates();
+      return coords.length ? coords[coords.length - 1] : null;
+    }
+    if (type === "Polygon") {
+      const ring = geometry.getCoordinates()[0];
+      return ring?.length ? ring[ring.length - 1] : null;
+    }
+    if (type === "MultiPolygon") {
+      const polys = geometry.getCoordinates();
+      const ring = polys[polys.length - 1]?.[0];
+      return ring?.length ? ring[ring.length - 1] : null;
+    }
+    return null;
+  }
+
+  /** 区域外勾画:中止当前绘制并移除无效地块 */
+  rejectOutsideDraw(feature) {
+    if (feature && this.kmap?.polygonLayer?.source) {
+      this.kmap.polygonLayer.source.removeFeature(feature);
+    }
+    if (this.kmap?.draw && typeof this.kmap.draw.abortDrawing === "function") {
+      this.kmap.draw.abortDrawing();
+    }
+    this.notifyDrawOutsideBoundary();
+  }
+
+  unbindSketchBoundaryConstraint(sketch, listener) {
+    sketch?.getGeometry()?.un("change", listener);
+  }
+
+  bindSketchBoundaryConstraint(sketch) {
+    const onSketchChange = () => {
+      const geometry = sketch.getGeometry();
+      if (!geometry) return;
+      const type = geometry.getType();
+      // 仅在手绘轨迹(线)阶段判断越界;面要素未完成时 intersect 会误判
+      if (type !== "LineString" && type !== "Point") return;
+      const last = this.getLastCoordinateFromGeometry(geometry);
+      if (last && !this.isCoordinateInsideBoundary(last)) {
+        this.unbindSketchBoundaryConstraint(sketch, onSketchChange);
+        this.rejectOutsideDraw();
+      }
+    };
+
+    sketch.getGeometry()?.on("change", onSketchChange);
+    const cleanup = () => this.unbindSketchBoundaryConstraint(sketch, onSketchChange);
+    this.kmap.draw.once("drawend", cleanup);
+    this.kmap.draw.once("drawabort", cleanup);
+  }
+
   setupConstrainedDrawing() {
     if (!this.kmap || this.constrainedDrawingReady) return;
     this.constrainedDrawingReady = true;
     this.kmap.initDraw((e) => {
-      if (e.feature) {
-        this.clipFeatureToBoundary(e.feature);
+      if (!e.feature) return;
+      if (!this.clipFeatureToBoundary(e.feature)) {
+        if (this.kmap?.polygonLayer?.source) {
+          this.kmap.polygonLayer.source.removeFeature(e.feature);
+        }
+        this.notifyDrawOutsideBoundary();
       }
     });
+    this.kmap.draw.on("drawstart", (e) => {
+      const coordinate = e.coordinate || this.getLastCoordinateFromGeometry(e.feature?.getGeometry());
+      if (!coordinate || !this.isCoordinateInsideBoundary(coordinate)) {
+        this.rejectOutsideDraw();
+        return;
+      }
+      this.bindSketchBoundaryConstraint(e.feature);
+    });
     this.kmap.modifyDraw((e) => {
-      e.features.forEach((feature) => this.clipFeatureToBoundary(feature));
+      e.features.forEach((feature) => {
+        if (!this.clipFeatureToBoundary(feature)) {
+          if (this.kmap?.polygonLayer?.source) {
+            this.kmap.polygonLayer.source.removeFeature(feature);
+          }
+          this.notifyDrawOutsideBoundary();
+        }
+      });
     });
   }
 
@@ -211,16 +299,17 @@ class MapManage {
   }
 
   clipFeatureToBoundary(feature) {
-    if (!feature || !this.boundaryGeometry || !this.kmap?.polygonLayer?.source) return;
+    if (!feature || !this.boundaryGeometry || !this.kmap?.polygonLayer?.source) return false;
     const geometry = feature.getGeometry();
-    if (!geometry) return;
+    if (!geometry) return false;
     const clipped = this.intersectWithBoundary(geometry);
     if (!clipped) {
       this.kmap.polygonLayer.source.removeFeature(feature);
-      return;
+      return false;
     }
     feature.setGeometry(clipped);
     feature.setStyle(this.createDrawnPestStyle());
+    return true;
   }
 
   setBoundaryWkt(wkt) {
@@ -672,6 +761,7 @@ class MapManage {
     this.constrainedDrawing = false;
     this.constrainedDrawingReady = false;
     this.boundaryGeometry = null;
+    this.onDrawOutsideBoundary = null;
     this.selectedGridIds?.clear();
   }
 

+ 39 - 8
src/views/old_mini/recordDetails/mapManage.vue

@@ -4,7 +4,7 @@
         <div class="map-manage-content">
             <!-- <locationSearch class="location-search" @change="handleLocationChange"></locationSearch> -->
             <div class="map-container" ref="mapContainer"></div>
-            <div class="map-tip" v-if="recordType === 'pest'">{{ $t('建立您的专属病虫害地图档案,生成分区靶向治疗方案') }}</div>
+            <div class="map-tip" v-if="recordType === 'pest'">{{ $t('请在虚线管理区域内勾画病虫害地块,区域外不可勾画') }}</div>
             <div class="new-region-btn" v-show="!drawingEnabled && recordType !== 'pest'" @click="onStartRegionDrawing">
                 {{ $t('新建管理分区') }}</div>
             <div class="map-icon" v-show="drawingEnabled && recordType !== 'pest'" @click="handleMapIconClick">
@@ -12,6 +12,10 @@
             </div>
             <div class="map-legend" v-if="recordType === 'pest'">
                 <div class="map-legend__item">
+                    <span class="map-legend__line map-legend__line--boundary"></span>
+                    <span class="map-legend__text">{{ $t('管理区域') }}</span>
+                </div>
+                <div class="map-legend__item">
                     <span class="map-legend__dot map-legend__dot--past"></span>
                     <span class="map-legend__text">{{ $t('过往病虫害') }}</span>
                 </div>
@@ -161,6 +165,10 @@ const confirmPayload = ref({
     selectedGridIds: [],
 });
 
+/** 病虫害:仅区域名称弹窗确认后才写入 store */
+const pestMapPayloadCommitted = ref(false);
+const pestMapStoreSnapshot = ref(null);
+
 function buildMapConfirmPayload(overrides = {}) {
     const selectedGrids = mapManage.getSelectedGrids();
     const drawnPayload = mapManage.getAreaGeometry();
@@ -181,6 +189,7 @@ function buildMapConfirmPayload(overrides = {}) {
 
 const handleRegionNameConfirm = (regionName) => {
     confirmPayload.value.zone_name = regionName;
+    pestMapPayloadCommitted.value = true;
     store.commit('recordDetails/SET_MAP_CONFIRM_PAYLOAD', buildMapConfirmPayload({
         zone_name: regionName,
     }));
@@ -231,6 +240,9 @@ function initMapManageView() {
     mapManage.initMap(getFarmMapLocation(), mapContainer.value, {
         editable: recordType.value !== 'pest',
         constrainedDrawing: recordType.value === 'pest',
+        onDrawOutsideBoundary: recordType.value === 'pest'
+            ? () => ElMessage.warning('请在虚线管理区域内勾画')
+            : undefined,
     });
 
     const stored = store.state.recordDetails.mapConfirmPayload;
@@ -288,20 +300,27 @@ function initMapManageView() {
 }
 
 onActivated(() => {
+    pestMapPayloadCommitted.value = false;
+    if (recordType.value === 'pest') {
+        const stored = store.state.recordDetails.mapConfirmPayload;
+        pestMapStoreSnapshot.value = {
+            zone_name: stored.zone_name || '',
+            coordinates: stored.coordinates,
+            gridMultipolygon: stored.gridMultipolygon || '',
+            selectedGridIds: Array.isArray(stored.selectedGridIds) ? [...stored.selectedGridIds] : [],
+        };
+    }
     nextTick(() => initMapManageView());
 });
 
 onDeactivated(() => {
-    if (recordType.value === 'pest' && mapManage.kmap) {
-        const payload = buildMapConfirmPayload();
-        confirmPayload.value = {
-            ...confirmPayload.value,
-            ...payload,
-        };
-        store.commit('recordDetails/SET_MAP_CONFIRM_PAYLOAD', payload);
+    if (recordType.value === 'pest' && !pestMapPayloadCommitted.value && pestMapStoreSnapshot.value) {
+        store.commit('recordDetails/SET_MAP_CONFIRM_PAYLOAD', { ...pestMapStoreSnapshot.value });
     }
     mapManage.destroyMap();
     drawingEnabled.value = false;
+    pestMapPayloadCommitted.value = false;
+    pestMapStoreSnapshot.value = null;
 });
 </script>
 
@@ -405,6 +424,18 @@ onDeactivated(() => {
                     background: rgba(0, 0, 0, 0.57);
                 }
             }
+
+            &__line {
+                display: inline-block;
+                width: 18px;
+                height: 0;
+                border-top: 2px dashed rgba(255, 255, 255, 0.85);
+                box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
+
+                &--boundary {
+                    flex-shrink: 0;
+                }
+            }
         }
     }