Explorar el Código

feat:添加物候记录页面组件和上传组件弹窗

wangsisi hace 4 días
padre
commit
62b04e87d6

+ 0 - 731
src/components/pageComponents/FarmWorkPlanTimeline copy.vue

@@ -1,731 +0,0 @@
-<template>
-    <div
-        class="timeline-container"
-        ref="timelineContainerRef"
-        :class="{ 'timeline-container-plant': pageType === 'plant' }"
-    >
-        <div class="timeline-list" :style="getListStyle">
-            <div class="timeline-middle-line"></div>
-            <!-- 物候期覆盖条(progress 为起点,progress2 为终点,单位 %) -->
-            <div
-                v-for="(p, idx) in phenologyList"
-                :key="p.id ?? idx"
-                class="phenology-bar"
-                :style="getPhenologyBarStyle(p)"
-            >
-                <div class="reproductive-list">
-                    <div
-                        v-for="(r, rIdx) in Array.isArray(p.reproductiveList) ? p.reproductiveList : []"
-                        :key="r.id ?? rIdx"
-                        class="reproductive-item"
-                        :class="{
-                            'horizontal-text': getReproductiveItemHeight(p) < 30,
-                            'vertical-lr-text': getReproductiveItemHeight(p) >= 30,
-                        }"
-                        :style="
-                            getReproductiveItemHeight(p) < 30
-                                ? { '--item-height': `${getReproductiveItemHeight(p)}px` }
-                                : {}
-                        "
-                    >
-                        {{ r.name }}
-                        <div class="arranges">
-                            <div
-                                v-for="(fw, aIdx) in Array.isArray(r.farmWorkArrangeList) ? r.farmWorkArrangeList : []"
-                                :key="fw.id ?? aIdx"
-                                class="arrange-card"
-                                :class="getArrangeStatusClass(fw)"
-                                @click="handleRowClick(fw)"
-                            >
-                                <div class="card-header">
-                                    <div class="header-left">
-                                        <span class="farm-work-name">{{ fw.farmWorkName || "--" }}</span>
-                                        <span class="tag-standard">标准农事</span>
-                                    </div>
-                                    <div class="header-right">
-                                        {{ fw.isFollow == 1 ? "已关注" : fw.isFollow == 2 ? "托管农事" : "" }}
-                                    </div>
-                                </div>
-                                <div class="card-content">
-                                    <span>{{ fw.interactionQuestion || "暂无提示" }}</span>
-                                    <span v-if="!disableClick" class="edit-link" @click.stop="handleEdit(fw)"
-                                        >点击编辑</span
-                                    >
-                                </div>
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            </div>
-            <div v-for="t in solarTerms" :key="t.id" class="timeline-term" :style="getTermStyle(t)">
-                <span class="term-name">{{ t.displayName }}</span>
-            </div>
-        </div>
-    </div>
-    <!-- 互动设置弹窗 -->
-    <interact-popup ref="interactPopupRef" @handleSaveSuccess="getFarmWorkPlan"></interact-popup>
-</template>
-
-<script setup>
-import { ref, computed, nextTick, watch } from "vue";
-import interactPopup from "@/components/popup/interactPopup.vue";
-import { ElMessage } from "element-plus";
-
-const props = defineProps({
-    // 农场 ID,用于请求农事规划数据
-    farmId: {
-        type: [String, Number],
-        default: null,
-    },
-    // 页面类型:种植方案 / 农事规划,用来控制高度样式
-    pageType: {
-        type: String,
-        default: "",
-    },
-    // 是否禁用所有点击事件(用于只读展示)
-    disableClick: {
-        type: Boolean,
-        default: false,
-    },
-    containerId: {
-        type: [Number, String],
-        default: null,
-    },
-});
-
-const emits = defineEmits(["row-click"]);
-
-const solarTerms = ref([]);
-const phenologyList = ref([]);
-const timelineContainerRef = ref(null);
-// 标记是否为首次加载
-const isInitialLoad = ref(true);
-
-// 获取当前季节
-const getCurrentSeason = () => {
-    const month = new Date().getMonth() + 1; // 1-12
-    if (month >= 3 && month <= 5) {
-        return "spring"; // 春季:3-5月
-    } else if (month >= 6 && month <= 8) {
-        return "summer"; // 夏季:6-8月
-    } else if (month >= 9 && month <= 10) {
-        return "autumn"; // 秋季:9-10月
-    } else {
-        return "winter"; // 冬季:11-2月
-    }
-};
-
-// 安全解析时间到时间戳(ms)
-const safeParseDate = (val) => {
-    if (!val) return NaN;
-    if (val instanceof Date) return val.getTime();
-    if (typeof val === "number") return val;
-    if (typeof val === "string") {
-        // 兼容 "YYYY-MM-DD HH:mm:ss" -> Safari
-        const s = val.replace(/-/g, "/").replace("T", " ");
-        const d = new Date(s);
-        return isNaN(d.getTime()) ? NaN : d.getTime();
-    }
-    return NaN;
-};
-
-// 计算最小progress值(第一个节气的progress)
-const minProgress = computed(() => {
-    if (!solarTerms.value || solarTerms.value.length === 0) return 0;
-    const progresses = solarTerms.value.map((t) => Number(t?.progress) || 0).filter((p) => !isNaN(p));
-    return progresses.length > 0 ? Math.min(...progresses) : 0;
-});
-
-// 计算最大progress值
-const maxProgress = computed(() => {
-    if (!solarTerms.value || solarTerms.value.length === 0) return 100;
-    const progresses = solarTerms.value.map((t) => Number(t?.progress) || 0).filter((p) => !isNaN(p));
-    return progresses.length > 0 ? Math.max(...progresses) : 100;
-});
-
-// 计算所有节气的调整位置,确保相邻节气之间至少有36px间隔
-const adjustedTermPositions = computed(() => {
-    if (!solarTerms.value || solarTerms.value.length === 0) return new Map();
-
-    const minP = minProgress.value;
-    const maxP = maxProgress.value;
-    const range = Math.max(1, maxP - minP);
-    const total = calculateTotalHeightByFarmWorks();
-    const termHeight = 46;
-    const minSpacing = 36; // 最小间隔36px
-
-    // 计算所有节气的初始位置
-    const termsWithPositions = solarTerms.value.map((term) => {
-        const p = Math.max(0, Math.min(100, Number(term?.progress) || 0));
-        const normalizedP = range > 0 ? ((p - minP) / range) * 100 : 0;
-        const originalTop = (normalizedP / 100) * total;
-        return {
-            ...term,
-            id:
-                term.id ??
-                term.solarTermsId ??
-                term.termId ??
-                `${term.name || term.solarTermsName || term.termName || "term"}-${term.createDate || ""}`,
-            progress: p,
-            originalTop,
-        };
-    });
-
-    // 按原始位置排序
-    const sortedTerms = [...termsWithPositions].sort((a, b) => a.originalTop - b.originalTop);
-
-    // 调整位置,确保相邻节气之间至少有36px间隔
-    const adjustedPositions = new Map();
-    const termPositions = new Array(sortedTerms.length);
-
-    // 先确定最后一个节气的位置(与物候期底部对齐)
-    const lastIndex = sortedTerms.length - 1;
-    const lastTerm = sortedTerms[lastIndex];
-    if (lastTerm.progress === maxP && range > 0) {
-        termPositions[lastIndex] = total - termHeight;
-    } else {
-        termPositions[lastIndex] = lastTerm.originalTop;
-    }
-    adjustedPositions.set(lastTerm.id, termPositions[lastIndex]);
-
-    // 从后往前调整,确保相邻节气之间至少有36px间隔
-    for (let i = lastIndex - 1; i >= 0; i--) {
-        const term = sortedTerms[i];
-        let adjustedTop = term.originalTop;
-
-        // 检查与后一个节气的间隔
-        const nextTop = termPositions[i + 1];
-        if (nextTop - adjustedTop < minSpacing) {
-            adjustedTop = nextTop - minSpacing;
-        }
-
-        termPositions[i] = adjustedTop;
-        adjustedPositions.set(term.id, adjustedTop);
-    }
-
-    return adjustedPositions;
-});
-
-// 计算物候期需要的实际高度(基于农事数量)
-const getPhenologyRequiredHeight = (item) => {
-    // 统计该物候期内的农事数量
-    let farmWorkCount = 0;
-
-    if (Array.isArray(item.reproductiveList)) {
-        item.reproductiveList.forEach((reproductive) => {
-            if (Array.isArray(reproductive.farmWorkArrangeList)) {
-                farmWorkCount += reproductive.farmWorkArrangeList.length;
-            }
-        });
-    }
-
-    // 如果没有农事,给一个最小高度
-    if (farmWorkCount === 0) {
-        return 50; // 最小50px
-    }
-
-    // 每个农事卡片的高度(根据实际内容,卡片高度可能因内容而异)
-    // 卡片包含:padding(8px*2) + header(约25px) + content margin(4px+2px) + content(约25-30px) = 约72-77px
-    // 考虑到内容可能换行,实际高度可能更高,设置为120px更安全,避免卡片重叠
-    const farmWorkCardHeight = 120; // 卡片高度估算,确保能容纳内容且不重叠
-    // 卡片之间的间距(与CSS中的gap保持一致)
-    const cardGap = 12;
-
-    // 计算总高度:卡片数量 * 卡片高度 + (卡片数量 - 1) * 间距
-    // 如果有多个卡片,需要加上它们之间的间距
-    const totalHeight = farmWorkCount * farmWorkCardHeight + (farmWorkCount > 1 ? (farmWorkCount - 1) * cardGap : 0);
-
-    // 返回精确的总高度,只保留最小高度限制,不添加额外余量
-    return Math.max(totalHeight, 50); // 最小50px,精确匹配农事卡片高度
-};
-
-// 计算所有物候期的累积位置和总高度
-const calculatePhenologyPositions = () => {
-    let currentTop = 10; // 起始位置,留出顶部间距
-    const positions = new Map();
-
-    // 按progress排序物候期,确保按时间顺序排列
-    const sortedPhenologyList = [...phenologyList.value].sort((a, b) => {
-        const aProgress = Math.min(Number(a?.progress) || 0, Number(a?.progress2) || 0);
-        const bProgress = Math.min(Number(b?.progress) || 0, Number(b?.progress2) || 0);
-        return aProgress - bProgress;
-    });
-
-    sortedPhenologyList.forEach((phenology) => {
-        const height = getPhenologyRequiredHeight(phenology);
-        // 使用与数据生成时相同的ID生成逻辑
-        const itemId =
-            phenology.id ?? phenology.phenologyId ?? phenology.name ?? `${phenology.progress}-${phenology.progress2}`;
-        positions.set(itemId, {
-            top: currentTop,
-            height: height,
-        });
-        currentTop += height; // 紧挨着下一个物候期,不留间距
-    });
-
-    return {
-        positions,
-        totalHeight: currentTop, // 总高度 = 最后一个物候期的底部位置,不添加额外间距
-    };
-};
-
-// 计算所有农事的总高度(基于物候期紧挨排列)
-const calculateTotalHeightByFarmWorks = () => {
-    const { totalHeight } = calculatePhenologyPositions();
-
-    // 计算最后一个物候期的底部位置
-    let lastPhenologyBottom = 0;
-    if (phenologyList.value && phenologyList.value.length > 0) {
-        const sortedPhenologyList = [...phenologyList.value].sort((a, b) => {
-            const aProgress = Math.min(Number(a?.progress) || 0, Number(a?.progress2) || 0);
-            const bProgress = Math.min(Number(b?.progress) || 0, Number(b?.progress2) || 0);
-            return aProgress - bProgress;
-        });
-
-        let currentTop = 10; // 起始位置
-        sortedPhenologyList.forEach((phenology) => {
-            const height = getPhenologyRequiredHeight(phenology);
-            currentTop += height;
-        });
-        lastPhenologyBottom = currentTop; // 最后一个物候期的底部位置
-    }
-
-    // 直接使用最后一个物候期的底部位置作为总高度,不添加额外余量
-    // 在getTermStyle中,我们已经调整了最后一个节气的top位置(total - 46)
-    // 这样最后一个节气的底部(total - 46 + 46 = total)就能与物候期底部对齐
-    if (lastPhenologyBottom > 0) {
-        const baseHeight = (solarTerms.value?.length || 0) * 50;
-        // 总高度 = 最后一个物候期的底部位置,精确匹配,不添加额外余量
-        return Math.max(lastPhenologyBottom, totalHeight, baseHeight);
-    }
-
-    // 基础高度:每个节气至少需要一定高度,确保节气标签能显示
-    const baseHeight = (solarTerms.value?.length || 0) * 50;
-
-    // 返回物候期总高度和基础高度的较大值,不添加最小高度限制
-    return Math.max(totalHeight, baseHeight);
-};
-
-// 列表高度
-const getListStyle = computed(() => {
-    const minP = minProgress.value;
-    const maxP = maxProgress.value;
-    const range = Math.max(1, maxP - minP); // 避免除0
-    const total = calculateTotalHeightByFarmWorks();
-    const minH = range === 0 ? 0 : total;
-    return { minHeight: `${minH}px` };
-});
-
-const getTermStyle = (t) => {
-    // 生成与 adjustedTermPositions 中相同的 ID
-    const termId =
-        t.id ??
-        t.solarTermsId ??
-        t.termId ??
-        `${t.name || t.solarTermsName || t.termName || "term"}-${t.createDate || ""}`;
-
-    // 从调整后的位置映射中获取 top 值
-    const adjustedTop = adjustedTermPositions.value.get(termId);
-
-    // 如果找不到调整后的位置,使用原始计算方式作为后备
-    let top = adjustedTop;
-    if (adjustedTop === undefined) {
-        const p = Math.max(0, Math.min(100, Number(t?.progress) || 0));
-        const minP = minProgress.value;
-        const maxP = maxProgress.value;
-        const range = Math.max(1, maxP - minP);
-        const total = calculateTotalHeightByFarmWorks();
-        const termHeight = 46;
-        const normalizedP = range > 0 ? ((p - minP) / range) * 100 : 0;
-        top = (normalizedP / 100) * total;
-
-        // 如果是最后一个节气,调整top位置
-        if (p === maxP && range > 0) {
-            top = total - termHeight;
-        }
-    }
-
-    return {
-        position: "absolute",
-        top: `${top}px`,
-        left: 0,
-        width: "30px",
-        // height: "20px",
-        display: "flex",
-        alignItems: "flex-start",
-    };
-};
-
-// 点击季节 → 滚动到对应节气(立春/立夏/立秋/立冬)
-const handleSeasonClick = (seasonValue) => {
-    const mapping = {
-        spring: "立春",
-        summer: "立夏",
-        autumn: "立秋",
-        winter: "立冬",
-    };
-    const targetName = mapping[seasonValue];
-    if (!targetName) return;
-    const target = (solarTerms.value || []).find((t) => (t?.displayName || "") === targetName);
-    if (!target) return;
-    const p = Math.max(0, Math.min(100, Number(target.progress) || 0));
-    const minP = minProgress.value;
-    const maxP = maxProgress.value;
-    const range = Math.max(1, maxP - minP);
-    const total = calculateTotalHeightByFarmWorks(); // 使用动态计算的总高度
-    const normalizedP = range > 0 ? ((p - minP) / range) * 100 : 0;
-    const targetTop = (normalizedP / 100) * total; // 内容内的像素位置
-    const wrap = timelineContainerRef.value;
-    if (!wrap) return;
-    const viewH = wrap.clientHeight || 0;
-    const maxScroll = Math.max(0, wrap.scrollHeight - viewH);
-    // 将目标位置稍微靠上(使用 0.35 视口高度做偏移)
-    let scrollTop = Math.max(0, targetTop - viewH * 0.1);
-    if (scrollTop > maxScroll) scrollTop = maxScroll;
-    wrap.scrollTo({ top: scrollTop, behavior: "smooth" });
-};
-
-// 物候期覆盖条样式
-const getPhenologyBarStyle = (item) => {
-    const { positions } = calculatePhenologyPositions();
-    // 使用与数据生成时相同的ID生成逻辑
-    const itemId = item.id ?? item.phenologyId ?? item.name ?? `${item.progress}-${item.progress2}`;
-    const position = positions.get(itemId);
-
-    // 如果找不到位置信息,使用默认值
-    let topPx = 10;
-    let heightPx = 50;
-
-    if (position) {
-        topPx = position.top;
-        heightPx = position.height;
-    }
-
-    const p1 = Math.max(0, Math.min(100, Number(item?.progress) || 0));
-    const p2 = Math.max(0, Math.min(100, Number(item?.progress2) || 0));
-    const start = Math.min(p1, p2);
-    const now = Date.now();
-    const isFuture = Number.isFinite(item?.startTimeMs) ? item.startTimeMs > now : start > 0;
-    const barColor = isFuture ? "rgba(145, 145, 145, 0.1)" : "#2199F8";
-    const beforeBg = isFuture ? "rgba(145, 145, 145, 0.1)" : "rgba(33, 153, 248, 0.1)";
-
-    return {
-        position: "absolute",
-        left: "46px",
-        width: "25px",
-        top: `${topPx}px`,
-        height: `${heightPx}px`,
-        background: barColor,
-        color: isFuture ? "#747778" : "#fff",
-        "--bar-before-bg": beforeBg,
-        zIndex: 2,
-    };
-};
-
-// 农事状态样式映射(0:默认,1-4:正常,5:完成,6:预警)
-const getArrangeStatusClass = (fw) => {
-    const t = fw?.isFollow;
-    if (t == 0) return "normal-style";
-    if (t >= 0 && t <= 4) return "status-normal";
-    if (t === 5) return "status-complete";
-    if (t === 6) return "status-warning";
-    return "status-default";
-};
-
-// 计算 phenology-bar 的高度(px)
-const getPhenologyBarHeight = (item) => {
-    // 直接使用基于农事数量的高度
-    return getPhenologyRequiredHeight(item);
-};
-
-// 计算 reproductive-item 的高度(px)
-const getReproductiveItemHeight = (phenologyItem) => {
-    const barHeight = getPhenologyBarHeight(phenologyItem);
-    const listLength = Array.isArray(phenologyItem?.reproductiveList) ? phenologyItem.reproductiveList.length : 1;
-    return listLength > 0 ? barHeight / listLength : barHeight;
-};
-
-const handleRowClick = (item) => {
-    emits("row-click", item);
-};
-
-const interactPopupRef = ref(null);
-const handleEdit = (item) => {
-    if (props.disableClick) return;
-    if (interactPopupRef.value) {
-        interactPopupRef.value.showPopup(item);
-    }
-};
-
-const containerIdData = ref(null);
-
-// 获取农事规划数据
-const getFarmWorkPlan = () => {
-    if (!props.farmId && !props.containerId) return;
-    let savedScrollTop = 0;
-    if (!isInitialLoad.value && timelineContainerRef.value) {
-        savedScrollTop = timelineContainerRef.value.scrollTop || 0;
-    }
-
-    VE_API.monitor
-        .farmWorkPlan({ farmId: props.farmId, containerId: props.containerId })
-        .then(({ data, code }) => {
-            if (code === 0) {
-                containerIdData.value = data.phenologyList[0].containerSpaceTimeId;
-                const list = Array.isArray(data?.solarTermsList) ? data.solarTermsList : [];
-                const filtered = list
-                    .filter((t) => t && t.type === 1)
-                    .map((t) => ({
-                        id:
-                            t.id ??
-                            t.solarTermsId ??
-                            t.termId ??
-                            `${t.name || t.solarTermsName || t.termName || "term"}-${t.createDate || ""}`,
-                        displayName: t.name || t.solarTermsName || t.termName || "节气",
-                        createDate: t.createDate || null,
-                        progress: Number(t.progress) || 0,
-                    }));
-                solarTerms.value = filtered;
-                // 物候期数据
-                phenologyList.value = Array.isArray(data?.phenologyList)
-                    ? data.phenologyList.map((it) => {
-                          const reproductiveList = Array.isArray(it.reproductiveList)
-                              ? it.reproductiveList.map((r) => {
-                                    const farmWorkArrangeList = Array.isArray(r.farmWorkArrangeList)
-                                        ? r.farmWorkArrangeList.map((fw) => ({
-                                              ...fw,
-                                              containerSpaceTimeId: it.containerSpaceTimeId,
-                                          }))
-                                        : [];
-                                    return {
-                                        ...r,
-                                        farmWorkArrangeList,
-                                    };
-                                })
-                              : [];
-
-                          return {
-                              id: it.id ?? it.phenologyId ?? it.name ?? `${it.progress}-${it.progress2}`,
-                              progress: Number(it.progress) || 0, // 起点 %
-                              progress2: Number(it.progress2) || 0, // 终点 %
-                              startTimeMs: safeParseDate(
-                                  it.startDate || it.beginDate || it.startTime || it.start || it.start_at
-                              ),
-                              reproductiveList,
-                          };
-                      })
-                    : [];
-
-                nextTick(() => {
-                    if (isInitialLoad.value) {
-                        const currentSeason = getCurrentSeason();
-                        handleSeasonClick(currentSeason);
-                        isInitialLoad.value = false;
-                    } else if (timelineContainerRef.value && savedScrollTop > 0) {
-                        timelineContainerRef.value.scrollTop = savedScrollTop;
-                    }
-                });
-            }
-        })
-        .catch((error) => {
-            console.error("获取农事规划数据失败:", error);
-            ElMessage.error("获取农事规划数据失败");
-        });
-};
-
-watch(
-    () => props.farmId || props.containerId,
-    (val) => {
-        if (val) {
-            isInitialLoad.value = true;
-            getFarmWorkPlan();
-        }
-    },
-    { immediate: true }
-);
-</script>
-
-<style scoped lang="scss">
-.timeline-container {
-    height: 100%;
-    overflow: auto;
-    position: relative;
-    box-sizing: border-box;
-    .timeline-list {
-        position: relative;
-    }
-    .timeline-middle-line {
-        position: absolute;
-        left: 15px; /* 位于节气文字列中间(列宽约30px) */
-        top: 0;
-        bottom: 0;
-        width: 2px;
-        background: #e8e8e8;
-        z-index: 1;
-    }
-    .phenology-bar {
-        display: flex;
-        align-items: stretch;
-        justify-content: center;
-        box-sizing: border-box;
-        .reproductive-list {
-            display: grid;
-            grid-auto-rows: 1fr; /* 子项等高,整体等分父高度 */
-            align-items: stretch;
-            justify-items: center; /* 子项居中 */
-            width: 100%;
-            height: 100%;
-            box-sizing: border-box;
-        }
-        .reproductive-item {
-            font-size: 12px;
-            text-align: center;
-            word-break: break-all;
-            writing-mode: vertical-rl;
-            text-orientation: upright;
-            letter-spacing: 3px;
-            width: 100%;
-            line-height: 23px;
-            color: inherit;
-            position: relative;
-            &.horizontal-text {
-                writing-mode: horizontal-tb;
-                text-orientation: mixed;
-                letter-spacing: normal;
-                line-height: calc(var(--item-height, 15px) - 3px);
-            }
-            &.vertical-lr-text {
-                writing-mode: vertical-lr;
-                text-orientation: upright;
-                letter-spacing: 3px;
-                line-height: 26px;
-            }
-            .arranges {
-                position: absolute;
-                left: 40px; /* 列与中线右侧一段距离 */
-                top: 0;
-                z-index: 3;
-                display: flex;
-                max-width: calc(100vw - 100px);
-                gap: 12px;
-                letter-spacing: 0px;
-                .arrange-card {
-                    width: 97%;
-                    border: 0.5px solid #2199f8;
-                    border-radius: 8px;
-                    background: #fff;
-                    box-sizing: border-box;
-                    position: relative;
-                    padding: 8px;
-                    writing-mode: horizontal-tb;
-                    .card-header {
-                        display: flex;
-                        justify-content: space-between;
-                        align-items: center;
-                        .header-left {
-                            display: flex;
-                            align-items: center;
-                            gap: 8px;
-                            .farm-work-name {
-                                font-size: 14px;
-                                font-weight: 500;
-                                color: #1d2129;
-                            }
-                            .tag-standard {
-                                padding: 0 8px;
-                                background: rgba(119, 119, 119, 0.1);
-                                border-radius: 25px;
-                                font-weight: 400;
-                                font-size: 12px;
-                                color: #000;
-                            }
-                        }
-                        .header-right {
-                            font-size: 12px;
-                            color: #808080;
-                            padding: 0 8px;
-                            border-radius: 25px;
-                        }
-                    }
-                    .card-content {
-                        color: #909090;
-                        text-align: left;
-                        line-height: 1.55;
-                        margin: 4px 0 2px 0;
-                        .edit-link {
-                            color: #2199f8;
-                            margin-left: 5px;
-                        }
-                    }
-                    .status-icon {
-                        position: absolute;
-                        right: -8px;
-                        bottom: -8px;
-                        z-index: 3;
-                    }
-                    &::before {
-                        content: "";
-                        position: absolute;
-                        left: -6px;
-                        top: 50%;
-                        transform: translateY(-50%);
-                        width: 0;
-                        height: 0;
-                        border-top: 5px solid transparent;
-                        border-bottom: 5px solid transparent;
-                        border-right: 5px solid #2199f8;
-                    }
-                }
-                .arrange-card.normal-style {
-                    opacity: 0.4;
-                }
-                .arrange-card.status-warning {
-                    border-color: #ff953d;
-                    &::before {
-                        border-right-color: #ff953d;
-                    }
-                }
-                .arrange-card.status-complete {
-                    border-color: #1ca900;
-                    &::before {
-                        border-right-color: #1ca900;
-                    }
-                }
-                .arrange-card.status-normal {
-                    border-color: #2199f8;
-                    &::before {
-                        border-right-color: #2199f8;
-                    }
-                }
-            }
-        }
-    }
-    .reproductive-item + .reproductive-item {
-        border-top: 2px solid #fff;
-    }
-    .phenology-bar + .phenology-bar {
-        border-top: 2px solid #fff;
-    }
-    .timeline-term {
-        position: absolute;
-        width: 30px;
-        padding-right: 16px;
-        display: flex;
-        align-items: flex-start;
-        z-index: 2; /* 置于中线之上 */
-        .term-name {
-            display: inline-block;
-            width: 100%;
-            height: 46px;
-            line-height: 30px;
-            background: #f5f7fb;
-            font-size: 13px;
-            word-break: break-all;
-            writing-mode: vertical-rl;
-            text-orientation: upright;
-            color: rgba(174, 174, 174, 0.6);
-            text-align: center;
-        }
-    }
-}
-</style>

