Pārlūkot izejas kodu

fix: 录入信息对接

lxf 3 nedēļas atpakaļ
vecāks
revīzija
3fb39c4670

+ 37 - 0
.cursor/rules/vue-code-style.mdc

@@ -0,0 +1,37 @@
+---
+description: Vue/JS 代码结构与注释规范(录入流程等业务组件)
+alwaysApply: true
+---
+
+# Vue 代码结构与可维护性
+
+编写或重构 Vue 组件(尤其业务页、多步骤表单)时遵循:
+
+## 结构分块(script setup)
+
+按顺序组织,块之间用简短分隔注释:
+
+1. Props / Emits(props 加 JSDoc 说明业务含义)
+2. 常量(Session 键、默认值等集中为对象,如 `SESSION = { ... }`)
+3. 页面状态(ref/reactive,关键字段一行注释)
+4. 计算属性
+5. 工具函数(readJson、数据映射等)
+6. 业务逻辑(按领域分子块:加载、选中态、提交等)
+7. 事件处理 / 生命周期
+
+## 注释原则
+
+- 只注释**非显而易见**的内容:业务约定、缓存结构差异、分支原因
+- 不写「赋值给 x」「调用接口」类废话
+- 复杂函数顶部一行说明职责
+
+## 可维护性
+
+- 魔法字符串、Session 键名集中管理,不散落
+- 重复逻辑抽成小函数(命名表意,如 `buildSelectedPayload`、`ensureCustomGroupTop`)
+- 区分数据结构时用 helper(如 `isScopedCache`、`flattenEquipment`)
+- 改动克制:只动需求相关代码,不顺手大重构
+
+## 参考示例
+
+`src/views/old_mini/entry_information/components/selectEquipment.vue`

+ 12 - 4
src/App.vue

@@ -13,12 +13,20 @@
   </keep-alive>
 </router-view> -->
     <el-config-provider :locale="elementLocale">
+    <!-- keep-alive 必须常驻;用内部 component 的 v-if 区分是否缓存,避免切到非缓存页时整棵 keep-alive 被销毁 -->
     <router-view v-slot="{ Component }">
-        <keep-alive v-if="route.meta && route.meta.keepAlive">
-            <component :is="Component" />
+        <keep-alive>
+            <component
+                :is="Component"
+                v-if="Component && route.meta.keepAlive"
+                :key="route.name"
+            />
         </keep-alive>
-
-        <component v-else :is="Component" />
+        <component
+            :is="Component"
+            v-if="Component && !route.meta.keepAlive"
+            :key="route.name"
+        />
     </router-view>
 
 

+ 16 - 1
src/api/modules/entry.js

@@ -8,7 +8,7 @@ module.exports = {
     },
     // 获取小品种对应列表
     getVarietiesByCrop: {
-        url: config.base_new_url + "get_varieties_by_crop",
+        url: config.base_new_url + "questionnaire/farmer_crop_pheno",
         type: "get",
     },
     // 录入用户权属信息
@@ -16,4 +16,19 @@ module.exports = {
         url: config.base_new_url + "add_plot_info",
         type: "post",
     },
+    // 品类列表
+    getCrops: {
+        url: config.base_new_url + "questionnaire/crops",
+        type: "get",
+    },
+    // 农机设备列表(农户用)
+    getMachines: {
+        url: config.base_new_url + "questionnaire/farmer_crop_detail",
+        type: "get",
+    },
+    // 问卷-品类对应农事列表
+    getCropTasks: {
+        url: config.base_new_url + "questionnaire/farm_tasks",
+        type: "get",
+    },
 }

+ 1 - 0
src/components/pageComponents/locationSearch.vue

@@ -79,6 +79,7 @@ const handleSearchRes = (v) => {
         address: v.item?.title || v.item?.address,
         pointAddress: v.item?.province + v.item?.city + v.item?.district,
         city: v.item?.city + v.item?.district || '',
+        adcode: v.item?.adcode != null ? String(v.item.adcode) : "",
         item: v.item,
     });
 };

+ 2 - 26
src/views/old_mini/entry_information/components/baInformation.vue

@@ -3,12 +3,11 @@
         <div class="ba-information__content">
             <div class="page-header">
                 <div class="page-title">完善个人信息</div>
-                <div class="page-subtitle">请确保您的个人信息无误</div>
             </div>
 
             <div class="info-card">
                 <div class="section-title">
-                    <span class="title-icon"></span>
+                    <img class="title-icon" src="@/assets/img/home/label-icon.png" alt="" />
                     <span>基本信息</span>
                 </div>
 
