Prechádzať zdrojové kódy

fix: 初始化弹窗对接

lxf 2 týždňov pred
rodič
commit
09f708ba93

+ 14 - 0
src/api/modules/questionnaire.js

@@ -0,0 +1,14 @@
+const config = require("../config")
+
+module.exports = {
+    // 获取初始互动接口
+    getInitialInteraction: {
+        url: config.base_new_url + "pheno/initial_interact_options",
+        type: "get",
+    },
+    // 保存感知结果接口
+    saveInteractResult: {
+        url: config.base_new_url + "pheno/interact_record",
+        type: "post",
+    },
+}

+ 315 - 92
src/components/popup/restorePlantingPopup.vue

@@ -3,6 +3,7 @@
         v-model:show="visible"
         round
         teleport="body"
+        :z-index="20000"
         class="restore-planting-popup"
         :close-on-click-overlay="true"
     >
@@ -33,10 +34,6 @@
 
                 <div class="restore-planting-popup__photo">
                     <img :src="sampleImage" alt="" />
-                    <!-- <div class="restore-planting-popup__bbox">
-                        <span>{{ stageTag }}</span>
-                    </div>
-                    <div class="restore-planting-popup__detect">{{ detectText }}</div> -->
                 </div>
 
                 <el-date-picker
@@ -48,6 +45,8 @@
                     :placeholder="t('agriFile.selectTime')"
                     :editable="false"
                     :clearable="false"
+                    teleported
+                    popper-class="restore-planting-date-popper"
                     style="width: 100%"
                 />
             </div>
@@ -65,9 +64,13 @@
 
                 <div class="restore-planting-popup__photo restore-planting-popup__photo--guide">
                     <img :src="phenologyImage" alt="" />
-                    <div class="restore-planting-popup__guide-btn" @click="handleViewGuide">
-                        {{ t("agriFile.viewPatrolGuide") }}
-                    </div>
+                    <photo-provider :photo-closable="true">
+                        <photo-consumer :src="patrolGuideImage" :intro="patrolGuideImage">
+                            <div class="restore-planting-popup__guide-btn">
+                                {{ t("agriFile.viewPatrolGuide") }}
+                            </div>
+                        </photo-consumer>
+                    </photo-provider>
                 </div>
 
                 <div class="restore-planting-popup__options">
@@ -76,7 +79,7 @@
                         :key="item.value"
                         class="restore-planting-popup__option"
                         :class="{ active: phenologyAnswer === item.value }"
-                        @click="phenologyAnswer = item.value"
+                        @click="handlePhenologySelect(item.value)"
                     >
                         <span>{{ item.label }}</span>
                         <span v-if="phenologyAnswer === item.value" class="restore-planting-popup__option-check">
@@ -87,11 +90,12 @@
             </div>
 
             <div class="restore-planting-popup__actions">
-                <div class="restore-planting-popup__btn restore-planting-popup__btn--ghost" @click="handleChangeQuestion">
-                    {{ t("agriFile.changeQuestion") }}
-                </div>
-                <div class="restore-planting-popup__btn restore-planting-popup__btn--primary" @click="handleConfirm">
-                    {{ questionType === 'time' ? t("agriFile.confirmNow") : t("agriFile.submitInfo") }}
+                <div
+                    class="restore-planting-popup__btn restore-planting-popup__btn--primary"
+                    :class="{ disabled: submitting }"
+                    @click="handleConfirm"
+                >
+                    {{ questionType === "time" ? t("agriFile.confirmNow") : t("agriFile.submitInfo") }}
                 </div>
             </div>
         </div>
@@ -102,69 +106,314 @@
 import { computed, onMounted, ref } from "vue";
 import { Popup } from "vant";
 import { Check } from "@element-plus/icons-vue";
+import { ElMessage } from "element-plus";
 import { useI18n } from "@/i18n";
+import { base_img_url2 } from "@/api/config";
 
 const { t } = useI18n();
 
