瀏覽代碼

fix: 样式

lxf 3 天之前
父節點
當前提交
2013a61a0c
共有 8 個文件被更改,包括 1213 次插入 和 240 次删除
  1. 28 7
      src/api/mock/analysis.js
  2. 152 32
      src/api/mock/dongguan.js
  3. 25 7
      src/components/DataAnalysis.vue
  4. 56 20
      src/components/PatrolTimeline.vue
  5. 420 0
      src/data/phenoConfig.js
  6. 134 58
      src/map/FarmMap.js
  7. 1 1
      src/styles/app.css
  8. 397 115
      src/views/detail/index.vue

+ 28 - 7
src/api/mock/analysis.js

@@ -92,7 +92,7 @@ export async function mockGetWeatherSeries(baseId, params = {}) {
   })
 }
 
-function buildStressDetail(kind, selectedId) {
+function buildStressDetail(kind, selectedId, zonePair = '1B') {
   const weatherOpts = [
     { id: 'W-HD-1021', label: '高温干旱 · 10-21 起 · 8 天', type: 'high_dry', name: '高温干旱', start: '2025-10-21', end: '2025-10-28', days: 8, severity: 3, severityLabel: '已结束·重度' },
     { id: 'W-HH-0818', label: '高温高湿 · 08-18 起 · 9 天', type: 'high_humid', name: '高温高湿', start: '2025-08-18', end: '2025-08-26', days: 9, severity: 2, severityLabel: '已结束·中度' },
@@ -183,9 +183,23 @@ function buildStressDetail(kind, selectedId) {
           suggestions: ['严格监测虫源并及时防治', '试验区优先使用生物农药', '加密高风险样点巡查', '7 天后复评绩效差异'],
         }
 
+  const pair = zonePair === '1A' ? '1A' : '1B'
+  const zoneMeta =
+    pair === '1A'
+      ? { label: '分区 1A · 抗旱性试验区 · 实验 P1 / 对照 P2', exp: 'P1', ctl: 'P2' }
+      : {
+          label:
+            kind === 'weather'
+              ? '分区 1B · 抗寒性试验区 · 实验 P3 / 对照 P4'
+              : '分区 1B · 抗病性试验区 · 实验 P3 / 对照 P4',
+          exp: 'P3',
+          ctl: 'P4',
+        }
+
   return ok({
     kind,
-    zoneLabel: kind === 'weather' ? '分区 1B · 抗寒性试验区 · 实验 P3 / 对照 P4' : '分区 1B · 抗病性试验区 · 实验 P3 / 对照 P4',
+    zonePair: pair,
+    zoneLabel: zoneMeta.label,
     options,
     selectedId: selected.id,
     selected,
@@ -193,8 +207,8 @@ function buildStressDetail(kind, selectedId) {
     defaultIndicatorId: indicators[0].id,
     curves: curvesByInd,
     seriesMeta: {
-      experimental: { name: '实验组 P3', role: 'exp', style: 'solid' },
-      control: { name: '对照组 P4', role: 'ctl', style: 'dashed' },
+      experimental: { name: `实验组 ${zoneMeta.exp}`, role: 'exp', style: 'solid' },
+      control: { name: `对照组 ${zoneMeta.ctl}`, role: 'ctl', style: 'dashed' },
     },
     kpis,
     report,
@@ -203,12 +217,12 @@ function buildStressDetail(kind, selectedId) {
 
 export async function mockGetWeatherStress(baseId, params = {}) {
   await delay()
-  return buildStressDetail('weather', params.eventId)
+  return buildStressDetail('weather', params.eventId, params.zonePair)
 }
 
 export async function mockGetPestStress(baseId, params = {}) {
   await delay()
-  return buildStressDetail('pest', params.eventId)
+  return buildStressDetail('pest', params.eventId, params.zonePair)
 }
 
 export async function mockGetYieldEstimate(baseId, params = {}) {
@@ -229,8 +243,15 @@ export async function mockGetYieldEstimate(baseId, params = {}) {
   const fruitRateExp = 82.9
   const fruitRateCtl = 75.1
 
+  const pair = params.zonePair === '1A' ? '1A' : '1B'
+  const zoneLabel =
+    pair === '1A'
+      ? '分区 1A · 抗旱性试验区 · 实验 P1 / 对照 P2 · 荔枝'
+      : '分区 1B · 抗寒性试验区 · 实验 P3 / 对照 P4 · 荔枝'
+
   return ok({
-    zoneLabel: '分区 1B · 抗寒性试验区 · 实验 P3 / 对照 P4 · 荔枝',
+    zonePair: pair,
+    zoneLabel,
     crop: '荔枝',
     stages,
     selectedStageId: selected.id,

+ 152 - 32
src/api/mock/dongguan.js

@@ -3,6 +3,7 @@
  * 地图分区/样点来自 realBases GIS;其余指标/任务/照片为演示数据,字段对齐接口契约。
  */
 import mapPack from './dongguan-map.json'
+import { getPhenologyIndicatorPlan } from '@/data/phenoConfig'
 
 const BASE_ID = mapPack.id // FN-155
 
@@ -160,33 +161,48 @@ function buildLatestPhotos() {
 function buildTimeline() {
   // 对齐原型「当前周期」:7 月~11 月
   const tasks = [
-    { id: 'T-70', date: '2024-07-12', type: 'uav', status: 'done', label: '飞巡' },
-    { id: 'T-71', date: '2024-07-26', type: 'manual', status: 'done', label: '人工普查' },
-    { id: 'T-72', date: '2024-08-08', type: 'uav', status: 'done', label: '飞巡' },
-    { id: 'T-73', date: '2024-08-22', type: 'uav', status: 'done', label: '飞巡' },
-    { id: 'T-74', date: '2024-09-05', type: 'manual', status: 'done', label: '人工普查' },
-    { id: 'T-75', date: '2024-09-18', type: 'uav', status: 'done', label: '飞巡' },
-    { id: 'T-76', date: '2024-10-02', type: 'uav', status: 'done', label: '飞巡' },
-    { id: 'T-77', date: '2024-10-11', type: 'manual', status: 'done', label: '人工普查' },
-    { id: 'T-78', date: '2024-10-22', type: 'uav', status: 'done', label: '飞巡' },
-    { id: 'T-79', date: '2024-11-02', type: 'uav', status: 'done', label: '飞巡' },
-    { id: 'T-80', date: '2024-11-08', type: 'manual', status: 'pending', label: '人工普查' },
-    { id: 'T-81', date: '2024-11-18', type: 'uav', status: 'todo', label: '飞巡' },
-    { id: 'T-82', date: '2024-11-25', type: 'uav', status: 'overdue', label: '飞巡' },
-  ]
-  const indicators = [
-    { id: 'I1', name: '果实着色面积占比', date: '2024-07-20', value: 68.2, unit: '%', source: 'uav' },
-    { id: 'I2', name: '新梢整齐度', date: '2024-08-22', value: 72.5, unit: '%', source: 'uav' },
-    { id: 'I3', name: '抽穗率', date: '2024-09-12', value: 41.0, unit: '%', source: 'uav' },
-    { id: 'I4', name: '秋梢老熟度', date: '2024-10-11', value: 86.0, unit: '%', source: 'uav' },
-    { id: 'I5', name: '控梢效果指数', date: '2024-11-02', value: 91.3, unit: '%', source: 'manual' },
+    { id: 'T-70', date: '2024-07-12', type: 'uav', status: 'done', label: '飞巡', zoneId: 'P3' },
+    { id: 'T-71', date: '2024-07-26', type: 'manual', status: 'done', label: '人工普查', zoneId: 'P3' },
+    { id: 'T-72', date: '2024-08-08', type: 'uav', status: 'done', label: '飞巡', zoneId: 'P1' },
+    { id: 'T-73', date: '2024-08-22', type: 'uav', status: 'done', label: '飞巡', zoneId: 'P3' },
+    { id: 'T-74', date: '2024-09-05', type: 'manual', status: 'done', label: '人工普查', zoneId: 'P4' },
+    { id: 'T-75', date: '2024-09-18', type: 'uav', status: 'done', label: '飞巡', zoneId: 'P3' },
+    { id: 'T-76', date: '2024-10-02', type: 'uav', status: 'done', label: '飞巡', zoneId: 'P2' },
+    { id: 'T-77', date: '2024-10-11', type: 'manual', status: 'done', label: '人工普查', zoneId: 'P3' },
+    { id: 'T-78', date: '2024-10-22', type: 'uav', status: 'done', label: '飞巡', zoneId: 'P3' },
+    { id: 'T-79', date: '2024-11-02', type: 'uav', status: 'done', label: '飞巡', zoneId: 'P3' },
+    { id: 'T-80', date: '2024-11-08', type: 'manual', status: 'pending', label: '人工普查', zoneId: 'P4' },
+    { id: 'T-81', date: '2024-11-18', type: 'uav', status: 'todo', label: '飞巡', zoneId: 'P3' },
+    { id: 'T-82', date: '2024-11-25', type: 'uav', status: 'overdue', label: '飞巡', zoneId: 'P1' },
   ]
+  // 物候期对齐荔枝模板关键展示名
   const phenology = [
     { id: 'PH1', name: '果实成熟采收期', start: '2024-07-01', end: '2024-08-05', key: true },
     { id: 'PH2', name: '秋梢萌发期', start: '2024-08-06', end: '2024-09-10', key: false },
     { id: 'PH3', name: '秋梢老熟期', start: '2024-09-11', end: '2024-10-20', key: false },
-    { id: 'PH4', name: '控梢期(休眠期)', start: '2024-10-21', end: '2024-11-30', key: false },
+    { id: 'PH4', name: '控梢期(休眠期)', start: '2024-10-21', end: '2024-11-30', key: false },
   ]
+  // 识别指标来自 PHENO_CONFIG(荔枝模板),挂到各物候期中点
+  const plan = getPhenologyIndicatorPlan('荔枝')
+  const indicators = []
+  let idx = 1
+  phenology.forEach((ph) => {
+    const row = plan.find((p) => p.stage === ph.name || p.stage.includes(ph.name.replace(/[((].*$/, '')) || ph.name.includes(p.stage.replace(/[((].*$/, '')))
+    const names = row?.indicators?.length ? row.indicators : ['新梢整齐度']
+    const mid = new Date((+new Date(ph.start) + +new Date(ph.end)) / 2)
+    const date = mid.toISOString().slice(0, 10)
+    names.slice(0, 2).forEach((name, i) => {
+      indicators.push({
+        id: `I${idx++}`,
+        name,
+        date,
+        value: +(70 + idx * 1.7 + i).toFixed(1),
+        unit: '%',
+        source: i % 2 === 0 ? 'uav' : 'manual',
+        stage: ph.name,
+      })
+    })
+  })
   const stresses = [
     { id: 'S1', start: '2024-07-18', end: '2024-07-24', type: 'high_dry', label: '高温干旱' },
     { id: 'S2', start: '2024-08-14', end: '2024-08-19', type: 'high_humid', label: '高温高湿' },
@@ -238,27 +254,89 @@ export async function mockGetOverview(baseId) {
   return ok({
     id: BASE_ID,
     name: mapPack.name,
+    title: `${mapPack.prov}·${mapPack.name}`,
     prov: mapPack.prov,
     city: mapPack.city,
     crop: mapPack.crop,
     mu: mapPack.mu,
     lng: mapPack.lon0,
     lat: mapPack.lat0,
-    stage: { name: '控梢期', level: 'I', dayIndex: 20, dayTotal: 12 },
+    stage: {
+      name: '控梢期(休眠期)',
+      level: 'I',
+      dayIndex: 20,
+      dayTotal: 40,
+    },
+    stress: {
+      level: 'I',
+      type: '',
+      days: 12,
+      color: 'var(--w-red)',
+      has: true,
+    },
     survey: {
       zoneCount: 1,
       samplePoints: 219,
-      plants: mapPack.plantCount,
+      plants: mapPack.plantCount || 1039,
       grids: mapPack.gridCount,
     },
     mission: {
       lastFlightDate: '2024-11-02',
+      lastFlightLabel: '11-02',
       flightIndex: 97,
+      device: 'M3M',
       completionRate: 88.5,
       damageRate: 0.36,
       validPoints: { ok: 97, total: 97 },
       outliers: { ok: 24, total: 24 },
     },
+    /** 最近飞巡关键指标(左侧卡 KPI) */
+    indicators: [
+      { name: '秋梢老熟度', value: 88.5, unit: '%' },
+      { name: '抽梢率', value: 0.36, unit: '%' },
+    ],
+    progress: {
+      flightDone: 97,
+      flightTotal: 97,
+      indicatorDone: 24,
+      indicatorTotal: 24,
+    },
+    futureFlights: [
+      {
+        date: '2024-11-11',
+        dateLabel: '11-11',
+        afterDays: 8,
+        indicators: '秋梢老熟度、抽梢率',
+        mode: '多光谱',
+      },
+      {
+        date: '2024-11-17',
+        dateLabel: '11-17',
+        afterDays: 14,
+        indicators: '秋梢老熟度、抽梢率',
+        mode: '多光谱',
+      },
+    ],
+    experimentGroups: [
+      {
+        id: '1A',
+        name: '分区1A · 抗旱性试验区',
+        exp: ['P1'],
+        ctl: ['P2'],
+        focus: ['抗旱性', '长势'],
+        start: '2024-11-15',
+        end: '2024-11-30',
+      },
+      {
+        id: '1B',
+        name: '分区1B · 抗寒性试验区',
+        exp: ['P3'],
+        ctl: ['P4'],
+        focus: ['抗寒性', '长势'],
+        start: '2024-11-15',
+        end: '2024-11-30',
+      },
+    ],
     recentEvents: buildTimeline().events,
     focus: ['秋梢老熟度', '控梢效果', '花芽分化率'],
   })
@@ -300,9 +378,14 @@ export async function mockGetMapLayers(baseId) {
   })
 }
 
-export async function mockGetTimeline(baseId, _params = {}) {
+export async function mockGetTimeline(baseId, params = {}) {
   await delay()
-  return ok(buildTimeline())
+  const pack = buildTimeline()
+  const zoneId = params.zoneId
+  if (zoneId && zoneId !== 'all') {
+    pack.tasks = pack.tasks.filter((t) => !t.zoneId || t.zoneId === zoneId)
+  }
+  return ok(pack)
 }
 
 export async function mockGetCurve(baseId, params = {}) {
@@ -355,8 +438,33 @@ export async function mockGetLatestPhotos(baseId, params = {}) {
   if (params.zoneId && params.zoneId !== 'all') {
     list = list.filter((x) => x.zoneId === params.zoneId)
   }
+  if (params.date) {
+    // mock 照片 captureDate 多为 2026;按月日模糊匹配任务日
+    const md = String(params.date).slice(5)
+    list = list.filter((x) => (x.captureDate || '').includes(md) || (x.captureLabel || '') === md)
+    if (!list.length) {
+      // 无精确匹配时,返回该分区前几张并改写日期,便于任务点击演示
+      list = (pack.list || [])
+        .filter((x) => !params.zoneId || params.zoneId === 'all' || x.zoneId === params.zoneId)
+        .slice(0, 6)
+        .map((x, i) =>
+          withViewerMeta(
+            {
+              ...x,
+              captureDate: params.date,
+              captureLabel: md,
+              captureTime: `${params.date} 10:${String(12 + i).padStart(2, '0')}`,
+            },
+            i,
+          ),
+        )
+    }
+  }
+  if (params.taskId && params.taskId !== 'all') {
+    list = list.map((x) => ({ ...x, taskId: params.taskId }))
+  }
   return ok({
-    total: pack.total,
+    total: params.date || params.zoneId ? list.length : pack.total,
     filters: {
       zones: buildTimeline().zoneOptions,
       dates: [{ id: 'all', name: '全部日期' }, { id: '2024-11-02', name: '2024-11-02' }],
@@ -373,28 +481,40 @@ export async function mockGetLatestPhotos(baseId, params = {}) {
   })
 }
 
-export async function mockGetPointDetail(baseId, pointCode) {
+export async function mockGetPointDetail(baseId, pointCode, params = {}) {
   await delay()
   const pt = mapPack.zones.flatMap((z) => z.points.map((p) => ({ ...p, zone: z }))).find((p) => p.code === pointCode)
   const zone = pt?.zone || zoneP3
   const role = zone?.role || 'exp'
   const dates = datesBetween('2024-10-22', '2024-11-02', 2)
+  const activeDate = params.date && dates.includes(params.date) ? params.date : dates.at(-1)
+  const di = Math.max(0, dates.indexOf(activeDate))
+  const seed = (Number(String(pointCode).replace(/\D/g, '')) || 1) + di
+  const baseVal = role === 'exp' ? 94.0 : 78.4
+  const val = +(baseVal - di * 1.6 + (seed % 3) * 0.3).toFixed(1)
   return ok({
     pointCode,
     zoneId: zone?.id || 'P3',
     zoneName: zone?.name || '分区1B',
     role,
-    thumbUrl: photoUrl(Number(String(pointCode).replace(/\D/g, '')) || 1),
+    thumbUrl: photoUrl(seed),
     dates,
-    activeDate: dates.at(-1),
+    activeDate,
     metrics: [
       {
-        name: '秋梢老熟度',
-        value: role === 'exp' ? 94.0 : 78.4,
+        name: '新梢整齐度',
+        value: val,
         unit: '%',
-        delta: role === 'exp' ? -4.8 : -1.2,
+        delta: role === 'exp' ? +( -1.2 - di * 0.4).toFixed(1) : +( -0.6 - di * 0.2).toFixed(1),
         deltaUnit: '%',
       },
+      {
+        name: '末次梢老熟进度',
+        value: +(0.72 + di * 0.04 + (role === 'exp' ? 0.08 : 0)).toFixed(2),
+        unit: '',
+        delta: +(0.02 + di * 0.01).toFixed(2),
+        deltaUnit: '',
+      },
     ],
     lng: pt?.lng ?? mapPack.lon0,
     lat: pt?.lat ?? mapPack.lat0,

+ 25 - 7
src/components/DataAnalysis.vue

@@ -366,6 +366,8 @@ import {
 const props = defineProps({
   baseId: { type: String, required: true },
   active: { type: Boolean, default: false },
+  /** 试验区配对:1A / 1B */
+  zonePair: { type: String, default: '1A' },
 })
 
 const loading = ref(false)
@@ -602,15 +604,19 @@ function renderYieldTrend() {
 const wHolder = { chart: null }
 const pHolder = { chart: null }
 
+function zoneParams(extra = {}) {
+  return { zonePair: props.zonePair, ...extra }
+}
+
 async function loadAll() {
   if (!props.baseId) return
   loading.value = true
   try {
     const [wx, ws, ps, yd] = await Promise.all([
-      getWeatherSeries(props.baseId).then(unwrap),
-      getWeatherStress(props.baseId).then(unwrap),
-      getPestStress(props.baseId).then(unwrap),
-      getYieldEstimate(props.baseId, { stageId: stageId.value }).then(unwrap),
+      getWeatherSeries(props.baseId, zoneParams()).then(unwrap),
+      getWeatherStress(props.baseId, zoneParams()).then(unwrap),
+      getPestStress(props.baseId, zoneParams()).then(unwrap),
+      getYieldEstimate(props.baseId, zoneParams({ stageId: stageId.value })).then(unwrap),
     ])
     weather.value = wx
     weatherStress.value = ws
@@ -642,7 +648,7 @@ function renderAllCharts() {
 
 async function reloadWeatherStress() {
   weatherStress.value = unwrap(
-    await getWeatherStress(props.baseId, { eventId: weatherEventId.value }),
+    await getWeatherStress(props.baseId, zoneParams({ eventId: weatherEventId.value })),
   )
   await nextTick()
   renderCompareCurve(wCurveRef, wHolder, weatherStress.value, weatherInd.value)
@@ -650,14 +656,18 @@ async function reloadWeatherStress() {
 }
 
 async function reloadPestStress() {
-  pestStress.value = unwrap(await getPestStress(props.baseId, { eventId: pestEventId.value }))
+  pestStress.value = unwrap(
+    await getPestStress(props.baseId, zoneParams({ eventId: pestEventId.value })),
+  )
   await nextTick()
   renderCompareCurve(pCurveRef, pHolder, pestStress.value, pestInd.value)
   pChart = pHolder.chart
 }
 
 async function reloadYield() {
-  yieldData.value = unwrap(await getYieldEstimate(props.baseId, { stageId: stageId.value }))
+  yieldData.value = unwrap(
+    await getYieldEstimate(props.baseId, zoneParams({ stageId: stageId.value })),
+  )
   await nextTick()
   renderYieldTrend()
 }
@@ -674,6 +684,14 @@ watch(
   },
 )
 
+watch(
+  () => props.zonePair,
+  async () => {
+    if (!props.active || !loaded) return
+    await loadAll()
+  },
+)
+
 watch(weatherInd, () => {
   renderCompareCurve(wCurveRef, wHolder, weatherStress.value, weatherInd.value)
   wChart = wHolder.chart

+ 56 - 20
src/components/PatrolTimeline.vue

@@ -40,16 +40,6 @@
       <span v-for="s in stressLegend" :key="s.type">
         <i class="lg-ln" :style="{ background: s.color }"></i>{{ s.label }}
       </span>
-      <hr />
-      <button type="button" class="chip-btn" :class="{ on: widen.weather }" @click="widen.weather = !widen.weather">
-        气象数据加宽
-      </button>
-      <button type="button" class="chip-btn" :class="{ on: widen.pest }" @click="widen.pest = !widen.pest">
-        病虫指标加宽
-      </button>
-      <button type="button" class="chip-btn" :class="{ on: widen.pheno }" @click="widen.pheno = !widen.pheno">
-        物候加宽
-      </button>
     </div>
 
     <div class="tl-body">
@@ -77,13 +67,22 @@
           />
 
           <!-- 巡园任务 -->
-          <div
+          <button
             v-for="t in positionedTasks"
             :key="t.id"
+            type="button"
             class="tk"
-            :class="taskClass(t)"
+            :class="[taskClass(t), { on: t.date === markerDate }]"
             :style="{ left: t.x + 'px', top: '18px' }"
             :title="`${t.date} ${t.label}`"
+            @click="selectTask(t)"
+          />
+
+          <!-- 曲线同步日期标记 -->
+          <div
+            v-if="markerX != null"
+            class="tl-mark"
+            :style="{ left: markerX + 'px' }"
           />
 
           <!-- 识别指标 -->
@@ -124,7 +123,7 @@
             class="sline"
             :style="{
               left: s.left + 'px',
-              width: Math.max(s.width, widen.weather ? 28 : 14) + 'px',
+              width: Math.max(s.width, 14) + 'px',
               background: stressColor(s.type),
             }"
             :title="`${s.date} ${s.label}`"
@@ -160,20 +159,21 @@
 </template>
 
 <script setup>
-import { computed, reactive, ref, watch } from 'vue'
+import { computed, ref, watch } from 'vue'
 
 const props = defineProps({
   data: { type: Object, default: null },
   modelValue: { type: String, default: 'all' },
   activeIndicatorId: { type: String, default: '' },
+  /** 曲线/样点同步的高亮日期 YYYY-MM-DD */
+  markerDate: { type: String, default: '' },
 })
 
-const emit = defineEmits(['update:modelValue', 'change', 'select-indicator'])
+const emit = defineEmits(['update:modelValue', 'change', 'select-indicator', 'select-task'])
 
 const zone = ref(props.modelValue || 'all')
 const rangeMode = ref('cycle')
 const scrollRef = ref(null)
-const widen = reactive({ weather: false, pest: false, pheno: false })
 
 const PX = 3.2 // 每天像素
 
@@ -329,7 +329,7 @@ const positionedPhenology = computed(() =>
       return {
         ...p,
         left,
-        width: right - left + (widen.pheno ? 24 : 0),
+        width: right - left,
         isNow,
       }
     })
@@ -381,11 +381,31 @@ function selectIndicator(ind) {
   emit('select-indicator', ind)
 }
 
+function selectTask(t) {
+  emit('select-task', t)
+}
+
+const markerX = computed(() => {
+  if (!props.markerDate) return null
+  const d = new Date(props.markerDate)
+  if (Number.isNaN(+d) || d < range.value.start || d > range.value.end) return null
+  return xOf(d)
+})
+
 function scrollToToday() {
-  if (todayX.value == null || !scrollRef.value) return
+  scrollToDate(props.data?.today || todayDate())
+}
+
+function scrollToDate(dateLike) {
+  if (!scrollRef.value || dateLike == null) return
+  const d = dateLike instanceof Date ? dateLike : new Date(dateLike)
+  if (Number.isNaN(+d)) return
+  const x = xOf(d)
   const el = scrollRef.value
-  el.scrollLeft = Math.max(0, todayX.value - el.clientWidth * 0.55)
+  el.scrollLeft = Math.max(0, x - el.clientWidth * 0.55)
 }
+
+defineExpose({ scrollToToday, scrollToDate })
 </script>
 
 <style scoped>
@@ -406,7 +426,23 @@ function scrollToToday() {
 .tl-inds {
   position: absolute !important;
 }
-.chip-btn {
+button.tk {
   cursor: pointer;
+  padding: 0;
+  appearance: none;
+}
+button.tk.on {
+  box-shadow: 0 0 0 2px var(--bg), 0 0 0 3.5px var(--amber);
+}
+.tl-mark {
+  position: absolute;
+  top: 0;
+  bottom: 22px;
+  width: 2px;
+  margin-left: -1px;
+  background: var(--amber);
+  opacity: 0.55;
+  pointer-events: none;
+  z-index: 4;
 }
 </style>

+ 420 - 0
src/data/phenoConfig.js

@@ -0,0 +1,420 @@
+/**
+ * 表型指标配置(对齐飞鸟田间表型平台 PHENO_CONFIG)
+ * 作物 → 模板生育期 → 源指标期 → 指标列表
+ */
+export const PHENO_CONFIG = {
+  sourceIndicators: {
+    grain: {
+      播前与整地期: ['历史长势积分与稳定性', '历史生长速度与物候偏差', '前茬成熟整齐度 / 倒伏异常比例', '基础土壤肥力'],
+      苗期: ['出苗率', '出苗整齐度', '缺苗断垄占比', '冠层初始覆盖度'],
+      越冬休眠期: ['冬前积温与苗情', '冬季积温与旺长情况'],
+      返青恢复期: ['返青后苗情与群体大小'],
+      '分蘖 / 分枝期': ['群体密度', '有效分蘖数', '株高均值', '株高差异度'],
+      '拔节 / 伸长期': ['病叶率', '茎部蛀孔株率', '株高整齐度', '群体透光性'],
+      孕穗现蕾期: ['花器受害占比', '生殖生长转换进度', '物候转换整齐度'],
+      '抽穗 / 开花期': ['有效穗数', '穗部整齐度', '冠层覆盖度'],
+      灌浆期: ['穗部灌浆饱满度', '冠层衰老起始时间', '病穗率 / 籽粒受害占比'],
+      成熟期: ['群体落黄程度', '倒伏率', '枯死冠层占比'],
+      收获与清茬期: ['籽粒成熟度与含水量', '气象灾害预警'],
+    },
+    fruit: {
+      休眠落叶期: ['前季果实品质历史数据', '根区土壤肥力', '枝干受害率 / 越冬虫卵密度', '冬季需冷量累积'],
+      梢期: ['新梢整齐度', '新梢表型状态', '末次梢老熟进度'],
+      '花芽分化后期、白点期、现蕾期': ['来花率 / 花芽显现率'],
+      萌芽花期: ['花量密度', '花芽萌发整齐度'],
+      现蕾期: ['花蕾受害占比 / 霉层覆盖占比'],
+      坐果期: ['坐果率', '落花落果占比', '幼果受害占比'],
+      膨果期: ['果实膨大速率', '挂果密度', '叶片黄化卷曲占比', '果实病斑果率 / 虫果率'],
+      转色成熟期: ['果实着色面积占比', '果型整齐度', '病果 / 落果占比', '树冠枯叶枯枝占比'],
+      周年监测: ['树高', '冠幅', '新梢生长量', '枯叶枯枝比例'],
+    },
+    leaf: {
+      播前与整地期: ['历年冠层覆盖峰值与生长速率', '前茬商品合格率与整齐度', '土壤基础肥力与质地'],
+      苗期: ['出苗率', '出苗整齐度', '缺苗率', '幼苗茎基部 / 叶部病斑株率'],
+      莲座旺盛生长期: ['株高整齐度', '开展度', '冠层覆盖度', '叶片卷曲黄化占比'],
+      成株采收期: ['植株整齐度', '裂球率', '死株率', '叶片病斑面积占比'],
+      叶球形成期: ['叶球大小 CV 与紧实度', '结球进程与心叶抱合情况'],
+    },
+    fruitveg: {
+      播前与整地期: ['历年产量与生长变异', '前茬果实商品品质'],
+      苗期: ['出苗率', '出苗整齐度', '壮苗占比', '苗期病虫害株率'],
+      开花坐果期: ['来花率', '坐果率', '落花落果比例', '花蕾受害占比'],
+      结果膨大期: ['果实膨大速率', '挂果密度', '叶片病斑面积占比', '卷叶萎蔫占比'],
+      采收期: ['果实整齐度', '受害果占比', '死株率'],
+      连续结果采收期: ['新花 / 幼果 / 膨大果各阶段比例', '老叶黄化程度'],
+    },
+    flowerveg: {
+      播前与整地期: ['历年冠层覆盖峰值与生长速率', '前茬花球商品品质'],
+      苗期: ['出苗率', '出苗整齐度', '缺苗率'],
+      营养生长期: ['株高整齐度', '冠层覆盖度', '黄化萎蔫占比', '旺长期病虫害率'],
+      '现蕾 - 花球形成期': ['现蕾整齐度', '花球形成率', '花球大小整齐度', '花蕾受害占比'],
+      采收期: ['花球商品合格占比', '病株占比'],
+      采收与清园期: ['采收前病害占比', '冬季清园与病虫残留'],
+    },
+  },
+  templates: {
+    paddy: [
+      ['整地与育秧期', '播前与整地期'],
+      ['移栽/返青期', '苗期'],
+      ['分蘖期', '分蘖 / 分枝期'],
+      ['拔节期', '拔节 / 伸长期'],
+      ['孕穗期', '孕穗现蕾期'],
+      ['抽穗开花期', '抽穗 / 开花期'],
+      ['灌浆期', '灌浆期'],
+      ['黄熟成熟期', '成熟期'],
+      ['收获与清茬期', '收获与清茬期'],
+    ],
+    wheat: [
+      ['越冬休眠期', '越冬休眠期'],
+      ['返青恢复期', '返青恢复期'],
+      ['拔节期', '拔节 / 伸长期'],
+      ['孕穗期', '孕穗现蕾期'],
+      ['抽穗开花期', '抽穗 / 开花期'],
+      ['灌浆期', '灌浆期'],
+      ['成熟收获期', '成熟期'],
+      ['整地播种期', '播前与整地期'],
+      ['苗期/分蘖期', '苗期'],
+    ],
+    grain: [
+      ['播前与整地期', '播前与整地期'],
+      ['苗期', '苗期'],
+      ['营养生长期', '分蘖 / 分枝期'],
+      ['拔节/伸长期', '拔节 / 伸长期'],
+      ['现蕾/孕穗期', '孕穗现蕾期'],
+      ['开花期', '抽穗 / 开花期'],
+      ['籽粒形成/灌浆期', '灌浆期'],
+      ['成熟期', '成熟期'],
+      ['收获与清茬期', '收获与清茬期'],
+    ],
+    tuber: [
+      ['整地播种期', '播前与整地期'],
+      ['出苗期', '苗期'],
+      ['枝叶旺长期', '分蘖 / 分枝期'],
+      ['块茎形成期', '孕穗现蕾期'],
+      ['块茎膨大期', '灌浆期'],
+      ['茎叶衰老期', '成熟期'],
+      ['收获期', '收获与清茬期'],
+    ],
+    fruitEver: [
+      ['秋梢老熟期', '梢期'],
+      ['花芽分化期', '花芽分化后期、白点期、现蕾期'],
+      ['花蕾形成期', '现蕾期'],
+      ['开花期', '萌芽花期'],
+      ['坐果期', '坐果期'],
+      ['幼果膨大期', '膨果期'],
+      ['果实膨大期', '膨果期'],
+      ['转色成熟期', '转色成熟期'],
+      ['新梢生长期', '梢期'],
+    ],
+    fruitDecid: [
+      ['休眠落叶期', '休眠落叶期'],
+      ['萌芽新梢期', '梢期'],
+      ['花芽现蕾期', '花芽分化后期、白点期、现蕾期'],
+      ['开花期', '萌芽花期'],
+      ['坐果期', '坐果期'],
+      ['果实膨大期', '膨果期'],
+      ['转色成熟期', '转色成熟期'],
+      ['采收与秋梢期', '梢期'],
+      ['休眠落叶期', '休眠落叶期'],
+    ],
+    banana: [
+      ['营养生长期', '梢期'],
+      ['花芽分化期', '花芽分化后期、白点期、现蕾期'],
+      ['抽蕾期', '现蕾期'],
+      ['开花期', '萌芽花期'],
+      ['坐果期', '坐果期'],
+      ['果穗膨大期', '膨果期'],
+      ['转熟采收期', '转色成熟期'],
+    ],
+    leaf: [
+      ['整地育苗期', '播前与整地期'],
+      ['苗期', '苗期'],
+      ['莲座旺盛生长期', '莲座旺盛生长期'],
+      ['叶球形成期', '叶球形成期'],
+      ['成株采收期', '成株采收期'],
+    ],
+    fruitveg: [
+      ['播前与整地期', '播前与整地期'],
+      ['苗期', '苗期'],
+      ['开花坐果期', '开花坐果期'],
+      ['结果膨大期', '结果膨大期'],
+      ['连续结果采收期', '连续结果采收期'],
+      ['采收期', '采收期'],
+    ],
+    rootveg: [
+      ['整地播种期', '播前与整地期'],
+      ['出苗期', '苗期'],
+      ['叶片旺盛生长期', '莲座旺盛生长期'],
+      ['肉质根膨大期', '莲座旺盛生长期'],
+      ['成熟采收期', '成株采收期'],
+    ],
+    tea: [
+      ['越冬休整期', '休眠落叶期'],
+      ['春梢萌动期', '梢期'],
+      ['春茶采摘期', '梢期'],
+      ['夏梢生长期', '梢期'],
+      ['夏茶采摘期', '梢期'],
+      ['秋梢生长期', '梢期'],
+      ['秋茶采摘期', '梢期'],
+      ['树势恢复期', '梢期'],
+    ],
+    cotton: [
+      ['播前整地期', '播前与整地期'],
+      ['苗期', '苗期'],
+      ['蕾期', '孕穗现蕾期'],
+      ['初花期', '抽穗 / 开花期'],
+      ['花铃期', '灌浆期'],
+      ['吐絮期', '成熟期'],
+      ['采收期', '收获与清茬期'],
+    ],
+    sugar: [
+      ['种茎萌芽期', '苗期'],
+      ['分蘖期', '分蘖 / 分枝期'],
+      ['拔节伸长期', '拔节 / 伸长期'],
+      ['茎秆快速增长期', '拔节 / 伸长期'],
+      ['糖分积累期', '灌浆期'],
+      ['成熟收获期', '成熟期'],
+    ],
+    flowerveg: [
+      ['整地育苗期', '播前与整地期'],
+      ['苗期', '苗期'],
+      ['营养生长期', '营养生长期'],
+      ['现蕾及花球形成期', '现蕾 - 花球形成期'],
+      ['采收期', '采收期'],
+      ['采收清园期', '采收与清园期'],
+    ],
+    corn: [
+      ['整地播种期', '播前与整地期'],
+      ['出苗期', '苗期'],
+      ['拔节期', '拔节 / 伸长期'],
+      ['大喇叭口期', '拔节 / 伸长期'],
+      ['抽雄吐丝期', '抽穗 / 开花期'],
+      ['籽粒形成期', '灌浆期'],
+      ['籽粒灌浆期', '灌浆期'],
+      ['乳熟成熟期', '成熟期'],
+      ['收获清茬期', '收获与清茬期'],
+    ],
+    soy: [
+      ['播前整地期', '播前与整地期'],
+      ['出苗期', '苗期'],
+      ['分枝期', '分蘖 / 分枝期'],
+      ['始花期', '抽穗 / 开花期'],
+      ['结荚期', '孕穗现蕾期'],
+      ['鼓粒期', '灌浆期'],
+      ['成熟期', '成熟期'],
+      ['收获期', '收获与清茬期'],
+    ],
+    rapeseed: [
+      ['播种出苗期', '苗期'],
+      ['冬前莲座期', '分蘖 / 分枝期'],
+      ['越冬期', '越冬休眠期'],
+      ['返青期', '返青恢复期'],
+      ['现蕾抽薹期', '孕穗现蕾期'],
+      ['盛花期', '抽穗 / 开花期'],
+      ['角果发育期', '灌浆期'],
+      ['角果成熟期', '成熟期'],
+      ['收获期', '收获与清茬期'],
+    ],
+    sunflower: [
+      ['播种出苗期', '苗期'],
+      ['莲座生长期', '分蘖 / 分枝期'],
+      ['茎秆伸长期', '拔节 / 伸长期'],
+      ['现蕾期', '孕穗现蕾期'],
+      ['开花期', '抽穗 / 开花期'],
+      ['籽粒灌浆期', '灌浆期'],
+      ['成熟采收期', '成熟期'],
+    ],
+    litchi: [
+      ['花芽分化/控梢促花期', '花芽分化后期、白点期、现蕾期'],
+      ['白点现蕾期', '现蕾期'],
+      ['花穗发育/开花期', '萌芽花期'],
+      ['坐果期', '坐果期'],
+      ['幼果发育期', '膨果期'],
+      ['果实膨大期', '膨果期'],
+      ['果实成熟采收期', '转色成熟期'],
+      ['秋梢萌发期', '梢期'],
+      ['秋梢老熟期', '梢期'],
+      ['控梢期(休眠期)', '梢期'],
+    ],
+    citrus: [
+      ['越冬花芽分化期', '花芽分化后期、白点期、现蕾期'],
+      ['春梢萌芽期', '梢期'],
+      ['现蕾开花期', '萌芽花期'],
+      ['谢花坐果期', '坐果期'],
+      ['生理落果期', '坐果期'],
+      ['幼果膨大期', '膨果期'],
+      ['果实转色期', '转色成熟期'],
+      ['成熟采收期', '转色成熟期'],
+      ['采后恢复期', '梢期'],
+    ],
+    apple: [
+      ['休眠期', '休眠落叶期'],
+      ['萌芽展叶期', '梢期'],
+      ['花序分离与开花期', '萌芽花期'],
+      ['幼果坐果期', '坐果期'],
+      ['生理落果期', '坐果期'],
+      ['果实膨大期', '膨果期'],
+      ['转色成熟期', '转色成熟期'],
+      ['采收与落叶期', '休眠落叶期'],
+    ],
+    grape: [
+      ['冬季休眠期', '休眠落叶期'],
+      ['伤流萌芽期', '梢期'],
+      ['展叶伸蔓期', '梢期'],
+      ['花序显现期', '花芽分化后期、白点期、现蕾期'],
+      ['开花坐果期', '坐果期'],
+      ['幼果膨大期', '膨果期'],
+      ['转色期', '转色成熟期'],
+      ['成熟采收期', '转色成熟期'],
+      ['落叶休眠期', '休眠落叶期'],
+    ],
+    peach: [
+      ['休眠期', '休眠落叶期'],
+      ['花芽萌动期', '花芽分化后期、白点期、现蕾期'],
+      ['盛花期', '萌芽花期'],
+      ['坐果期', '坐果期'],
+      ['幼果膨大期', '膨果期'],
+      ['硬核期', '膨果期'],
+      ['果实成熟期', '转色成熟期'],
+      ['采后新梢期', '梢期'],
+      ['落叶休眠期', '休眠落叶期'],
+    ],
+    mango: [
+      ['秋梢老熟期', '梢期'],
+      ['花芽分化期', '花芽分化后期、白点期、现蕾期'],
+      ['抽穗现蕾期', '现蕾期'],
+      ['开花期', '萌芽花期'],
+      ['坐果稳果期', '坐果期'],
+      ['果实膨大期', '膨果期'],
+      ['转色采收期', '转色成熟期'],
+      ['采后抽梢期', '梢期'],
+    ],
+    tomato: [
+      ['播前与育苗期', '播前与整地期'],
+      ['定植缓苗期', '苗期'],
+      ['开花坐果期', '开花坐果期'],
+      ['幼果膨大期', '结果膨大期'],
+      ['转色成熟期', '结果膨大期'],
+      ['连续采收期', '连续结果采收期'],
+      ['清园换茬期', '采收期'],
+    ],
+    pepper: [
+      ['播种育苗期', '播前与整地期'],
+      ['定植缓苗期', '苗期'],
+      ['分枝现蕾期', '开花坐果期'],
+      ['开花坐果期', '开花坐果期'],
+      ['椒果膨大期', '结果膨大期'],
+      ['转色采收期', '采收期'],
+      ['连续采收期', '连续结果采收期'],
+    ],
+    watermelon: [
+      ['整地育苗期', '播前与整地期'],
+      ['伸蔓期', '苗期'],
+      ['花芽形成期', '开花坐果期'],
+      ['开花坐瓜期', '开花坐果期'],
+      ['膨瓜期', '结果膨大期'],
+      ['转熟成熟期', '采收期'],
+      ['采收清园期', '采收期'],
+    ],
+    cucumber: [
+      ['育苗定植期', '苗期'],
+      ['伸蔓期', '苗期'],
+      ['雌花形成期', '开花坐果期'],
+      ['坐瓜期', '开花坐果期'],
+      ['幼瓜膨大期', '结果膨大期'],
+      ['连续采收期', '连续结果采收期'],
+    ],
+  },
+  cropTemplate: {
+    水稻: 'paddy',
+    小麦: 'wheat',
+    玉米: 'corn',
+    大豆: 'soy',
+    油菜: 'rapeseed',
+    向日葵: 'sunflower',
+    马铃薯: 'tuber',
+    青稞: 'wheat',
+    荔枝: 'litchi',
+    柑橘: 'citrus',
+    苹果: 'apple',
+    梨: 'apple',
+    葡萄: 'grape',
+    猕猴桃: 'fruitDecid',
+    芒果: 'mango',
+    桃: 'peach',
+    枸杞: 'fruitDecid',
+    香蕉: 'banana',
+    番茄: 'tomato',
+    辣椒: 'pepper',
+    西瓜: 'watermelon',
+    大白菜: 'leaf',
+    黄瓜: 'cucumber',
+    胡萝卜: 'rootveg',
+    茶叶: 'tea',
+    棉花: 'cotton',
+    甘蔗: 'sugar',
+  },
+}
+
+/** 模板名 → 源指标族 */
+const TEMPLATE_FAMILY = {
+  paddy: 'grain',
+  wheat: 'grain',
+  grain: 'grain',
+  tuber: 'grain',
+  corn: 'grain',
+  soy: 'grain',
+  rapeseed: 'grain',
+  sunflower: 'grain',
+  cotton: 'grain',
+  sugar: 'grain',
+  fruitEver: 'fruit',
+  fruitDecid: 'fruit',
+  banana: 'fruit',
+  tea: 'fruit',
+  litchi: 'fruit',
+  citrus: 'fruit',
+  apple: 'fruit',
+  grape: 'fruit',
+  peach: 'fruit',
+  mango: 'fruit',
+  leaf: 'leaf',
+  rootveg: 'leaf',
+  fruitveg: 'fruitveg',
+  tomato: 'fruitveg',
+  pepper: 'fruitveg',
+  watermelon: 'fruitveg',
+  cucumber: 'fruitveg',
+  flowerveg: 'flowerveg',
+}
+
+export function getCropTemplate(crop) {
+  return PHENO_CONFIG.cropTemplate[crop] || null
+}
+
+export function getStageIndicators(crop, stageName) {
+  const tplKey = getCropTemplate(crop)
+  if (!tplKey) return []
+  const stages = PHENO_CONFIG.templates[tplKey] || []
+  const hit = stages.find(([label]) => label === stageName || label.includes(stageName) || stageName?.includes?.(label.replace(/[((].*$/, '')))
+  const sourceKey = hit?.[1]
+  const family = TEMPLATE_FAMILY[tplKey]
+  const bag = PHENO_CONFIG.sourceIndicators[family] || {}
+  return sourceKey ? bag[sourceKey] || [] : []
+}
+
+/**
+ * 按作物模板生成「物候期 → 指标」列表,供时间轴识别指标行使用
+ * @returns {{ stage: string, sourceStage: string, indicators: string[] }[]}
+ */
+export function getPhenologyIndicatorPlan(crop) {
+  const tplKey = getCropTemplate(crop)
+  if (!tplKey) return []
+  const family = TEMPLATE_FAMILY[tplKey]
+  const bag = PHENO_CONFIG.sourceIndicators[family] || {}
+  return (PHENO_CONFIG.templates[tplKey] || []).map(([stage, sourceStage]) => ({
+    stage,
+    sourceStage,
+    indicators: bag[sourceStage] || [],
+  }))
+}

+ 134 - 58
src/map/FarmMap.js

@@ -1,8 +1,6 @@
 /**
  * 基地详情地块图
- * 构造方式对齐 workDetail/areaMap.js:
- *   this.kmap = new KMap.Map(target, level, lng, lat, null, 1, 22, 'vec', true, true)
- *   this.kmap.addXYZLayer(base_img_url + 'map/lby/{z}/{x}/{y}.png', ...)
+ * 构造方式对齐 workDetail/areaMap.js
  */
 import VectorLayer from 'ol/layer/Vector'
 import VectorSource from 'ol/source/Vector'
@@ -10,6 +8,7 @@ import Feature from 'ol/Feature'
 import Polygon from 'ol/geom/Polygon'
 import Point from 'ol/geom/Point'
 import { Fill, Stroke, Style, Circle as CircleStyle, Text } from 'ol/style'
+import { unByKey } from 'ol/Observable'
 import { metersToLonLat } from '@/utils/geo'
 import { cssVar } from '@/data/dict'
 import config from '@/config'
@@ -23,85 +22,156 @@ export default class FarmMap {
     this.ptSource = new VectorSource()
     this.plotLayer = null
     this.ptLayer = null
+    this.selectedCode = ''
+    this.highlightZoneId = ''
+    this._clickKey = null
+    this.onSelect = null
   }
 
   /**
    * @param {HTMLElement|string} target
    * @param {[number, number]} center [lng, lat]
+   * @param {{ onSelect?: (payload: object) => void }} [opts]
    */
-  async init(target, center = [113.6, 23.5]) {
+  async init(target, center = [113.6, 23.5], opts = {}) {
+    this.onSelect = opts.onSelect || null
+
     this.plotLayer = new VectorLayer({
       source: this.plotSource,
       zIndex: 10,
-      style: (f) => {
-        const role = f.get('role')
-        const fill =
-          role === 'exp'
-            ? 'rgba(255,212,137,.28)'
-            : role === 'ctl'
-              ? 'rgba(90,183,236,.22)'
-              : 'rgba(95,212,142,.18)'
-        const stroke =
-          role === 'exp'
-            ? cssVar('--amber') || '#ffd489'
-            : role === 'ctl'
-              ? cssVar('--c-cash') || '#5ab7ec'
-              : cssVar('--c-veg') || '#5fd48e'
-        return new Style({
-          fill: new Fill({ color: fill }),
-          stroke: new Stroke({ color: stroke, width: 2 }),
-          text: new Text({
-            text: f.get('label') || '',
-            fill: new Fill({ color: cssVar('--text') || '#ece7d3' }),
-            font: 'bold 12px sans-serif',
-            stroke: new Stroke({ color: '#0a0c08', width: 3 }),
-          }),
-        })
-      },
+      style: (f) => this._plotStyle(f),
     })
 
     this.ptLayer = new VectorLayer({
       source: this.ptSource,
       zIndex: 11,
-      style: (f) => {
-        const role = f.get('role')
-        const fill =
-          role === 'ctl'
-            ? cssVar('--c-cash') || '#5ab7ec'
-            : cssVar('--amber') || '#ffd489'
-        return new Style({
-          image: new CircleStyle({
-            radius: 5,
-            fill: new Fill({ color: fill }),
-            stroke: new Stroke({ color: '#0a0c08', width: 1 }),
-          }),
-          text: new Text({
-            text: f.get('code') || '',
-            offsetY: -12,
-            fill: new Fill({ color: cssVar('--muted') || '#9d9a83' }),
-            font: '10px sans-serif',
-          }),
-        })
-      },
+      style: (f) => this._ptStyle(f),
     })
 
-    // —— 对齐 areaMap.js:23-36 ——
     const level = 16
     const [lng, lat] = center
     this.kmap = new KMap.Map(target, level, lng, lat, null, 1, 22, 'vec', true, true)
     this.kmap.addLayer(this.plotLayer)
     this.kmap.addLayer(this.ptLayer)
 
-    // areaMap 额外叠加荔博园/影像 XYZ
     const xyz = config.base_img_url + 'map/lby/{z}/{x}/{y}.png'
     this.kmap.addXYZLayer(xyz, { minZoom: 8, maxZoom: 22 }, 2)
 
     this.map = this.kmap.map
+    this._clickKey = this.map.on('singleclick', (evt) => this._onClick(evt))
     await this.kmap.baseReady
     return this.map
   }
 
+  _plotStyle(f) {
+    const role = f.get('role')
+    const plot = f.get('plot') || {}
+    const hid = this.highlightZoneId
+    const isCtl = role === 'ctl'
+    const fill =
+      role === 'exp'
+        ? 'rgba(255,212,137,.22)'
+        : isCtl
+          ? 'rgba(90,183,236,.16)'
+          : 'rgba(95,212,142,.14)'
+    const stroke = role === 'exp' ? '#ffd489' : isCtl ? '#5ab7ec' : cssVar('--c-veg') || '#5fd48e'
+    const active = hid && plot.id === hid
+    return new Style({
+      fill: new Fill({ color: fill }),
+      stroke: new Stroke({
+        color: stroke,
+        width: active ? 3 : 2,
+        lineDash: isCtl ? [6, 5] : undefined,
+      }),
+      text: new Text({
+        text: f.get('label') || '',
+        fill: new Fill({ color: cssVar('--text') || '#ece7d3' }),
+        font: 'bold 12px sans-serif',
+        stroke: new Stroke({ color: '#0a0c08', width: 3 }),
+      }),
+    })
+  }
+
+  /** 常态:白芯 + 角色色描边 2px;选中:半径放大,描边 3px */
+  _ptStyle(f) {
+    const role = f.get('role')
+    const code = f.get('code')
+    const selected = code && code === this.selectedCode
+    const strokeColor = role === 'ctl' ? '#5ab7ec' : '#ffd489'
+    return new Style({
+      image: new CircleStyle({
+        radius: selected ? 8 : 5,
+        fill: new Fill({ color: '#ffffff' }),
+        stroke: new Stroke({
+          color: strokeColor,
+          width: selected ? 3 : 2,
+        }),
+      }),
+      zIndex: selected ? 20 : 1,
+    })
+  }
+
+  _onClick(evt) {
+    if (!this.map) return
+    let hit = null
+    this.map.forEachFeatureAtPixel(
+      evt.pixel,
+      (feature, layer) => {
+        if (layer === this.ptLayer && feature.get('code')) {
+          hit = {
+            kind: 'point',
+            code: feature.get('code'),
+            zoneId: feature.get('zoneId'),
+            role: feature.get('role'),
+            lng: feature.getGeometry().getCoordinates()[0],
+            lat: feature.getGeometry().getCoordinates()[1],
+          }
+          return true
+        }
+        if (layer === this.plotLayer && feature.get('plot')) {
+          const plot = feature.get('plot')
+          hit = { kind: 'plot', zoneId: plot.id, role: plot.role, plot }
+          return true
+        }
+        return undefined
+      },
+      { hitTolerance: 8 },
+    )
+    if (hit && typeof this.onSelect === 'function') this.onSelect(hit)
+  }
+
+  setSelectedPoint(code) {
+    this.selectedCode = code || ''
+    this.ptLayer?.changed()
+  }
+
+  setHighlightZone(zoneId) {
+    this.highlightZoneId = zoneId && zoneId !== 'all' ? zoneId : ''
+    this.plotLayer?.changed()
+    this.ptLayer?.changed()
+    if (this.highlightZoneId) this.fitZone(this.highlightZoneId)
+  }
+
+  fitZone(zoneId) {
+    if (!zoneId || !this.map) return
+    const feats = this.plotSource.getFeatures().filter((f) => f.get('plot')?.id === zoneId)
+    if (!feats.length) return
+    let extent = feats[0].getGeometry().getExtent().slice()
+    feats.slice(1).forEach((f) => {
+      const e = f.getGeometry().getExtent()
+      extent[0] = Math.min(extent[0], e[0])
+      extent[1] = Math.min(extent[1], e[1])
+      extent[2] = Math.max(extent[2], e[2])
+      extent[3] = Math.max(extent[3], e[3])
+    })
+    this.kmap.fit(extent, [60, 60, 60, 60])
+  }
+
   destroy() {
+    if (this._clickKey) {
+      unByKey(this._clickKey)
+      this._clickKey = null
+    }
     this.kmap?.destroy()
     this.kmap = null
     this.map = null
@@ -149,14 +219,13 @@ export default class FarmMap {
               ox - 70 + 140 * p.u,
               oy - 55 + 110 * p.v,
             )
-            return { code: p.code, lng, lat }
+            return { code: p.code, lng, lat, role: i % 2 === 0 ? 'exp' : 'ctl', zoneId: `P${i + 1}` }
           }),
         })
       }
       plots = demo
     }
 
-    // EPSG:4326:坐标直接用 [lng, lat],不再 fromLonLat
     plots.forEach((p) => {
       let ring = p.ring
       if (!ring && p.r) {
@@ -196,17 +265,24 @@ export default class FarmMap {
         const pf = new Feature({ geometry: new Point([lng, lat]) })
         pf.set('code', pt.code || `S${idx + 1}`)
         pf.set('role', pt.role || p.role)
+        pf.set('zoneId', pt.zoneId || p.id)
         this.ptSource.addFeature(pf)
       })
     })
 
-    const extent = this.plotSource.getExtent()
-    if (extent && isFinite(extent[0])) {
-      this.kmap.fit(extent, [40, 40, 40, 40])
+    if (this.highlightZoneId) {
+      this.fitZone(this.highlightZoneId)
     } else {
-      this.map.getView().setCenter([lon0, lat0])
-      this.map.getView().setZoom(16)
+      const extent = this.plotSource.getExtent()
+      if (extent && isFinite(extent[0])) {
+        this.kmap.fit(extent, [40, 40, 40, 40])
+      } else {
+        this.map.getView().setCenter([lon0, lat0])
+        this.map.getView().setZoom(16)
+      }
     }
+    this.plotLayer?.changed()
+    this.ptLayer?.changed()
   }
 
   resize() {

+ 1 - 1
src/styles/app.css

@@ -320,7 +320,7 @@ body.in-detail .app{height:auto;min-height:100%}
 .tl-canvas>*{position:absolute}
 .tl-sep{top:0;height:170px;width:1px;background:var(--line)}
 .tl-cur{top:0;height:170px;background:rgba(255,212,137,.045)}
-.tk{width:11px;height:14px;border-radius:3px;border:2px solid transparent;cursor:default}
+.tk{width:11px;height:14px;border-radius:3px;border:2px solid transparent;cursor:pointer}
 .tk.fly{background:var(--amber)}.tk.man{background:var(--w-blue)}
 .tk.pending{background:transparent;border-color:var(--amber)}
 .tk.man.pending{border-color:var(--w-blue)}

+ 397 - 115
src/views/detail/index.vue

@@ -40,47 +40,129 @@
       </button>
     </div>
 
-    <div class="mapwrap" v-show="!hideMapChrome">
+    <div class="mapwrap" v-show="!hideMapChrome && showMapArea">
       <div ref="farmRef" class="mapcanvas" id="farm"></div>
 
-      <aside class="glass bcard mleft-card" v-if="overview">
-        <div class="bc-name">{{ overview.name }}</div>
-        <div class="bc-meta">
-          <span>{{ overview.id }}</span>
-          <span>·</span>
-          <span>{{ overview.crop }}</span>
-          <span>·</span>
-          <span class="num">{{ overview.mu }}</span> 亩
-        </div>
-        <div class="bc-loc">{{ overview.prov }}{{ overview.city }}</div>
-        <div class="bc-stage" v-if="overview.stage">
-          <b>{{ overview.stage.name }}</b>
-          <span>{{ overview.stage.level }} 级 · 第 {{ overview.stage.dayIndex }} 天 / {{ overview.stage.dayTotal }} 天</span>
-        </div>
-        <div class="bc-stats" v-if="overview.survey">
-          <div><em>{{ overview.survey.zoneCount }}</em><span>分区</span></div>
-          <div><em>{{ overview.survey.samplePoints }}</em><span>样点</span></div>
-          <div><em>{{ overview.survey.plants }}</em><span>单株</span></div>
-        </div>
-        <div class="bc-mission" v-if="overview.mission">
-          <div>飞巡 {{ overview.mission.lastFlightDate }} · 第 {{ overview.mission.flightIndex }} 次</div>
-          <div>
-            完成率 <b>{{ overview.mission.completionRate }}%</b>
-            · 损伤率 {{ overview.mission.damageRate }}%
+      <!-- 左侧浮层:随 Tab 切换(表型/农事=基地卡;数据分析=基地卡+试验区列表) -->
+      <div class="mleft" v-if="overview && showMapLeft">
+        <aside class="glass bcard" aria-label="基地详情卡片">
+          <div class="bc-h">
+            <h4 :title="overview.title || overview.name">
+              {{ overview.title || `${overview.prov}·${overview.name}` }}
+            </h4>
+            <span class="id">{{ overview.id }}</span>
           </div>
-          <div>
-            有效点 {{ overview.mission.validPoints.ok }}/{{ overview.mission.validPoints.total }}
-            · 异常 {{ overview.mission.outliers.ok }}/{{ overview.mission.outliers.total }}
+
+          <div class="bc-grid">
+            <div>
+              <span>作物</span>
+              <b>{{ overview.crop }} <em>{{ overview.mu }}亩</em></b>
+            </div>
+            <div>
+              <span>地理位置</span>
+              <b>{{ overview.prov }}{{ overview.city }}</b>
+            </div>
+            <div v-if="overview.stage">
+              <span>当下物候期</span>
+              <b>{{ overview.stage.name }} <em>第{{ overview.stage.dayIndex }}天</em></b>
+            </div>
+            <div>
+              <span>气象胁迫</span>
+              <b v-if="overview.stress?.has">
+                <i class="wd" :style="{ '--wc': overview.stress.color || 'var(--w-red)' }"></i>
+                {{ overview.stress.type || '' }}{{ overview.stress.level }} 级
+                <em>持续 {{ overview.stress.days }}天</em>
+              </b>
+              <b v-else>
+                <i class="wd" style="--wc: var(--w-ok)"></i>无胁迫
+              </b>
+            </div>
           </div>
-        </div>
-        <ul class="bc-events" v-if="overview.recentEvents?.length">
-          <li v-for="e in overview.recentEvents" :key="e.date + e.title">
-            <span>{{ e.date.slice(5) }}</span>{{ e.title }}
-          </li>
-        </ul>
-      </aside>
 
-      <aside class="glass point-card" v-if="pointDetail">
+          <div class="bc-line" v-if="overview.survey">
+            <span>实测地块</span>
+            <b>{{ overview.survey.zoneCount }} 分区 · {{ overview.survey.samplePoints }} 采样点</b>
+            <em>单株 {{ overview.survey.plants }}</em>
+          </div>
+
+          <div class="bc-line" v-if="overview.mission">
+            <span>最近飞巡</span>
+            <b>{{ overview.mission.lastFlightLabel || overview.mission.lastFlightDate?.slice(5) }}</b>
+            <em>第{{ overview.mission.flightIndex }}次 · {{ overview.mission.device || 'M3M' }}</em>
+          </div>
+
+          <div class="bc-ind" v-if="overview.indicators?.length">
+            <div v-for="ind in overview.indicators.slice(0, 2)" :key="ind.name" :title="ind.name">
+              <span>{{ ind.name }}</span>
+              <b>{{ ind.value }}<small>{{ ind.unit }}</small></b>
+            </div>
+          </div>
+
+          <div class="bc-prog" v-if="overview.progress">
+            <div>
+              <span>
+                飞巡完成度
+                <b>{{ overview.progress.flightDone }}/{{ overview.progress.flightTotal }}</b>
+              </span>
+              <span
+                class="bar"
+                :style="{ '--bc': progColor(overview.progress.flightDone, overview.progress.flightTotal) }"
+              >
+                <i :style="{ width: progPct(overview.progress.flightDone, overview.progress.flightTotal) + '%' }"></i>
+              </span>
+            </div>
+            <div>
+              <span>
+                指标完善度
+                <b>{{ overview.progress.indicatorDone }}/{{ overview.progress.indicatorTotal }}</b>
+              </span>
+              <span
+                class="bar"
+                :style="{ '--bc': progColor(overview.progress.indicatorDone, overview.progress.indicatorTotal) }"
+              >
+                <i :style="{ width: progPct(overview.progress.indicatorDone, overview.progress.indicatorTotal) + '%' }"></i>
+              </span>
+            </div>
+          </div>
+
+          <div class="bc-plan" v-if="overview.futureFlights?.length">
+            <div class="t">未来飞巡规划</div>
+            <div v-for="f in overview.futureFlights" :key="f.date">
+              <b>{{ f.dateLabel }}</b>
+              <em>{{ f.afterDays === 0 ? '今日' : f.afterDays + '天后' }}</em>
+              <span>{{ f.indicators }}</span>
+              <i>{{ f.mode }}</i>
+            </div>
+          </div>
+        </aside>
+
+        <!-- 仅数据分析:试验区列表 -->
+        <aside class="glass zlist" v-if="tab === 'ana'" aria-label="试验区列表">
+          <div class="zl-h">
+            <b>试验区列表</b>
+            <em>{{ (overview.experimentGroups || []).length }} 个</em>
+          </div>
+          <div class="zl-list">
+            <button
+              v-for="g in overview.experimentGroups || []"
+              :key="g.id"
+              type="button"
+              class="zl-item"
+              :class="{ on: anaZone === g.id }"
+              @click="onSelectAnaZone(g)"
+            >
+              <span class="zl-n">{{ g.name }}</span>
+              <span class="zl-m">实验 {{ g.exp.join('、') }} · 对照 {{ g.ctl.join('、') }}</span>
+              <span class="zl-f">
+                <i v-for="f in g.focus" :key="f">{{ f }}</i>
+                <em>{{ g.start.slice(5) }}~{{ g.end.slice(5) }}</em>
+              </span>
+            </button>
+          </div>
+        </aside>
+      </div>
+
+      <aside class="glass point-card" v-if="pointDetail && tab === 'pheno'">
         <div class="pc-h">
           <b>{{ pointDetail.pointCode }}</b>
           <span>{{ pointDetail.zoneName }}</span>
@@ -97,6 +179,7 @@
             :key="d"
             type="button"
             :class="{ on: d === pointDetail.activeDate }"
+            @click="onPointDateClick(d)"
           >
             {{ d.slice(5) }}
           </button>
@@ -113,10 +196,10 @@
       </aside>
 
       <div class="glass maplegend">
-        <span><i class="exp"></i>实验组</span>
-        <span><i class="ctl"></i>对照组</span>
-        <span v-if="mapLayers">● 样点 {{ mapLayers.legend?.samplePoints }}</span>
-        <span v-if="mapLayers">网格 {{ mapLayers.legend?.grids }}</span>
+        <span><i class="exp"></i>实验组地块</span>
+        <span><i class="ctl"></i>对照组地块</span>
+        <span><i class="pt-dot"></i>采样点</span>
+        <span v-if="mapLayers">样点 {{ mapLayers.legend?.samplePoints }}</span>
         <span v-if="mapLayers">单株 {{ mapLayers.legend?.plants }}</span>
       </div>
     </div>
@@ -124,10 +207,14 @@
     <div v-show="tab === 'pheno'" class="detail-body">
       <div class="row73">
         <PatrolTimeline
+          ref="tlRef"
           v-model="tlZone"
           :data="timeline"
           :active-indicator-id="activeIndicatorId"
+          :marker-date="syncDate"
+          @change="onTlChange"
           @select-indicator="onSelectIndicator"
+          @select-task="onSelectTask"
         />
 
         <section class="panel curve-panel">
@@ -252,7 +339,12 @@
     <PhotoViewer v-model:open="viewerOpen" :list="viewerList" :start-index="viewerIndex" />
 
     <div v-show="tab === 'ana'" class="detail-body">
-      <DataAnalysis v-if="phenoId" :base-id="phenoId" :active="tab === 'ana'" />
+      <DataAnalysis
+        v-if="phenoId"
+        :base-id="phenoId"
+        :active="tab === 'ana'"
+        :zone-pair="anaZone"
+      />
     </div>
 
     <div v-show="tab === 'farm'" class="detail-body">
@@ -314,7 +406,6 @@ import {
   getComparePhotos,
   getLatestPhotos,
   getPointDetail,
-  isDongguanBase,
 } from '@/api/phenotype'
 
 const route = useRoute()
@@ -330,8 +421,10 @@ const tabs = [
 const tab = ref('pheno')
 const farmRef = ref(null)
 const curveRef = ref(null)
+const tlRef = ref(null)
 let farmMap = null
 let chart = null
+let syncingZone = false
 
 const overview = ref(null)
 const mapLayers = ref(null)
@@ -343,19 +436,60 @@ const pointDetail = ref(null)
 const tlZone = ref('P3')
 const curveZone = ref('1B')
 const photoZone = ref('P3')
+/** 数据分析左侧试验区选中(1A / 1B) */
+const anaZone = ref('1A')
 const phenoId = ref('')
-const activeIndicatorId = ref('I2')
+const showMapArea = computed(() => tab.value === 'pheno' || tab.value === 'ana' || tab.value === 'farm')
+const showMapLeft = computed(() => showMapArea.value)
+const activeIndicatorId = ref('')
 const activeIndicatorName = ref('新梢整齐度')
+/** 曲线 / 样点 / 时间轴共用的同步日期 */
+const syncDate = ref('')
 const viewerOpen = ref(false)
 const viewerList = ref([])
 const viewerIndex = ref(0)
 const farmDetail = ref(null)
 const hideMapChrome = computed(() => !!farmDetail.value)
 
+function zoneToPair(zoneId) {
+  if (!zoneId || zoneId === 'all') return null
+  if (zoneId === '1A' || zoneId === 'P1' || zoneId === 'P2') return '1A'
+  if (zoneId === '1B' || zoneId === 'P3' || zoneId === 'P4') return '1B'
+  return null
+}
+
+function pairToDefaultZone(pair) {
+  return pair === '1A' ? 'P1' : 'P3'
+}
+
 function onFarmDetailChange(payload) {
   farmDetail.value = payload
 }
 
+function progPct(done, total) {
+  if (!total) return 0
+  return Math.round((done / total) * 100)
+}
+
+function progColor(done, total) {
+  const v = progPct(done, total)
+  if (v >= 85) return 'var(--w-ok)'
+  if (v >= 65) return 'var(--amber)'
+  return 'var(--w-orange)'
+}
+
+function onSelectAnaZone(g) {
+  if (!g?.id) return
+  anaZone.value = g.id
+  curveZone.value = g.id
+  const expId = g.exp?.[0]
+  if (expId) {
+    tlZone.value = expId
+    photoZone.value = expId
+    farmMap?.setHighlightZone(expId)
+  }
+}
+
 const base = computed(() => basesStore.byId(route.params.id))
 const filteredLatest = computed(() => {
   const list = latest.value?.list || []
@@ -433,13 +567,23 @@ function renderFarm() {
     lat: overview.value?.lat ?? base.value.lat,
   }
   farmMap.render(b, plotsFromLayers(mapLayers.value))
+  if (pointDetail.value?.pointCode) farmMap.setSelectedPoint(pointDetail.value.pointCode)
+  farmMap.setHighlightZone(tlZone.value)
 }
 
 function renderCurve() {
   if (!curveRef.value || !curve.value) return
-  if (!chart) chart = echarts.init(curveRef.value)
+  if (!chart) {
+    chart = echarts.init(curveRef.value)
+    chart.on('click', (params) => {
+      if (params?.componentType !== 'series') return
+      const full = curve.value?.series?.[0]?.points?.[params.dataIndex]?.date
+      if (full) onCurveDateClick(full)
+    })
+  }
   const series = curve.value.series || []
   const xs = series[0]?.points?.map((p) => p.date.slice(5)) || []
+  const mark = syncDate.value
   chart.setOption({
     backgroundColor: 'transparent',
     grid: { left: 42, right: 14, top: 16, bottom: 28 },
@@ -468,7 +612,19 @@ function renderCurve() {
         width: 2,
       },
       itemStyle: { color: s.role === 'ctl' ? '#5ab7ec' : '#ffd489' },
-      symbolSize: 6,
+      symbolSize: (val, params) => {
+        const d = s.points[params.dataIndex]?.date
+        return d && d === mark ? 10 : 6
+      },
+      markLine: mark
+        ? {
+            silent: true,
+            symbol: 'none',
+            lineStyle: { color: '#ffd489', type: 'dashed', width: 1, opacity: 0.7 },
+            data: [{ xAxis: mark.slice(5) }],
+            label: { show: false },
+          }
+        : undefined,
     })),
   })
 }
@@ -485,21 +641,134 @@ async function reloadCurve() {
   renderCurve()
 }
 
+async function reloadPoint(code, date) {
+  if (!phenoId.value || !code) return
+  pointDetail.value = unwrap(
+    await getPointDetail(phenoId.value, code, date ? { date } : {}),
+  )
+  farmMap?.setSelectedPoint(code)
+  if (pointDetail.value?.activeDate) syncDate.value = pointDetail.value.activeDate
+}
+
+async function onMapSelect(hit) {
+  if (!phenoId.value || !hit) return
+  if (hit.kind === 'point' && hit.code) {
+    await reloadPoint(hit.code)
+    if (hit.zoneId && hit.zoneId !== tlZone.value) {
+      syncingZone = true
+      tlZone.value = hit.zoneId
+      photoZone.value = hit.zoneId
+      const pair = zoneToPair(hit.zoneId)
+      if (pair) curveZone.value = pair
+      farmMap?.setHighlightZone(hit.zoneId)
+      timeline.value = unwrap(await getTimeline(phenoId.value, { zoneId: hit.zoneId }))
+      latest.value = unwrap(await getLatestPhotos(phenoId.value, { zoneId: hit.zoneId }))
+      await reloadCurve()
+      syncingZone = false
+    }
+    return
+  }
+  if (hit.kind === 'plot' && hit.zoneId) {
+    const pt =
+      mapLayers.value?.points?.find((p) => p.zoneId === hit.zoneId) ||
+      null
+    syncingZone = true
+    tlZone.value = hit.zoneId
+    photoZone.value = hit.zoneId
+    const pair = zoneToPair(hit.zoneId)
+    if (pair) curveZone.value = pair
+    farmMap?.setHighlightZone(hit.zoneId)
+    timeline.value = unwrap(await getTimeline(phenoId.value, { zoneId: hit.zoneId }))
+    latest.value = unwrap(await getLatestPhotos(phenoId.value, { zoneId: hit.zoneId }))
+    await reloadCurve()
+    if (pt) await reloadPoint(pt.code)
+    syncingZone = false
+  }
+}
+
+async function onPointDateClick(date) {
+  const code = pointDetail.value?.pointCode
+  if (!code) return
+  syncDate.value = date
+  await reloadPoint(code, date)
+  tlRef.value?.scrollToDate(date)
+}
+
+async function onTlChange({ zoneId }) {
+  if (syncingZone || !phenoId.value) return
+  const pair = zoneToPair(zoneId)
+  syncingZone = true
+  if (pair && curveZone.value !== pair) curveZone.value = pair
+  photoZone.value = zoneId === 'all' ? 'all' : zoneId
+  farmMap?.setHighlightZone(zoneId)
+  timeline.value = unwrap(await getTimeline(phenoId.value, { zoneId }))
+  latest.value = unwrap(await getLatestPhotos(phenoId.value, { zoneId: photoZone.value }))
+  await reloadCurve()
+  if (zoneId && zoneId !== 'all') {
+    const pt = mapLayers.value?.points?.find((p) => p.zoneId === zoneId)
+    if (pt) await reloadPoint(pt.code, syncDate.value || undefined)
+  }
+  syncingZone = false
+}
+
 function onSelectIndicator(ind) {
   activeIndicatorId.value = ind.id
   activeIndicatorName.value = ind.name
+  if (ind.date) {
+    syncDate.value = ind.date
+    tlRef.value?.scrollToDate(ind.date)
+  }
   reloadCurve()
 }
 
+async function onSelectTask(task) {
+  if (!phenoId.value || !task) return
+  syncDate.value = task.date
+  tlRef.value?.scrollToDate(task.date)
+  const zoneId = photoZone.value !== 'all' ? photoZone.value : tlZone.value
+  const photos = unwrap(
+    await getLatestPhotos(phenoId.value, {
+      zoneId: zoneId === 'all' ? undefined : zoneId,
+      date: task.date,
+      taskId: task.id,
+    }),
+  )
+  const list = photos?.list || []
+  if (list.length) {
+    openViewer(list, 0)
+  } else if (pointDetail.value?.pointCode) {
+    await reloadPoint(pointDetail.value.pointCode, task.date)
+    openPointViewer()
+  }
+}
+
+async function onCurveDateClick(date) {
+  syncDate.value = date
+  tlRef.value?.scrollToDate(date)
+  const code = pointDetail.value?.pointCode
+  if (code) {
+    // 样点卡日期轴可能不覆盖曲线全区间:尽量贴近
+    const dates = pointDetail.value?.dates || []
+    const hit = dates.includes(date)
+      ? date
+      : dates.reduce((best, d) => {
+          if (!best) return d
+          return Math.abs(+new Date(d) - +new Date(date)) < Math.abs(+new Date(best) - +new Date(date))
+            ? d
+            : best
+        }, null)
+    if (hit) await reloadPoint(code, hit)
+  }
+  renderCurve()
+}
+
 async function loadPhenotype() {
   const b = base.value
   if (!b) return
   const id = resolvePhenotypeBaseId(b)
   phenoId.value = id
 
-  // 非东莞基地:仍可走 mock 东莞包演示;真实对接后按 id 请求
-  const useDemo = isDongguanBase(b) || import.meta.env.VITE_USE_MOCK !== 'false'
-  const bid = useDemo && isDongguanBase(b) ? id : id
+  const bid = id
 
   const [ov, layers, tl, cv, cmp, lt] = await Promise.all([
     getBaseOverview(bid).then(unwrap),
@@ -516,9 +785,24 @@ async function loadPhenotype() {
   compare.value = cmp
   latest.value = lt
 
-  const firstPt = layers?.points?.find((p) => p.zoneId === 'P3') || layers?.points?.[0]
+  // 默认选中首个 PHENO_CONFIG 指标
+  const firstInd = tl?.indicators?.[0]
+  if (firstInd) {
+    activeIndicatorId.value = firstInd.id
+    activeIndicatorName.value = firstInd.name
+    if (!cv || cv.indicator !== firstInd.name) {
+      curve.value = unwrap(
+        await getIndicatorCurve(bid, { indicator: firstInd.name, zonePair: curveZone.value }),
+      )
+    }
+  }
+
+  const preferZone = tlZone.value
+  const firstPt =
+    layers?.points?.find((p) => p.zoneId === preferZone) || layers?.points?.[0]
   if (firstPt) {
     pointDetail.value = unwrap(await getPointDetail(bid, firstPt.code))
+    syncDate.value = pointDetail.value?.activeDate || ''
   }
 
   await nextTick()
@@ -532,21 +816,49 @@ watch(base, async () => {
 })
 
 watch(photoZone, async () => {
-  if (!phenoId.value) return
+  if (!phenoId.value || syncingZone) return
   latest.value = unwrap(await getLatestPhotos(phenoId.value, { zoneId: photoZone.value }))
 })
 
-watch(curveZone, () => {
+watch(curveZone, async (pair) => {
+  if (syncingZone) {
+    reloadCurve()
+    return
+  }
+  // 4A:曲线区 → 时间轴默认试验区
+  const z = pairToDefaultZone(pair)
+  if (z && tlZone.value !== z && tlZone.value !== 'all') {
+    syncingZone = true
+    tlZone.value = z
+    photoZone.value = z
+    farmMap?.setHighlightZone(z)
+    if (phenoId.value) {
+      timeline.value = unwrap(await getTimeline(phenoId.value, { zoneId: z }))
+      latest.value = unwrap(await getLatestPhotos(phenoId.value, { zoneId: z }))
+    }
+    syncingZone = false
+  }
   reloadCurve()
 })
 
-watch(hideMapChrome, async (hidden) => {
-  if (hidden) return
+watch([hideMapChrome, showMapArea], async ([hidden, show]) => {
+  if (hidden || !show) return
   await nextTick()
   farmMap?.resize()
   chart?.resize()
 })
 
+watch(tab, async (t) => {
+  if (t === 'ana' && overview.value?.experimentGroups?.length) {
+    const g = overview.value.experimentGroups.find((x) => x.id === anaZone.value)
+    if (g) onSelectAnaZone(g)
+  }
+  if (t === 'pheno' || t === 'ana' || t === 'farm') {
+    await nextTick()
+    farmMap?.resize()
+  }
+})
+
 onMounted(async () => {
   await basesStore.init()
   await nextTick()
@@ -555,7 +867,9 @@ onMounted(async () => {
     return
   }
   farmMap = new FarmMap()
-  await farmMap.init(farmRef.value, [base.value.lng, base.value.lat])
+  await farmMap.init(farmRef.value, [base.value.lng, base.value.lat], {
+    onSelect: onMapSelect,
+  })
   await loadPhenotype()
   window.addEventListener('resize', onResize)
 })
@@ -659,65 +973,18 @@ onUnmounted(() => {
   min-height: 500px;
 }
 .mapcanvas { position: absolute; inset: 0;height: 500px; }
-.mleft-card {
-  position: absolute;
-  top: 12px;
-  left: 12px;
-  width: 268px;
-  z-index: 3;
-  padding: 12px 14px;
-  max-height: calc(100% - 24px);
-  overflow: auto;
-}
-.bc-name { font-size: 16px; font-weight: 800; margin-bottom: 4px; color: var(--amber); }
-.bc-meta, .bc-loc { color: var(--muted); font-size: 12px; display: flex; flex-wrap: wrap; gap: 4px; }
-.bc-loc { margin-top: 2px; }
-.bc-stage {
-  margin-top: 10px;
-  display: flex;
-  flex-direction: column;
-  gap: 2px;
-  font-size: 12.5px;
-  color: var(--text);
-}
-.bc-stage b { color: var(--amber); font-size: 14px; }
-.bc-stats {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 6px;
-  margin-top: 10px;
-}
-.bc-stats div {
-  background: var(--panel-2);
-  border: 1px solid var(--line);
-  border-radius: 8px;
-  padding: 6px 4px;
-  text-align: center;
-}
-.bc-stats em {
-  display: block;
-  font-style: normal;
-  font-weight: 800;
-  color: var(--amber);
-  font-size: 15px;
+/* 左侧浮层用全局 .mleft / .bcard / .zlist;此处只微调地图内高度 */
+.mapwrap > .mleft {
+  width: min(240px, calc(100% - 20px));
 }
-.bc-stats span { font-size: 11px; color: var(--faint); }
-.bc-mission {
-  margin-top: 10px;
-  font-size: 12px;
-  color: var(--muted);
-  line-height: 1.55;
+.mapwrap > .mleft .bcard {
+  /* flex: 1 1 auto; */
+  min-height: 0;
 }
-.bc-mission b { color: var(--amber); }
-.bc-events {
-  margin: 10px 0 0;
-  padding: 0;
-  list-style: none;
-  font-size: 12px;
-  color: var(--muted);
+.mapwrap > .mleft .zlist {
+  flex: 0 1 auto;
+  max-height: 42%;
 }
-.bc-events li { display: flex; gap: 8px; padding: 3px 0; }
-.bc-events span { color: var(--amber); min-width: 42px; }
 
 .point-card {
   position: absolute;
@@ -767,7 +1034,8 @@ onUnmounted(() => {
 
 .maplegend {
   position: absolute;
-  left: 12px;
+  left: 50%;
+  transform: translateX(-50%);
   bottom: 12px;
   z-index: 3;
   display: flex;
@@ -777,17 +1045,31 @@ onUnmounted(() => {
   font-size: 12px;
   color: var(--muted);
   max-width: calc(100% - 250px);
-  transform: none;
 }
 .maplegend i {
   display: inline-block;
-  width: 10px;
+  width: 14px;
   height: 10px;
   border-radius: 2px;
   margin-right: 4px;
+  vertical-align: -1px;
+  box-sizing: border-box;
+}
+.maplegend .exp {
+  background: transparent;
+  border: 2px solid #ffd489;
+}
+.maplegend .ctl {
+  background: transparent;
+  border: 2px dashed #5ab7ec;
+}
+.maplegend .pt-dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: #fff;
+  border: 2px solid #ffd489;
 }
-.maplegend .exp { background: rgba(255, 212, 137, 0.85); }
-.maplegend .ctl { background: rgba(90, 183, 236, 0.85); }
 
 .detail-body { display: flex; flex-direction: column; gap: 12px; }
 .row73 {