@@ -259,33 +258,10 @@ onBeforeUnmount(() => {
         color: #1a1a1a;
 
         .title-icon {
-            position: relative;
             width: 14px;
             height: 14px;
             flex-shrink: 0;
-
-            &::before,
-            &::after {
-                content: "";
-                position: absolute;
-                width: 10px;
-                height: 10px;
-                border-radius: 50%;
-            }
-
-            &::before {
-                left: 0;
-                top: 2px;
-                background: #2199f8;
-                opacity: 0.85;
-            }
-
-            &::after {
-                right: 0;
-                top: 0;
-                background: #7ec8ff;
-                opacity: 0.9;
-            }
+            object-fit: contain;
         }
     }
 

+ 26 - 3
src/views/old_mini/entry_information/components/manualFarmingService.vue

@@ -2,8 +2,10 @@
     <div class="manual-farming">
         <div class="manual-farming__content">
             <div class="page-header">
-                <div class="page-title">完善人工农事服务</div>
-                <div class="page-subtitle">让农事调度更高效(可多选)</div>
+                <div class="page-title has-tag-wrap">完善人工农事服务
+                    <span v-if="cropGroupLabel" class="page-tag">{{ cropGroupLabel }}</span>
+                </div>
+                <div class="page-title">让农事调度更高效<span class="page-title-tip">(可多选)</span></div>
             </div>
 
             <div class="toolbar">
@@ -112,6 +114,8 @@ const props = defineProps({
     hasFruit: { type: Boolean, default: false },
 });
 
+const cropGroupLabel = ref("大田类");
+
 const FIELD_TASKS = [
     { id: 1, name: "人工补苗" },
     { id: 2, name: "人工授粉" },
@@ -434,7 +438,23 @@ onMounted(() => {
             font-size: 26px;
             color: #005599;
             font-family: "PangMenZhengDao";
-            line-height: 36px;
+            line-height: 30px;
+            
+            &.has-tag-wrap {
+                display: flex;
+                align-items: center;
+                gap: 6px;
+            }
+            .page-tag {
+                height: 24px;
+                line-height: 24px;
+                display: inline-block;
+                background: #2199F8;
+                color: #FFFFFF;
+                font-size: 14px;
+                padding: 0 6px;
+                border-radius: 4px;
+            }
         }
 
         .page-subtitle {
@@ -444,6 +464,9 @@ onMounted(() => {
             color: #005599;
             line-height: 22px;
         }
+        .page-title-tip {
+            font-size: 20px;
+        }
     }
 
     .toolbar {

+ 149 - 70
src/views/old_mini/entry_information/components/selectCategory.vue

@@ -2,8 +2,8 @@
     <div class="select-category">
         <div class="select-category__content">
             <div class="page-header">
-                <div class="page-title">请选择您的种植品类<span class="page-title-tip">({{ multiple ? "多选" : "单选" }})</span></div>
-                <div class="page-subtitle">完善档案,精准匹配农机服务与农情预警</div>
+                <div class="page-title">完善种植品类</div>
+                <div class="page-title">精准匹配农机服务<span class="page-title-tip">({{ multiple ? "多选" : "单选" }})</span></div>
             </div>
 
             <div class="major-tabs">
@@ -64,11 +64,25 @@
 </template>
 
 <script setup>
-import { computed, ref, watch } from "vue";
+import { computed, onMounted, ref, watch } from "vue";
 import { ElMessage } from "element-plus";
 import { Search } from "@element-plus/icons-vue";
 
 const SESSION_KEY = "ENTRY_SELECTED_CATEGORY";
+const VARIETY_LIST_KEY = "ENTRY_SELECTED_VARIETY_LIST";
+const EQUIPMENT_KEY = "ENTRY_SELECTED_EQUIPMENT";
+const TASK_KEY = "ENTRY_SELECTED_FARM_TASKS";
+const LOCATION_KEY = "ENTRY_RESIDENT_LOCATION";
+const META_KEYS = new Set(["city", "county", "county_code", "province"]);
+/** 与下游步骤约定一致的 majorKey */
+const FIRST_CROP_KEY_MAP = {
+    果树: "fruit",
+    大田: "field",
+};
+const TAB_ORDER = ["果树", "大田"];
+const DEFAULT_COUNTY_CODE = "440113";
+const MAP_KEY = "CZLBZ-LJICQ-R4A5J-BN62X-YXCRJ-GNBUT";
+const DEFAULT_POINT = "POINT(113.6142086995688 23.585836479509055)";
 
 const props = defineProps({
     multiple: { type: Boolean, default: false },
@@ -76,14 +90,8 @@ const props = defineProps({
 
 const emit = defineEmits(["prev", "next"]);
 
-/** first_crop 与接口约定一致:果树 / 大田 / 蔬菜 */
-const majorTabs = ref([
-    { key: "fruit", label: "果树类", firstCrop: "果树", groups: [], loaded: false },
-    { key: "field", label: "大田类", firstCrop: "大田", groups: [], loaded: false },
-    // { key: "vegetable", label: "蔬菜类", firstCrop: "蔬菜", groups: [], loaded: false },
-]);
-
-const activeTabKey = ref("fruit");
+const majorTabs = ref([]);
+const activeTabKey = ref("");
 const searchKeyword = ref("");
 const appliedKeyword = ref("");
 const loading = ref(false);
@@ -137,20 +145,46 @@ const nextBtnText = computed(() => {
     return `下一步 (2/${total})`;
 });
 
-const mapHierarchyToGroups = (list) => {
+const mapCropGroups = (list) => {
     if (!Array.isArray(list)) return [];
     return list
         .map((group) => ({
-            name: group.second_type || group.name || "",
-            items: (group.third_crops || []).map((crop) => ({
-                id: crop.id,
-                name: crop.name,
+            name: group.crop_type || "",
+            hasAdvantage: !!group.has_advantage,
+            items: (group.items || []).map((crop) => ({
+                id: crop.crop_id,
+                name: crop.crop_name,
+                isAdvantage: !!crop.is_advantage,
+                advantageRank: crop.advantage_rank,
                 selected: false,
             })),
         }))
         .filter((group) => group.name && group.items.length);
 };
 
+const resolveTabKey = (firstCrop) => FIRST_CROP_KEY_MAP[firstCrop] || firstCrop;
+
+const buildTabsFromData = (data) => {
+    if (!data || typeof data !== "object") return [];
+    const cropKeys = Object.keys(data).filter(
+        (key) => !META_KEYS.has(key) && Array.isArray(data[key])
+    );
+    cropKeys.sort((a, b) => {
+        const ai = TAB_ORDER.indexOf(a);
+        const bi = TAB_ORDER.indexOf(b);
+        if (ai === -1 && bi === -1) return 0;
+        if (ai === -1) return 1;
+        if (bi === -1) return -1;
+        return ai - bi;
+    });
+    return cropKeys.map((firstCrop) => ({
+        key: resolveTabKey(firstCrop),
+        label: `${firstCrop}类`,
+        firstCrop,
+        groups: mapCropGroups(data[firstCrop]),
+    }));
+};
+
 const cropTypeRank = (item) => {
     if (item?.firstCrop === "大田" || item?.majorKey === "field") return 0;
     if (item?.firstCrop === "果树" || item?.majorKey === "fruit") return 1;
@@ -160,6 +194,16 @@ const cropTypeRank = (item) => {
 const sortFieldFirst = (list) =>
     [...(list || [])].sort((a, b) => cropTypeRank(a) - cropTypeRank(b));
 
+const getCategorySignature = (list) =>
+    [...(list || [])].map((item) => String(item.id)).sort().join(",");
+
+const clearDownstreamDrafts = () => {
+    sessionStorage.removeItem(VARIETY_LIST_KEY);
+    sessionStorage.removeItem(EQUIPMENT_KEY);
+    sessionStorage.removeItem(TASK_KEY);
+    sessionStorage.removeItem("ENTRY_MANUAL_VIEW");
+};
+
 const getCachedSelectedItems = () => {
     try {
         const raw = sessionStorage.getItem(SESSION_KEY);
@@ -184,25 +228,21 @@ const clearAllSelection = () => {
     });
 };
 
-const applySelectionToTab = (tab) => {
+const applyCachedSelection = () => {
     const selectedIds = new Set(getSelectedIds());
     if (!selectedIds.size) return;
-    tab.groups.forEach((group) => {
-        group.items.forEach((item) => {
-            item.selected = selectedIds.has(String(item.id));
+    majorTabs.value.forEach((tab) => {
+        tab.groups.forEach((group) => {
+            group.items.forEach((item) => {
+                item.selected = selectedIds.has(String(item.id));
+            });
         });
     });
 };
 
 const persistSelection = () => {
-    const loadedKeys = new Set(
-        majorTabs.value.filter((tab) => tab.loaded).map((tab) => tab.key)
-    );
-    const cachedFromUnloaded = getCachedSelectedItems().filter(
-        (item) => !loadedKeys.has(item.majorKey)
-    );
     const list = props.multiple
-        ? sortFieldFirst([...cachedFromUnloaded, ...selectedItems.value])
+        ? sortFieldFirst(selectedItems.value)
         : selectedItems.value.slice(0, 1);
     if (list.length) {
         sessionStorage.setItem(SESSION_KEY, JSON.stringify(list));
@@ -211,26 +251,80 @@ const persistSelection = () => {
     }
 };
 
-const fetchTabGroups = async (tabKey, force = false) => {
-    const tab = majorTabs.value.find((item) => item.key === tabKey);
-    if (!tab) return;
-    if (tab.loaded && !force) return;
+const readResidentLocation = () => {
+    try {
+        const raw = sessionStorage.getItem(LOCATION_KEY);
+        return raw ? JSON.parse(raw) : null;
+    } catch {
+        return null;
+    }
+};
+
+const parsePointCoordinate = (point) => {
+    const match = String(point || "").match(/POINT\s*\(([\d.\-]+)\s+([\d.\-]+)\)/i);
+    if (!match) return null;
+    return [Number(match[1]), Number(match[2])];
+};
 
+const fetchAdcodeByPoint = async (point) => {
+    const coordinate = parsePointCoordinate(point);
+    if (!coordinate) return "";
+    try {
+        const { result } = await VE_API.old_mini_map.location({
+            key: MAP_KEY,
+            location: `${coordinate[1]},${coordinate[0]}`,
+        });
+        const adcode = result?.ad_info?.adcode;
+        return adcode != null && adcode !== "" ? String(adcode) : "";
+    } catch {
+        return "";
+    }
+};
+
+const resolveCountyCode = async () => {
+    const saved = readResidentLocation();
+    if (saved?.adcode) return String(saved.adcode);
+
+    const point =
+        saved?.point ||
+        localStorage.getItem("MINI_USER_LOCATION_POINT") ||
+        DEFAULT_POINT;
+    const adcode = await fetchAdcodeByPoint(point);
+    if (adcode && saved?.point) {
+        try {
+            sessionStorage.setItem(
+                LOCATION_KEY,
+                JSON.stringify({ ...saved, adcode })
+            );
+        } catch {
+            // ignore
+        }
+    }
+    return adcode || DEFAULT_COUNTY_CODE;
+};
+
+const fetchCrops = async () => {
     loading.value = true;
     try {
-        const res = await VE_API.entry.getCropHierarchy({
-            first_crop: tab.firstCrop,
+        const countyCode = await resolveCountyCode();
+        const res = await VE_API.entry.getCrops({
+            county_code: countyCode,
         });
         if (res.code === 200) {
-            tab.groups = mapHierarchyToGroups(res.data);
-            tab.loaded = true;
-            applySelectionToTab(tab);
+            const tabs = buildTabsFromData(res.data);
+            majorTabs.value = tabs;
+            if (!tabs.some((tab) => tab.key === activeTabKey.value)) {
+                activeTabKey.value = tabs[0]?.key || "";
+            }
+            applyCachedSelection();
         } else {
-            tab.groups = [];
+            majorTabs.value = [];
+            activeTabKey.value = "";
             ElMessage.error(res.msg || "获取种植品类失败");
         }
     } catch {
-        tab.groups = [];
+        majorTabs.value = [];
+        activeTabKey.value = "";
         ElMessage.error("获取种植品类失败,请稍后再试");
     } finally {
         loading.value = false;
@@ -253,46 +347,31 @@ const handleSelect = (item) => {
     persistSelection();
 };
 
-const getSubmitSelectedItems = () => {
-    const current = selectedItems.value;
-    const cached = getCachedSelectedItems();
-    const loadedKeys = new Set(
-        majorTabs.value.filter((tab) => tab.loaded).map((tab) => tab.key)
-    );
-    const cachedFromUnloaded = cached.filter((item) => !loadedKeys.has(item.majorKey));
-    if (props.multiple) {
-        const map = new Map();
-        [...cachedFromUnloaded, ...current].forEach((row) => {
-            map.set(String(row.id), row);
-        });
-        return sortFieldFirst(Array.from(map.values()));
-    }
-    if (current.length) return current.slice(0, 1);
-    return cachedFromUnloaded.slice(0, 1);
-};
-
 const handleNext = () => {
-    const list = getSubmitSelectedItems();
+    const list = props.multiple
+        ? sortFieldFirst(selectedItems.value)
+        : selectedItems.value.slice(0, 1);
     if (!list.length) {
         ElMessage.warning(props.multiple ? "请选择种植品类" : "请选择一个种植品类");
         return;
     }
-    sessionStorage.setItem(SESSION_KEY, JSON.stringify(list));
-    if (props.multiple) {
-        sessionStorage.removeItem("ENTRY_SELECTED_VARIETY_LIST");
+    const prevSignature = getCategorySignature(getCachedSelectedItems());
+    const nextSignature = getCategorySignature(list);
+    if (prevSignature !== nextSignature) {
+        clearDownstreamDrafts();
     }
+    sessionStorage.setItem(SESSION_KEY, JSON.stringify(list));
     emit("next", list);
 };
 
-watch(
-    activeTabKey,
-    (key) => {
-        appliedKeyword.value = "";
-        searchKeyword.value = "";
-        fetchTabGroups(key);
-    },
-    { immediate: true },
-);
+watch(activeTabKey, () => {
+    appliedKeyword.value = "";
+    searchKeyword.value = "";
+});
+
+onMounted(() => {
+    fetchCrops();
+});
 </script>
 
 <style lang="scss" scoped>
@@ -315,7 +394,7 @@ watch(
             font-size: 26px;
             color: #005599;
             font-family: "PangMenZhengDao";
-            line-height: 36px;
+            line-height: 30px;
             .page-title-tip {
                 font-size: 20px;
             }

+ 296 - 290
src/views/old_mini/entry_information/components/selectEquipment.vue

@@ -2,8 +2,11 @@
     <div class="select-equipment" :class="{ 'is-service-equipment': isService }">
         <div class="select-equipment__content">
             <div class="page-header">
-                <div class="page-title">{{ pageTitle }}</div>
-                <div class="page-subtitle">{{ pageSubtitle }}</div>
+                <div class="page-title has-tag-wrap">
+                    完善设备信息
+                    <span v-if="cropGroupLabel" class="page-tag">{{ cropGroupLabel }}</span>
+                </div>
+                <div class="page-title">让农机调度更精准<span class="page-tag-tip">(可多选)</span></div>
             </div>
 
             <div class="toolbar">
@@ -85,162 +88,64 @@ import { Search } from "@element-plus/icons-vue";
 import addMachinePopup from "./addMachinePopup.vue";
 import tipPopup from "@/components/popup/tipPopup.vue";
 
+// ---------------------------------------------------------------------------
+// Props / Emits
+// ---------------------------------------------------------------------------
 const emit = defineEmits(["prev", "next", "confirm"]);
 const props = defineProps({
+    /** 农服录入:大田/果树分步选设备;农户录入为 false */
     isService: { type: Boolean, default: false },
+    /** 当前是否为录入流程最后一步(决定主按钮文案与是否可提交) */
     isLastStep: { type: Boolean, default: true },
+    /** 农服场景下的设备范围:field | fruit | "" */
     equipmentScope: { type: String, default: "" },
     totalSteps: { type: Number, default: 4 },
 });
 
-const EQUIPMENT_KEY = "ENTRY_SELECTED_EQUIPMENT";
-const TASK_KEY = "ENTRY_SELECTED_FARM_TASKS";
-const SELECTED_LIST_KEY = "ENTRY_SELECTED_VARIETY_LIST";
-const CATEGORY_SESSION_KEY = "ENTRY_SELECTED_CATEGORY";
-const LOCATION_KEY = "ENTRY_RESIDENT_LOCATION";
-const EDIT_UID_KEY = "ENTRY_VARIETY_EDIT_UID";
-const STEP_KEY = "ENTRY_INFORMATION_STEP";
+// ---------------------------------------------------------------------------
+// Session 键名(与录入流程其他步骤共享)
+// ---------------------------------------------------------------------------
+const SESSION = {
+    EQUIPMENT: "ENTRY_SELECTED_EQUIPMENT",
+    TASK: "ENTRY_SELECTED_FARM_TASKS",
+    VARIETY: "ENTRY_SELECTED_VARIETY_LIST",
+    CATEGORY: "ENTRY_SELECTED_CATEGORY",
+    LOCATION: "ENTRY_RESIDENT_LOCATION",
+    EDIT_UID: "ENTRY_VARIETY_EDIT_UID",
+    STEP: "ENTRY_INFORMATION_STEP",
+    BA_FORM: "ENTRY_BA_FORM",
+    MANUAL_VIEW: "ENTRY_MANUAL_VIEW",
+    INVITE_TYPE: "ENTRY_INVITE_TYPE",
+};
+
+/** 用户手动添加的农机归入此分组,始终置顶 */
+const CUSTOM_GROUP_NAME = "我的农机";
+/** 自定义农机 ID 起始值,避免与接口 machine_id 冲突 */
+const CUSTOM_MACHINE_ID_START = 9000;
 
+// ---------------------------------------------------------------------------
+// 页面状态
+// ---------------------------------------------------------------------------
 const loading = ref(false);
 const submitting = ref(false);
 const showAddPopup = ref(false);
+const showSuccessPopup = ref(false);
 const searchKeyword = ref("");
 const appliedKeyword = ref("");
+/** 已选农机 ID 集合(Set 便于 O(1) 切换) */
 const selectedIds = reactive(new Set());
+/** 按农事阶段分组的农机列表(接口 + 自定义项) */
 const equipmentGroups = ref([]);
-let customMachineId = 9000;
-
-const DEFAULT_GROUPS = [
-    {
-        name: "耕地(整地、施肥、撒肥、中耕等)",
-        items: [
-            { id: 101, name: "滴灌系统" },
-            { id: 102, name: "喷灌设备" },
-            { id: 103, name: "水肥一体机" },
-            { id: 104, name: "水泵机组" },
-            { id: 105, name: "施肥机" },
-            { id: 106, name: "过滤设备" },
-        ],
-    },
-    {
-        name: "种(播种、育秧、移栽、补苗等)",
-        items: [
-            { id: 201, name: "拖拉机" },
-            { id: 202, name: "旋耕机" },
-            { id: 203, name: "开沟机" },
-            { id: 204, name: "植保机" },
-            { id: 205, name: "无人机" },
-            { id: 206, name: "运输车" },
-        ],
-    },
-    {
-        name: "收(收割、烘干、运输等)",
-        items: [
-            { id: 301, name: "修剪机" },
-            { id: 302, name: "采收机" },
-            { id: 303, name: "割草机" },
-            { id: 304, name: "粉碎机" },
-            { id: 305, name: "发电机" },
-            { id: 306, name: "其他设备" },
-        ],
-    },
-];
-
-const FIELD_GROUPS = [
-    {
-        name: "耕地(整地、施肥、撒肥、中耕等)",
-        items: [
-            { id: 1001, name: "大中型拖拉机" },
-            { id: 1002, name: "手扶拖拉机" },
-            { id: 1003, name: "旋耕机" },
-            { id: 1004, name: "微耕机" },
-            { id: 1005, name: "挖掘机" },
-            { id: 1006, name: "培土机" },
-            { id: 1007, name: "撒肥机" },
-            { id: 1008, name: "秸秆还田机" },
-            { id: 1009, name: "开沟机" },
-            { id: 1010, name: "水田打浆机" },
-            { id: 1011, name: "深松机" },
-            { id: 1012, name: "起垄机" },
-            { id: 1013, name: "甘蔗开行机" },
-            { id: 1014, name: "棉花拔杆机" },
-        ],
-    },
-    {
-        name: "种(播种、育秧、移栽、补苗等)",
-        items: [
-            { id: 2001, name: "插秧机" },
-            { id: 2002, name: "一体育秧机" },
-            { id: 2003, name: "播种机" },
-            { id: 2004, name: "移栽机" },
-            { id: 2005, name: "无人机播种" },
-            { id: 2006, name: "小麦/玉米精量播种机" },
-            { id: 2007, name: "免耕播种机" },
-            { id: 2008, name: "花生覆膜播种机" },
-            { id: 2009, name: "马铃薯播种机" },
-        ],
-    },
-    {
-        name: "收(收割、烘干、运输等)",
-        items: [
-            { id: 3001, name: "联合收割机" },
-            { id: 3002, name: "半喂入收割机" },
-            { id: 3003, name: "玉米联合收割机" },
-            { id: 3004, name: "水稻收割机" },
-            { id: 3005, name: "烘干机" },
-            { id: 3006, name: "运粮车" },
-            { id: 3007, name: "挂车" },
-        ],
-    },
-];
-
-const FRUIT_GROUPS = [
-    {
-        name: "土壤与园地管理",
-        desc: "(整地、除草、清园、开沟修渠等)",
-        items: [
-            { id: 4001, name: "旋耕机" },
-            { id: 4002, name: "微耕机" },
-            { id: 4003, name: "挖掘机" },
-            { id: 4004, name: "旋耕开沟机" },
-            { id: 4005, name: "割草机" },
-            { id: 4006, name: "清沟机" },
-            { id: 4007, name: "运输车" },
-            { id: 4008, name: "开沟施肥机" },
-            { id: 4009, name: "树盘管理机" },
-            { id: 4010, name: "行间除草机" },
-            { id: 4011, name: "生草播种机" },
-            { id: 4012, name: "压草机" },
-        ],
-    },
-    {
-        name: "肥水管理",
-        desc: "(施肥、灌水、灌药等)",
-        items: [
-            { id: 5001, name: "水肥一体化" },
-            { id: 5002, name: "滴灌系统" },
-            { id: 5003, name: "注肥/注药泵" },
-            { id: 5004, name: "施肥枪" },
-            { id: 5005, name: "移动水车" },
-            { id: 5006, name: "水泵" },
-        ],
-    },
-    {
-        name: "植保打药",
-        desc: "(冠层打药、内膛打药等)",
-        items: [
-            { id: 6001, name: "植保无人机" },
-            { id: 6002, name: "风送式喷雾机" },
-            { id: 6003, name: "背负式喷雾器" },
-            { id: 6004, name: "柴油喷药机" },
-            { id: 6005, name: "烟雾机" },
-            { id: 6006, name: "高架喷雾机" },
-        ],
-    },
-];
-
+/** 页头品类标签,如「大田类」 */
+const cropGroupLabel = ref("");
+let customMachineId = CUSTOM_MACHINE_ID_START;
+
+// ---------------------------------------------------------------------------
+// 计算属性
+// ---------------------------------------------------------------------------
+/** 按关键词过滤后的展示分组 */
 const displayGroups = computed(() => {
-    const keyword = (appliedKeyword.value || "").trim();
+    const keyword = appliedKeyword.value.trim();
     if (!keyword) return equipmentGroups.value;
     return equipmentGroups.value
         .map((group) => ({
@@ -250,21 +155,21 @@ const displayGroups = computed(() => {
         .filter((group) => group.items.length);
 });
 
+/** 扁平化所有农机项,供选中态与草稿保存使用 */
 const allEquipmentItems = computed(() =>
     equipmentGroups.value.flatMap((group) => group.items)
 );
 
-const pageTitle = computed(() =>
-    props.isService ? "完善设备信息" : "请填写您的农场设备"
-);
-const pageSubtitle = computed(() =>
-    props.isService ? "让农机调度更精准(可多选)" : "完善设备信息,让农机调度更精准"
-);
 const primaryBtnText = computed(() => {
     if (submitting.value) return "提交中...";
-    return props.isLastStep ? "提交信息" : `下一步 (3/${props.isService ? props.totalSteps : 4})`;
+    if (props.isLastStep) return "提交信息";
+    const total = props.isService ? props.totalSteps : 4;
+    return `下一步 (3/${total})`;
 });
 
+// ---------------------------------------------------------------------------
+// Session 读写
+// ---------------------------------------------------------------------------
 function readJson(key) {
     try {
         const raw = sessionStorage.getItem(key);
@@ -274,12 +179,64 @@ function readJson(key) {
     }
 }
 
-function cloneGroups(groups) {
-    return groups.map((group) => ({
-        name: group.name,
-        desc: group.desc || "",
-        items: group.items.map((item) => ({ ...item })),
-    }));
+function writeJson(key, value) {
+    sessionStorage.setItem(key, JSON.stringify(value));
+}
+
+// ---------------------------------------------------------------------------
+// 品类与设备范围(农服分大田/果树两步)
+// ---------------------------------------------------------------------------
+function getSelectedCategories() {
+    const list = readJson(SESSION.CATEGORY);
+    return Array.isArray(list) ? list : [];
+}
+
+function isFieldCategory(item) {
+    return item?.firstCrop === "大田" || item?.majorKey === "field";
+}
+
+function isFruitCategory(item) {
+    return item?.firstCrop === "果树" || item?.majorKey === "fruit";
+}
+
+/** 当前步骤应对应的品类:农服按 scope 取大田/果树,农户取第一个 */
+function getCategoryForMachines() {
+    const categories = getSelectedCategories();
+    const scope = getCurrentScope();
+    if (scope === "fruit") return categories.find(isFruitCategory) ?? null;
+    if (scope === "field") return categories.find(isFieldCategory) ?? null;
+    return categories[0] ?? null;
+}
+
+function getCurrentScope() {
+    if (props.equipmentScope === "fruit") return "fruit";
+    if (props.equipmentScope === "field") return "field";
+    return "";
+}
+
+// ---------------------------------------------------------------------------
+// 农机数据结构转换
+// ---------------------------------------------------------------------------
+/** 接口 machines(按 stage 分组)→ 页面分组结构 */
+function mapMachinesToGroups(machines) {
+    if (!Array.isArray(machines)) return [];
+    return machines
+        .map((group) => ({
+            name: group.stage || "",
+            desc: "",
+            items: (group.items || []).map((item) => ({
+                id: item.machine_id,
+                name: item.display_name || item.machine_name || "",
+                machineName: item.machine_name || "",
+                isCropSpecific: !!item.is_crop_specific,
+            })),
+        }))
+        .filter((group) => group.name && group.items.length);
+}
+
+/** 农服缓存为 { field, fruit };农户为数组 */
+function isScopedEquipmentCache(cached) {
+    return cached && !Array.isArray(cached) && (cached.field || cached.fruit);
 }
 
 function flattenEquipment(cached) {
@@ -288,88 +245,152 @@ function flattenEquipment(cached) {
     return [...(cached.field || []), ...(cached.fruit || [])];
 }
 
-function getScopeList(cached, scope) {
+function getScopeEquipmentList(cached, scope) {
     if (!cached) return [];
     if (Array.isArray(cached)) return scope === "fruit" ? [] : cached;
     return cached[scope] || [];
 }
 
-function getCurrentScope() {
-    if (props.equipmentScope === "fruit") return "fruit";
-    if (props.equipmentScope === "field") return "field";
-    return "";
+function findGroupByItemId(itemId) {
+    return equipmentGroups.value.find((group) =>
+        group.items.some((row) => String(row.id) === String(itemId))
+    );
+}
+
+function updateCropGroupLabel(category, apiCropGroup) {
+    if (apiCropGroup) {
+        cropGroupLabel.value = `${apiCropGroup}类`;
+        return;
+    }
+    cropGroupLabel.value =
+        category?.majorLabel ||
+        (category?.firstCrop ? `${category.firstCrop}类` : "");
 }
 
+// ---------------------------------------------------------------------------
+// 选中态:恢复 / 持久化
+// ---------------------------------------------------------------------------
+/** 从 session 恢复当前 scope 下的选中项;自定义项若不在列表中则补回分组 */
 function restoreSelection() {
-    const cached = readJson(EQUIPMENT_KEY);
+    const cached = readJson(SESSION.EQUIPMENT);
     const scope = getCurrentScope();
-    const list = scope ? getScopeList(cached, scope) : flattenEquipment(cached);
+    const list = scope ? getScopeEquipmentList(cached, scope) : flattenEquipment(cached);
+
     list.forEach((item) => {
         if (item?.id == null) return;
         selectedIds.add(String(item.id));
+
         if (!item.custom) return;
-        const exists = allEquipmentItems.value.some((row) => String(row.id) === String(item.id));
+        const exists = allEquipmentItems.value.some(
+            (row) => String(row.id) === String(item.id)
+        );
         if (exists) return;
-        let group = equipmentGroups.value.find((row) => row.name === item.groupName);
-        if (!group) group = equipmentGroups.value[equipmentGroups.value.length - 1];
-        if (group) {
-            group.items.push({ id: item.id, name: item.name, custom: true });
+
+        const group =
+            equipmentGroups.value.find((row) => row.name === item.groupName) ||
+            equipmentGroups.value.at(-1);
+        group?.items.push({ id: item.id, name: item.name, custom: true });
+
+        if (Number(item.id) >= customMachineId) {
+            customMachineId = Number(item.id);
         }
-        if (Number(item.id) >= customMachineId) customMachineId = Number(item.id);
     });
 }
 
-function saveSelectionDraft() {
+/** 将当前选中项写入 session;农服按 field/fruit 分桶存储 */
+function buildSelectedEquipmentPayload() {
     const scope = getCurrentScope();
-    const selected = allEquipmentItems.value
+    return allEquipmentItems.value
         .filter((item) => selectedIds.has(String(item.id)))
-        .map((item) => {
-            const group = equipmentGroups.value.find((row) =>
-                row.items.some((rowItem) => String(rowItem.id) === String(item.id))
-            );
-            return { ...item, scope: scope || "farmer", groupName: group?.name };
-        });
+        .map((item) => ({
+            ...item,
+            scope: scope || "farmer",
+            groupName: findGroupByItemId(item.id)?.name,
+        }));
+}
+
+function saveSelectionDraft() {
+    const selected = buildSelectedEquipmentPayload();
+    const scope = getCurrentScope();
+
     if (!props.isService) {
-        sessionStorage.setItem(EQUIPMENT_KEY, JSON.stringify(selected));
+        writeJson(SESSION.EQUIPMENT, selected);
         return;
     }
-    const cached = readJson(EQUIPMENT_KEY);
-    const next = {
-        field: Array.isArray(cached) ? cached : cached?.field || [],
-        fruit: Array.isArray(cached) ? [] : cached?.fruit || [],
-    };
+
+    const cached = readJson(SESSION.EQUIPMENT);
+    const next = isScopedEquipmentCache(cached)
+        ? { field: cached.field || [], fruit: cached.fruit || [] }
+        : { field: Array.isArray(cached) ? cached : [], fruit: [] };
+
     if (scope === "fruit") next.fruit = selected;
     else next.field = selected;
-    sessionStorage.setItem(EQUIPMENT_KEY, JSON.stringify(next));
+    writeJson(SESSION.EQUIPMENT, next);
 }
 
-function fetchEquipmentList() {
+// ---------------------------------------------------------------------------
+// 数据加载
+// ---------------------------------------------------------------------------
+async function fetchEquipmentList() {
+    const category = getCategoryForMachines();
+    const cropId = category?.id;
+    updateCropGroupLabel(category);
+
+    if (cropId == null || cropId === "") {
+        equipmentGroups.value = [];
+        ElMessage.warning("请先选择种植品类");
+        return;
+    }
+
     loading.value = true;
-    if (!props.isService) {
-        equipmentGroups.value = cloneGroups(DEFAULT_GROUPS);
-    } else if (props.equipmentScope === "fruit") {
-        equipmentGroups.value = cloneGroups(FRUIT_GROUPS);
-    } else {
-        equipmentGroups.value = cloneGroups(FIELD_GROUPS);
+    try {
+        const res = await VE_API.entry.getMachines({ crop_id: cropId });
+        if (res.code === 200) {
+            const data = res.data || {};
+            updateCropGroupLabel(category, data.crop_group);
+            equipmentGroups.value = mapMachinesToGroups(data.machines);
+        } else {
+            equipmentGroups.value = [];
+            ElMessage.error(res.msg || "获取农机列表失败");
+        }
+    } catch {
+        equipmentGroups.value = [];
+        ElMessage.error("获取农机列表失败,请稍后再试");
+    } finally {
+        loading.value = false;
     }
-    loading.value = false;
 }
 
-const handleSearch = () => {
-    appliedKeyword.value = searchKeyword.value;
-};
+// ---------------------------------------------------------------------------
+// 交互:搜索 / 选择 / 自定义农机
+// ---------------------------------------------------------------------------
+function handleSearch() {
+    appliedKeyword.value = searchKeyword.value.trim();
+}
 
-const handleAddMachine = () => {
+function handleAddMachine() {
     showAddPopup.value = true;
-};
+}
 
-const handleAddConfirm = ({ name }) => {
+function ensureCustomGroupTop() {
+    let customGroup = equipmentGroups.value.find((g) => g.name === CUSTOM_GROUP_NAME);
+    if (!customGroup) {
+        customGroup = { name: CUSTOM_GROUP_NAME, items: [] };
+        equipmentGroups.value.unshift(customGroup);
+        return customGroup;
+    }
+    const index = equipmentGroups.value.indexOf(customGroup);
+    if (index > 0) {
+        equipmentGroups.value.splice(index, 1);
+        equipmentGroups.value.unshift(customGroup);
+    }
+    return customGroup;
+}
+
+function handleAddConfirm({ name }) {
     const machineName = String(name || "").trim();
     if (!machineName) return;
 
-    const CUSTOM_GROUP_NAME = "我的农机";
-
-    // 已存在同名则直接选中
     const exists = allEquipmentItems.value.find((item) => item.name === machineName);
     if (exists) {
         selectedIds.add(String(exists.id));
@@ -378,42 +399,26 @@ const handleAddConfirm = ({ name }) => {
         return;
     }
 
-    // 确保「自定义类」作为第一组存在
-    let customGroup = equipmentGroups.value.find((group) => group.name === CUSTOM_GROUP_NAME);
-    if (!customGroup) {
-        customGroup = { name: CUSTOM_GROUP_NAME, items: [] };
-        equipmentGroups.value.unshift(customGroup);
-    } else {
-        const index = equipmentGroups.value.indexOf(customGroup);
-        if (index > 0) {
-            equipmentGroups.value.splice(index, 1);
-            equipmentGroups.value.unshift(customGroup);
-        }
-    }
-
-    const newItem = {
-        id: ++customMachineId,
-        name: machineName,
-        custom: true,
-    };
+    const customGroup = ensureCustomGroupTop();
+    const newItem = { id: ++customMachineId, name: machineName, custom: true };
     customGroup.items.push(newItem);
     selectedIds.add(String(newItem.id));
     saveSelectionDraft();
     ElMessage.success("添加成功");
-};
+}
 
-const toggleSelect = (item) => {
+function toggleSelect(item) {
     const id = String(item.id);
-    if (selectedIds.has(id)) {
-        selectedIds.delete(id);
-    } else {
-        selectedIds.add(id);
-    }
+    if (selectedIds.has(id)) selectedIds.delete(id);
+    else selectedIds.add(id);
     saveSelectionDraft();
-};
+}
 
+// ---------------------------------------------------------------------------
+// 提交:组装 payload / 清 session
+// ---------------------------------------------------------------------------
 function getVarietyDraftList() {
-    const draft = readJson(SELECTED_LIST_KEY);
+    const draft = readJson(SESSION.VARIETY);
     if (Array.isArray(draft)) return draft;
     if (Array.isArray(draft?.list)) return draft.list;
     return [];
@@ -423,15 +428,20 @@ function getPhenophaseLabel(item) {
     return item.phenophase || item.phenologyName || String(item.phenologyId || "");
 }
 
+function flattenTasks(taskCache) {
+    if (!taskCache) return [];
+    if (Array.isArray(taskCache)) return taskCache;
+    return [...(taskCache.field || []), ...(taskCache.fruit || [])];
+}
+
+/** 汇总各步骤 session 数据,供 addPlotInfo 提交 */
 function buildPlotPayload(includeEquipment = true) {
-    const baForm = readJson("ENTRY_BA_FORM") || {};
+    const baForm = readJson(SESSION.BA_FORM) || {};
     const varietyList = getVarietyDraftList();
-    const equipmentCache = readJson(EQUIPMENT_KEY);
+    const equipmentCache = readJson(SESSION.EQUIPMENT);
     const equipmentList = includeEquipment ? flattenEquipment(equipmentCache) : [];
-    const taskCache = readJson(TASK_KEY);
-    const taskList = Array.isArray(taskCache)
-        ? taskCache
-        : [...(taskCache?.field || []), ...(taskCache?.fruit || [])];
+    const taskCache = readJson(SESSION.TASK);
+    const taskList = flattenTasks(taskCache);
 
     return {
         user_name: baForm.name,
@@ -456,35 +466,30 @@ function buildPlotPayload(includeEquipment = true) {
             id: item.id,
             name: item.name,
         })),
-        field_tasks: (taskCache?.field || []).map((item) => ({ id: item.id, name: item.name })),
-        fruit_tasks: (taskCache?.fruit || []).map((item) => ({ id: item.id, name: item.name })),
+        field_tasks: (taskCache?.field || []).map((item) => ({
+            id: item.id,
+            name: item.name,
+        })),
+        fruit_tasks: (taskCache?.fruit || []).map((item) => ({
+            id: item.id,
+            name: item.name,
+        })),
     };
 }
 
 function clearEntrySession() {
-    sessionStorage.removeItem(SELECTED_LIST_KEY);
-    sessionStorage.removeItem(CATEGORY_SESSION_KEY);
-    sessionStorage.removeItem("ENTRY_BA_FORM");
-    sessionStorage.removeItem(LOCATION_KEY);
-    sessionStorage.removeItem(EDIT_UID_KEY);
-    sessionStorage.removeItem(EQUIPMENT_KEY);
-    sessionStorage.removeItem(TASK_KEY);
-    sessionStorage.removeItem("ENTRY_MANUAL_VIEW");
-    sessionStorage.removeItem(STEP_KEY);
-    sessionStorage.removeItem("ENTRY_INVITE_TYPE");
+    Object.values(SESSION).forEach((key) => sessionStorage.removeItem(key));
 }
 
-const showSuccessPopup = ref(false);
-
 async function submitEntry(includeEquipment = true) {
     if (submitting.value) return;
-    const varietyList = getVarietyDraftList();
 
-    if (!props.isService && !varietyList.length) {
+    if (!props.isService && !getVarietyDraftList().length) {
         ElMessage.warning("请先完善种植品种信息");
         return;
     }
-    const baForm = readJson("ENTRY_BA_FORM");
+
+    const baForm = readJson(SESSION.BA_FORM);
     if (!baForm?.name || !baForm?.phone) {
         ElMessage.warning("请先完善个人信息");
         return;
@@ -513,30 +518,31 @@ async function submitEntry(includeEquipment = true) {
     }
 }
 
-const handleComplete = () => {
+function handleComplete() {
     showSuccessPopup.value = false;
     emit("confirm");
-};
+}
 
-const handleSkip = () => {
+/** 农户最后一步可跳过设备选择,仍提交其余信息 */
+function handleSkip() {
     if (!props.isLastStep) {
         emit("next");
         return;
     }
     submitEntry(false);
-};
+}
 
-const handleSubmit = () => {
+function handleSubmit() {
     saveSelectionDraft();
     if (!props.isLastStep) {
         emit("next");
         return;
     }
     submitEntry(true);
-};
+}
 
-onMounted(() => {
-    fetchEquipmentList();
+onMounted(async () => {
+    await fetchEquipmentList();
     restoreSelection();
 });
 </script>
@@ -554,17 +560,35 @@ onMounted(() => {
         min-height: 0;
         overflow-y: auto;
         -webkit-overflow-scrolling: touch;
-        padding: 8px 16px 80px;
+        padding: 12px 10px 80px;
     }
 
     .page-header {
-        padding: 8px 4px 16px;
+        padding: 8px 0 16px;
 
         .page-title {
             font-size: 26px;
             color: #005599;
             font-family: "PangMenZhengDao";
-            line-height: 36px;
+            line-height: 30px;
+            .page-tag-tip {
+                font-size: 20px;
+            }
+            &.has-tag-wrap {
+                display: flex;
+                align-items: center;
+                gap: 6px;
+            }
+            .page-tag {
+                height: 24px;
+                line-height: 24px;
+                display: inline-block;
+                background: #2199F8;
+                color: #FFFFFF;
+                font-size: 14px;
+                padding: 0 6px;
+                border-radius: 4px;
+            }
         }
 
         .page-subtitle {
@@ -682,22 +706,25 @@ onMounted(() => {
             position: relative;
             border-radius: 6px;
             box-sizing: border-box;
-            height: 36px;
+            min-height: 36px;
+            padding: 6px 4px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
             text-align: center;
-            line-height: 36px;
             cursor: pointer;
             color: #000;
             background: #f3f4f6;
             border: 1px solid transparent;
 
             .text {
-                display: inline-block;
-                max-width: 100%;
-                overflow: hidden;
-                text-overflow: ellipsis;
-                white-space: nowrap;
-                vertical-align: top;
-                padding: 0 6px;
+                display: block;
+                width: 100%;
+                padding: 0 4px;
+                font-size: 13px;
+                line-height: 16px;
+                white-space: normal;
+                word-break: break-all;
             }
 
             &.selected {
@@ -778,27 +805,6 @@ onMounted(() => {
             color: #005599;
             line-height: 22px;
         }
-
-        .tag-item {
-            height: auto;
-            min-height: 36px;
-            padding: 6px 4px;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            line-height: 16px;
-
-            .text {
-                display: block;
-                white-space: normal;
-                overflow: visible;
-                text-overflow: unset;
-                font-size: 13px;
-                line-height: 16px;
-                padding: 0 4px;
-                word-break: break-all;
-            }
-        }
     }
 }
 </style>

+ 20 - 7
src/views/old_mini/entry_information/components/selectVariety.vue

@@ -2,8 +2,8 @@
     <div class="select-variety">
         <div class="select-variety__content">
             <div class="page-header">
-                <div class="page-title">请选择您的种植品种<span class="page-title-tip">(单选)</span></div>
-                <div class="page-subtitle">精细管理每一块地、每一个品种</div>
+                <div class="page-title">完善种植品种</div>
+                <div class="page-title">精准管理每一块地块<span class="page-title-tip">(单选)</span></div>
             </div>
 
 
@@ -61,7 +61,7 @@
                             :value="opt.id"
                         />
                     </el-select>
-                    <span v-if="hasVariety" class="delete-btn" @click="clearVariety">删除</span>
+                    <!-- <span v-if="hasVariety" class="delete-btn" @click="clearVariety">删除</span> -->
                 </div>
 
                 <div
@@ -415,10 +415,18 @@ const fetchVarietiesByCrop = async (cropId, force = false) => {
         });
         if (res.code === 200 && res.data) {
             const data = res.data;
-            tab.varieties = (data.varieties || []).map((item) => ({
-                id: item.id,
-                name: item.name,
-            }));
+            tab.varieties = (data.varieties || [])
+                .map((item) => {
+                    if (typeof item === "string") {
+                        return { id: item, name: item };
+                    }
+                    const name = item?.name || "";
+                    return {
+                        id: item?.id ?? name,
+                        name,
+                    };
+                })
+                .filter((item) => item.name);
             tab.phenology = mapPhenologyList(data.phenology);
             tab.startingPointQuestion =
                 data.starting_point_question || "起种点问题";
@@ -523,6 +531,10 @@ const ensureDefaultForm = () => {
         return;
     }
     const item = selectedList.value[0];
+    if (String(item.categoryId) !== String(category.id)) {
+        selectedList.value = [createEmptyForm(category)];
+        return;
+    }
     item.categoryId = category.id;
     item.categoryName = category.name;
 };
@@ -729,6 +741,7 @@ const handleConfirm = () => {
             .page-title {
                 font-size: 26px;
                 color: #005599;
+                line-height: 30px;
                 font-family: "PangMenZhengDao";
                 .page-title-tip {
                     font-size: 20px;

+ 2 - 0
src/views/old_mini/entry_information/map/selectLocationMap.js

@@ -24,6 +24,7 @@ export const POINT_ICON = {
 class SelectLocationMap {
   constructor(options = {}) {
     const iconSrc = options.iconSrc || POINT_ICON.resident;
+    this.onMapClick = options.onMapClick || null;
     this.clickPointLayer = new KMap.VectorLayer("clickPointLayer", 9999, {
       style: () => {
         return new Style({
@@ -123,6 +124,7 @@ class SelectLocationMap {
     that._clickKey = that.kmap.on("singleclick", (evt) => {
       that.setMapPoint(evt.coordinate);
       mapLocation.data = evt.coordinate;
+      that.onMapClick?.(evt.coordinate);
     });
   }
 

+ 36 - 5
src/views/old_mini/entry_information/selectLocation.vue

@@ -33,6 +33,7 @@ import { convertPointToArray } from "@/utils/index";
 const SESSION_KEY_RESIDENT = "ENTRY_RESIDENT_LOCATION";
 const SESSION_KEY_GROWTH = "GROWTH_REPORT_LOCATION";
 const DEFAULT_POINT = "POINT(113.6142086995688 23.585836479509055)";
+const MAP_KEY = "CZLBZ-LJICQ-R4A5J-BN62X-YXCRJ-GNBUT";
 
 const router = useRouter();
 const route = useRoute();
@@ -49,13 +50,19 @@ const iconSrc =
         : isPlantPoint
           ? POINT_ICON.plant
           : POINT_ICON.resident;
-const selectLocationMap = new SelectLocationMap({
-    iconSrc,
-});
 
 const userLocation = ref(
     store.state.home.miniUserLocation || localStorage.getItem("MINI_USER_LOCATION") || "113.61702297075017,23.584863449735067"
 );
+/** 搜索选中时带出的区县编码 */
+const selectedAdcode = ref("");
+
+const selectLocationMap = new SelectLocationMap({
+    iconSrc,
+    onMapClick: () => {
+        selectedAdcode.value = "";
+    },
+});
 
 function getDefaultPoint() {
     if (route.query.mapCenter) return route.query.mapCenter;
@@ -72,6 +79,20 @@ function toPointWkt(coordinate) {
     return `POINT(${lng} ${lat})`;
 }
 
+async function fetchAdcodeByCoordinate(coordinate) {
+    if (!coordinate?.length) return "";
+    try {
+        const { result } = await VE_API.old_mini_map.location({
+            key: MAP_KEY,
+            location: `${coordinate[1]},${coordinate[0]}`,
+        });
+        const adcode = result?.ad_info?.adcode;
+        return adcode != null && adcode !== "" ? String(adcode) : "";
+    } catch {
+        return "";
+    }
+}
+
 onMounted(() => {
     const point = getDefaultPoint();
     selectLocationMap.initMap(point, mapContainer.value);
@@ -82,11 +103,16 @@ onUnmounted(() => {
 });
 
 const handleLocationChange = (payload) => {
-    if (!payload?.coordinateArray) return;
+    if (!payload?.coordinateArray) {
+        selectedAdcode.value = "";
+        return;
+    }
+    selectedAdcode.value = payload.adcode ? String(payload.adcode) : "";
     selectLocationMap.setMapPosition(payload.coordinateArray);
 };
 
 const handleLocate = () => {
+    selectedAdcode.value = "";
     const point =
         localStorage.getItem("MINI_USER_LOCATION_POINT") ||
         store.state.home.miniUserLocationPoint ||
@@ -95,7 +121,7 @@ const handleLocate = () => {
     selectLocationMap.setMapPosition(coordinate);
 };
 
-const handleSubmit = () => {
+const handleSubmit = async () => {
     const coordinate = mapLocation.data;
     if (!coordinate) {
         ElMessage.warning(
@@ -104,11 +130,16 @@ const handleSubmit = () => {
         return;
     }
     const point = toPointWkt(coordinate);
+    let adcode = selectedAdcode.value;
+    if (!adcode) {
+        adcode = await fetchAdcodeByCoordinate(coordinate);
+    }
     sessionStorage.setItem(
         sessionKey,
         JSON.stringify({
             point,
             coordinate: [Number(coordinate[0]), Number(coordinate[1])],
+            adcode: adcode || "",
         })
     );
     router.back();

+ 151 - 2
src/views/old_mini/growth_report/index.vue

@@ -4,8 +4,35 @@
             <div class="map-container" ref="mapContainer"></div>
         </div>
 
+        <!-- 浮窗:长势互动 -->
+        <div v-if="showInteractCard" class="interact-float">
+            <div class="interact-card">
+                <div class="interact-card__head">
+                    <div class="interact-card__tab">
+                        <img class="interact-card__star" src="@/assets/img/agricultural/start.png" alt="" />
+                        <span>长势互动</span>
+                    </div>
+                    <el-icon class="interact-card__close" @click="showInteractCard = false"><Close /></el-icon>
+                </div>
+                <div class="interact-card__qa">
+                    <div class="interact-card__question">{{ interactQuestionText }}</div>
+                    <div class="interact-card__options">
+                        <div
+                            v-for="item in interactOptions"
+                            :key="item.id"
+                            class="interact-option"
+                            :class="{ selected: selectedOptionId === item.id }"
+                            @click="selectedOptionId = item.id"
+                        >
+                            <span class="interact-option__text">{{ item.name }}</span>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
         <!-- 左上角:位置切换 -->
-        <div class="location-bar">
+        <div v-if="!showInteractCard" class="location-bar">
             <el-icon class="location-bar__icon"><LocationFilled /></el-icon>
             <span class="location-bar__name van-ellipsis">{{ locationName }}</span>
             <span class="location-bar__action" @click="handleSwitchLocation">切换位置</span>
@@ -82,7 +109,7 @@
 import { computed, nextTick, onActivated, onMounted, ref } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { useStore } from "vuex";
-import { LocationFilled } from "@element-plus/icons-vue";
+import { Close, LocationFilled } from "@element-plus/icons-vue";
 import { convertPointToArray } from "@/utils/index";
 import GrowthReportMap from "./growthReportMap.js";
 import CropAlertPanel from "./components/CropAlertPanel.vue";
@@ -123,6 +150,15 @@ const panelHeight = ref(280);
 const inviteBottom = computed(() => panelHeight.value);
 const invitePopupRef = ref(null);
 
+const showInteractCard = ref(true);
+const interactQuestionText = ref("具体问题具体问题具体问题具体问题具体?");
+const interactOptions = ref([
+    { id: 1, name: "选项一" },
+    { id: 2, name: "选项二" },
+    { id: 3, name: "选项三" },
+]);
+const selectedOptionId = ref(null);
+
 const stressMenuItems = [
     { key: "stress", line1: "胁迫", line2: "胁迫", icon: menuIcon },
     { key: "hot-drought-1", line1: "高温", line2: "干旱", icon: menuIcon },
@@ -357,6 +393,119 @@ onActivated(() => {
     background: #f5f7fb;
     box-sizing: border-box;
 
+    .interact-float {
+        position: fixed;
+        top: 10px;
+        left: 10px;
+        right: 10px;
+        z-index: 30;
+        pointer-events: none;
+
+        .interact-card {
+            pointer-events: auto;
+            overflow: hidden;
+            border-radius: 12px;
+            background: #fff;
+            box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+        }
+
+        .interact-card__head {
+            position: relative;
+            background: linear-gradient(180deg, #e5f4ff 0%, #ffffff 72%);
+        }
+
+        .interact-card__tab {
+            display: inline-flex;
+            align-items: center;
+            gap: 5px;
+            height: 32px;
+            padding: 0 32px 10px 8px;
+            color: #fff;
+            font-size: 16px;
+            font-family: "PangMenZhengDao";
+            background: url("@/assets/img/agricultural/title-bg.png") no-repeat center / 100% 100%;
+
+            span {
+                margin-top: -3px;
+            }
+        }
+
+        .interact-card__star {
+            width: 16px;
+            height: 14px;
+        }
+
+        .interact-card__close {
+            position: absolute;
+            top: 8px;
+            right: 10px;
+            font-size: 16px;
+            color: #9c9c9c;
+            cursor: pointer;
+        }
+
+        .interact-card__qa {
+            margin: 0 10px 10px;
+            padding: 10px;
+            border-radius: 8px;
+            background: rgba(33, 153, 248, 0.1);
+        }
+
+        .interact-card__question {
+            font-size: 14px;
+            font-weight: 500;
+            color: #333;
+        }
+
+        .interact-card__options {
+            display: flex;
+            gap: 8px;
+            margin-top: 10px;
+        }
+
+        .interact-option {
+            position: relative;
+            flex: 1;
+            min-width: 0;
+            height: 36px;
+            line-height: 36px;
+            border-radius: 6px;
+            background: #fff;
+            border: 1px solid transparent;
+            text-align: center;
+            box-sizing: border-box;
+            cursor: pointer;
+
+            &__text {
+                display: block;
+                padding: 0 6px;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                white-space: nowrap;
+                font-size: 14px;
+                color: #666;
+            }
+
+            &.selected {
+                border-color: #2199f8;
+
+                .interact-option__text {
+                    color: #2199f8;
+                }
+
+                &::after {
+                    content: "";
+                    position: absolute;
+                    top: -1px;
+                    right: -1px;
+                    width: 18px;
+                    height: 14px;
+                    background: url("@/assets/img/home/checked-bg-top.png") no-repeat bottom right / 18px 13px;
+                }
+            }
+        }
+    }
+
     .location-bar {
         position: fixed;
         top: 12px;

+ 1 - 14
src/views/old_mini/work_execute/index.vue

@@ -71,7 +71,7 @@ const indexMap = new IndexMap();
 const mapContainer = ref(null);
 const calendarRef = ref(null);
 const showFarmCalendarPopup = ref(false);
-/** 控制调查问卷弹窗显示 */
+/** 调查问卷弹窗:后续由接口决定是否展示 */
 const showSurveyAskPopup = ref(true);
 const tabBarHeight = computed(() => store.state.home.tabBarHeight);
 
@@ -179,24 +179,11 @@ onMounted(() => {
             indexMap.initMap(mapPoint.value, mapContainer.value, true);
             indexMap.initData(getMapMarkerList(), "farmName", "wkt");
         }
-        // keepAlive 热更新后可能未重新 setup,这里再同步一次显示状态
-        if (showSurveyAskPopup.value) {
-            showSurveyAskPopup.value = false;
-            nextTick(() => {
-                showSurveyAskPopup.value = true;
-            });
-        }
     });
 });
 
 onActivated(() => {
     nextTick(() => {
-        if (showSurveyAskPopup.value) {
-            showSurveyAskPopup.value = false;
-            nextTick(() => {
-                showSurveyAskPopup.value = true;
-            });
-        }
         if (!indexMap.kmap) {
             if (mapContainer.value) {
                 mapPoint.value = store.state.home.miniUserLocationPoint || "POINT(113.614209 23.585836)";

+ 47 - 113
src/views/old_mini/work_execute/surveyEntry.vue

@@ -26,7 +26,7 @@
             <div class="category-panel" v-loading="loading">
                 <div v-for="group in displayGroups" :key="group.name" class="category-card">
                     <div class="section-title">
-                        <span class="title-icon"></span>
+                        <img class="title-icon" src="@/assets/img/home/label-icon.png" alt="" />
                         <span>{{ group.name }}</span>
                     </div>
                     <div class="tag-group">
@@ -65,6 +65,7 @@ const { t } = useI18n();
 const router = useRouter();
 
 const SESSION_KEY = "SURVEY_SELECTED_WORK_TYPES";
+const DEFAULT_CROP_ID = 2;
 
 const loading = ref(false);
 const categoryGroups = ref([]);
@@ -72,50 +73,6 @@ const searchKeyword = ref("");
 const appliedKeyword = ref("");
 const selectedIds = reactive(new Set());
 
-const DEFAULT_GROUPS = [
-    {
-        name: "建园与土壤管理",
-        items: [
-            { id: 101, name: "整地类" },
-            { id: 102, name: "除草" },
-            { id: 103, name: "清园" },
-            { id: 104, name: "清沟修渠" },
-        ],
-    },
-    {
-        name: "肥水管理",
-        items: [
-            { id: 201, name: "根部肥" },
-            { id: 202, name: "根部灌水" },
-            { id: 203, name: "根部灌药" },
-            { id: 204, name: "飞防叶面肥" },
-            { id: 205, name: "叶面喷水" },
-        ],
-    },
-    {
-        name: "打药管理",
-        items: [
-            { id: 301, name: "飞防打药" },
-            { id: 302, name: "内膛打药" },
-        ],
-    },
-    {
-        name: "树体管理",
-        items: [
-            { id: 401, name: "整形修剪" },
-            { id: 402, name: "嫁接" },
-            { id: 403, name: "环割环剥" },
-        ],
-    },
-    {
-        name: "花果管理",
-        items: [
-            { id: 501, name: "疏花疏果" },
-            { id: 502, name: "果实套袋" },
-        ],
-    },
-];
-
 const displayGroups = computed(() => {
     const keyword = (appliedKeyword.value || "").trim();
     if (!keyword) return categoryGroups.value;
@@ -131,35 +88,28 @@ const allCategoryItems = computed(() =>
     categoryGroups.value.flatMap((group) => group.items)
 );
 
-const normalizeGroups = (list) => {
-    if (!Array.isArray(list) || !list.length) return [];
-    // 已是分组结构
-    if (list[0]?.items) {
-        return list
-            .map((group, gIndex) => ({
-                name: group.name || group.category_name || `分类${gIndex + 1}`,
-                items: (group.items || group.list || [])
-                    .map((item, index) => ({
-                        id: item.id ?? item.type ?? item.code ?? `${gIndex}-${index + 1}`,
-                        name: item.name || item.type_name || item.label || "",
-                    }))
-                    .filter((item) => item.name),
-            }))
-            .filter((group) => group.items.length);
+const getCropId = () => {
+    try {
+        const farm = JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
+        if (farm?.crop_id != null && farm.crop_id !== "") return farm.crop_id;
+    } catch {
+        // ignore
     }
-    // 扁平列表兜底:按 category / group 字段分组
-    const map = new Map();
-    list.forEach((item, index) => {
-        const name = item.name || item.type_name || item.label || "";
-        if (!name) return;
-        const groupName = item.category_name || item.group_name || item.category || "其他";
-        if (!map.has(groupName)) map.set(groupName, []);
-        map.get(groupName).push({
-            id: item.id ?? item.type ?? item.code ?? index + 1,
-            name,
-        });
-    });
-    return Array.from(map.entries()).map(([name, items]) => ({ name, items }));
+    return DEFAULT_CROP_ID;
+};
+
+const mapStagesToGroups = (stages) => {
+    if (!Array.isArray(stages)) return [];
+    return stages
+        .map((group) => ({
+            name: group.stage || "",
+            items: (group.items || []).map((item) => ({
+                id: item.task_id,
+                name: item.task_name || "",
+                isCropSpecific: !!item.is_crop_specific,
+            })),
+        }))
+        .filter((group) => group.name && group.items.length);
 };
 
 const restoreSelection = () => {
@@ -176,12 +126,16 @@ const restoreSelection = () => {
 const fetchCategories = async () => {
     loading.value = true;
     try {
-        categoryGroups.value = DEFAULT_GROUPS;
-        // const res = await VE_API.z_farm_work_record.getFarmWorkTypeList();
-        // const groups = normalizeGroups(res?.data);
-        // categoryGroups.value = groups.length ? groups : DEFAULT_GROUPS;
+        const res = await VE_API.entry.getCropTasks({ crop_id: getCropId() });
+        if (res.code === 200) {
+            categoryGroups.value = mapStagesToGroups(res.data?.stages);
+        } else {
+            categoryGroups.value = [];
+            ElMessage.error(res.msg || t("workExecute.surveyNoMatch"));
+        }
     } catch {
-        categoryGroups.value = DEFAULT_GROUPS;
+        categoryGroups.value = [];
+        ElMessage.error(t("workExecute.surveyNoMatch"));
     } finally {
         loading.value = false;
     }
@@ -211,9 +165,9 @@ const handleSubmit = () => {
     router.back();
 };
 
-onMounted(() => {
+onMounted(async () => {
+    await fetchCategories();
     restoreSelection();
-    fetchCategories();
 });
 </script>
 
@@ -332,33 +286,10 @@ onMounted(() => {
     color: #1a1a1a;
 
     .title-icon {
-        position: relative;
         width: 14px;
         height: 14px;
+        object-fit: contain;
         flex-shrink: 0;
-
-        &::before,
-        &::after {
-            content: "";
-            position: absolute;
-            width: 10px;
-            height: 10px;
-            border-radius: 50%;
-        }
-
-        &::before {
-            left: 0;
-            top: 2px;
-            background: #2199f8;
-            opacity: 0.85;
-        }
-
-        &::after {
-            right: 0;
-            top: 0;
-            background: #7ec8ff;
-            opacity: 0.9;
-        }
     }
 }
 
@@ -373,22 +304,25 @@ onMounted(() => {
         position: relative;
         border-radius: 6px;
         box-sizing: border-box;
-        height: 36px;
+        min-height: 36px;
+        padding: 6px 4px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
         text-align: center;
-        line-height: 36px;
         cursor: pointer;
         color: #000;
         background: #f3f4f6;
         border: 1px solid transparent;
 
         .text {
-            display: inline-block;
-            max-width: 100%;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-            vertical-align: top;
-            padding: 0 6px;
+            display: block;
+            width: 100%;
+            padding: 0 4px;
+            font-size: 13px;
+            line-height: 16px;
+            white-space: normal;
+            word-break: break-all;
         }
 
         &.selected {