-/** 弹窗显隐由组件内接口控制,默认不展示 */
+// ---------------------------------------------------------------------------
+// Props
+// ---------------------------------------------------------------------------
+const props = defineProps({
+    /** 分区 id,初始互动接口必填 */
+    zoneId: { type: [Number, String], default: 42 },
+});
+
+// ---------------------------------------------------------------------------
+// 常量
+// ---------------------------------------------------------------------------
+const PHENOLOGY_PREFIX = "您的农场是否进入到 ";
+const DEFAULT_SAMPLE_IMAGE = require("@/assets/img/agricultural/baidian.png");
+const DEFAULT_PHENOLOGY_IMAGE = require("@/assets/img/common/sd-1.jpg");
+/** 巡园向导预览图 */
+const PATROL_GUIDE_IMAGE_PATH =
+    "e84a8f1b-9842-434c-951f-068ff6963f31/6c8b7135-95b7-4c04-82ef-a2daeb674d0c/DJI_202608190758_001_6c8b7135-95b7-4c04-82ef-a2daeb674d0c/DJI_20260819080818_0085_V_code-ws0fsmg8zg8u.jpeg";
+const patrolGuideImage = base_img_url2 + PATROL_GUIDE_IMAGE_PATH;
+
+// ---------------------------------------------------------------------------
+// 页面状态
+// ---------------------------------------------------------------------------
+/** 弹窗显隐由接口数据控制,默认不展示 */
 const visible = ref(false);
 const questionType = ref("time"); // time | phenology
 const selectedDate = ref("");
-const phenologyAnswer = ref("reached");
-
-/** 问题内容:后续由接口赋值 */
-const questionText = ref("还记得农场的 “来梢” 的时间吗?");
-const highlightWord = ref("来梢");
-const hintText = ref("时间误差 5-7 天无需担心,系统将结合作物生长模拟模型自动校准");
-const sampleImage = ref(require("@/assets/img/agricultural/baidian.png"));
-const stageTag = ref("白点");
-const detectText = ref("生育期检测:当前检测到白点期");
-const phenologyQuestion = ref("您的农场是否进入到 某某物候期 ?");
-const phenologyHighlight = ref("某某物候期");
-const phenologyImage = ref(require("@/assets/img/common/sd-1.jpg"));
-
+const phenologyAnswer = ref("");
+const submitting = ref(false);
+
+const hasDateInteract = ref(false);
+const hasOptionsInteract = ref(false);
+/** date_interact 原始数据,提交时可能用到 */
+const dateInteractData = ref(null);
+/** options_interact.interact 映射表,key 为 start_with 同类序号 */
+const interactMap = ref({});
+/** 当前展示的物候期序号(对应 interact 的 key) */
+const currentInteractKey = ref("");
+/** 用户确认「已到达」后写入,提交接口使用 */
+const selectedPeriodCode = ref("");
+
+const questionText = ref("");
+const highlightWord = ref("");
+const hintText = ref("");
+const sampleImage = ref(DEFAULT_SAMPLE_IMAGE);
+const phenologyQuestion = ref("");
+const phenologyHighlight = ref("");
+const phenologyImage = ref(DEFAULT_PHENOLOGY_IMAGE);
+
+// ---------------------------------------------------------------------------
+// 计算属性
+// ---------------------------------------------------------------------------
 const phenologyOptions = computed(() => [
     { value: "not_reached", label: t("agriFile.notReached") },
     { value: "reached", label: t("agriFile.reached") },
     { value: "passed", label: t("agriFile.passed") },
 ]);
 
