Jelajahi Sumber

feat:对接遥感图表接口数据和修改校准物候期组件样式

wangsisi 1 hari lalu
induk
melakukan
2866516e0a

TEMPAT SAMPAH
src/assets/img/monitor/approve.png


+ 6 - 5
src/components/pageComponents/ArchivesFarmTimeLine.vue

@@ -272,11 +272,12 @@ const statusColorObj = {
 
 // 农事卡片状态样式(work_status)
 const getArrangeStatusClass = (fw) => {
-    const t = fw?.work_status;
-    if (t == 0 || t == 1) return "status-orange";
-    if (t == 2) return "status-normal";
-    if (t == 4) return "status-green-info";
-    if (t == 6) return "status-normal-farm";
+    const status = fw?.work_status;
+    const type = fw?.farm_work_type;
+    if (status == 0 || status == 1) return "status-orange";
+    if (status == 2) return "status-normal";
+    if (status == 4) return "status-green-info";
+    if (status == 6) return "status-normal-farm";
     return "future-card";
 };
 

+ 365 - 107
src/components/pageComponents/GrowthStageTimeline.vue

@@ -2,26 +2,6 @@
     <div class="growth-stage-timeline">
         <div class="growth-stage-timeline__scroll" ref="scrollRef">
             <div class="growth-stage-timeline__inner" :style="innerStyle">
-                <!-- 生育期背景:同一 period 连续多列合并为一格,标题与描述跨列居中 -->
-                <div
-                    class="growth-stage-timeline__bg"
-                    :style="{ gridTemplateColumns: gridCols }"
-                >
-                    <div
-                        v-for="(run, ri) in periodRuns"
-                        :key="'bg-run-' + ri"
-                        class="growth-stage-timeline__bg-cell growth-stage-timeline__bg-cell--period"
-                        :style="{ gridColumn: `span ${run.span}` }"
-                    >
-                        <div class="growth-stage-timeline__period-title">
-                            {{ run.periodTitle }}
-                        </div>
-                        <div class="growth-stage-timeline__period-sub">
-                            {{ run.periodSubtitle }}
-                        </div>
-                    </div>
-                </div>
-
                 <!-- 轨道:横线 + 节点 + 拖动手柄 -->
                 <div class="growth-stage-timeline__track" ref="trackRef">
                     <div class="growth-stage-timeline__track-line" aria-hidden="true"></div>
@@ -61,7 +41,13 @@
                                     aria-hidden="true"
                                 ></div>
                             </div>
-                            <div class="growth-stage-timeline__handle-body">
+                            <div
+                                class="growth-stage-timeline__handle-body"
+                                :class="{
+                                    'growth-stage-timeline__handle-body--wide':
+                                        activeLabelIsShort,
+                                }"
+                            >
                                 <span class="growth-stage-timeline__handle-bar"></span>
                                 <span class="growth-stage-timeline__handle-bar"></span>
                                 <span class="growth-stage-timeline__handle-bar"></span>
@@ -79,6 +65,10 @@
                         v-for="(stage, i) in normalizedStages"
                         :key="'lb-' + i"
                         class="growth-stage-timeline__label-col"
+                        :class="{
+                            'growth-stage-timeline__label-col--wide':
+                                isShortStageLabel(stage.label),
+                        }"
                     >
                         <div
                             class="growth-stage-timeline__label-text"
@@ -101,6 +91,30 @@
                         </div>
                     </div>
                 </div>
+
+                <!-- 生育期卡片:与上方阶段列同网格对齐,随横向滚动联动 -->
+                <div
+                    class="growth-stage-timeline__bg"
+                    :style="{ gridTemplateColumns: gridCols }"
+                >
+                    <div
+                        v-for="(run, ri) in periodRuns"
+                        :key="'bg-run-' + ri"
+                        class="growth-stage-timeline__bg-cell growth-stage-timeline__bg-cell--period"
+                        :class="{
+                            'growth-stage-timeline__bg-cell--active':
+                                ri === activePeriodRunIndex,
+                        }"
+                        :style="{ gridColumn: `span ${run.span}` }"
+                    >
+                        <div class="growth-stage-timeline__period-title">
+                            {{ run.periodTitle }}
+                        </div>
+                        <div class="growth-stage-timeline__period-sub">
+                            {{ run.periodSubtitle }}
+                        </div>
+                    </div>
+                </div>
             </div>
         </div>
     </div>
@@ -186,19 +200,57 @@ const colCount = computed(() =>
     Math.max(1, normalizedStages.value.length)
 );
 
