Browse Source

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

wangsisi 2 weeks ago
parent
commit
640128e8d9

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

@@ -47,6 +47,9 @@
                     </el-icon>
                     </el-icon>
                 </div>
                 </div>
             </div>
             </div>
+            <div v-if="$slots.footer" class="image-preview-popup__footer" @click.stop>
+                <slot name="footer" />
+            </div>
         </div>
         </div>
     </popup>
     </popup>
 </template>
 </template>
@@ -133,6 +136,7 @@ function handleClose() {
 .image-preview-popup {
 .image-preview-popup {
     width: 100% !important;
     width: 100% !important;
     max-width: 100% !important;
     max-width: 100% !important;
+    background: transparent;
 
 
     &__content {
     &__content {
         width: 100%;
         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"
     <popup :show="show" round :close-on-click-overlay="false" class="upload-progress-popup"
         @update:show="emit('update:show', $event)">
         @update:show="emit('update:show', $event)">
         <slot name="header"></slot>
         <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="box-header">
                 <div class="upload-title">
                 <div class="upload-title">
                     <span>{{ t('上传照片') }}</span>
                     <span>{{ t('上传照片') }}</span>
                     <span v-if="!uploadRequired" class="optional">{{ t('(可选)') }}</span>
                     <span v-if="!uploadRequired" class="optional">{{ t('(可选)') }}</span>
                 </div>
                 </div>
             </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>
         </div>
         <slot name="footer"></slot>
         <slot name="footer"></slot>
         <div class="upload-action-btns">
         <div class="upload-action-btns">
@@ -21,33 +45,29 @@
         </div>
         </div>
     </popup>
     </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="identify-result-panel" v-if="previewResult">
                 <div class="result-header">
                 <div class="result-header">
                     <span class="accent-bar"></span>
                     <span class="accent-bar"></span>
                     <span class="result-name">{{ t('病虫名称:') }}{{ getIdentifyField(previewResult, ['disease_name', 'name', 'pest_name']) }}</span>
                     <span class="result-name">{{ t('病虫名称:') }}{{ getIdentifyField(previewResult, ['disease_name', 'name', 'pest_name']) }}</span>
                 </div>
                 </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>
-        </div>
-    </popup>
+        </template>
+    </ImagePreviewPopup>
 </template>
 </template>
 
 
 <script setup>
 <script setup>
 import { useI18n } from "@/i18n";
 import { useI18n } from "@/i18n";
 const { t } = useI18n();
 const { t } = useI18n();
 import { ref, computed, watch } from 'vue';
 import { ref, computed, watch } from 'vue';
-import { Popup } from 'vant';
+import { Popup, Uploader } from 'vant';
 import { ElMessage } from 'element-plus';
 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 UploadFile from "@/utils/upliadFile";
+import 'vant/lib/uploader/style';
 import { getFileExt } from "@/utils/util";
 import { getFileExt } from "@/utils/util";
 import { base_img_url2 } from '@/api/config';
 import { base_img_url2 } from '@/api/config';
 
 
@@ -56,10 +76,6 @@ const props = defineProps({
         type: Boolean,
         type: Boolean,
         default: false,
         default: false,
     },
     },
-    popupImageUploadLoading: {
-        type: Boolean,
-        default: false,
-    },
     initImgArr: {
     initImgArr: {
         type: Array,
         type: Array,
         default: () => [],
         default: () => [],
@@ -73,37 +89,29 @@ const props = defineProps({
         type: Boolean,
         type: Boolean,
         default: true,
         default: true,
     },
     },
+    /** 是否开启上传后 AI 病虫识别(物候上传等场景关闭) */
+    enableIdentify: {
+        type: Boolean,
+        default: false,
+    },
 });
 });
 
 
 const emit = defineEmits(['update:show', 'cancel', 'confirm', 'reset']);
 const emit = defineEmits(['update:show', 'cancel', 'confirm', 'reset']);
 
 
-const uploadRef = ref(null);
+const fileList = ref([]);
 const popupInnerImgArr = ref([]);
 const popupInnerImgArr = ref([]);
 /** 用户已上传/删除过图片后,以 popupInnerImgArr 为准,不再回退 props.initImgArr */
 /** 用户已上传/删除过图片后,以 popupInnerImgArr 为准,不再回退 props.initImgArr */
 const imgTouched = ref(false);
 const imgTouched = ref(false);
-const popupInnerLoading = ref(false);
 const uploadFileObj = new UploadFile();
 const uploadFileObj = new UploadFile();
 const miniUserId = localStorage.getItem("MINI_USER_ID");
 const miniUserId = localStorage.getItem("MINI_USER_ID");
 
 
-const loading = computed(() => props.popupImageUploadLoading || popupInnerLoading.value);
-
 const showIdentifyPreview = ref(false);
 const showIdentifyPreview = ref(false);
 const previewImageUrl = ref('');
 const previewImageUrl = ref('');
 const previewResult = ref(null);
 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) {
 function getIdentifyField(result, fields) {
     for (const field of fields) {
     for (const field of fields) {
@@ -122,51 +130,50 @@ function getConfirmImgArr() {
     return [...(props.initImgArr || [])];
     return [...(props.initImgArr || [])];
 }
 }
 
 
-/** 与 upload 组件内 imgArr 同步(含删除),用于确认提交等 */
-function onUploadChange({ imgArr }) {
+function onDeleteUpload(_file, detail) {
     imgTouched.value = true;
     imgTouched.value = true;
-    popupInnerImgArr.value = [...(imgArr || [])];
+    popupInnerImgArr.value.splice(detail.index, 1);
 }
 }
 
 
-const beforeReadUpload = () => {
-    popupInnerLoading.value = false;
-    return true;
-};
-
 const afterReadUpload = async (data) => {
 const afterReadUpload = async (data) => {
     if (!Array.isArray(data)) {
     if (!Array.isArray(data)) {
         data = [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';
                 file.identifyStatus = 'identifying';
                 identifySingleImage(file, resFilename);
                 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) {
 async function identifySingleImage(file, resFilename) {
     let farmData = {};
     let farmData = {};
     try {
     try {
@@ -181,23 +188,26 @@ async function identifySingleImage(file, resFilename) {
     try {
     try {
         const res = await VE_API.record.batchPestIdentify(params);
         const res = await VE_API.record.batchPestIdentify(params);
         if (res.code === 200 && res.results?.length) {
         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 {
         } else {
-            file.identifyStatus = 'failed';
+            const patch = { identifyStatus: 'failed' };
+            Object.assign(file, patch);
+            patchFileItem(resFilename, patch);
             ElMessage.error(t('AI识别失败,请稍后再试!'));
             ElMessage.error(t('AI识别失败,请稍后再试!'));
         }
         }
     } catch {
     } catch {
-        file.identifyStatus = 'failed';
+        const patch = { identifyStatus: 'failed' };
+        Object.assign(file, patch);
+        patchFileItem(resFilename, patch);
         ElMessage.error(t('AI识别失败,请稍后再试!'));
         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;
     showIdentifyPreview.value = true;
 }
 }
 
 
@@ -211,17 +221,31 @@ function handleConfirm() {
 }
 }
 
 
 function uploadReset() {
 function uploadReset() {
+    fileList.value = [];
     popupInnerImgArr.value = [];
     popupInnerImgArr.value = [];
     imgTouched.value = false;
     imgTouched.value = false;
-    popupInnerLoading.value = false;
     showIdentifyPreview.value = false;
     showIdentifyPreview.value = false;
     previewImageUrl.value = '';
     previewImageUrl.value = '';
     previewResult.value = null;
     previewResult.value = null;
-    activeResultTab.value = 0;
-    uploadRef.value?.uploadReset?.();
 }
 }
 
 
 watch(
 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,
     () => props.show,
     (val) => {
     (val) => {
         if (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 {
     .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>
 </style>

+ 5 - 87
src/components/upload.vue

@@ -11,25 +11,9 @@
     </div>
     </div>
     <div class="upload-content">
     <div class="upload-content">
       <img v-if="exampleImg" @click="showExample(null)" class="example" src="@/assets/img/home/example-4.png" alt="" />
       <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">
         <template v-if="exampleImg">
           <slot v-if="!fileList.length"></slot>
           <slot v-if="!fileList.length"></slot>
           <img class="plus" v-else src="@/assets/img/home/plus.png" alt="">
           <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 { base_img_url2 } from "@/api/config";
 import { getFileExt } from "@/utils/util";
 import { getFileExt } from "@/utils/util";
 import { ElMessage } from "element-plus";
 import { ElMessage } from "element-plus";
-import { CircleCheck, ArrowRight } from "@element-plus/icons-vue";
 import UploadFile from "@/utils/upliadFile";
 import UploadFile from "@/utils/upliadFile";
 import 'vant/lib/uploader/style';
 import 'vant/lib/uploader/style';
 import { useStore } from "vuex";
 import { useStore } from "vuex";
@@ -99,18 +82,13 @@ const props = defineProps({
     type: Function,
     type: Function,
     default: null,
     default: null,
   },
   },
-  /** 上传后展示 AI 识别状态条 */
-  enableIdentifyStatus: {
-    type: Boolean,
-    default: false,
-  },
 })
 })
 
 
 
 
 const store = useStore();
 const store = useStore();
 const miniUserId = store.state.home.miniUserId;
 const miniUserId = store.state.home.miniUserId;
 
 
-const emit = defineEmits(['handleUpload', 'clickPreview'])
+const emit = defineEmits(['handleUpload'])
 
 
 //上传照片
 //上传照片
 const fileList = ref([]);
 const fileList = ref([]);
@@ -139,10 +117,6 @@ const onAfterRead = async (files) => {
   return defaultAfterRead(files);
   return defaultAfterRead(files);
 };
 };
 
 
-const onClickPreview = (file, detail) => {
-  emit('clickPreview', file, detail);
-};
-
 const defaultAfterRead = async (files) => {
 const defaultAfterRead = async (files) => {
   if (!Array.isArray(files)) {
   if (!Array.isArray(files)) {
     files = [files];
     files = [files];
@@ -223,16 +197,9 @@ function uploadReset() {
   imgArr.value = []
   imgArr.value = []
 }
 }
 
 
-function syncImgArr(arr) {
-  imgArr.value = [...(arr || [])]
-  fileArr.value = imgArr.value.map((item) =>
-    props.fullPath ? base_img_url2 + item : item,
-  )
-}
 
 
 defineExpose({
 defineExpose({
-  uploadReset,
-  syncImgArr,
+  uploadReset
 })
 })
 
 
 onMounted(() => {
 onMounted(() => {
@@ -311,24 +278,6 @@ onMounted(() => {
         --van-padding-xs: 6px;
         --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 {
   .uploader-list {
@@ -355,37 +304,6 @@ onMounted(() => {
     top: 0;
     top: 0;
     left: 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 {
 .example-popup {

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

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

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

@@ -151,7 +151,7 @@
         <ImagePreviewPopup v-model:show="showPhenotypeImagePreview" :images="phenotypePreviewImages" />
         <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">
             @cancel="handleCancelPhenologyUploadPopup" :upload-required="false" @confirm="handleConfirmPhenologyUpload">
             <template #header>
             <template #header>
                 <div class="upload-progress-title">
                 <div class="upload-progress-title">
@@ -162,8 +162,8 @@
         </UploadProgressPopup>
         </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"
             :confirm-text="t('recordDetails.confirmUpload')" :upload-required="false"
             @reset="handlePestUploadPopupReset" @cancel="handleCancelPestUploadPopup"
             @reset="handlePestUploadPopupReset" @cancel="handleCancelPestUploadPopup"
             @confirm="handleConfirmPestUpload">
             @confirm="handleConfirmPestUpload">
@@ -176,7 +176,7 @@
                         <el-input v-model="formData.ratio" type="number" size="large"
                         <el-input v-model="formData.ratio" type="number" size="large"
                             :placeholder="t('recordDetails.inputPlaceholder')">
                             :placeholder="t('recordDetails.inputPlaceholder')">
                             <template #suffix>
                             <template #suffix>
-                                %
+                                
                             </template>
                             </template>
                         </el-input>
                         </el-input>
                     </div>
                     </div>
@@ -525,11 +525,8 @@ const pestInitImgArr = ref([]);
 const phenologyUploadPopupRef = ref(null);
 const phenologyUploadPopupRef = ref(null);
 const pestUploadPopupRef = ref(null);
 const pestUploadPopupRef = ref(null);
 const phenologyTrackTimelineRef = ref(null);
 const phenologyTrackTimelineRef = ref(null);
-const pestPopupImageUploadLoading = ref(false);
-
 const handlePestUploadPopupReset = () => {
 const handlePestUploadPopupReset = () => {
     pestInitImgArr.value = [];
     pestInitImgArr.value = [];
-    pestPopupImageUploadLoading.value = false;
 };
 };
 
 
 const curStage = ref({});
 const curStage = ref({});

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

@@ -131,9 +131,10 @@ class MapManage {
   }
   }
 
 
   initMap(location, target, options = {}) {
   initMap(location, target, options = {}) {
-    const { editable = true, constrainedDrawing = false } = options;
+    const { editable = true, constrainedDrawing = false, onDrawOutsideBoundary } = options;
     this.editable = editable;
     this.editable = editable;
     this.constrainedDrawing = constrainedDrawing;
     this.constrainedDrawing = constrainedDrawing;
+    this.onDrawOutsideBoundary = typeof onDrawOutsideBoundary === "function" ? onDrawOutsideBoundary : null;
     this.constrainedDrawingReady = false;
     this.constrainedDrawingReady = false;
     this.boundaryGeometry = null;
     this.boundaryGeometry = null;
     let level = 16;
     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() {
   setupConstrainedDrawing() {
     if (!this.kmap || this.constrainedDrawingReady) return;
     if (!this.kmap || this.constrainedDrawingReady) return;
     this.constrainedDrawingReady = true;
     this.constrainedDrawingReady = true;
     this.kmap.initDraw((e) => {
     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) => {
     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) {
   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();
     const geometry = feature.getGeometry();
-    if (!geometry) return;
+    if (!geometry) return false;
     const clipped = this.intersectWithBoundary(geometry);
     const clipped = this.intersectWithBoundary(geometry);
     if (!clipped) {
     if (!clipped) {
       this.kmap.polygonLayer.source.removeFeature(feature);
       this.kmap.polygonLayer.source.removeFeature(feature);
-      return;
+      return false;
     }
     }
     feature.setGeometry(clipped);
     feature.setGeometry(clipped);
     feature.setStyle(this.createDrawnPestStyle());
     feature.setStyle(this.createDrawnPestStyle());
+    return true;
   }
   }
 
 
   setBoundaryWkt(wkt) {
   setBoundaryWkt(wkt) {
@@ -672,6 +761,7 @@ class MapManage {
     this.constrainedDrawing = false;
     this.constrainedDrawing = false;
     this.constrainedDrawingReady = false;
     this.constrainedDrawingReady = false;
     this.boundaryGeometry = null;
     this.boundaryGeometry = null;
+    this.onDrawOutsideBoundary = null;
     this.selectedGridIds?.clear();
     this.selectedGridIds?.clear();
   }
   }
 
 

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

@@ -4,7 +4,7 @@
         <div class="map-manage-content">
         <div class="map-manage-content">
             <!-- <locationSearch class="location-search" @change="handleLocationChange"></locationSearch> -->
             <!-- <locationSearch class="location-search" @change="handleLocationChange"></locationSearch> -->
             <div class="map-container" ref="mapContainer"></div>
             <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">
             <div class="new-region-btn" v-show="!drawingEnabled && recordType !== 'pest'" @click="onStartRegionDrawing">
                 {{ $t('新建管理分区') }}</div>
                 {{ $t('新建管理分区') }}</div>
             <div class="map-icon" v-show="drawingEnabled && recordType !== 'pest'" @click="handleMapIconClick">
             <div class="map-icon" v-show="drawingEnabled && recordType !== 'pest'" @click="handleMapIconClick">
@@ -12,6 +12,10 @@
             </div>
             </div>
             <div class="map-legend" v-if="recordType === 'pest'">
             <div class="map-legend" v-if="recordType === 'pest'">
                 <div class="map-legend__item">
                 <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__dot map-legend__dot--past"></span>
                     <span class="map-legend__text">{{ $t('过往病虫害') }}</span>
                     <span class="map-legend__text">{{ $t('过往病虫害') }}</span>
                 </div>
                 </div>
@@ -161,6 +165,10 @@ const confirmPayload = ref({
     selectedGridIds: [],
     selectedGridIds: [],
 });
 });
 
 
+/** 病虫害:仅区域名称弹窗确认后才写入 store */
+const pestMapPayloadCommitted = ref(false);
+const pestMapStoreSnapshot = ref(null);
+
 function buildMapConfirmPayload(overrides = {}) {
 function buildMapConfirmPayload(overrides = {}) {
     const selectedGrids = mapManage.getSelectedGrids();
     const selectedGrids = mapManage.getSelectedGrids();
     const drawnPayload = mapManage.getAreaGeometry();
     const drawnPayload = mapManage.getAreaGeometry();
@@ -181,6 +189,7 @@ function buildMapConfirmPayload(overrides = {}) {
 
 
 const handleRegionNameConfirm = (regionName) => {
 const handleRegionNameConfirm = (regionName) => {
     confirmPayload.value.zone_name = regionName;
     confirmPayload.value.zone_name = regionName;
+    pestMapPayloadCommitted.value = true;
     store.commit('recordDetails/SET_MAP_CONFIRM_PAYLOAD', buildMapConfirmPayload({
     store.commit('recordDetails/SET_MAP_CONFIRM_PAYLOAD', buildMapConfirmPayload({
         zone_name: regionName,
         zone_name: regionName,
     }));
     }));
@@ -231,6 +240,9 @@ function initMapManageView() {
     mapManage.initMap(getFarmMapLocation(), mapContainer.value, {
     mapManage.initMap(getFarmMapLocation(), mapContainer.value, {
         editable: recordType.value !== 'pest',
         editable: recordType.value !== 'pest',
         constrainedDrawing: recordType.value === 'pest',
         constrainedDrawing: recordType.value === 'pest',
+        onDrawOutsideBoundary: recordType.value === 'pest'
+            ? () => ElMessage.warning('请在虚线管理区域内勾画')
+            : undefined,
     });
     });
 
 
     const stored = store.state.recordDetails.mapConfirmPayload;
     const stored = store.state.recordDetails.mapConfirmPayload;
@@ -288,20 +300,27 @@ function initMapManageView() {
 }
 }
 
 
 onActivated(() => {
 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());
     nextTick(() => initMapManageView());
 });
 });
 
 
 onDeactivated(() => {
 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();
     mapManage.destroyMap();
     drawingEnabled.value = false;
     drawingEnabled.value = false;
+    pestMapPayloadCommitted.value = false;
+    pestMapStoreSnapshot.value = null;
 });
 });
 </script>
 </script>
 
 
@@ -405,6 +424,18 @@ onDeactivated(() => {
                     background: rgba(0, 0, 0, 0.57);
                     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;
+                }
+            }
         }
         }
     }
     }