+ 634 - 0
src/components/pageComponents/GrowthStageTimeline.vue

@@ -0,0 +1,634 @@
+<template>
+    <div class="growth-stage-timeline">
+        <div class="growth-stage-timeline__scroll" ref="scrollRef">
+            <div class="growth-stage-timeline__inner" :style="innerStyle">
+                <!-- 生育期背景 -->
+                <div
+                    class="growth-stage-timeline__bg"
+                    :style="{ gridTemplateColumns: gridCols }"
+                >
+                    <div
+                        v-for="(stage, i) in normalizedStages"
+                        :key="'bg-' + i"
+                        class="growth-stage-timeline__bg-cell"
+                        :class="{
+                            'growth-stage-timeline__bg-cell--start': isPeriodStart(i),
+                        }"
+                    >
+                        <template v-if="isPeriodStart(i)">
+                            <div class="growth-stage-timeline__period-title">
+                                {{ stage.periodTitle }}
+                            </div>
+                            <div class="growth-stage-timeline__period-sub">
+                                {{ stage.periodSubtitle }}
+                            </div>
+                        </template>
+                    </div>
+                </div>
+
+                <!-- 轨道:横线 + 节点 + 拖动手柄 -->
+                <div class="growth-stage-timeline__track" ref="trackRef">
+                    <div class="growth-stage-timeline__track-line" aria-hidden="true"></div>
+                    <div
+                        class="growth-stage-timeline__track-grid"
+                        :style="{ gridTemplateColumns: gridCols }"
+                    >
+                        <div
+                            v-for="(_, i) in normalizedStages"
+                            :key="'dot-' + i"
+                            class="growth-stage-timeline__dot-wrap"
+                        >
+                            <span class="growth-stage-timeline__dot"></span>
+                        </div>
+                    </div>
+
+                    <div
+                        ref="handleRef"
+                        class="growth-stage-timeline__handle"
+                        :style="handleStyle"
+                        @pointerdown.stop.prevent="onHandlePointerDown"
+                    >
+                        <div class="growth-stage-timeline__handle-core">
+                            <div
+                                v-if="showHandleTooltip"
+                                class="growth-stage-timeline__tooltip"
+                            >
+                                {{ tooltipText }}
+                            </div>
+                            <div class="growth-stage-timeline__handle-body">
+                                <span class="growth-stage-timeline__handle-bar"></span>
+                                <span class="growth-stage-timeline__handle-bar"></span>
+                                <span class="growth-stage-timeline__handle-bar"></span>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- 阶段文案 + 绿色标签 -->
+                <div
+                    class="growth-stage-timeline__labels"
+                    :style="{ gridTemplateColumns: gridCols }"
+                >
+                    <div
+                        v-for="(stage, i) in normalizedStages"
+                        :key="'lb-' + i"
+                        class="growth-stage-timeline__label-col"
+                    >
+                        <div
+                            class="growth-stage-timeline__label-text"
+                            :class="{
+                                'growth-stage-timeline__label-text--active':
+                                    i === activeIndex,
+                            }"
+                        >
+                            {{ stage.label }}
+                        </div>
+                        <div
+                            v-if="stage.tags && stage.tags.length"
+                            class="growth-stage-timeline__tags"
+                        >
+                            <span
+                                v-for="(tag, ti) in stage.tags"
+                                :key="ti"
+                                class="growth-stage-timeline__tag"
+                            >{{ tag }}</span>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import {
+    computed,
+    ref,
+    watch,
+    onMounted,
+    onBeforeUnmount,
+    nextTick,
+} from "vue";
+
+/**
+ * @typedef {Object} GrowthStageItem
+ * @property {string} label 节点文案,如「60%展开」
+ * @property {string[]} [tags] 绿色标签,如 ['最佳给肥点']
+ * @property {string} periodTitle 生育期大标题
+ * @property {string} periodSubtitle 生育期描述小字
+ */
+
+const props = defineProps({
+    /** @type {import('vue').PropType<GrowthStageItem[]>} */
+    stages: {
+        type: Array,
+        default: () => [],
+    },
+    /** 当前选中的阶段索引(与 v-model 同步);不传时为中间一档 */
+    modelValue: {
+        type: Number,
+        default: undefined,
+    },
+    tooltipText: {
+        type: String,
+        default: "此为预估进程,请左右移动进行校准!",
+    },
+    /** 每列最小宽度(px),列数多时可横向滚动 */
+    minColWidth: {
+        type: Number,
+        default: 72,
+    },
+});
+
+const emit = defineEmits(["update:modelValue", "change"]);
+
+const scrollRef = ref(null);
+const trackRef = ref(null);
+const handleRef = ref(null);
+
+const normalizedStages = computed(() =>
+    Array.isArray(props.stages) ? props.stages : []
+);
+
+const colCount = computed(() =>
+    Math.max(1, normalizedStages.value.length)
+);
+
+const gridCols = computed(() =>
+    `repeat(${colCount.value}, minmax(${props.minColWidth}px, 1fr))`
+);
+
+/** 左右留白:手柄 translate(-50%) 与气泡在首尾否则会溢出滚动宽度,且气泡换行会拉高整列导致视觉上“掉下去” */
+const INNER_EDGE_PAD_PX = 24;
+
+const innerStyle = computed(() => ({
+    boxSizing: "border-box",
+    minWidth: `${colCount.value * props.minColWidth + INNER_EDGE_PAD_PX * 2}px`,
+    paddingLeft: `${INNER_EDGE_PAD_PX}px`,
+    paddingRight: `${INNER_EDGE_PAD_PX}px`,
+}));
+
+function periodKey(stage) {
+    const s = stage || {};
+    return `${s.periodTitle || ""}\0${s.periodSubtitle || ""}`;
+}
+
+function isPeriodStart(i) {
+    if (i === 0) return true;
+    const list = normalizedStages.value;
+    return periodKey(list[i]) !== periodKey(list[i - 1]);
+}
+
+function clampStageIndex(v) {
+    const last = Math.max(0, colCount.value - 1);
+    return Math.min(Math.max(0, v), last);
+}
+
+/** 项数为 n 时默认选中索引 floor((n-1)/2),偶数个时偏左中间 */
+function middleStageIndex() {
+    const n = colCount.value;
+    return Math.max(0, Math.floor((n - 1) / 2));
+}
+
+const activeIndex = ref(0);
+
+watch(
+    [colCount, () => props.modelValue],
+    () => {
+        const mv = props.modelValue;
+        const next =
+            mv === undefined || mv === null
+                ? middleStageIndex()
+                : clampStageIndex(mv);
+        activeIndex.value = next;
+        if (mv === undefined || mv === null) {
+            nextTick(() => {
+                emit("update:modelValue", next);
+            });
+        }
+    },
+    { immediate: true }
+);
+
+/** 手柄水平位置:0~1,对应轨道宽度 */
+const handleRatio = ref(0);
+
+function indexToRatio(idx) {
+    const n = colCount.value;
+    if (n <= 1) return 0.5;
+    return (idx + 0.5) / n;
+}
+
+function ratioToIndex(r) {
+    const n = colCount.value;
+    if (n <= 1) return 0;
+    const idx = Math.round(r * n - 0.5);
+    return Math.min(Math.max(0, idx), n - 1);
+}
+
+/** 禁止在轨道上横向拖滚:仅在手柄拖动时由脚本更新 scrollLeft */
+let scrollAreaTouchStartX = 0;
+let scrollAreaTouchStartY = 0;
+
+function onScrollAreaTouchStart(e) {
+    if (!e.touches?.length) return;
+    scrollAreaTouchStartX = e.touches[0].clientX;
+    scrollAreaTouchStartY = e.touches[0].clientY;
+}
+
+function onScrollAreaTouchMove(e) {
+    if (!e.touches?.length) return;
+    const dx = e.touches[0].clientX - scrollAreaTouchStartX;
+    const dy = e.touches[0].clientY - scrollAreaTouchStartY;
+    if (Math.abs(dx) > Math.abs(dy)) {
+        e.preventDefault();
+    }
+}
+
+function getScrollLayout() {
+    const scrollEl = scrollRef.value;
+    if (!scrollEl) return null;
+    const inner = scrollEl.querySelector(".growth-stage-timeline__inner");
+    if (!inner) return null;
+    const w = inner.offsetWidth;
+    const cw = scrollEl.clientWidth;
+    const maxScroll = Math.max(0, scrollEl.scrollWidth - cw);
+    const pad = INNER_EDGE_PAD_PX;
+    const contentW = Math.max(0, w - pad * 2);
+    return { scrollEl, cw, maxScroll, pad, contentW };
+}
+
+/** 将轨道上的比例位置(0~1,与手柄 left 一致)滚到视口水平中央 */
+function scrollToCenterRatio(ratio, behavior = "auto") {
+    const m = getScrollLayout();
+    if (!m) return;
+    const { scrollEl, cw, maxScroll, pad, contentW } = m;
+    const r = Math.min(Math.max(0, ratio), 1);
+    const centerX = pad + r * contentW;
+    let left = centerX - cw / 2;
+    left = Math.max(0, Math.min(left, maxScroll));
+    scrollEl.scrollTo({ left, behavior });
+}
+
+function syncScrollToRatio(ratio) {
+    const m = getScrollLayout();
+    if (!m) return;
+    const { scrollEl, cw, maxScroll, pad, contentW } = m;
+    const r = Math.min(Math.max(0, ratio), 1);
+    const centerX = pad + r * contentW;
+    let left = centerX - cw / 2;
+    left = Math.max(0, Math.min(left, maxScroll));
+    scrollEl.scrollLeft = left;
+}
+
+function onTimelineWheel(e) {
+    if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+        e.preventDefault();
+    }
+}
+
+watch(
+    [activeIndex, colCount],
+    () => {
+        handleRatio.value = indexToRatio(activeIndex.value);
+        nextTick(() => {
+            scrollToCenterRatio(indexToRatio(activeIndex.value), "auto");
+        });
+    },
+    { immediate: true }
+);
+
+let resizeObserver = null;
+
+const handleStyle = computed(() => ({
+    left: `${handleRatio.value * 100}%`,
+}));
+
+/** 本次进入页面内展示;拖动手柄产生位移后关闭,下次路由/页面再进入会重新挂载并再次显示 */
+const showHandleTooltip = ref(true);
+
+let dragging = false;
+let activePointerId = null;
+/** 本次按下后是否发生过 pointermove(视为“移动过”) */
+let movedDuringHandleDrag = false;
+
+function getTrackRect() {
+    return trackRef.value?.getBoundingClientRect() || null;
+}
+
+function clientXToRatio(clientX) {
+    const rect = getTrackRect();
+    if (!rect || rect.width <= 0) return handleRatio.value;
+    const x = clientX - rect.left;
+    const r = x / rect.width;
+    return Math.min(Math.max(0, r), 1);
+}
+
+function onHandlePointerDown(e) {
+    if (!trackRef.value) return;
+    dragging = true;
+    movedDuringHandleDrag = false;
+    activePointerId = e.pointerId;
+    try {
+        handleRef.value?.setPointerCapture(e.pointerId);
+    } catch (_) {
+        /* ignore */
+    }
+    handleRatio.value = clientXToRatio(e.clientX);
+    syncScrollToRatio(handleRatio.value);
+    document.body.style.userSelect = "none";
+}
+
+function onPointerMove(e) {
+    if (!dragging || e.pointerId !== activePointerId) return;
+    movedDuringHandleDrag = true;
+    handleRatio.value = clientXToRatio(e.clientX);
+    syncScrollToRatio(handleRatio.value);
+}
+
+function onPointerUp(e) {
+    if (!dragging || e.pointerId !== activePointerId) return;
+    dragging = false;
+    activePointerId = null;
+    document.body.style.userSelect = "";
+    try {
+        handleRef.value?.releasePointerCapture(e.pointerId);
+    } catch (_) {
+        /* ignore */
+    }
+    const nextIdx = ratioToIndex(handleRatio.value);
+    activeIndex.value = nextIdx;
+    handleRatio.value = indexToRatio(nextIdx);
+    if (movedDuringHandleDrag) {
+        showHandleTooltip.value = false;
+    }
+    movedDuringHandleDrag = false;
+    emit("update:modelValue", nextIdx);
+    emit("change", nextIdx, normalizedStages.value[nextIdx]);
+}
+
+onMounted(() => {
+    window.addEventListener("pointermove", onPointerMove);
+    window.addEventListener("pointerup", onPointerUp);
+    window.addEventListener("pointercancel", onPointerUp);
+    const el = scrollRef.value;
+    el?.addEventListener("wheel", onTimelineWheel, { passive: false });
+    el?.addEventListener("touchstart", onScrollAreaTouchStart, {
+        passive: true,
+    });
+    el?.addEventListener("touchmove", onScrollAreaTouchMove, {
+        passive: false,
+    });
+    resizeObserver =
+        typeof ResizeObserver !== "undefined"
+            ? new ResizeObserver(() => {
+                  nextTick(() => {
+                      scrollToCenterRatio(
+                          indexToRatio(activeIndex.value),
+                          "auto"
+                      );
+                  });
+              })
+            : null;
+    if (el && resizeObserver) {
+        resizeObserver.observe(el);
+    }
+});
+
+onBeforeUnmount(() => {
+    window.removeEventListener("pointermove", onPointerMove);
+    window.removeEventListener("pointerup", onPointerUp);
+    window.removeEventListener("pointercancel", onPointerUp);
+    const el = scrollRef.value;
+    el?.removeEventListener("wheel", onTimelineWheel);
+    el?.removeEventListener("touchstart", onScrollAreaTouchStart);
+    el?.removeEventListener("touchmove", onScrollAreaTouchMove);
+    resizeObserver?.disconnect();
+    resizeObserver = null;
+});
+</script>
+
+<style scoped lang="scss">
+.growth-stage-timeline {
+    width: 100%;
+    overflow: hidden;
+}
+
+.growth-stage-timeline__scroll {
+    overflow-x: hidden;
+    overflow-y: hidden;
+    touch-action: pan-y;
+    overscroll-behavior-x: none;
+    -webkit-overflow-scrolling: touch;
+}
+
+.growth-stage-timeline__inner {
+    display: flex;
+    flex-direction: column;
+    gap: 0;
+    padding-bottom: 4px;
+}
+
+.growth-stage-timeline__bg {
+    display: grid;
+    min-height: 56px;
+    border-radius: 6px 6px 0 0;
+    overflow: hidden;
+}
+
+.growth-stage-timeline__bg-cell {
+    background: #f0f2f5;
+    padding: 8px 6px;
+    text-align: center;
+    box-sizing: border-box;
+    border-right: 1px solid rgba(0, 0, 0, 0.04);
+
+    &:last-child {
+        border-right: none;
+    }
+
+    &--start {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        gap: 2px;
+    }
+}
+
+.growth-stage-timeline__period-title {
+    font-size: 15px;
+    font-weight: 600;
+    color: #1d2129;
+    line-height: 1.2;
+}
+
+.growth-stage-timeline__period-sub {
+    font-size: 11px;
+    color: rgba(60, 60, 60, 0.45);
+    line-height: 1.3;
+}
+
+.growth-stage-timeline__track {
+    position: relative;
+    min-height: 44px;
+    margin-top: 4px;
+}
+
+.growth-stage-timeline__track-line {
+    position: absolute;
+    left: 0;
+    right: 0;
+    top: 50%;
+    height: 2px;
+    margin-top: -1px;
+    background: #e5e6eb;
+    pointer-events: none;
+    z-index: 0;
+}
+
+.growth-stage-timeline__track-grid {
+    position: relative;
+    z-index: 1;
+    display: grid;
+    align-items: center;
+    min-height: 44px;
+}
+
+.growth-stage-timeline__dot-wrap {
+    position: relative;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    pointer-events: none;
+}
+
+.growth-stage-timeline__dot {
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: #e5e6eb;
+    border: 2px solid #fff;
+    box-sizing: border-box;
+}
+
+.growth-stage-timeline__handle {
+    position: absolute;
+    top: 50%;
+    z-index: 2;
+    /* 旋转中心仅含按钮高度:tooltip 绝对叠在上方,避免参与 translate(-50%,-50%) 的包围盒 */
+    transform: translate(-50%, -50%);
+    touch-action: none;
+    cursor: grab;
+
+    &:active {
+        cursor: grabbing;
+    }
+}
+
+.growth-stage-timeline__handle-core {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+}
+
+.growth-stage-timeline__tooltip {
+    position: absolute;
+    left: 50%;
+    bottom: 100%;
+    transform: translateX(-50%);
+    max-width: 220px;
+    white-space: nowrap;
+    margin-bottom: 6px;
+    padding: 6px 10px;
+    border-radius: 6px;
+    background: #808080;
+    color: #fff;
+    font-size: 11px;
+    line-height: 1.4;
+    text-align: center;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
+    pointer-events: none;
+
+    &::after {
+        content: "";
+        position: absolute;
+        left: 50%;
+        bottom: -5px;
+        transform: translateX(-50%);
+        border-width: 5px 5px 0 5px;
+        border-style: solid;
+        border-color: #808080 transparent transparent transparent;
+    }
+}
+
+.growth-stage-timeline__handle-body {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 3px;
+    width: 36px;
+    height: 28px;
+    border-radius: 6px;
+    background: #2199f8;
+    box-shadow: 0 2px 6px rgba(33, 153, 248, 0.35);
+}
+
+.growth-stage-timeline__handle-bar {
+    display: block;
+    width: 14px;
+    height: 2px;
+    border-radius: 1px;
+    background: #fff;
+}
+
+.growth-stage-timeline__labels {
+    display: grid;
+    margin-top: 10px;
+    gap: 0;
+}
+
+.growth-stage-timeline__label-col {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    text-align: center;
+    padding: 0 4px 6px;
+    box-sizing: border-box;
+}
+
+.growth-stage-timeline__label-text {
+    font-size: 12px;
+    color: #c0c4cc;
+    line-height: 1.3;
+    word-break: break-all;
+
+    &--active {
+        color: #2199f8;
+        font-weight: 600;
+    }
+}
+
+.growth-stage-timeline__tags {
+    margin-top: 6px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 4px;
+    width: 100%;
+}
+
+.growth-stage-timeline__tag {
+    display: inline-block;
+    max-width: 100%;
+    padding: 2px 6px;
+    border-radius: 3px;
+    background: #00a870;
+    color: #fff;
+    font-size: 10px;
+    line-height: 1.3;
+    word-break: break-all;
+}
+</style>