+const interactKeys = computed(() =>
+    Object.keys(interactMap.value).sort((a, b) => Number(a) - Number(b))
+);
+
+// ---------------------------------------------------------------------------
+// 工具函数
+// ---------------------------------------------------------------------------
+/** 当天日期 YYYY-MM-DD */
+const formatToday = () => {
+    const d = new Date();
+    const y = d.getFullYear();
+    const m = String(d.getMonth() + 1).padStart(2, "0");
+    const day = String(d.getDate()).padStart(2, "0");
+    return `${y}-${m}-${day}`;
+};
+
+/** 日期选择器 YYYY.MM.DD → 接口 YYYY-MM-DD */
+const toApiDate = (value) => (value ? String(value).replace(/\./g, "-") : "");
+
+/** 拼接 CDN 图片地址 */
+const resolveImageUrl = (path) => {
+    if (!path) return "";
+    if (/^https?:\/\//i.test(path)) return path;
+    return base_img_url2 + String(path).replace(/^\//, "");
+};
+
+/** 从 interact_theme 引号中提取高亮词,如「来梢」 */
+const extractHighlightWord = (text) => {
+    if (!text) return "";
+    const match = String(text).match(/[“"「]([^”"」]+)[”"」]/);
+    return match ? match[1] : "";
+};
+
+/** Message 需高于弹窗 z-index: 20000 */
+const showMessage = (message, type = "warning") => {
+    ElMessage({
+        message,
+        type,
+        customClass: "restore-planting-message",
+        zIndex: 40000,
+    });
+};
+
+const showWarning = (message) => showMessage(message, "warning");
+
 const resetForm = () => {
-    questionType.value = "time";
+    questionType.value = hasDateInteract.value ? "time" : "phenology";
     selectedDate.value = "";
-    phenologyAnswer.value = "reached";
+    phenologyAnswer.value = "";
+    selectedPeriodCode.value = "";
+};
+
+// ---------------------------------------------------------------------------
+// 业务逻辑:填充问题 / 物候期切换
+// ---------------------------------------------------------------------------
+const applyDateInteract = (data) => {
+    dateInteractData.value = data;
+    questionText.value = data.interact_theme || "";
+    highlightWord.value = extractHighlightWord(data.interact_theme);
+    hintText.value = data.interact_reason || "";
+    sampleImage.value = resolveImageUrl(data.guide_url) || DEFAULT_SAMPLE_IMAGE;
+};
+
+/** 按 interact key 刷新问题二文案与配图 */
+const applyPhenologyByKey = (key) => {
+    const item = interactMap.value[key];
+    if (!item) return;
+    currentInteractKey.value = String(key);
+    const name = item.period_name || "";
+    phenologyHighlight.value = name;
+    phenologyQuestion.value = `${PHENOLOGY_PREFIX}${name} ?`;
+    phenologyImage.value = resolveImageUrl(item.url) || DEFAULT_PHENOLOGY_IMAGE;
+    selectedPeriodCode.value = "";
+};
+
+/** 暂未到达 → 前一个;已过 → 后一个 */
+const movePhenology = (direction) => {
+    const keys = interactKeys.value;
+    const idx = keys.indexOf(String(currentInteractKey.value));
+    if (idx < 0) return false;
+    const nextIdx = direction === "prev" ? idx - 1 : idx + 1;
+    if (nextIdx < 0 || nextIdx >= keys.length) {
+        showWarning(direction === "prev" ? "已是最早物候期" : "已是最晚物候期");
+        return false;
+    }
+    applyPhenologyByKey(keys[nextIdx]);
+    phenologyAnswer.value = "";
+    return true;
+};
+
+const applyQuestionData = (data) => {
+    hasDateInteract.value = !!data?.date_interact;
+    hasOptionsInteract.value = !!data?.options_interact?.interact;
+
+    if (hasDateInteract.value) {
+        applyDateInteract(data.date_interact);
+    }
+
+    if (hasOptionsInteract.value) {
+        interactMap.value = data.options_interact.interact || {};
+        const startKey = String(data.options_interact.start_with ?? interactKeys.value[0] ?? "");
+        applyPhenologyByKey(startKey);
+    }
+
+    // 有时间题优先展示;仅物候题则直接进入问题二
+    questionType.value = hasDateInteract.value ? "time" : "phenology";
+    visible.value = hasDateInteract.value || hasOptionsInteract.value;
 };
 
 /** 请求接口判断是否展示,并填充问题内容 */
 const checkShouldShow = async () => {
     try {
-        // TODO: 对接恢复种植服务弹窗展示接口
-        // const res = await xxxApi();
-        // visible.value = !!res?.needShow;
-        // if (res?.needShow) applyQuestionData(res);
-        visible.value = false;
+        const res = await VE_API.questionnaire.getInitialInteraction({
+            zone_id: props.zoneId,
+            date: formatToday(),
+            // date: '2025-07-10',
+        });
+        if (res?.code === 200 && res.data) {
+            applyQuestionData(res.data);
+        } else {
+            visible.value = false;
+        }
     } catch (e) {
         visible.value = false;
     }
 };
 
+// ---------------------------------------------------------------------------
+// 事件处理 / 生命周期
+// ---------------------------------------------------------------------------
 onMounted(() => {
     checkShouldShow();
 });
 
-const handleChangeQuestion = () => {
-    questionType.value = questionType.value === "time" ? "phenology" : "time";
+/**
+ * 暂未到达 / 已过:立刻切到相邻物候期;
+ * 已到达:选中并记下当前 period_code,供提交使用
+ */
+const handlePhenologySelect = (value) => {
+    if (value === "not_reached") {
+        movePhenology("prev");
+        return;
+    }
+    if (value === "passed") {
+        movePhenology("next");
+        return;
+    }
+    phenologyAnswer.value = "reached";
+    const current = interactMap.value[currentInteractKey.value];
+    selectedPeriodCode.value = current?.period_code || "";
+};
+
+/**
+ * 组装保存感知结果载荷
+ * @param {'time'|'phenology'} type 问题一不含 period_code;问题二不含 interact_code
+ */
+const buildSubmitPayload = (type = "time") => {
+    const payload = {
+        zone_id: Number(props.zoneId) || props.zoneId,
+        date: toApiDate(selectedDate.value) || formatToday(),
+    };
+    if (type === "time") {
+        payload.interact_code = dateInteractData.value?.interact_code || "";
+    } else {
+        payload.period_code = selectedPeriodCode.value || "";
+    }
+    return payload;
+};
+
+/** 从 axios 错误中取出可读信息 */
+const getRequestErrorMessage = (err) => {
+    const data = err?.response?.data;
+    if (typeof data === "string" && data) return data;
+    if (data?.msg) return data.msg;
+    if (data?.message) return data.message;
+    if (err?.message) return err.message;
+    return "提交失败,请稍后再试";
 };
 
-const handleViewGuide = () => {
-    // TODO: 跳转巡园向导
+/**
+ * 调用保存感知结果接口
+ * @param {'time'|'phenology'} type
+ * @param {{ closeOnSuccess?: boolean }} options closeOnSuccess=false 时成功后不关弹窗(用于继续答问题二)
+ */
+const submitInteractResult = async (type = "time", { closeOnSuccess = true } = {}) => {
+    if (submitting.value) return false;
+    submitting.value = true;
+    try {
+        const res = await VE_API.questionnaire.saveInteractResult(buildSubmitPayload(type));
+        if (res?.code === 200) {
+            if (closeOnSuccess) {
+                showMessage(res.msg || "提交成功", "success");
+                visible.value = false;
+                resetForm();
+            }
+            return true;
+        }
+        showMessage(res?.msg || "提交失败,请稍后再试", "error");
+        return false;
+    } catch (err) {
+        showMessage(getRequestErrorMessage(err), "error");
+        return false;
+    } finally {
+        submitting.value = false;
+    }
 };
 
 const handleConfirm = async () => {
-    // TODO: 对接提交接口
-    visible.value = false;
-    resetForm();
+    if (submitting.value) return;
+
+    // 问题一:立即确认 → 提交(不含 period_code)
+    if (questionType.value === "time") {
+        if (!selectedDate.value) {
+            showWarning(t("agriFile.selectTime"));
+            return;
+        }
+        const ok = await submitInteractResult("time", {
+            // 还有问题二则提交成功后继续展示,不关弹窗
+            closeOnSuccess: !hasOptionsInteract.value,
+        });
+        if (ok && hasOptionsInteract.value) {
+            questionType.value = "phenology";
+        }
+        return;
+    }
+
+    // 问题二:需已选择「已到达」并写入 period_code;提交不含 interact_code
+    if (phenologyAnswer.value !== "reached" || !selectedPeriodCode.value) {
+        const current = interactMap.value[currentInteractKey.value];
+        if (phenologyAnswer.value === "reached" && current?.period_code) {
+            selectedPeriodCode.value = current.period_code;
+        } else {
+            showWarning("请确认当前物候期是否已到达");
+            return;
+        }
+    }
+
+    await submitInteractResult("phenology");
 };
+
+defineExpose({
+    checkShouldShow,
+    buildSubmitPayload,
+    selectedPeriodCode,
+});
 </script>
 
 <style scoped lang="scss">
@@ -174,15 +423,11 @@ const handleConfirm = async () => {
     box-sizing: border-box;
     background: linear-gradient(180deg, #d7ecff 0%, #ffffff 28%);
     overflow: visible;
-    
 
     &__header {
-        // position: relative;
         display: flex;
         align-items: flex-start;
         justify-content: space-between;
-        // padding-right: 72px;
-        // min-height: 64px;
     }
 
     &__title {
@@ -219,7 +464,7 @@ const handleConfirm = async () => {
     }
 
     &__question {
-        font-size: 16px;
+        font-size: 15px;
         line-height: 24px;
         color: #252525;
         font-weight: 500;
@@ -233,7 +478,7 @@ const handleConfirm = async () => {
         margin-top: 4px;
         font-size: 12px;
         line-height: 16px;
-        color: #9E9E9E;
+        color: #9e9e9e;
     }
 
     &__photo {
@@ -255,43 +500,6 @@ const handleConfirm = async () => {
         }
     }
 
-    &__bbox {
-        position: absolute;
-        top: 36%;
-        left: 50%;
-        width: 72px;
-        height: 72px;
-        transform: translate(-50%, -50%);
-        border: 2px solid #2199f8;
-        box-sizing: border-box;
-        pointer-events: none;
-
-        span {
-            position: absolute;
-            top: -18px;
-            left: 50%;
-            transform: translateX(-50%);
-            padding: 0 4px;
-            font-size: 12px;
-            line-height: 16px;
-            color: #2199f8;
-            white-space: nowrap;
-            background: rgba(255, 255, 255, 0.85);
-        }
-    }
-
-    &__detect {
-        position: absolute;
-        left: 0;
-        right: 0;
-        bottom: 0;
-        padding: 8px 10px;
-        background: rgba(0, 0, 0, 0.55);
-        color: #fff;
-        font-size: 12px;
-        line-height: 18px;
-    }
-
     &__guide-btn {
         position: absolute;
         top: 8px;
@@ -374,8 +582,7 @@ const handleConfirm = async () => {
         margin-top: 12px;
         display: flex;
         align-items: center;
-        justify-content: space-between;
-        gap: 12px;
+        justify-content: center;
     }
 
     &__btn {
@@ -390,15 +597,31 @@ const handleConfirm = async () => {
         font-weight: 500;
         box-sizing: border-box;
 
-        &--ghost {
-            background: rgba(236, 236, 236, 0.5);
-            color: rgba(0, 0, 0, 0.6);
-        }
-
         &--primary {
-            background: linear-gradient(180deg, #76C3FF 0%, #2199F8 100%);
+            // flex: 1;
+            background: linear-gradient(180deg, #76c3ff 0%, #2199f8 100%);
             color: #fff;
+
+            &.disabled {
+                opacity: 0.6;
+                pointer-events: none;
+            }
         }
     }
 }
 </style>
+
+<!-- 日期面板 / 图片预览 / Message teleport 到 body,需高于弹窗 z-index: 20000 -->
+<style lang="scss">
+.restore-planting-date-popper {
+    z-index: 30000 !important;
+}
+
+.PhotoSlider__Wrapper {
+    z-index: 30001 !important;
+}
+
+.el-message.restore-planting-message {
+    z-index: 40000 !important;
+}
+</style>