-const gridCols = computed(() =>
-    `repeat(${colCount.value}, minmax(${props.minColWidth}px, 1fr))`
+/** time_discribe 文案过短(<3 字)时,该列占 3 个普通点宽度,便于下方标签展示 */
+const SHORT_LABEL_MAX_LEN = 3;
+const SHORT_LABEL_COL_WEIGHT = 3;
+
+function isShortStageLabel(label) {
+    return String(label ?? "").length < SHORT_LABEL_MAX_LEN;
+}
+
+function getStageColumnWeight(stage) {
+    return isShortStageLabel(stage?.label) ? SHORT_LABEL_COL_WEIGHT : 1;
+}
+
+const columnWeights = computed(() =>
+    normalizedStages.value.map((stage) => getStageColumnWeight(stage))
+);
+
+const totalColumnWeight = computed(() =>
+    Math.max(
+        1,
+        columnWeights.value.reduce((sum, weight) => sum + weight, 0)
+    )
 );
 
+const gridCols = computed(() => {
+    const stages = normalizedStages.value;
+    if (!stages.length) {
+        return `minmax(${props.minColWidth}px, 1fr)`;
+    }
+    return stages
+        .map((stage) => {
+            const weight = getStageColumnWeight(stage);
+            return `minmax(${weight * props.minColWidth}px, ${weight}fr)`;
+        })
+        .join(" ");
+});
+
 /** 左右留白:手柄 translate(-50%) 与气泡在首尾否则会溢出滚动宽度,且气泡换行会拉高整列导致视觉上“掉下去” */
 const INNER_EDGE_PAD_PX = 12;
 
-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`,
-}));
+const innerStyle = computed(() => {
+    const minContentWidth = columnWeights.value.reduce(
+        (sum, weight) => sum + weight * props.minColWidth,
+        0
+    );
+    return {
+        boxSizing: "border-box",
+        minWidth: `${minContentWidth + INNER_EDGE_PAD_PX * 2}px`,
+        paddingLeft: `${INNER_EDGE_PAD_PX}px`,
+        paddingRight: `${INNER_EDGE_PAD_PX}px`,
+    };
+});
 
 function periodKey(stage) {
     const s = stage || {};
@@ -237,6 +289,20 @@ const periodRuns = computed(() => {
     return runs;
 });
 
+/** 生育期卡片列宽与上方阶段列对齐:每段占 span 列,fr 比例与阶段网格一致 */
+const periodGridCols = computed(() => {
+    const runs = periodRuns.value;
+    if (!runs.length) {
+        return `minmax(${props.minColWidth}px, 1fr)`;
+    }
+    return runs
+        .map(
+            (run) =>
+                `minmax(${run.span * props.minColWidth}px, ${run.span}fr)`
+        )
+        .join(" ");
+});
+
 function clampStageIndex(v) {
     const last = Math.max(0, colCount.value - 1);
     return Math.min(Math.max(0, v), last);
@@ -250,6 +316,10 @@ function middleStageIndex() {
 
 const activeIndex = ref(0);
 
+const activeLabelIsShort = computed(() =>
+    isShortStageLabel(normalizedStages.value[activeIndex.value]?.label)
+);
+
 watch(
     [colCount, () => props.modelValue],
     () => {
@@ -272,17 +342,54 @@ watch(
 /** 手柄水平位置:0~1,对应轨道宽度 */
 const handleRatio = ref(0);
 
+function stageIndexToPeriodRunIndex(stageIdx) {
+    let start = 0;
+    for (let ri = 0; ri < periodRuns.value.length; ri++) {
+        const span = periodRuns.value[ri].span;
+        if (stageIdx >= start && stageIdx < start + span) {
+            return ri;
+        }
+        start += span;
+    }
+    return 0;
+}
+
+/** 视口中心(手柄)所在生育期段索引,随滚动/拖动实时高亮 */
+const activePeriodRunIndex = computed(() =>
+    stageIndexToPeriodRunIndex(ratioToIndex(handleRatio.value))
+);
+
 function indexToRatio(idx) {
-    const n = colCount.value;
-    if (n <= 1) return 0.5;
-    return (idx + 0.5) / n;
+    const weights = columnWeights.value;
+    const total = totalColumnWeight.value;
+    if (weights.length <= 1) return 0.5;
+    let start = 0;
+    for (let i = 0; i < idx; i++) {
+        start += weights[i] ?? 1;
+    }
+    const center = start + (weights[idx] ?? 1) / 2;
+    return center / total;
 }
 
 function ratioToIndex(r) {
-    const n = colCount.value;
+    const weights = columnWeights.value;
+    const total = totalColumnWeight.value;
+    const n = weights.length;
     if (n <= 1) return 0;
-    const idx = Math.round(r * n - 0.5);
-    return Math.min(Math.max(0, idx), n - 1);
+    const target = Math.min(Math.max(0, r), 1) * total;
+    let acc = 0;
+    let bestIdx = 0;
+    let bestDist = Infinity;
+    for (let i = 0; i < n; i++) {
+        const center = acc + (weights[i] ?? 1) / 2;
+        const dist = Math.abs(target - center);
+        if (dist < bestDist) {
+            bestDist = dist;
+            bestIdx = i;
+        }
+        acc += weights[i] ?? 1;
+    }
+    return bestIdx;
 }
 
 /** 禁止在轨道上横向拖滚:仅在手柄拖动时由脚本更新 scrollLeft */
@@ -317,27 +424,116 @@ function getScrollLayout() {
     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 });
+/** 滚动缓动系数,越小滚动越慢(0~1) */
+const SCROLL_LERP = 0.08;
+
+let scrollTargetLeft = null;
+let scrollAnimRafId = null;
+
+function getPeriodRunBounds(runIdx) {
+    let stageStart = 0;
+    for (let ri = 0; ri < runIdx; ri++) {
+        stageStart += periodRuns.value[ri].span;
+    }
+    const span = periodRuns.value[runIdx]?.span ?? 1;
+    const weights = columnWeights.value;
+    const total = totalColumnWeight.value;
+    if (!weights.length) {
+        return { startRatio: 0, endRatio: 1, centerRatio: 0.5 };
+    }
+    let weightStart = 0;
+    for (let i = 0; i < stageStart; i++) {
+        weightStart += weights[i] ?? 1;
+    }
+    let weightSpan = 0;
+    for (let i = stageStart; i < stageStart + span; i++) {
+        weightSpan += weights[i] ?? 1;
+    }
+    return {
+        startRatio: weightStart / total,
+        endRatio: (weightStart + weightSpan) / total,
+        centerRatio: (weightStart + weightSpan / 2) / total,
+    };
 }
 
-function syncScrollToRatio(ratio) {
+function ratioToScrollLeft(ratio, stageIdx = null) {
     const m = getScrollLayout();
-    if (!m) return;
-    const { scrollEl, cw, maxScroll, pad, contentW } = m;
+    if (!m) return null;
+    const { 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;
+    const handleCenterX = pad + r * contentW;
+
+    const idx =
+        stageIdx != null
+            ? clampStageIndex(stageIdx)
+            : ratioToIndex(ratio);
+    const runIdx = stageIndexToPeriodRunIndex(idx);
+    const { startRatio, endRatio } = getPeriodRunBounds(runIdx);
+    const periodLeft = pad + startRatio * contentW;
+    const periodRight = pad + endRatio * contentW;
+    const periodWidth = periodRight - periodLeft;
+
+    let left = handleCenterX - cw / 2;
+    if (periodWidth <= cw) {
+        if (periodLeft < left) left = periodLeft;
+        if (periodRight > left + cw) left = periodRight - cw;
+    } else {
+        left = Math.max(periodLeft, Math.min(left, periodRight - cw));
+    }
+
+    return Math.max(0, Math.min(left, maxScroll));
+}
+
+function cancelScrollAnimation() {
+    if (scrollAnimRafId != null) {
+        cancelAnimationFrame(scrollAnimRafId);
+        scrollAnimRafId = null;
+    }
+}
+
+function runScrollAnimation() {
+    const scrollEl = scrollRef.value;
+    if (!scrollEl || scrollTargetLeft == null) {
+        scrollAnimRafId = null;
+        return;
+    }
+    const current = scrollEl.scrollLeft;
+    const diff = scrollTargetLeft - current;
+    if (Math.abs(diff) < 0.5) {
+        scrollEl.scrollLeft = scrollTargetLeft;
+        scrollTargetLeft = null;
+        scrollAnimRafId = null;
+        return;
+    }
+    scrollEl.scrollLeft = current + diff * SCROLL_LERP;
+    scrollAnimRafId = requestAnimationFrame(runScrollAnimation);
+}
+
+function applyScrollToRatio(ratio, immediate = false, stageIdx = null) {
+    const left = ratioToScrollLeft(ratio, stageIdx);
+    if (left == null) return;
+    const scrollEl = scrollRef.value;
+    if (!scrollEl) return;
+
+    scrollTargetLeft = left;
+    if (immediate) {
+        cancelScrollAnimation();
+        scrollEl.scrollLeft = left;
+        scrollTargetLeft = null;
+        return;
+    }
+    if (scrollAnimRafId == null) {
+        scrollAnimRafId = requestAnimationFrame(runScrollAnimation);
+    }
+}
+
+/** 将轨道上的比例位置(0~1,与手柄 left 一致)滚到视口水平中央 */
+function scrollToCenterRatio(ratio, behavior = "smooth", stageIdx = null) {
+    applyScrollToRatio(ratio, behavior === "auto", stageIdx);
+}
+
+function syncScrollToRatio(ratio, immediate = false) {
+    applyScrollToRatio(ratio, immediate, ratioToIndex(ratio));
 }
 
 function onTimelineWheel(e) {
@@ -351,7 +547,11 @@ watch(
     () => {
         handleRatio.value = indexToRatio(activeIndex.value);
         nextTick(() => {
-            scrollToCenterRatio(indexToRatio(activeIndex.value), "auto");
+            scrollToCenterRatio(
+                indexToRatio(activeIndex.value),
+                "smooth",
+                activeIndex.value
+            );
             clampTooltipToScrollArea();
         });
     },
@@ -462,6 +662,7 @@ function onPointerUp(e) {
     const nextIdx = ratioToIndex(handleRatio.value);
     activeIndex.value = nextIdx;
     handleRatio.value = indexToRatio(nextIdx);
+    syncScrollToRatio(handleRatio.value);
     if (movedDuringHandleDrag) {
         showHandleTooltip.value = false;
     }
@@ -548,7 +749,8 @@ onMounted(() => {
                   nextTick(() => {
                       scrollToCenterRatio(
                           indexToRatio(activeIndex.value),
-                          "auto"
+                          "auto",
+                          activeIndex.value
                       );
                       clampTooltipToScrollArea();
                   });
@@ -572,6 +774,7 @@ onBeforeUnmount(() => {
     el?.removeEventListener("touchmove", onScrollAreaTouchMove);
     resizeObserver?.disconnect();
     resizeObserver = null;
+    cancelScrollAnimation();
     clearScrollSettledTimers();
     pendingScrollSettled = false;
 });
@@ -585,7 +788,7 @@ onBeforeUnmount(() => {
 
 .growth-stage-timeline__scroll {
     overflow-x: hidden;
-    overflow-y: hidden;
+    overflow-y: visible;
     touch-action: pan-y;
     overscroll-behavior-x: none;
     -webkit-overflow-scrolling: touch;
@@ -595,59 +798,95 @@ onBeforeUnmount(() => {
     display: flex;
     flex-direction: column;
     gap: 0;
-    padding-bottom: 4px;
+    padding-top: 36px;
+    padding-bottom: 8px;
+    width: 100%;
 }
 
 .growth-stage-timeline__bg {
     display: grid;
     align-items: stretch;
-    border-radius: 6px 6px 0 0;
-    overflow: hidden;
+    width: 100%;
+    margin-top: 8px;
+    gap: 0;
 }
 
 .growth-stage-timeline__bg-cell {
-    background: #f0f2f5;
-    padding: 8px 10px;
-    text-align: center;
+    background: #f5f5f5;
+    padding: 12px 8px;
+    text-align: left;
     box-sizing: border-box;
-    border-right: 1px solid rgba(0, 0, 0, 0.04);
+    border: none;
+    border-top: 1px solid #d9d9d9;
+    border-radius: 8px;
     min-width: 0;
+    margin: 0 2px;
+    transition: background-color 0.2s, border-color 0.2s;
+
+    &:first-child {
+        margin-left: 0;
+    }
 
     &:last-child {
-        border-right: none;
+        margin-right: 0;
     }
 
     &--period {
         display: flex;
         flex-direction: column;
-        align-items: center;
-        justify-content: center;
-        gap: 4px;
+        align-items: flex-start;
+        justify-content: flex-start;
+        gap: 3px;
+    }
+
+    &--active {
+        background: #e6f4ff;
+        border-top-width: 3px;
+        border-top-color: #1890ff;
+
+        .growth-stage-timeline__period-title {
+            color: #1d2129;
+            font-weight: 600;
+        }
+
+        .growth-stage-timeline__period-sub {
+            color: #666;
+        }
+    }
+
+    &:not(&--active) {
+        .growth-stage-timeline__period-title {
+            color: #8c8c8c;
+            font-weight: 500;
+        }
+
+        .growth-stage-timeline__period-sub {
+            color: #bfbfbf;
+        }
     }
 }
 
 .growth-stage-timeline__period-title {
-    font-size: 14px;
+    font-size: 15px;
     font-weight: 600;
     color: #1d2129;
-    line-height: 1.2;
-    text-align: center;
+    text-align: left;
     max-width: 100%;
+    word-break: break-word;
 }
 
 .growth-stage-timeline__period-sub {
-    font-size: 11px;
-    color: rgba(60, 60, 60, 0.45);
-    line-height: 1.3;
-    text-align: center;
+    font-size: 12px;
+    color: #666;
+    text-align: left;
     max-width: 100%;
     word-break: break-word;
+    white-space: normal;
 }
 
 .growth-stage-timeline__track {
     position: relative;
-    min-height: 44px;
-    margin-top: 4px;
+    min-height: 48px;
 }
 
 .growth-stage-timeline__track-line {
@@ -655,9 +894,9 @@ onBeforeUnmount(() => {
     left: 0;
     right: 0;
     top: 50%;
-    height: 2px;
-    margin-top: -1px;
-    background: #e5e6eb;
+    height: 1px;
+    margin-top: -0.5px;
+    background: #e8e8e8;
     pointer-events: none;
     z-index: 0;
 }
@@ -667,7 +906,8 @@ onBeforeUnmount(() => {
     z-index: 1;
     display: grid;
     align-items: center;
-    min-height: 44px;
+    min-height: 48px;
+    width: 100%;
 }
 
 .growth-stage-timeline__dot-wrap {
@@ -679,19 +919,20 @@ onBeforeUnmount(() => {
 }
 
 .growth-stage-timeline__dot {
-    width: 8px;
-    height: 8px;
+    width: 10px;
+    height: 10px;
     border-radius: 50%;
-    background: #e5e6eb;
-    border: 2px solid #fff;
+    background: #CDCDCD;
+    border: 1px solid #fff;
     box-sizing: border-box;
+    box-shadow: 0px 2px 3px 0px #00000012;
+
 }
 
 .growth-stage-timeline__handle {
     position: absolute;
     top: 50%;
     z-index: 2;
-    /* 旋转中心仅含按钮高度:tooltip 绝对叠在上方,避免参与 translate(-50%,-50%) 的包围盒 */
     transform: translate(-50%, -50%);
     touch-action: none;
     cursor: grab;
@@ -716,22 +957,21 @@ onBeforeUnmount(() => {
     display: flex;
     flex-direction: column;
     align-items: center;
-    margin-bottom: 6px;
+    margin-bottom: 8px;
     pointer-events: none;
     z-index: 3;
 }
 
 .growth-stage-timeline__tooltip {
-    max-width: min(220px, calc(100vw - 24px));
+    max-width: min(240px, calc(100vw - 24px));
     white-space: nowrap;
-    padding: 6px 10px;
-    border-radius: 6px;
-    background: #808080;
+    padding: 6px 12px;
+    border-radius: 999px;
+    background: #8c8c8c;
     color: #fff;
     font-size: 11px;
     line-height: 1.4;
     text-align: center;
-    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
 }
 
 .growth-stage-timeline__tooltip-caret {
@@ -740,34 +980,42 @@ onBeforeUnmount(() => {
     margin-top: -1px;
     border-width: 5px 5px 0 5px;
     border-style: solid;
-    border-color: #808080 transparent transparent transparent;
+    border-color: #8c8c8c transparent transparent transparent;
 }
 
 .growth-stage-timeline__handle-body {
     display: flex;
-    flex-direction: column;
+    flex-direction: row;
     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);
+    gap: 2px;
+    width: 16px;
+    height: 30px;
+    border-radius: 7px;
+    background: #1890ff;
+    box-shadow: 0 2px 6px rgba(24, 144, 255, 0.35);
+    transition: width 0.2s;
+
+    &--wide {
+        width: 48px;
+        gap: 4px;
+        border-radius: 10px;
+    }
 }
 
 .growth-stage-timeline__handle-bar {
     display: block;
-    width: 14px;
-    height: 2px;
+    width: 2px;
+    height: 10px;
     border-radius: 1px;
     background: #fff;
 }
 
 .growth-stage-timeline__labels {
     display: grid;
-    margin-top: 10px;
+    margin-top: 2px;
     gap: 0;
+    width: 100%;
 }
 
 .growth-stage-timeline__label-col {
@@ -775,18 +1023,28 @@ onBeforeUnmount(() => {
     flex-direction: column;
     align-items: center;
     text-align: center;
-    padding: 0 4px 6px;
+    padding: 0 4px 4px;
     box-sizing: border-box;
+    min-width: 0;
+
+    &--wide {
+        .growth-stage-timeline__label-text {
+            max-width: 100%;
+            white-space: nowrap;
+        }
+    }
 }
 
 .growth-stage-timeline__label-text {
-    font-size: 12px;
-    color: #c0c4cc;
-    line-height: 1.3;
+    font-size: 11px;
+    color: #bfbfbf;
+    width: max-content;
     word-break: break-all;
+    transition: color 0.2s, font-size 0.2s;
 
     &--active {
-        color: #2199f8;
+        font-size: 14px;
+        color: #1890ff;
         font-weight: 600;
     }
 }
@@ -804,7 +1062,7 @@ onBeforeUnmount(() => {
     display: inline-block;
     max-width: 100%;
     padding: 2px 6px;
-    border-radius: 3px;
+    border-radius: 4px;
     background: #00a870;
     color: #fff;
     font-size: 10px;

+ 2 - 11
src/views/old_mini/agri_file/components/fileFloat.vue

@@ -34,7 +34,7 @@
                     </div>
                 </template>
                 <div v-else-if="isRemoteSensingTab" class="remote-sensing-chart">
-                    <div v-if="!isBaseMapTool" class="remote-sensing-chart__legend">
+                    <div class="remote-sensing-chart__legend">
                         <div
                             v-for="item in remoteSensingLegendItems"
                             :key="item.key"
@@ -59,9 +59,6 @@
                         </div>
                     </div>
                     <div class="tab-loading" v-if="loading">{{ t('agriFile.loading') }}</div>
-                    <div class="tab-empty" v-else-if="isBaseMapTool">
-                        {{ t('agriFile.remoteSensingSelectHint') }}
-                    </div>
                     <remote-sensing-chart v-else />
                 </div>
             </div>
@@ -95,10 +92,6 @@ const props = defineProps({
         type: Boolean,
         default: false,
     },
-    mapTool: {
-        type: Object,
-        default: null,
-    },
     cropVariety: {
         type: String,
         default: "",
@@ -109,7 +102,7 @@ const emit = defineEmits(["update:activeTab", "update:activeSubTab"]);
 
 const anchors = [
     130,
-    Math.round(0.4 * window.innerHeight),
+    Math.round(0.45 * window.innerHeight),
     Math.round(0.8 * window.innerHeight),
 ];
 const height = ref(anchors[0]);
@@ -137,8 +130,6 @@ const isAgriRecordTab = computed(() => floatTabLabels.value[props.activeTab]?.va
 
 const isRemoteSensingTab = computed(() => floatTabLabels.value[props.activeTab]?.value === "remoteSensing");
 
-const isBaseMapTool = computed(() => (props.mapTool?.index ?? 0) === 0);
-
 const REMOTE_SENSING_LEGEND_ITEMS = [
     { key: "ndwi", labelKey: "agriFile.remoteSensingLegendNdwi", color: "#6277FB", iconType: "line" },
     { key: "ndvi", labelKey: "agriFile.remoteSensingLegendNdvi", color: "#1CC277", iconType: "line" },

+ 212 - 42
src/views/old_mini/agri_file/components/remoteSensingChart.vue

@@ -1,7 +1,16 @@
 <template>
-    <div v-if="loading" class="remote-sensing-line-chart__status">{{ t("agriFile.loading") }}</div>
-    <div v-else-if="!normalizedData" class="remote-sensing-line-chart__status">{{ t("agriFile.noData") }}</div>
-    <div v-else ref="chartRef" class="remote-sensing-line-chart"></div>
+    <div class="remote-sensing-chart-wrap">
+        <div v-if="loading" class="remote-sensing-chart-wrap__status">{{ t("agriFile.loading") }}</div>
+        <div v-else-if="!normalizedData" class="remote-sensing-chart-wrap__status">{{ t("agriFile.noData") }}</div>
+        <template v-else>
+            <div ref="chartRef" class="remote-sensing-line-chart"></div>
+            <div
+                v-if="reportText"
+                class="remote-sensing-report"
+                v-html="formattedReport"
+            ></div>
+        </template>
+    </div>
 </template>
 
 <script setup>
@@ -14,6 +23,7 @@ const { t } = useI18n();
 const chartRef = ref(null);
 const chartInstance = shallowRef(null);
 const chartData = ref(null);
+const reportText = ref("");
 const loading = ref(false);
 let resizeObserver = null;
 
@@ -24,44 +34,149 @@ const SERIES_COLORS = {
     avgPrecipitation: "#66BBFF",
 };
 
+/** 一屏默认展示的数据点数量,超出后可横向滑动 */
+const DATA_ZOOM_VISIBLE_COUNT = 30;
+
+const toDateKey = (value) => String(value ?? "").slice(0, 10);
+
+const getTodayDateString = () => {
+    const now = new Date();
+    const y = now.getFullYear();
+    const m = String(now.getMonth() + 1).padStart(2, "0");
+    const d = String(now.getDate()).padStart(2, "0");
+    return `${y}-${m}-${d}`;
+};
+
+const findAnchorIndexByToday = (dates) => {
+    const today = getTodayDateString();
+    const exactIndex = dates.findIndex((item) => toDateKey(item) === today);
+    if (exactIndex >= 0) return exactIndex;
+
+    for (let i = dates.length - 1; i >= 0; i--) {
+        if (toDateKey(dates[i]) <= today) return i;
+    }
+    return 0;
+};
+
+const getDataZoomRange = (dates) => {
+    const count = dates?.length ?? 0;
+    if (count <= DATA_ZOOM_VISIBLE_COUNT) return null;
+
+    const anchorIndex = findAnchorIndexByToday(dates);
+    let endIndex = anchorIndex;
+    let startIndex = Math.max(0, endIndex - DATA_ZOOM_VISIBLE_COUNT + 1);
+
+    if (endIndex - startIndex + 1 < DATA_ZOOM_VISIBLE_COUNT) {
+        endIndex = Math.min(count - 1, startIndex + DATA_ZOOM_VISIBLE_COUNT - 1);
+    }
+
+    return {
+        start: (startIndex / count) * 100,
+        end: ((endIndex + 1) / count) * 100,
+    };
+};
+
+const buildDataZoom = (dates) => {
+    const range = getDataZoomRange(dates);
+    if (!range) return [];
+
+    return [
+        {
+            type: "inside",
+            xAxisIndex: 0,
+            start: range.start,
+            end: range.end,
+            zoomLock: true,
+            zoomOnMouseWheel: false,
+            moveOnMouseMove: true,
+            moveOnMouseWheel: false,
+        },
+        {
+            type: "slider",
+            xAxisIndex: 0,
+            start: range.start,
+            end: range.end,
+            height: 14,
+            bottom: 2,
+            borderColor: "transparent",
+            backgroundColor: "#f0f0f0",
+            fillerColor: "rgba(33, 153, 248, 0.12)",
+            handleSize: "70%",
+            handleStyle: {
+                color: "#2199f8",
+                borderColor: "#2199f8",
+            },
+            showDetail: false,
+            brushSelect: false,
+        },
+    ];
+};
+
 const toNumber = (value) => {
     const num = Number(value);
     return Number.isNaN(num) ? null : num;
 };
 
+const formatDateLabel = (dateStr) => {
+    if (!dateStr) return "";
+    const parts = String(dateStr).split("-");
+    if (parts.length >= 3) {
+        return `${parts[1]}-${parts[2]}`;
+    }
+    return dateStr;
+};
+
+const extractReport = (raw) => {
+    if (!raw) return "";
+    if (typeof raw === "string") return raw;
+    if (raw.report) return String(raw.report);
+
+    const list = Array.isArray(raw) ? raw : raw?.list ?? raw?.series ?? [];
+    if (!Array.isArray(list)) return "";
+
+    const item = list.find((entry) => entry?.report) ?? list[list.length - 1];
+    return item?.report ? String(item.report) : "";
+};
+
+const escapeHtml = (text) =>
+    String(text)
+        .replace(/&/g, "&amp;")
+        .replace(/</g, "&lt;")
+        .replace(/>/g, "&gt;")
+        .replace(/"/g, "&quot;");
+
+const REPORT_HIGHLIGHT_PATTERN =
+    /(\d+(?:\.\d+)?(?:mm|%|天)?)|长势良好|长势一般|增加|减少|削弱/g;
+
+const formatReportHtml = (text) => {
+    if (!text) return "";
+    return escapeHtml(text).replace(
+        REPORT_HIGHLIGHT_PATTERN,
+        '<span class="remote-sensing-report__highlight">$&</span>'
+    );
+};
+
+const formattedReport = computed(() => formatReportHtml(reportText.value));
+
 const normalizeChartData = (raw) => {
-    console.log("raw", raw);
     if (!raw) return null;
 
-    if (Array.isArray(raw)) {
-        if (!raw.length) return null;
-        return {
-            timeLabels: raw.map((item) => item.time ?? item.label ?? item.date ?? ""),
-            ndvi: raw.map((item) => toNumber(item.ndvi)),
-            ndwi: raw.map((item) => toNumber(item.ndwi)),
-            precipitation: raw.map((item) =>
-                toNumber(item.precipitation ?? item.rain ?? item.rainfall)
-            ),
-            avgPrecipitation: raw.map((item) =>
-                toNumber(
-                    item.avgPrecipitation ??
-                        item.avgRain ??
-                        item.avgRainfall ??
-                        item.avg_precipitation
-                )
-            ),
-        };
-    }
+    const list = Array.isArray(raw) ? raw : raw?.list ?? raw?.series ?? [];
+    if (!Array.isArray(list) || !list.length) return null;
 
-    const timeLabels = raw.timeLabels ?? raw.times ?? raw.labels ?? [];
-    if (!timeLabels.length) return null;
+    const sorted = [...list].sort((a, b) => String(a.date).localeCompare(String(b.date)));
 
     return {
-        timeLabels,
-        ndvi: raw.ndvi ?? [],
-        ndwi: raw.ndwi ?? [],
-        precipitation: raw.precipitation ?? raw.rain ?? [],
-        avgPrecipitation: raw.avgPrecipitation ?? raw.avg_rain ?? raw.avg_precipitation ?? [],
+        dates: sorted.map((item) => toDateKey(item.date)),
+        timeLabels: sorted.map((item) => formatDateLabel(item.date)),
+        ndvi: sorted.map((item) => toNumber(item.NDVI_before ?? item.ndvi)),
+        ndwi: sorted.map((item) => toNumber(item.NDWI_before ?? item.ndwi)),
+        precipitation: sorted.map((item) =>
+            toNumber(item.this_year_precip ?? item.precipitation ?? item.rain)
+        ),
+        avgPrecipitation: sorted.map((item) =>
+            toNumber(item.mean_precip ?? item.avgPrecipitation ?? item.avg_precipitation)
+        ),
     };
 };
 
@@ -76,6 +191,7 @@ const normalizedData = computed(() => {
         series.slice(0, length).map((item) => toNumber(item));
 
     return {
+        dates: (data.dates ?? []).slice(0, length),
         timeLabels: data.timeLabels.slice(0, length),
         ndvi: mapSeries(data.ndvi),
         ndwi: mapSeries(data.ndwi),
@@ -88,32 +204,61 @@ const fetchChartData = async () => {
     
     const params = {
         zone_id: '260',
-        date: '2026-05-28',
+        date: getTodayDateString(),
     };
     loading.value = true;
     try {
         const res = await VE_API.record.getZoneRsSeries(params);
         if (res.code === 200) {
             chartData.value = normalizeChartData(res.data);
+            reportText.value = extractReport(res.data);
         } else {
             chartData.value = null;
+            reportText.value = "";
         }
     } catch {
         chartData.value = null;
+        reportText.value = "";
     } finally {
         loading.value = false;
     }
 };
 
-const buildOption = (data) => ({
+const getPrecipitationAxisMax = (data) => {
+    const values = [...(data.precipitation ?? []), ...(data.avgPrecipitation ?? [])].filter(
+        (v) => v != null && !Number.isNaN(v)
+    );
+    if (!values.length) return 10;
+
+    const max = Math.max(...values);
+    const padded = max * 1.2;
+    if (padded <= 10) return Math.ceil(padded);
+    if (padded <= 50) return Math.ceil(padded / 5) * 5;
+    return Math.ceil(padded / 10) * 10;
+};
+
+const getPrecipitationAxisInterval = (max) => {
+    if (max <= 10) return 2;
+    if (max <= 50) return 10;
+    return 25;
+};
+
+const buildOption = (data) => {
+    const precipMax = getPrecipitationAxisMax(data);
+    const precipInterval = getPrecipitationAxisInterval(precipMax);
+    const dataZoom = buildDataZoom(data.dates);
+    const hasDataZoom = dataZoom.length > 0;
+
+    return {
     animation: false,
     grid: {
         left: 4,
         right: 4,
-        top: 28,
-        bottom: 8,
+        top: 24,
+        bottom: hasDataZoom ? 22 : 8,
         containLabel: true,
     },
+    dataZoom,
     xAxis: {
         type: "category",
         boundaryGap: true,
@@ -123,6 +268,7 @@ const buildOption = (data) => ({
         axisLabel: {
             color: "#C1C1C1",
             fontSize: 11,
+            hideOverlap: true,
         },
     },
     yAxis: [
@@ -133,11 +279,12 @@ const buildOption = (data) => ({
             interval: 0.5,
             name: t("agriFile.remoteSensingAxisIndex"),
             nameLocation: "end",
-            nameGap: 8,
+            nameGap: 12,
             nameTextStyle: {
                 color: "#C1C1C1",
                 fontSize: 11,
                 align: "left",
+                padding: [0, 0, 0, -20],
             },
             axisLine: { show: false },
             axisTick: { show: false },
@@ -154,15 +301,16 @@ const buildOption = (data) => ({
         {
             type: "value",
             min: 0,
-            max: 100,
-            interval: 25,
+            max: precipMax,
+            interval: precipInterval,
             name: t("agriFile.remoteSensingAxisPrecipitation"),
             nameLocation: "end",
-            nameGap: 8,
+            nameGap: 12,
             nameTextStyle: {
                 color: "#C1C1C1",
                 fontSize: 11,
                 align: "right",
+                padding: [0, -20, 0, 0],
             },
             axisLine: { show: false },
             axisTick: { show: false },
@@ -227,7 +375,8 @@ const buildOption = (data) => ({
             z: 4,
         },
     ],
-});
+    };
+};
 
 const resizeChart = () => {
     chartInstance.value?.resize();
@@ -297,11 +446,9 @@ defineExpose({
 </script>
 
 <style lang="scss" scoped>
-.remote-sensing-line-chart {
+.remote-sensing-chart-wrap {
     width: 100%;
     min-width: 0;
-    height: 194px;
-    margin-top: 10px;
 
     &__status {
         text-align: center;
@@ -311,4 +458,27 @@ defineExpose({
         margin-top: 10px;
     }
 }
+
+.remote-sensing-line-chart {
+    width: 100%;
+    min-width: 0;
+    height: 210px;
+    margin-top: 10px;
+}
+
+.remote-sensing-report {
+    margin-top: 10px;
+    padding: 12px 14px;
+    background: #f7f7f7;
+    border-radius: 8px;
+    font-size: 13px;
+    line-height: 1.65;
+    color: #666666;
+    word-break: break-word;
+
+    :deep(.remote-sensing-report__highlight) {
+        color: #0d0d0d;
+        font-weight: 600;
+    }
+}
 </style>

+ 1 - 12
src/views/old_mini/agri_file/index.vue

@@ -65,7 +65,6 @@
                 v-model:active-sub-tab="activeRecordSubTab"
                 :farm-record-data="farmRecordData"
                 :loading="farmRecordLoading"
-                :map-tool="activeMapToolItem"
                 :crop-variety="farmVarietyName"
             />
         </div>
@@ -129,16 +128,6 @@ const MAP_TOOL_ITEMS = [
 const mapToolItems = MAP_TOOL_ITEMS;
 const activeMapTool = ref(0);
 
-const activeMapToolItem = computed(() => {
-    const item = mapToolItems[activeMapTool.value];
-    if (!item) return null;
-    return {
-        index: activeMapTool.value,
-        key: item.key,
-        label: item.label,
-    };
-});
-
 const changeMapTool = (index) => {
     activeMapTool.value = index;
 };
@@ -360,7 +349,7 @@ onActivated(async () => {
             position: absolute;
             left: 12px;
             top: 50%;
-            transform: translateY(-62%);
+            transform: translateY(-78%);
             z-index: 15;
             display: flex;
             flex-direction: column;