+ 0 - 204
src/components/popup/harvestTimePopup.vue

@@ -1,204 +0,0 @@
-<template>
-    <popup
-        v-model:show="showValue"
-        class="harvest-time-popup"
-        closeable
-    >
-        <div class="popup-content">
-            <div
-                v-for="(item, index) in innerCrops"
-                :key="index"
-                class="crop-row"
-            >
-                <div class="crop-label">
-                    <span class="crop-name">{{ item.name }}</span>
-                    <span>的成熟收获时间</span>
-                </div>
-                <div class="date-inputs">
-                    <div class="date-item">
-                        <el-select
-                            v-model="item.month"
-                            filterable
-                            size="large"
-                            placeholder="请选择月份"
-                            class="month-select"
-                            @change="handleMonthChange(item)"
-                        >
-                            <el-option
-                                v-for="m in 12"
-                                :key="m"
-                                :label="`${m}月`"
-                                :value="m"
-                            />
-                        </el-select>
-                    </div>
-                    <div class="date-item">
-                        <el-select
-                            v-model="item.day"
-                            filterable
-                            size="large"
-                            placeholder="请选择日期"
-                            class="day-select"
-                        >
-                            <el-option
-                                v-for="d in getDayCount(item.month)"
-                                :key="d"
-                                :label="`${d}日`"
-                                :value="d"
-                            />
-                        </el-select>
-                    </div>
-                </div>
-            </div>
-
-            <div class="btn-confirm" @click="handleConfirm">
-                确认
-            </div>
-        </div>
-    </popup>
-</template>
-
-<script setup>
-import { Popup } from "vant";
-import { computed, watch, ref } from "vue";
-import { ElMessage } from "element-plus";
-
-const props = defineProps({
-    show: {
-        type: Boolean,
-        default: false,
-    },
-    // [{ name: '荔枝', month: '', day: '' }]
-    crops: {
-        type: Array,
-        default: () => [],
-    },
-});
-
-const emit = defineEmits(["update:show", "confirm"]);
-
-const showValue = computed({
-    get: () => props.show,
-    set: (val) => emit("update:show", val),
-});
-
-// 内部可编辑数据,避免直接修改 props
-const innerCrops = ref([]);
-
-const isLeapYear = (year) => {
-    return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
-};
-
-const getDayCount = (month) => {
-    const monthNum = Number(month);
-    if (!monthNum) return 30;
-    if (monthNum === 2) {
-        const currentYear = new Date().getFullYear();
-        return isLeapYear(currentYear) ? 29 : 28;
-    }
-    if ([4, 6, 9, 11].includes(monthNum)) return 30;
-    return 31;
-};
-
-const handleMonthChange = (item) => {
-    const maxDay = getDayCount(item.month);
-    if (Number(item.day) > maxDay) {
-        item.day = "";
-    }
-};
-
-watch(
-    () => props.crops,
-    (val) => {
-        innerCrops.value = (val || []).map((item) => ({
-            name: item.name,
-            month: item.month || "",
-            day: item.day || "",
-        }));
-    },
-    { immediate: true, deep: true }
-);
-
-const handleConfirm = () => {
-    const invalid = innerCrops.value.some(
-        (item) => !item.name || !item.month || !item.day
-    );
-    if (invalid) {
-        ElMessage.warning("请先将所有作物的成熟时间填写完整");
-        return;
-    }
-    emit("confirm", innerCrops.value);
-};
-</script>
-
-<style scoped lang="scss">
-.harvest-time-popup {
-    width: 100%;
-    padding: 24px 18px 20px;
-    border-radius: 12px;
-    background: linear-gradient(0deg, #ffffff 70%, #d1ebff 100%);
-
-    ::v-deep {
-        .van-popup__close-icon {
-            color: #333333;
-        }
-
-        .el-input__wrapper {
-            padding: 4px 8px;
-            box-shadow: none;
-            border-radius: 6px;
-        }
-
-        .el-input__inner {
-            text-align: center;
-        }
-    }
-
-    .popup-content {
-        display: flex;
-        flex-direction: column;
-        align-items: stretch;
-    }
-
-    .crop-row + .crop-row {
-        margin-top: 16px;
-    }
-
-    .crop-label {
-        margin-bottom: 8px;
-        font-size: 14px;
-        color: #333;
-
-        .crop-name {
-            color: #0089f5;
-            margin-right: 4px;
-        }
-    }
-
-    .date-inputs {
-        display: flex;
-        justify-content: space-between;
-        gap: 12px;
-    }
-
-    .date-item {
-        flex: 1;
-        display: flex;
-        align-items: center;
-        background: #ffffff;
-        border-radius: 6px;
-    }
-
-    .btn-confirm {
-        margin-top: 24px;
-        height: 40px;
-        line-height: 40px;
-        text-align: center;
-        border-radius: 24px;
-        background: #2199f8;
-        color: #ffffff;
-        font-size: 16px;
-    }
-}
-</style>
-

+ 0 - 604
src/components/popup/offerPopup.vue

@@ -1,604 +0,0 @@
-<template>
-    <popup class="offer-popup" teleport="body" :overlay-style="{'z-index': 9999}" v-model:show="show" :close-on-click-overlay="false" :closeable="stepIndex === 2">
-        <div class="step-1" v-if="stepIndex === 1">
-            <div class="title">
-                <div class="text">
-                    <div>请输入</div>
-                    <div class="blue">实际交易金额</div>
-                </div>
-                <img src="@/assets/img/home/offer-icon.png" alt="" />
-            </div>
-            <!-- <div class="tips">注:本次成本不对外公开,仅作为投入产出比的计算</div> -->
-            <el-form ref="formRef" :model="formData" :rules="rules" label-width="0">
-                <div class="inputs-wrap">
-                    <div class="input-row">
-                        <el-form-item prop="agriculturalInput" class="input-item">
-                            <div class="input-header">农资投入</div>
-                            <el-input
-                                class="input-field"
-                                type="number"
-                                v-model="formData.agriculturalInput"
-                                placeholder="请输入数字"
-                            >
-                                <template #suffix>
-                                    <span class="unit">元</span>
-                                </template>
-                            </el-input>
-                        </el-form-item>
-                        <el-form-item prop="serviceInput" class="input-item">
-                            <div class="input-header">农服投入</div>
-                            <el-input
-                                class="input-field"
-                                type="number"
-                                v-model="formData.serviceInput"
-                                placeholder="请输入数字"
-                            >
-                                <template #suffix>
-                                    <span class="unit">元</span>
-                                </template>
-                            </el-input>
-                        </el-form-item>
-                    </div>
-                    <div class="input-row total-row">
-                        <div class="total-label">总金额</div>
-                        <el-form-item prop="totalAmount" class="total-form-item">
-                            <el-input
-                                class="input-field total-input"
-                                type="number"
-                                v-model="formData.totalAmount"
-                                placeholder="请输入数字"
-                            >
-                                <template #suffix>
-                                    <span class="unit">元</span>
-                                </template>
-                            </el-input>
-                        </el-form-item>
-                    </div>
-                </div>
-            </el-form>
-        </div>
-        <div class="step-2" v-else>
-            <div class="upload-wrap" :class="{ 'upload-cont': fileList.length }">
-                <div class="name"><span class="required">*</span>请上传执行照片(至少两张)</div>
-                <uploader
-                    class="uploader"
-                    v-model="fileList"
-                    multiple
-                    :max-count="5"
-                    :after-read="afterRead"
-                    @click="handleClick('rg')"
-                >
-                    <img class="img" v-show="!fileList.length" src="@/assets/img/home/example-4.png" alt="" />
-                    <img class="plus" src="@/assets/img/home/plus.png" alt="" />
-                </uploader>
-            </div>
-
-            <div class="time-wrap">
-                <div class="name"><span class="required">*</span>请选择 {{ executionData.farmWorkName }} 实际执行时间</div>
-                <div class="time-input">
-                    <el-date-picker
-                        v-model="executeTime"
-                        popper-style="z-index: 99999 !important;"
-                        :disabled-date="disabledDate"
-                        size="large"
-                        style="width: 100%"
-                        type="date"
-                        placeholder="请选择日期"
-                        :editable="false"
-                    />
-                </div>
-            </div>
-        </div>
-        <div class="tips-text">注:交易信息保密不公开</div>
-        <div class="button-wrap" v-if="stepIndex === 1">
-            <div class="button second" @click="handleCancel">取消</div>
-            <div
-                @click="handleNextStep"
-                class="button primary"
-                :class="{ 'btn-color': formData.totalAmount && formData.totalAmount.length > 0 }"
-            >
-                下一步
-            </div>
-        </div>
-        <div class="button-wrap" v-if="stepIndex === 2">
-            <div class="button second" @click="toggleStep(1)">上一步</div>
-            <div v-if="stepIndex === 2" @click="closeTask" class="button primary btn-color">确认上传</div>
-        </div>
-    </popup>
-</template>
-
-<script setup>
-import { Popup, Uploader } from "vant";
-import { ref, watch, reactive } from "vue";
-import { useStore } from "vuex";
-import { getFileExt } from "@/utils/util";
-import UploadFile from "@/utils/upliadFile";
-import { base_img_url2 } from "@/api/config";
-import { ElMessage, ElMessageBox } from "element-plus";
-import wx from "weixin-js-sdk";
-import { useRouter } from "vue-router";
-const router = useRouter();
-
-const store = useStore();
-const miniUserId = store.state.home.miniUserId;
-const show = ref(false);
-const formRef = ref(null);
-
-const formData = reactive({
-    agriculturalInput: "",
-    serviceInput: "",
-    totalAmount: "",
-});
-
-const rules = {
-    agriculturalInput: [
-        { required: false, message: "请输入农资投入", trigger: "blur" },
-        {
-            validator: (rule, value, callback) => {
-                if (isNaN(value) || Number(value) < 0) {
-                    callback(new Error("请输入有效的数字"));
-                } else {
-                    callback();
-                }
-            },
-            trigger: "blur",
-        },
-    ],
-    serviceInput: [
-        { required: false, message: "请输入农服投入", trigger: "blur" },
-        {
-            validator: (rule, value, callback) => {
-                if (isNaN(value) || Number(value) < 0) {
-                    callback(new Error("请输入有效的数字"));
-                } else {
-                    callback();
-                }
-            },
-            trigger: "blur",
-        },
-    ],
-    totalAmount: [
-        { required: true, message: "请输入总金额", trigger: "blur" },
-        {
-            validator: (rule, value, callback) => {
-                if (!value || value.trim() === "") {
-                    callback(new Error("请输入总金额"));
-                } else if (isNaN(value) || Number(value) < 0) {
-                    callback(new Error("请输入有效的数字"));
-                } else {
-                    callback();
-                }
-            },
-            trigger: "blur",
-        },
-    ],
-};
-
-const stepIndex = ref(1);
-const executeTime = ref("");
-const fileList = ref([]);
-const fileArr = ref([]);
-
-const imgType = ref("");
-const handleClick = (type) => {
-    imgType.value = type;
-};
-
-const handleCancel = () => {
-    show.value = false;
-    resetForm();
-};
-
-const uploadFileObj = new UploadFile();
-const afterRead = (file) => {
-    // 处理多张照片的情况:file 可能是数组
-    const files = Array.isArray(file) ? file : [file];
-    
-    files.forEach((item) => {
-        // 将文件上传至服务器
-        let fileVal = item.file;
-        if (!fileVal) return; // 如果没有 file 属性,跳过
-        
-        item.status = "uploading";
-        item.message = "上传中...";
-        let ext = getFileExt(fileVal.name);
-        let key = `birdseye-look-mini/${miniUserId}/${new Date().getTime()}.${ext}`;
-        uploadFileObj.put(key, fileVal).then((resFilename) => {
-            item.status = "done";
-            item.message = "";
-            fileArr.value.push(resFilename);
-        }).catch(() => {
-            item.status = 'failed';
-            item.message = '上传失败';
-            ElMessage.error('图片上传失败,请稍后再试!')
-        });
-    });
-};
-
-function toggleStep(val) {
-    stepIndex.value = val;
-}
-
-function resetForm() {
-    // 重置表单验证状态
-    if (formRef.value) {
-        formRef.value.clearValidate();
-        formRef.value.resetFields();
-    }
-}
-
-function handleNextStep() {
-    if (!formRef.value) return;
-
-    formRef.value.validate((valid) => {
-        if (valid) {
-            toggleStep(2);
-        } else {
-            ElMessage.warning("请完善信息");
-        }
-    });
-}
-
-function formatDate(date) {
-    let year = date.getFullYear();
-    let month = String(date.getMonth() + 1).padStart(2, "0");
-    let day = String(date.getDate()).padStart(2, "0");
-    return `${year}-${month}-${day}`;
-}
-
-const emit = defineEmits(['uploadSuccess']);
-function closeTask() {
-    // stepIndex.value = 2
-    if (!fileArr.value.length || fileArr.value.length < 2) return ElMessage.warning('请上传至少两张图片')
-    if (!executeTime.value) return ElMessage.warning('请选择实际执行时间')
-    const params = {
-        recordId: executionData.value.id,
-        executeDate: formatDate(executeTime.value),
-        executeEvidence: fileArr.value,
-        actualAgriculturalInput: formData.agriculturalInput,
-        actualFarmServiceInput: formData.serviceInput,
-        actualTotalInput: formData.totalAmount,
-    }
-    VE_API.z_farm_work_record.addExecuteImgAndComplete(params).then((res) => {
-        if (res.code === 0) {
-            ElMessage.success('上传成功')
-            show.value = false
-            emit('uploadSuccess')
-        }
-    })
-    // show.value = false;
-
-    // router.push("/review_work");
-
-    // if(!input.value.length) return ElMessage.warning('请上传图片')
-    // const params = {
-    //     ...props.executionData,
-    //     orderStatus: 4,
-    //     farmWorkservicecost: input.value,
-    //     confirmPicture: fileArr.value
-    // }
-    // VE_API.order.confirm(params).then(({ code }) => {
-    //     if (code === 0) {
-    //         ElMessage({
-    //             message: "操作成功",
-    //             type: "success",
-    //         });
-    //         setTimeout(() => {
-    //             // wx.miniProgram.navigateBack()
-    //             router.replace("/feature_home_album?list=true");
-    //         }, 500)
-    //     }
-    // })
-}
-
-// 计算总金额
-function calculateTotalAmount() {
-    const agricultural = parseFloat(formData.agriculturalInput) || 0;
-    const service = parseFloat(formData.serviceInput) || 0;
-    
-    if (agricultural > 0 || service > 0) {
-        const total = agricultural + service;
-        formData.totalAmount = total > 0 ? total.toString() : "";
-        formRef.value.validateField("totalAmount");
-    } else {
-        formData.totalAmount = "";
-    }
-}
-
-// 监听农资投入和农服投入的变化,自动计算总金额
-watch(
-    () => [formData.agriculturalInput, formData.serviceInput],
-    () => {
-        calculateTotalAmount();
-    }
-);
-
-const executionData = ref(null);
-function openPopup(item) {
-    show.value = true;
-    executionData.value = item;
-    stepIndex.value = 1;
-    fileArr.value = [];
-    fileList.value = [];
-    // 重置表单数据
-    formData.agriculturalInput = "";
-    formData.serviceInput = "";
-    formData.totalAmount = "";
-}
-
-const disabledDate = (time) => {
-    // 获取今天的开始时间(00:00:00)
-    const today = new Date();
-    today.setHours(0, 0, 0, 0);
-    
-    // 获取明天的开始时间(00:00:00)
-    const tomorrow = new Date(today);
-    tomorrow.setDate(tomorrow.getDate() + 1);
-    
-    // 如果时间 >= 明天的开始时间,则禁用(不能选今天之后)
-    // 可以选择今天及之前的时间
-    return time.getTime() >= tomorrow.getTime();
-}
-
-defineExpose({
-    openPopup,
-});
-</script>
-
-<style lang="scss" scoped>
-.offer-popup {
-    z-index: 9999 !important;
-    width: 90%;
-    padding: 10px 12px;
-    border-radius: 8px;
-    background: linear-gradient(360deg, #ffffff 74.2%, #d1ebff 100%);
-    ::v-deep {
-        .van-popup__close-icon {
-            color: #000;
-        }
-    }
-    .step-1 {
-        .tips {
-            color: #2199f8;
-            padding: 4px;
-            font-family: "PangMenZhengDao";
-            background: rgba(33, 153, 248, 0.1);
-            border-radius: 4px;
-            text-align: center;
-            margin-bottom: 8px;
-        }
-    }
-    .title {
-        margin: 10px 0 12px 0;
-        display: flex;
-        align-items: stretch;
-        justify-content: space-between;
-        .text {
-            font-size: 30px;
-            div {
-                font-family: "PangMenZhengDao";
-                line-height: 38px;
-            }
-            .blue {
-                color: #2199f8;
-                margin-top: -5px;
-            }
-        }
-        img {
-            width: 96px;
-            height: 96px;
-        }
-    }
-    .input {
-        width: 100%;
-        ::v-deep {
-            .el-input__inner {
-                text-align: center;
-            }
-            --el-input-placeholder-color: rgba(33, 153, 248, 0.43);
-        }
-    }
-    .inputs-wrap {
-        ::v-deep {
-            .el-form-item {
-                margin-bottom: 0;
-                .el-form-item__error {
-                    position: absolute;
-                    bottom: -18px;
-                    left: 0;
-                    font-size: 12px;
-                    color: #f56c6c;
-                    line-height: 1;
-                    padding-top: 2px;
-                }
-            }
-            .el-input__wrapper {
-                border-radius: 6px;
-                border: 1px solid rgba(162, 213, 253, 0.8);
-                box-shadow: none;
-                padding: 7px 12px;
-                background: #fff;
-                &.is-error {
-                    border-color: #f56c6c;
-                }
-            }
-            .el-input__inner {
-                text-align: left;
-                color: #000000;
-                font-size: 16px;
-            }
-            .el-input__suffix {
-                .unit {
-                    color: rgba(0, 0, 0, 0.2);
-                    font-size: 16px;
-                }
-            }
-        }
-        .input-row {
-            display: flex;
-            gap: 12px;
-            margin-bottom: 20px;
-            position: relative;
-            .input-item {
-                flex: 1;
-                position: relative;
-                .input-header {
-                    background: #2199f8;
-                    color: #fff;
-                    padding: 0 12px;
-                    border-radius: 6px 6px 0 0;
-                    font-size: 16px;
-                    text-align: center;
-                    line-height: 28px;
-                    width: 100%;
-                }
-                .input-field {
-                    ::v-deep {
-                        .el-input__wrapper {
-                            border-radius: 0 0 6px 6px;
-                            border-top: none;
-                        }
-                    }
-                }
-            }
-            &.total-row {
-                align-items: center;
-                margin-bottom: 0;
-                .total-label {
-                    color: #2199f8;
-                    font-size: 16px;
-                    min-width: 60px;
-                }
-                .total-form-item {
-                    flex: 1;
-                    position: relative;
-                }
-                .total-input {
-                    flex: 1;
-                }
-            }
-        }
-    }
-    .tips-text {
-        font-size: 14px;
-        color: #b0b0b0;
-        text-align: center;
-        margin-top: 16px;
-    }
-    .button-wrap {
-        display: flex;
-        padding: 16px 0 10px 0;
-    }
-
-    .button {
-        border-radius: 20px;
-        color: #fff;
-        padding: 7px 0;
-        text-align: center;
-        font-size: 16px;
-        &.primary {
-            margin-left: 12px;
-            flex: 1;
-            background: #2199f8;
-            color: #fff;
-            &.btn-color {
-                background: #2199f8;
-            }
-        }
-        &.second {
-            color: #666666;
-            text-align: center;
-            font-size: 16px;
-            width: 100px;
-            border: 1px solid #bbbbbb;
-        }
-    }
-}
-.upload-wrap {
-    ::v-deep {
-        .avatar-uploader .el-upload {
-            width: 100%;
-            border: 1px dashed #dddddd;
-            border-radius: 6px;
-            cursor: pointer;
-            position: relative;
-            overflow: hidden;
-        }
-
-        .van-uploader,
-        .van-uploader__wrapper,
-        .van-uploader__input-wrapper {
-            width: 100%;
-        }
-
-        .el-icon.avatar-uploader-icon {
-            font-size: 28px;
-            color: #8c939d;
-            width: 100%;
-            height: 128px;
-            text-align: center;
-            background: #f6f6f6;
-        }
-    }
-}
-.step-2 {
-    .title {
-        margin: 0;
-        .text {
-            margin: 0 auto;
-        }
-        text-align: center;
-    }
-    .tips {
-        font-family: "PangMenZhengDao";
-        color: #9a9a9a;
-        font-size: 14px;
-        background: linear-gradient(0deg, #ffffff 55%, rgba(33, 153, 248, 0.25) 100%);
-        border: 1px solid rgba(33, 153, 248, 0.4);
-        border-radius: 25px;
-        width: 80%;
-        margin: 5px auto 20px auto;
-        text-align: center;
-        span {
-            color: #269fff;
-        }
-    }
-    .name {
-        color: #000000;
-        font-size: 16px;
-        font-weight: 500;
-        padding-bottom: 12px;
-        .required {
-            color: #ff4d4f;
-            margin-right: 2px;
-        }
-    }
-    .upload-wrap {
-        // position: relative;
-        // border: 1px dashed #2199F8;
-        // background: rgba(33, 153, 248, 0.1);
-        border-radius: 10px;
-        padding: 14px 0 10px 0;
-        &.upload-cont {
-            ::v-deep {
-                .van-uploader__wrapper {
-                    flex-wrap: nowrap;
-                }
-            }
-        }
-        .img {
-            width: 80px;
-            height: 80px;
-            margin-right: 12px;
-        }
-        .plus {
-            margin-right: 12px;
-            width: 80px;
-            height: 80px;
-        }
-    }
-    .upload-wrap + .upload-wrap {
-        margin-top: 12px;
-    }
-}
-</style>

+ 0 - 120
src/components/popup/reminderTimePopup.vue

@@ -1,120 +0,0 @@
-<template>
-    <popup
-        v-model:show="showValue"
-        class="reminder-time-popup"
-        closeable
-    >
-        <div class="popup-content">
-            <!-- 标题 -->
-            <div class="popup-header">
-                <span class="required">*</span>
-                <span class="popup-title">请选择下次提醒时间</span>
-            </div>
-
-            <!-- 时间选择输入框 -->
-            <div class="time-input-wrapper">
-                <el-date-picker
-                    v-model="selectedTime"
-                    type="date"
-                    size="large"
-                    placeholder="请选择日期"
-                    format="YYYY-MM-DD"
-                    value-format="YYYY-MM-DD"
-                    style="width: 100%"
-                    :editable="false"
-                    :clearable="false"
-                />
-            </div>
-
-            <!-- 确认按钮 -->
-            <div class="btn-confirm" @click="handleConfirm">确认</div>
-        </div>
-    </popup>
-</template>
-
-<script setup>
-import { Popup } from "vant";
-import { computed, ref } from "vue";
-import { ElMessage } from "element-plus";
-
-const props = defineProps({
-    // 控制弹窗显示/隐藏
-    show: {
-        type: Boolean,
-        default: false,
-    },
-});
-
-const emit = defineEmits(["update:show", "confirm"]);
-
-// 处理v-model双向绑定
-const showValue = computed({
-    get: () => props.show,
-    set: (value) => emit("update:show", value),
-});
-
-// 选中的时间
-const selectedTime = ref("");
-
-// 确认按钮点击
-const handleConfirm = () => {
-    if (!selectedTime.value) {
-        ElMessage.warning("请选择日期");
-        return;
-    }
-    emit("confirm", selectedTime.value);
-    emit("update:show", false);
-    // 重置日期
-    selectedTime.value = "";
-};
-</script>
-
-<style scoped lang="scss">
-.reminder-time-popup {
-    width: 90%;
-    padding: 24px 18px 20px;
-    border-radius: 8px;
-    background: linear-gradient(360deg, #FFFFFF 74.2%, #D1EBFF 100%);
-
-
-    ::v-deep {
-        .van-popup__close-icon {
-            color: #333333;
-        }
-    }
-
-    .popup-content {
-        display: flex;
-        flex-direction: column;
-    }
-
-    .popup-header {
-        display: flex;
-        align-items: center;
-        margin-bottom: 16px;
-
-        .required {
-            color: #ff4d4f;
-            margin-right: 4px;
-            font-size: 16px;
-        }
-
-        .popup-title {
-            font-size: 16px;
-        }
-    }
-
-    .time-input-wrapper {
-        margin-bottom: 24px;
-    }
-
-    .btn-confirm {
-        padding: 10px;
-        background: #2199f8;
-        color: #ffffff;
-        border-radius: 25px;
-        font-size: 16px;
-        text-align: center;
-    }
-}
-</style>

+ 0 - 109
src/components/popup/saveRegionSuccessPopup.vue

@@ -1,109 +0,0 @@
-<template>
-    <popup v-model:show="showValue" round class="save-region-success-popup" :close-on-click-overlay="false"
-        teleport="body">
-        <img class="tip-icon" src="@/assets/img/home/right.png" alt="" />
-        <div class="title">
-            {{ title }}
-        </div>
-        <div class="actions" :class="{ 'single-btn': !hasNext }">
-            <div class="btn btn-ghost" @click="handleKnow">我知道了</div>
-            <div v-if="hasNext" class="btn btn-primary" @click="handleNext">
-                勾选下一个品种
-            </div>
-        </div>
-    </popup>
-</template>
-
-<script setup>
-import { Popup } from "vant";
-import { computed } from "vue";
-
-const props = defineProps({
-    show: {
-        type: Boolean,
-        default: false,
-    },
-    /** 弹窗标题,例如:桂味 区域已保存成功 */
-    title: {
-        type: String,
-        default: "区域已保存成功",
-    },
-    /** 是否还有下一个品种,用于控制“勾选下一个品种”按钮显示 */
-    hasNext: {
-        type: Boolean,
-        default: true,
-    },
-});
-
-const emit = defineEmits(["update:show", "know", "next"]);
-
-const showValue = computed({
-    get: () => props.show,
-    set: (value) => emit("update:show", value),
-});
-
-const handleKnow = () => {
-    emit("know");
-    emit("update:show", false);
-};
-
-const handleNext = () => {
-    emit("next");
-    emit("update:show", false);
-};
-</script>
-
-<style scoped lang="scss">
-.save-region-success-popup {
-    width: 100%;
-    padding: 20px;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    justify-content: center;
-
-    .tip-icon {
-        width: 68px;
-        height: 68px;
-    }
-    .title {
-        font-size: 24px;
-        margin-bottom: 32px;
-        margin-top: 16px;
-    }
-
-    .actions {
-        display: flex;
-        justify-content: space-between;
-        gap: 10px;
-        width: 100%;
-        .btn {
-            height: 40px;
-            line-height: 40px;
-            border-radius: 25px;
-            text-align: center;
-        }
-    
-        .btn-ghost {
-            width: 100px;
-            color: #666666;
-            border: 1px solid rgba(153, 153, 153, 0.5);
-        }
-    
-        .btn-primary {
-            background: #2199f8;
-            color: #ffffff;
-            border: 1px solid #2199f8;
-            width: calc(100% - 110px);
-        }
-
-        &.single-btn {
-            justify-content: center;
-
-            .btn-ghost {
-                width: 70%;
-            }
-        }
-    }
-}
-</style>

+ 0 - 1
src/router/globalRoutes.js

@@ -185,7 +185,6 @@ export default [
     {
         path: "/map_manage",
         name: "MapManage",
-        meta: { keepAlive: true },
         component: () => import("@/views/old_mini/recordDetails/mapManage.vue"),
     },
 ];

+ 353 - 28
src/views/old_mini/recordDetails/index.vue

@@ -21,8 +21,13 @@
                         <span class="item-value">表型特征</span>
                     </div>
                 </div>
+                <div class="tabs-list" v-if="!showMap">
+                    <div class="item-tab" :class="{ 'item-tab--active': activeTab === index }"
+                        v-for="(item, index) in tabsList" :key="index" @click="handleTabClick(index)">{{ item.label }}
+                    </div>
+                </div>
                 <div class="card-wrap">
-                    <div class="map-wrap">
+                    <div class="border-wrap" v-if="showMap">
                         <div class="question-box">
                             <span>问题问题问题问题问题问题</span>
                             <el-input class="input" v-model="input" size="large" type="number">
@@ -41,14 +46,31 @@
                             </div>
                         </div>
                     </div>
+                    <div v-else class="border-wrap no-map-wrap">
+                        <div class="question-info">
+                            <span class="title">关键防治需肥期评估:</span>
+                            <span class="content">具体问题具体问题具体问</span>
+                            <span class="current-status">当前现状:当前60%进入到红黄叶进程</span>
+                        </div>
+                        <div class="time-line">
+                            <GrowthStageTimeline v-model="growthStageIndex" :stages="growthStages" />
+                        </div>
+                        <div class="confirm-btn-wrap">
+                            <uploader @click="handleUploadClick" :before-read="beforeReadUpload" class="upload-wrap"
+                                multiple :max-count="10" :after-read="afterReadUpload">
+                                <div class="upload-btn">
+                                    <el-icon>
+                                        <Plus />
+                                    </el-icon>
+                                    <span>点击上传照片</span>
+                                </div>
+                            </uploader>
+                            <div class="confirm-btn">确认信息</div>
+                        </div>
+                    </div>
                     <div class="phenology-track-section">
-                        <PhenologyTrackTimelineItem
-                            v-for="(row, idx) in phenologyTrackList"
-                            :key="idx"
-                            :date="row.date"
-                            :content="row.content"
-                            :images="row.images"
-                        />
+                        <PhenologyTrackTimelineItem v-for="(row, idx) in phenologyTrackList" :key="idx" :date="row.date"
+                            :content="row.content" :images="row.images" />
                     </div>
                 </div>
             </div>
@@ -70,15 +92,46 @@
                 分区管理
             </div>
         </div>
+
+        <popup v-model:show="showUploadProgressPopup" round :close-on-click-overlay="false"
+            class="upload-progress-popup">
+            <div class="upload-progress-title">
+                <span class="label">当前现状:</span>
+                <span class="value">60% 进入红黄叶进程</span>
+            </div>
+            <div class="upload-box" v-loading="popupImageUploadLoading" element-loading-text="上传中...">
+                <div class="box-header">
+                    <div class="upload-title">
+                        <span>上传照片</span>
+                        <span class="optional">(可选)</span>
+                    </div>
+                    <div class="ai-btn">AI 智能分析</div>
+                </div>
+                <upload ref="uploadRef" :maxCount="10" :initImgArr="initImgArr" @handleUpload="handleUploadSuccess">
+                </upload>
+                <div class="upload-result">AI识别结果:该病为该病为该病为该病为病为该病为病为该病为</div>
+            </div>
+            <div class="upload-action-btns">
+                <div class="cancel-btn" @click="handleCancelUploadPopup">取消</div>
+                <div class="confirm-btn" @click="handleConfirmUpload">确认信息</div>
+            </div>
+        </popup>
     </div>
 </template>
 
 <script setup>
 import customHeader from "@/components/customHeader.vue";
 import PhenologyTrackTimelineItem from "@/components/pageComponents/PhenologyTrackTimelineItem.vue";
+import GrowthStageTimeline from "@/components/pageComponents/GrowthStageTimeline.vue";
 import { ref, onMounted } from 'vue';
 import { useRouter } from 'vue-router';
+import { Uploader, Popup } from "vant";
+import { ElMessage } from "element-plus";
+import upload from "@/components/upload.vue";
+import UploadFile from "@/utils/upliadFile";
+import { getFileExt } from "@/utils/util";
 import IndexMap from "./map/index.js";
+import { Plus } from "@element-plus/icons-vue";
 
 const router = useRouter();
 
@@ -86,11 +139,83 @@ function goPartitionManage() {
     router.push({ name: 'MapManage' });
 }
 
+const showMap = ref(false);
+
 const indexMap = new IndexMap();
 const mapContainer = ref(null);
 const location = ref(null);
 const input = ref('');
 
+const tabsList = ref([
+    { label: '分区一', value: 0 },
+    { label: '分区二', value: 1 },
+    { label: '分区三', value: 2 },
+    { label: '分区四', value: 3 },
+]);
+
+const showUploadProgressPopup = ref(false);
+const initImgArr = ref([]);
+const uploadRef = ref(null);
+const popupImageUploadLoading = ref(false);
+const uploadFileObj = new UploadFile();
+const miniUserId = localStorage.getItem("MINI_USER_ID");
+
+const beforeReadUpload = (file) => {
+    showUploadProgressPopup.value = true;
+    initImgArr.value = [];
+    popupImageUploadLoading.value = false;
+    uploadRef.value && uploadRef.value.uploadReset();
+    return true;
+};
+
+const afterReadUpload = async (data) => {
+    initImgArr.value = [];
+    if (!Array.isArray(data)) {
+        data = [data];
+    }
+    popupImageUploadLoading.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 = "";
+                initImgArr.value.push(resFilename);
+            } else {
+                file.status = "failed";
+                file.message = "上传失败";
+                ElMessage.error("图片上传失败,请稍后再试!");
+            }
+        }
+    } finally {
+        popupImageUploadLoading.value = false;
+    }
+};
+
+const uploadData = ref([]);
+const handleUploadSuccess = (data) => {
+    uploadData.value = data.imgArr;
+};
+
+const handleCancelUploadPopup = () => {
+    showUploadProgressPopup.value = false;
+};
+
+const handleConfirmUpload = () => {
+    if (!uploadData.value || uploadData.value.length === 0) {
+        ElMessage.warning("请先上传照片");
+        return;
+    }
+    showUploadProgressPopup.value = false;
+};
+
+const handleUploadClick = () => { };
+
 /** 物候跟踪时间轴示例数据,接入接口后可替换 */
 const phenologyTrackList = ref([
     { date: '04/18', content: '有 60%已经来花了', images: [] },
@@ -106,9 +231,50 @@ const phenologyTrackList = ref([
     },
 ]);
 
+const activeTab = ref(0);
+
+const handleTabClick = (index) => {
+    activeTab.value = index;
+};
+
+/** 生育期进程时间轴:接入接口后可替换为接口数据 */
+/** 不设初值时由 GrowthStageTimeline 默认选中间一档 */
+const growthStageIndex = ref();
+const growthStages = ref([
+    {
+        label: "5%萌动",
+        tags: ["最佳给肥点"],
+        periodTitle: "生育期",
+        periodSubtitle: "描述的小字描述的",
+    },
+    {
+        label: "30%展开",
+        periodTitle: "生育期",
+        periodSubtitle: "描述的小字描述的",
+    },
+    {
+        label: "60%展开",
+        tags: ["最佳防治点", "最佳追肥点"],
+        periodTitle: "生育期",
+        periodSubtitle: "描述的小字描述的",
+    },
+    {
+        label: "30%红黄",
+        periodTitle: "生育期",
+        periodSubtitle: "描述的小字描述的",
+    },
+    {
+        label: "60%红黄",
+        periodTitle: "生育期",
+        periodSubtitle: "描述的小字描述的",
+    },
+]);
+
 onMounted(() => {
-    location.value = "POINT(113.6142086995688 23.585836479509055)";
-    indexMap.initMap(location.value, mapContainer.value);
+    if (showMap.value) {
+        location.value = "POINT(113.6142086995688 23.585836479509055)";
+        indexMap.initMap(location.value, mapContainer.value);
+    }
 });
 
 </script>
@@ -142,6 +308,33 @@ onMounted(() => {
         .record-body {
             padding: 10px;
 
+            .tabs-list {
+                margin: 10px 0;
+                flex-shrink: 0;
+                display: grid;
+                grid-template-columns: repeat(4, 1fr);
+                gap: 8px;
+
+                .item-tab {
+                    box-sizing: border-box;
+                    padding: 4px;
+                    border-radius: 2px;
+                    color: #767676;
+                    background: #fff;
+                    display: flex;
+                    align-items: center;
+                    justify-content: center;
+                    text-align: center;
+                    word-break: break-word;
+                    overflow-wrap: anywhere;
+                }
+
+                .item-tab--active {
+                    background: #2199F8;
+                    color: #ffffff;
+                }
+            }
+
             .card-wrap {
                 background: #fff;
                 border-radius: 8px;
@@ -161,7 +354,7 @@ onMounted(() => {
                     margin-top: 8px;
                 }
 
-                .map-wrap {
+                .border-wrap {
                     border-radius: 8px;
                     border: 0.5px solid #2199F8;
                     padding: 10px;
@@ -199,28 +392,81 @@ onMounted(() => {
                                 z-index: 2;
                             }
                         }
+                    }
+
+                    .confirm-btn-wrap {
+                        display: flex;
+                        gap: 13px;
 
-                        .confirm-btn-wrap {
+                        div {
+                            flex: 1;
+                            text-align: center;
+                            border-radius: 5px;
+                            font-size: 16px;
+                            padding: 8px 0;
+                        }
+
+                        .cancel-btn {
+                            border: 1px solid #D6D6D6;
+                            color: #7F7F7F;
+                        }
+
+                        .confirm-btn {
+                            border: 1px solid #2199F8;
+                            background: #2199F8;
+                            color: #fff;
+                        }
+                    }
+
+                    .question-info {
+                        padding: 8px 10px;
+                        border-radius: 5px;
+                        background: rgba(33, 153, 248, 0.1);
+                        color: #909090;
+                        font-weight: 500;
+
+                        .content {
+                            font-weight: 400;
+                        }
+
+                        .current-status {
+                            color: #2199F8;
+                        }
+                    }
+
+                    .time-line {
+                        margin: 10px 0;
+                    }
+                }
+
+                .no-map-wrap {
+                    .confirm-btn-wrap {
+                        div {
+                            font-size: 14px;
+                            flex: none;
+                        }
+
+                        .upload-wrap {
+                            width: calc(100% - 96px - 13px);
+                            padding: 0;
+                            background: rgba(255, 149, 61, 0.1);
+                            color: #FF953D;
+                            border-radius: 4px;
                             display: flex;
-                            gap: 13px;
-                            div{
-                                flex: 1;
-                                text-align: center;
-                                border-radius: 5px;
-                                font-size: 16px;
-                                padding: 8px 0;
-                            }
-                            .cancel-btn {
-                                border: 1px solid #D6D6D6;
-                                color: #7F7F7F;
-                            }
-                            .confirm-btn {
-                                border: 1px solid #2199F8;
-                                background: #2199F8;
-                                color: #fff;
+                            justify-content: center;
+                            border: 1px solid #FF953D;
+
+                            .upload-btn {
+                                display: flex;
+                                align-items: center;
+                                justify-content: center;
+                                gap: 3px;
                             }
                         }
 
+                        .confirm-btn {
+                            width: 96px;
+                        }
                     }
                 }
             }
@@ -276,4 +522,83 @@ onMounted(() => {
         }
     }
 }
+
+.upload-progress-popup {
+    width: 100%;
+    padding: 24px 16px;
+    background: linear-gradient(360deg, #FFFFFF 74.2%, #D1EBFF 100%);
+
+    .upload-progress-title {
+        color: rgba(60, 60, 60, 0.5);
+        font-weight: 500;
+        background: rgba(33, 153, 248, 0.1);
+        border-radius: 5px;
+        padding: 5px 10px;
+        margin-bottom: 12px;
+
+        .value {
+            color: #2199F8;
+        }
+    }
+
+    .upload-box {
+        margin-bottom: 12px;
+        position: relative;
+        min-height: 88px;
+        .box-header{
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            margin-bottom: 12px;
+            .upload-title{
+                font-size: 16px;
+                .optional{
+                    font-size: 12px;
+                    color: rgba(18, 18, 18, 0.2);
+                }
+            }
+            .ai-btn{
+                padding: 5px 10px;
+                border-radius: 4px;
+                background: rgba(33, 153, 248, 0.1);
+                color: #2199F8;
+                border: 1px solid #2199F8;
+                opacity: 0.5;
+            }
+        }
+        .upload-result{
+            color: #646464;
+            padding: 6px 10px;
+            background: rgba(161, 161, 161, 0.1);
+            border-radius: 5px;
+            margin-top: 12px;
+        }
+    }
+
+    .upload-action-btns {
+        display: flex;
+        gap: 10px;
+        margin-top: 16px;
+
+        .cancel-btn,
+        .confirm-btn {
+            flex: 1;
+            border-radius: 4px;
+            padding: 8px;
+            text-align: center;
+            font-size: 16px;
+        }
+
+        .cancel-btn {
+            border: 1px solid #dcdfe6;
+            color: #606266;
+            background: #ffffff;
+        }
+
+        .confirm-btn {
+            background: #2199f8;
+            color: #ffffff;
+        }
+    }
+}
 </style>

+ 66 - 14
src/views/old_mini/recordDetails/map/mapManage.js

@@ -3,13 +3,40 @@ import * as util from "@/common/ol_common.js";
 import config from "@/api/config.js";
 import Style from "ol/style/Style";
 import Icon from "ol/style/Icon";
-import { Point } from 'ol/geom';
+import { Point } from "ol/geom";
 import Feature from "ol/Feature";
+import DragPan from "ol/interaction/DragPan";
+import MouseWheelZoom from "ol/interaction/MouseWheelZoom";
+import PinchZoom from "ol/interaction/PinchZoom";
+import PinchRotate from "ol/interaction/PinchRotate";
+import DoubleClickZoom from "ol/interaction/DoubleClickZoom";
+import KeyboardPan from "ol/interaction/KeyboardPan";
+import KeyboardZoom from "ol/interaction/KeyboardZoom";
+import DragRotateAndZoom from "ol/interaction/DragRotateAndZoom";
 import { reactive } from "vue";
 import WKT from "ol/format/WKT.js";
 import * as proj from "ol/proj";
 import { getArea } from "ol/sphere.js";
 
+const VIEWPORT_INTERACTION_TYPES = [
+  DragPan,
+  MouseWheelZoom,
+  PinchZoom,
+  PinchRotate,
+  DoubleClickZoom,
+  KeyboardPan,
+  KeyboardZoom,
+  DragRotateAndZoom,
+];
+
+function setViewportInteractionsActive(olMap, active) {
+  olMap.getInteractions().forEach((ix) => {
+    if (VIEWPORT_INTERACTION_TYPES.some((T) => ix instanceof T)) {
+      ix.setActive(active);
+    }
+  });
+}
+
 export let mapLocation = reactive({
   data: null,
 });
@@ -19,13 +46,11 @@ export let mapLocation = reactive({
  */
 class MapManage {
   constructor() {
-    let that = this;
     let vectorStyle = new KMap.VectorStyle();
     this.vectorStyle = vectorStyle;
-
-    // 位置图标
+    this.regionDrawingActive = false;
     this.clickPointLayer = new KMap.VectorLayer("clickPointLayer", 9999, {
-      style: (f) => {
+      style: () => {
         return new Style({
           image: new Icon({
             src: require("@/assets/img/home/garden-point.png"),
@@ -44,11 +69,42 @@ class MapManage {
     let xyz2 = config.base_img_url3 + "map/lby/{z}/{x}/{y}.png";
     this.kmap.addXYZLayer(xyz2, { minZoom: 8, maxZoom: 22 }, 2);
     this.kmap.addLayer(this.clickPointLayer.layer);
-    this.setMapPoint(coordinate);
 
     this.kmap.initDraw(() => {});
-    this.kmap.startDraw();
     this.kmap.modifyDraw();
+
+    this.setRegionDrawingActive(false);
+  }
+
+  /**
+   * 是否允许平移/缩放、勾画与编辑;为 false 时同时隐藏中心点位图标
+   */
+  setRegionDrawingActive(active) {
+    if (!this.kmap) return;
+    this.regionDrawingActive = active;
+    setViewportInteractionsActive(this.kmap.map, active);
+    if (this.kmap.draw) {
+      this.kmap.draw.setActive(active);
+    }
+    if (this.kmap.modify) {
+      this.kmap.modify.setActive(active);
+    }
+    if (active) {
+      const c = this.kmap.getView().getCenter();
+      this.setMapPoint(c);
+    } else {
+      this.clickPointLayer.source.clear();
+    }
+  }
+
+  enableRegionDrawing() {
+    this.setRegionDrawingActive(true);
+  }
+
+  setMapPoint(coordinate) {
+    this.clickPointLayer.source.clear();
+    let point = new Feature(new Point(coordinate));
+    this.clickPointLayer.addFeature(point);
   }
 
   setMapPosition(center) {
@@ -57,13 +113,9 @@ class MapManage {
       zoom: 16,
       duration: 0,
     });
-    this.setMapPoint(center)
-  }
-
-  setMapPoint(coordinate) {
-    this.clickPointLayer.source.clear()
-    let point = new Feature(new Point(coordinate))
-    this.clickPointLayer.addFeature(point)
+    if (this.regionDrawingActive) {
+      this.setMapPoint(center);
+    }
   }
 
   clearLayer() {

+ 29 - 4
src/views/old_mini/recordDetails/mapManage.vue

@@ -4,13 +4,21 @@
         <div class="map-manage-content">
             <locationSearch class="location-search" @change="handleLocationChange"></locationSearch>
             <div class="map-container" ref="mapContainer"></div>
-            <div class="map-icon" @click="handleMapIconClick">
+            <div
+                class="new-region-btn"
+                v-show="!drawingEnabled"
+                @click="onStartRegionDrawing"
+            >新建管理分区</div>
+            <div class="map-icon" v-show="drawingEnabled" @click="handleMapIconClick">
                 <img src="@/assets/img/map/map-icon.png" alt="">
             </div>
         </div>
-        <div class="custom-bottom-fixed-btns">
-            <div class="bottom-btn secondary-btn" @click="handleClearDraw">取消勾选</div>
-            <div class="bottom-btn primary-btn" @click="handleConfirmDraw">确认区域</div>
+        <div class="custom-bottom-fixed-btns" :style="{ justifyContent: drawingEnabled ? 'space-between' : 'center' }">
+            <template v-if="drawingEnabled">
+                <div class="bottom-btn secondary-btn" @click="handleClearDraw">取消勾选</div>
+                <div class="bottom-btn primary-btn" @click="handleConfirmDraw">确认区域</div>
+            </template>
+            <div v-else class="bottom-btn secondary-btn">邀请勾画</div>
         </div>
     </div>
 </template>
@@ -28,6 +36,12 @@ const router = useRouter();
 const mapManage = new MapManage();
 
 const mapContainer = ref(null);
+const drawingEnabled = ref(false);
+
+const onStartRegionDrawing = () => {
+    drawingEnabled.value = true;
+    mapManage.enableRegionDrawing();
+};
 
 const handleLocationChange = (location) => {
     // console.log(location);
@@ -103,6 +117,17 @@ onMounted(() => {
                 height: 19px;
             }
         }
+        .new-region-btn{
+            position: absolute;
+            top: 50%;
+            left: 50%;
+            transform: translate(-50%, -50%);
+            z-index: 3;
+            background: #2199F8;
+            color: #fff;
+            border-radius: 25px;
+            padding: 10px 24px;
+        }
     }
     .custom-bottom-fixed-btns {
         justify-content: space-between;