From 2ac1335f3819f7085dad672bdcef0f6b10dc9f1e Mon Sep 17 00:00:00 2001
From: hejianhao <15708179461@qq.com>
Date: 星期四, 23 四月 2026 14:57:27 +0800
Subject: [PATCH] feat(home): 添加附近车辆显示与自动缩放功能

---
 src/pages/robotaxi/home/hooks/use-nearby-vehicles.ts                               |  346 ++++++++++++++++++++++++++++++++++
 src/robotaxi/constants/subscribe.ts                                                |   36 +++
 src/pages/robotaxi/home/hooks/use-map-state.ts                                     |   17 +
 src/robotaxi/pages/select-map-site/index.tsx                                       |   18 +
 src/robotaxi/pages/begin-route/components/calling-car-modules/fare-detail-card.tsx |   10 
 src/robotaxi/api/home.ts                                                           |    9 
 src/utils/Robotaxi-public-method.ts                                                |   32 +++
 src/pages/robotaxi/home/index.tsx                                                  |   52 ++++
 src/utils/subscribe.ts                                                             |   56 +++++
 src/pages/robotaxi/home/hooks/use-region-change.ts                                 |   12 +
 src/robotaxi/pages/begin-route/hooks/use-order-actions.ts                          |    2 
 11 files changed, 572 insertions(+), 18 deletions(-)

diff --git a/src/pages/robotaxi/home/hooks/use-map-state.ts b/src/pages/robotaxi/home/hooks/use-map-state.ts
index d703760..05188b8 100644
--- a/src/pages/robotaxi/home/hooks/use-map-state.ts
+++ b/src/pages/robotaxi/home/hooks/use-map-state.ts
@@ -1,6 +1,9 @@
 import { useRef, useState } from 'react';
 import Taro from '@tarojs/taro';
-import { fetchMapData } from '../../../../utils/Robotaxi-public-method';
+import { fetchMapData, getAutoScaleBySites } from '../../../../utils/Robotaxi-public-method';
+
+const AUTO_SCALE_MAX = 17; // 自动缩放最大等级
+const AUTO_SCALE_MIN = 11; // 自动缩放最小等级
 
 /**
  * 地图状态管理:拥有地图坐标、围栏、标记点及地图业务数据刷新能力。
@@ -20,6 +23,7 @@
   const isReturningToOrigin = useRef(false);
   const resetTimer = useRef<any>(null);
   const pendingNearbyRequestCountRef = useRef(0);
+  const autoScaleAppliedCityCodeRef = useRef(''); // 已执行自动缩放的城市 code
 
   /**
    * 根据经纬度和城市编码刷新地图业务数据(围栏内判断、电子围栏 ID、站点标记)。
@@ -36,6 +40,17 @@
       setIsOperatingArea(result.isInside);
       setElectronicAreaId(result.electronicAreaId);
       setMarkers(result.newMarkers);
+      if (autoScaleAppliedCityCodeRef.current !== code) {
+        const autoScale = getAutoScaleBySites(result?.sites || result?.newMarkers || []);
+        if (typeof autoScale === 'number') {
+          setMapScale((prev) => {
+            const nextScale = Math.max(AUTO_SCALE_MIN, Math.min(AUTO_SCALE_MAX, autoScale));
+            if (Math.abs(prev - nextScale) < 0.01) return prev;
+            return nextScale;
+          });
+        }
+        autoScaleAppliedCityCodeRef.current = code;
+      }
       return result;
     } finally {
       pendingNearbyRequestCountRef.current = Math.max(0, pendingNearbyRequestCountRef.current - 1);
diff --git a/src/pages/robotaxi/home/hooks/use-nearby-vehicles.ts b/src/pages/robotaxi/home/hooks/use-nearby-vehicles.ts
new file mode 100644
index 0000000..e8deca0
--- /dev/null
+++ b/src/pages/robotaxi/home/hooks/use-nearby-vehicles.ts
@@ -0,0 +1,346 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { getNearbyVehicles } from '../../../../robotaxi/api/home';
+import { getDistance } from '../../../../utils/Robotaxi-public-method';
+
+type Deps = {
+  isOperatingArea: boolean;
+};
+
+type NearbyCenter = {
+  lat: number;
+  lon: number;
+};
+
+type NearbyVehicleItem = {
+  id: string;
+  latitude: number;
+  longitude: number;
+  direction: number;
+};
+
+type VehicleTrackItem = {
+  id: string;
+  fromLat: number;
+  fromLon: number;
+  toLat: number;
+  toLon: number;
+  fromDirection: number;
+  toDirection: number;
+};
+
+const POLL_INTERVAL_MS = 5000; // 车辆轮询周期(毫秒)
+const SETTLE_DELAY_MS = 500; // 地图停止后的静止判定时间(毫秒)
+const MOVE_THRESHOLD_METERS = 500; // 触发新搜索的位移阈值(米)
+const ANIMATION_DURATION_MS = 4500; // 车辆位移动画时长(毫秒)
+const ANIMATION_FRAME_MS = 120; // 动画帧间隔(毫秒)
+const VEHICLE_MARKER_ID_BASE = 900000000; // 车辆 marker id 起始段
+const VEHICLE_MARKER_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/marker-car.png'; // 车辆 icon 地址
+
+/**
+ * 归一化角度到 [0, 360) 区间。
+ */
+const normalizeDirection = (value: number) => {
+  const safe = Number(value);
+  if (!Number.isFinite(safe)) return 0;
+  return ((safe % 360) + 360) % 360;
+};
+
+/**
+ * 插值航向角,选择最短旋转路径避免大角度跳变。
+ */
+const interpolateDirection = (from: number, to: number, progress: number) => {
+  const fromSafe = normalizeDirection(from);
+  const toSafe = normalizeDirection(to);
+  const delta = ((((toSafe - fromSafe) % 360) + 540) % 360) - 180;
+  return normalizeDirection(fromSafe + delta * progress);
+};
+
+/**
+ * 将任意字符串稳定映射到正整数,避免与站点 marker id 冲突。
+ */
+const hashToMarkerId = (rawId: string) => {
+  let hash = 0;
+  for (let i = 0; i < rawId.length; i += 1) {
+    hash = (hash << 5) - hash + rawId.charCodeAt(i);
+    hash |= 0;
+  }
+  return VEHICLE_MARKER_ID_BASE + (Math.abs(hash) % 9999999);
+};
+
+/**
+ * 附近车辆编排:处理停稳判定、500 米阈值、5 秒轮询与平滑动画。
+ */
+export default function useNearbyVehicles({ isOperatingArea }: Deps) {
+  const [vehicleMarkers, setVehicleMarkers] = useState<any[]>([]); // 车辆 marker 列表
+  const pollTimerRef = useRef<any>(null); // 轮询定时器句柄
+  const settleTimerRef = useRef<any>(null); // 停稳判定定时器句柄
+  const animationTimerRef = useRef<any>(null); // 动画帧定时器句柄
+  const requestLockRef = useRef(false); // 请求并发锁,防止重入
+  const latestSeqRef = useRef(0); // 请求序号,防止旧响应覆盖新状态
+  const activeSearchCenterRef = useRef<NearbyCenter | null>(null); // 当前轮询中心点
+  const lastSearchCenterRef = useRef<NearbyCenter | null>(null); // 上一次发请求的中心点
+  const renderedVehicleMapRef = useRef<Map<string, NearbyVehicleItem>>(new Map()); // 当前渲染中的车辆
+  const animationStateRef = useRef<{ startAt: number; endAt: number; tracks: VehicleTrackItem[] } | null>(null); // 当前动画轨迹状态
+
+  /**
+   * 清理动画定时器。
+   */
+  const clearAnimationTimer = useCallback(() => {
+    if (animationTimerRef.current) {
+      clearTimeout(animationTimerRef.current);
+      animationTimerRef.current = null;
+    }
+    animationStateRef.current = null;
+  }, []);
+
+  /**
+   * 清理轮询定时器。
+   */
+  const clearPollTimer = useCallback(() => {
+    if (pollTimerRef.current) {
+      clearInterval(pollTimerRef.current);
+      pollTimerRef.current = null;
+    }
+  }, []);
+
+  /**
+   * 清理停稳判定定时器。
+   */
+  const clearSettleTimer = useCallback(() => {
+    if (settleTimerRef.current) {
+      clearTimeout(settleTimerRef.current);
+      settleTimerRef.current = null;
+    }
+  }, []);
+
+  /**
+   * 清空车辆渲染状态。
+   */
+  const clearVehicles = useCallback(() => {
+    renderedVehicleMapRef.current = new Map();
+    setVehicleMarkers([]);
+    clearAnimationTimer();
+  }, [clearAnimationTimer]);
+
+  /**
+   * 停止车辆相关任务并清空车辆。
+   */
+  const disposeVehicles = useCallback(() => {
+    clearSettleTimer();
+    clearPollTimer();
+    clearVehicles();
+    activeSearchCenterRef.current = null;
+    lastSearchCenterRef.current = null;
+  }, [clearPollTimer, clearSettleTimer, clearVehicles]);
+
+  /**
+   * 将车辆位置数据映射为地图 marker。
+   */
+  const buildVehicleMarkers = useCallback((vehicles: NearbyVehicleItem[]) => {
+    return vehicles.map((vehicle) => ({
+      id: hashToMarkerId(vehicle.id),
+      markerType: 'vehicle',
+      vehicleId: vehicle.id,
+      latitude: vehicle.latitude,
+      longitude: vehicle.longitude,
+      rotate: vehicle.direction,
+      width: 20,
+      height: 20,
+      zIndex: 6,
+      anchor: { x: 0.5, y: 0.5 },
+      iconPath: VEHICLE_MARKER_ICON
+    }));
+  }, []);
+
+  /**
+   * 基于插值轨迹推进车辆动画帧。
+   */
+  const playAnimationFrame = useCallback(() => {
+    const animationState = animationStateRef.current;
+    if (!animationState) return;
+    const now = Date.now();
+    const progress = Math.max(0, Math.min(1, (now - animationState.startAt) / Math.max(1, animationState.endAt - animationState.startAt)));
+    const nextVehicleMap = new Map<string, NearbyVehicleItem>();
+    animationState.tracks.forEach((track) => {
+      const lat = track.fromLat + (track.toLat - track.fromLat) * progress;
+      const lon = track.fromLon + (track.toLon - track.fromLon) * progress;
+      const direction = interpolateDirection(track.fromDirection, track.toDirection, progress);
+      nextVehicleMap.set(track.id, {
+        id: track.id,
+        latitude: lat,
+        longitude: lon,
+        direction
+      });
+    });
+    renderedVehicleMapRef.current = nextVehicleMap;
+    setVehicleMarkers(buildVehicleMarkers(Array.from(nextVehicleMap.values())));
+    if (progress >= 1) {
+      animationStateRef.current = null;
+      animationTimerRef.current = null;
+      return;
+    }
+    animationTimerRef.current = setTimeout(() => {
+      playAnimationFrame();
+    }, ANIMATION_FRAME_MS);
+  }, [buildVehicleMarkers]);
+
+  /**
+   * 规范化接口返回,过滤非法经纬度。
+   */
+  const normalizeVehicleList = useCallback((response: any): NearbyVehicleItem[] => {
+    const rawList = Array.isArray(response)
+      ? response
+      : Array.isArray(response?.list)
+        ? response.list
+        : Array.isArray(response?.vehicles)
+          ? response.vehicles
+          : [];
+    return rawList
+      .map((item: any, index: number) => {
+        const latitude = Number(item?.latitude);
+        const longitude = Number(item?.longitude);
+        if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
+        const rawId = item?.id ?? `vehicle-${index}-${latitude}-${longitude}`;
+        return {
+          id: String(rawId),
+          latitude,
+          longitude,
+          direction: normalizeDirection(Number(item?.direction))
+        };
+      })
+      .filter(Boolean) as NearbyVehicleItem[];
+  }, []);
+
+  /**
+   * 请求车辆并衔接平滑位移动画。
+   */
+  const fetchAndAnimateVehicles = useCallback(async (center: NearbyCenter) => {
+    if (requestLockRef.current) return;
+    requestLockRef.current = true;
+    const seq = latestSeqRef.current + 1;
+    latestSeqRef.current = seq;
+    try {
+      const response = await getNearbyVehicles({ lat: center.lat, lon: center.lon });
+      if (seq !== latestSeqRef.current) return;
+      const normalizedVehicles = normalizeVehicleList(response);
+      if (normalizedVehicles.length === 0) {
+        clearVehicles();
+        return;
+      }
+      const now = Date.now();
+      const currentVehicleMap = renderedVehicleMapRef.current;
+      const tracks: VehicleTrackItem[] = normalizedVehicles.map((vehicle) => {
+        const currentVehicle = currentVehicleMap.get(vehicle.id);
+        return {
+          id: vehicle.id,
+          fromLat: currentVehicle?.latitude ?? vehicle.latitude,
+          fromLon: currentVehicle?.longitude ?? vehicle.longitude,
+          toLat: vehicle.latitude,
+          toLon: vehicle.longitude,
+          fromDirection: currentVehicle?.direction ?? vehicle.direction,
+          toDirection: vehicle.direction
+        };
+      });
+      clearAnimationTimer();
+      animationStateRef.current = {
+        startAt: now,
+        endAt: now + ANIMATION_DURATION_MS,
+        tracks
+      };
+      playAnimationFrame();
+    } catch (error) {
+      // 请求异常时保持当前车辆状态,避免闪断。
+    } finally {
+      requestLockRef.current = false;
+    }
+  }, [clearAnimationTimer, clearVehicles, normalizeVehicleList, playAnimationFrame]);
+
+  /**
+   * 启动 5 秒轮询,轮询中心遵循 activeSearchCenterRef。
+   */
+  const ensurePolling = useCallback(() => {
+    if (pollTimerRef.current) return;
+    pollTimerRef.current = setInterval(() => {
+      const center = activeSearchCenterRef.current;
+      if (!center || !isOperatingArea) {
+        disposeVehicles();
+        return;
+      }
+      fetchAndAnimateVehicles(center);
+    }, POLL_INTERVAL_MS);
+  }, [disposeVehicles, fetchAndAnimateVehicles, isOperatingArea]);
+
+  /**
+   * 激活指定中心点并立即拉起一次车辆查询。
+   */
+  const activateCenterAndFetch = useCallback((center: NearbyCenter) => {
+    activeSearchCenterRef.current = center;
+    lastSearchCenterRef.current = center;
+    fetchAndAnimateVehicles(center);
+    ensurePolling();
+  }, [ensurePolling, fetchAndAnimateVehicles]);
+
+  /**
+   * 首页初始化触发:围栏加载完成且中心点在 ODD 内时立即开始轮询。
+   */
+  const tryStartPollingOnInit = useCallback((payload: { lat: number; lon: number; isInside: boolean }) => {
+    if (!payload?.isInside || !isOperatingArea) return;
+    const lat = Number(payload.lat);
+    const lon = Number(payload.lon);
+    if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
+    if (lastSearchCenterRef.current) return;
+    activateCenterAndFetch({ lat, lon });
+  }, [activateCenterAndFetch, isOperatingArea]);
+
+  /**
+   * 地图拖动开始:仅清理停稳判定,不发起请求。
+   */
+  const handleRegionBegin = useCallback(() => {
+    clearSettleTimer();
+  }, [clearSettleTimer]);
+
+  /**
+   * 地图拖动结束:静止 500ms 后按 500 米阈值判断是否更新搜索中心。
+   */
+  const handleRegionSettled = useCallback((payload: { lat: number; lon: number; isInside: boolean }) => {
+    clearSettleTimer();
+    settleTimerRef.current = setTimeout(() => {
+      if (!payload?.isInside || !isOperatingArea) {
+        disposeVehicles();
+        return;
+      }
+      const currentCenter = { lat: payload.lat, lon: payload.lon };
+      const lastSearchCenter = lastSearchCenterRef.current;
+      if (!lastSearchCenter) {
+        activateCenterAndFetch(currentCenter);
+        return;
+      }
+      const movedDistance = getDistance(lastSearchCenter.lat, lastSearchCenter.lon, currentCenter.lat, currentCenter.lon);
+      if (movedDistance > MOVE_THRESHOLD_METERS) {
+        activateCenterAndFetch(currentCenter);
+      } else {
+        ensurePolling();
+      }
+    }, SETTLE_DELAY_MS);
+  }, [activateCenterAndFetch, clearSettleTimer, disposeVehicles, ensurePolling, isOperatingArea]);
+
+  useEffect(() => {
+    if (!isOperatingArea) {
+      disposeVehicles();
+    }
+  }, [disposeVehicles, isOperatingArea]);
+
+  useEffect(() => {
+    return () => {
+      disposeVehicles();
+    };
+  }, [disposeVehicles]);
+
+  return {
+    vehicleMarkers,
+    handleRegionBegin,
+    handleRegionSettled,
+    tryStartPollingOnInit,
+    disposeVehicles
+  };
+}
diff --git a/src/pages/robotaxi/home/hooks/use-region-change.ts b/src/pages/robotaxi/home/hooks/use-region-change.ts
index 7ad3a18..ab1cfa0 100644
--- a/src/pages/robotaxi/home/hooks/use-region-change.ts
+++ b/src/pages/robotaxi/home/hooks/use-region-change.ts
@@ -5,12 +5,14 @@
   mapState: any;
   siteState: any;
   toOrigin: () => void;
+  onRegionBegin?: () => void;
+  onRegionSettled?: (payload: { lat: number; lon: number; isInside: boolean }) => void;
 };
 
 /**
  * 地图 region-change 编排:处理地图拖拽/缩放后的吸附、回填和自动归位。
  */
-export default function useRegionChange({ mapState, siteState, toOrigin }: Deps) {
+export default function useRegionChange({ mapState, siteState, toOrigin, onRegionBegin, onRegionSettled }: Deps) {
   /**
    * 通过中心点主动触发吸附判断。
    */
@@ -63,7 +65,10 @@
           return;
         }
         try {
-          const { newMarkers, sites } = await mapState.updateMapData(lat, lng);
+          const { isInside, newMarkers, sites } = await mapState.updateMapData(lat, lng);
+          if (typeof onRegionSettled === 'function') {
+            onRegionSettled({ lat, lon: lng, isInside: !!isInside });
+          }
           const nearestCandidates = sites.length > 0 ? sites : newMarkers;
           const { adsorbed, marker } = siteState.tryAdsorb(lat, lng, nearestCandidates);
           if ((causedBy === 'gesture' || causedBy === 'drag' || wasReturningToOrigin) && marker) {
@@ -106,6 +111,9 @@
     }
     if (type === 'begin') {
       siteState.setShowToolTip(false);
+      if (typeof onRegionBegin === 'function') {
+        onRegionBegin();
+      }
       return;
     }
     if (type !== 'end') return;
diff --git a/src/pages/robotaxi/home/index.tsx b/src/pages/robotaxi/home/index.tsx
index 97e39cc..6af729c 100644
--- a/src/pages/robotaxi/home/index.tsx
+++ b/src/pages/robotaxi/home/index.tsx
@@ -1,4 +1,4 @@
-import Taro, { useDidShow } from "@tarojs/taro"
+import Taro, { useDidHide, useDidShow } from "@tarojs/taro"
 import { useEffect, useState, useRef, useMemo } from 'react';
 import { View, Map, Text, Image, Button } from '@tarojs/components';
 import './index.less';
@@ -46,6 +46,8 @@
 import useHomeBootstrap from './hooks/use-home-bootstrap';
 // 快捷胶囊编排
 import useQuickCapsule from './hooks/use-quick-capsule';
+// 附近车辆编排
+import useNearbyVehicles from './hooks/use-nearby-vehicles';
 import { DEFAULT_LATITUDE, DEFAULT_LONGITUDE, FIRST_ENTER_KEY } from '../../../robotaxi/constants/map';
 import { TripStatus, toTripStatus } from '../../../robotaxi/pages/begin-route/constants/trip-status';
 import {
@@ -86,7 +88,7 @@
   const {
     longitude,
     latitude,
-    markers,
+    markers: siteMarkers,
     polygons,
     mapScale,
     isOperatingArea,
@@ -380,12 +382,49 @@
   useDidShow(() => {
     bootstrap.onDidShow();
     refreshQuickCapsule();
+    if (typeof latitude === 'number' && typeof longitude === 'number') {
+      tryStartPollingOnInit({
+        lat: latitude,
+        lon: longitude,
+        isInside: isOperatingArea
+      });
+    }
   });
+
+  useDidHide(() => {
+    disposeVehicles();
+  });
+
+  const {
+    vehicleMarkers,
+    handleRegionBegin: handleVehicleRegionBegin,
+    handleRegionSettled: handleVehicleRegionSettled,
+    tryStartPollingOnInit,
+    disposeVehicles
+  } = useNearbyVehicles({
+    isOperatingArea
+  }); // 附近车辆编排
+
+  useEffect(() => {
+    if (nearbyLoading || isLocationAuthDenied) return;
+    if (typeof latitude !== 'number' || typeof longitude !== 'number') return;
+    tryStartPollingOnInit({
+      lat: latitude,
+      lon: longitude,
+      isInside: isOperatingArea
+    });
+  }, [nearbyLoading, isLocationAuthDenied, latitude, longitude, isOperatingArea, tryStartPollingOnInit]);
+
+  const mergedMapMarkers = useMemo(() => {
+    return [...siteMarkers, ...vehicleMarkers];
+  }, [siteMarkers, vehicleMarkers]); // 地图合并 marker 列表
 
   const { handleRegionChange, queryPointerByCenter } = useRegionChange({
     mapState,
     siteState,
-    toOrigin
+    toOrigin,
+    onRegionBegin: handleVehicleRegionBegin,
+    onRegionSettled: handleVehicleRegionSettled
   }); // 地图 region-change 编排
   const {
     ensureNetwork,
@@ -426,8 +465,11 @@
     const markerId = e?.detail?.markerId;
     if (markerId === undefined) return;
 
-    const targetMarker = markers.find(m => m.id === markerId);
+    const targetMarker = mergedMapMarkers.find(m => m.id === markerId);
     if (targetMarker) {
+      if (targetMarker.markerType === 'vehicle') {
+        return;
+      }
       // 忽略因调用 moveToLocation 引起的 region change,避免重复吸附计算
       shouldIgnoreRegionChange.current = true;
 
@@ -457,7 +499,7 @@
         longitude={longitude ?? DEFAULT_LONGITUDE}
         latitude={latitude ?? DEFAULT_LATITUDE}
         scale={mapScale}
-        markers={isLocationAuthDenied ? [] : markers.map(m => ({ ...m, title: '', name: '' }))}
+        markers={isLocationAuthDenied ? [] : mergedMapMarkers.map(m => ({ ...m, title: '', name: '' }))}
         polygons={polygons}
         showLocation={!!longitude}
         onRegionChange={handleRegionChange}
diff --git a/src/robotaxi/api/home.ts b/src/robotaxi/api/home.ts
index d1403ac..51a79a1 100644
--- a/src/robotaxi/api/home.ts
+++ b/src/robotaxi/api/home.ts
@@ -101,3 +101,12 @@
         payload
     }, true);
 }
+
+// 获取附近车辆
+export const getNearbyVehicles = async (payload: { lat: number; lon: number }) => {
+    return request({
+        url: _ROBOTAXI_HOST + '/vehicleConvoy/api/v1/mini-program/vehicle/nearby',
+        method: 'GET',
+        payload
+    }, true);
+}
diff --git a/src/robotaxi/constants/subscribe.ts b/src/robotaxi/constants/subscribe.ts
new file mode 100644
index 0000000..cedb0d7
--- /dev/null
+++ b/src/robotaxi/constants/subscribe.ts
@@ -0,0 +1,36 @@
+// 订阅场景枚举
+export enum SubscribeSceneKey {
+  OrderSuccess = 'ORDER_CREATE_SUCCESS',
+  OperationCouponGroup = 'OPERATION_COUPON_GROUP',
+}
+
+// 订阅场景配置类型
+export type SubscribeSceneConfig = {
+  sceneKey: SubscribeSceneKey;
+  tmplIds: string[];
+  limit: number;
+  needLogin: boolean;
+};
+
+// 下单成功订阅模板配置
+export const ORDER_SUCCESS_SUBSCRIBE_CONFIG: SubscribeSceneConfig = {
+  sceneKey: SubscribeSceneKey.OrderSuccess,
+  tmplIds: [
+    'NOi0ff4rpuTxcRnnZl0SJxg9SMbvrc8xB8VJgrwu3oo',
+    'elyV30kO0BZAD2OtihJ4Wdu7fwR3FdvCZYCM1BEbqWs'
+  ],
+  limit: 1,
+  needLogin: true
+};
+
+// 运营活动 + 券效期 + 券到账订阅模板配置
+export const OPERATION_COUPON_SUBSCRIBE_CONFIG: SubscribeSceneConfig = {
+  sceneKey: SubscribeSceneKey.OperationCouponGroup,
+  tmplIds: [
+    'S6gGP8jyuDTkW03h8LhOm2KVDdUtHr0OhL-Mt_PyRPU',
+    'ANtToR63JPCV_ujV69gmO15xCa8O3elnLWB52HLdcHM',
+    'P7z_ksdyzpiiOBO07EJ1AZ6WeHlWs4XveQYjmt9JRE8'
+  ],
+  limit: 1,
+  needLogin: true
+};
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/fare-detail-card.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/fare-detail-card.tsx
index dfaf358..5149013 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/fare-detail-card.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/fare-detail-card.tsx
@@ -68,7 +68,7 @@
                         行程费用
                     </View>
                     {
-                        fareBreakdown.operateFree == 1 ?
+                        fareBreakdown?.operateFree == 1 ?
                             <View className="car-card-end-particulars-free">
                                 载客测试免费
                             </View>
@@ -163,15 +163,15 @@
                         </View>
                     }
                 </View>
-                <View className="car-card-end-cost">
+                <View className="car-card-end-cost" onClick={toMoneyDetail}>
                     <View className="car-card-end-cost-money">
                         全程{formatTripDistance(fareBreakdown?.kilometer)}|{formatTripDurationFromSeconds(fareBreakdown?.time)}
                     </View>
                     <Image src={require('../../../../assets/ccc-icon-right.png')} />
                 </View>
                 {
-                    (fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney > 0 && fareBreakdown.operateFree != 1) &&
-                    <View className="car-card-end-through" onClick={toMoneyDetail}>
+                    (fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney > 0 && fareBreakdown?.operateFree != 1) &&
+                    <View className="car-card-end-through">
                         <View className="car-card-end-through-label">优惠活动</View>
                         <View className="car-card-end-through-content">
                             <View className="car-card-end-through-content-title">最佳优惠</View>
@@ -185,7 +185,7 @@
                 tripStatus === TripStatus.TripFinished &&
                 <View className="pay-box">
                     {
-                        fareBreakdown.operateFree != 1 ?
+                        fareBreakdown?.operateFree != 1 ?
                             <View className="pay-box-money"><View>0.00</View><View className="unit">元</View></View>
                             :
                             <View className="pay-box-free">载客测试免费</View>
diff --git a/src/robotaxi/pages/begin-route/hooks/use-order-actions.ts b/src/robotaxi/pages/begin-route/hooks/use-order-actions.ts
index 979defd..3f44898 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-order-actions.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-order-actions.ts
@@ -3,6 +3,7 @@
 import { saveOrder, getOrderPayment, cancelOrder, endOrder, routeStaleCheck } from '../../../api/begin-route';
 import { checkOtp } from '../../../../utils/tools';
 import { jumpTo } from '../../../../utils/jump';
+import { subscribeAfterOrderSuccess } from '../../../../utils/subscribe';
 import { TripStatus } from '../constants/trip-status';
 
 type Deps = {
@@ -211,6 +212,7 @@
       deps.setOrderId(nextOrderId);
       deps.setTripStatus(TripStatus.Publishing);
       deps.bindSocketListeners();
+      void subscribeAfterOrderSuccess(nextOrderId);
     } catch (error) {
       stopCallSubmitting();
     }
diff --git a/src/robotaxi/pages/select-map-site/index.tsx b/src/robotaxi/pages/select-map-site/index.tsx
index ffa9384..8c54ae9 100644
--- a/src/robotaxi/pages/select-map-site/index.tsx
+++ b/src/robotaxi/pages/select-map-site/index.tsx
@@ -16,6 +16,7 @@
 import noStar from '../../../robotaxi/assets/no-star.png'
 import {
   findNearestMarker,
+  getAutoScaleBySites,
   getDistance,
   formatGeofencePolygons,
   fetchMapData,
@@ -32,10 +33,13 @@
 import { jumpTo } from '../../../utils/jump';
 
 export default function SelectMapSite() {
+  const AUTO_SCALE_MAX = 17; // 自动缩放最大等级
+  const AUTO_SCALE_MIN = 11; // 自动缩放最小等级
 
   const [showCustomerService, setShowCustomerService] = useState(false); // 客服弹窗是否显示
   const [longitude, setLongitude] = useState<number | null>(null); // 经度
   const [latitude, setLatitude] = useState<number | null>(null); // 纬度
+  const [mapScale, setMapScale] = useState(14); // 地图缩放等级
   const [showToolTip, setShowToolTip] = useState(true); // 提示是否显示
   const [bounce, setBounce] = useState(false); // 是否弹跳
   const [markers, setMarkers] = useState<any[]>([]); // 标记点列表
@@ -60,6 +64,7 @@
   const waitingRadarSrc = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/waiting-radar.webp';
   const [nearbyLoading, setNearbyLoading] = useState(false);
   const pendingNearbyRequestCountRef = useRef(0);
+  const autoScaleLockedRef = useRef(false); // 自动缩放锁:仅初始化计算一次
 
   const router = Taro.useRouter();
 
@@ -191,6 +196,17 @@
       setIsOperatingArea(isInside);
       setMarkers(newMarkers);
       setNearbySites(sites);
+      if (!autoScaleLockedRef.current) {
+        const autoScale = getAutoScaleBySites(sites?.length > 0 ? sites : newMarkers);
+        if (typeof autoScale === 'number') {
+          setMapScale((prev) => {
+            const nextScale = Math.max(AUTO_SCALE_MIN, Math.min(AUTO_SCALE_MAX, autoScale));
+            if (Math.abs(prev - nextScale) < 0.01) return prev;
+            return nextScale;
+          });
+        }
+        autoScaleLockedRef.current = true;
+      }
 
       return { isInside, newMarkers, sites };
     } finally {
@@ -540,7 +556,7 @@
         id="map"
         longitude={longitude}
         latitude={latitude}
-        scale={14}
+        scale={mapScale}
         markers={markers.map(m => ({ ...m, title: '', name: '' }))}
         polyline={polyline}
         polygons={polygons}
diff --git a/src/utils/Robotaxi-public-method.ts b/src/utils/Robotaxi-public-method.ts
index bd46806..1caf11b 100644
--- a/src/utils/Robotaxi-public-method.ts
+++ b/src/utils/Robotaxi-public-method.ts
@@ -36,6 +36,38 @@
 };
 
 /**
+ * 根据站点空间分布估算推荐缩放等级(站点越分散,缩放越小)。
+ */
+export const getAutoScaleBySites = (sites: any[]) => {
+  if (!Array.isArray(sites) || sites.length === 0) return null;
+  if (sites.length === 1) return 16;
+  let minLat = Number.POSITIVE_INFINITY;
+  let maxLat = Number.NEGATIVE_INFINITY;
+  let minLng = Number.POSITIVE_INFINITY;
+  let maxLng = Number.NEGATIVE_INFINITY;
+  sites.forEach((site) => {
+    const lat = Number(site?.latitude);
+    const lng = Number(site?.longitude);
+    if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
+    if (lat < minLat) minLat = lat;
+    if (lat > maxLat) maxLat = lat;
+    if (lng < minLng) minLng = lng;
+    if (lng > maxLng) maxLng = lng;
+  });
+  if (!Number.isFinite(minLat) || !Number.isFinite(maxLat) || !Number.isFinite(minLng) || !Number.isFinite(maxLng)) {
+    return null;
+  }
+  const diagonalMeters = getDistance(minLat, minLng, maxLat, maxLng);
+  if (diagonalMeters > 12000) return 11;
+  if (diagonalMeters > 8000) return 12;
+  if (diagonalMeters > 5000) return 13;
+  if (diagonalMeters > 3000) return 14;
+  if (diagonalMeters > 1500) return 15;
+  if (diagonalMeters > 800) return 16;
+  return 17;
+};
+
+/**
  * 计算点 P 在线段 AB 上的投影点(吸附算法)
  * @param p 真实点 {latitude, longitude}
  * @param a 线段起点 {latitude, longitude}
diff --git a/src/utils/subscribe.ts b/src/utils/subscribe.ts
index 5d6cda2..15d1d92 100644
--- a/src/utils/subscribe.ts
+++ b/src/utils/subscribe.ts
@@ -6,6 +6,11 @@
 
 import Taro from "@tarojs/taro";
 import { checkOtp } from "./tools";
+import {
+  OPERATION_COUPON_SUBSCRIBE_CONFIG,
+  ORDER_SUCCESS_SUBSCRIBE_CONFIG,
+  SubscribeSceneConfig
+} from "../robotaxi/constants/subscribe";
 
 // 需要频控的订阅字段,用于清空订阅次数
 const subscribeFields = [
@@ -26,9 +31,8 @@
 export async function addSubscribeCount(type, tmplIds, limit = 3, needlogin = true) {
   const isLogin = await checkOtp();
   if (!isLogin && needlogin) return false;
-  const userInfo = Taro.getStorageSync(type);
-  console.log(userInfo, "userInfo");
-  Taro.setStorageSync(type, userInfo + 1 || 1);
+  const userInfo = Number(Taro.getStorageSync(type) || 0);
+  Taro.setStorageSync(type, userInfo + 1);
   return new Promise((resolve, reject) => {
     Taro.getSetting({
       withSubscriptions: true,
@@ -83,4 +87,48 @@
       return true;
     }
   })
-}
\ No newline at end of file
+}
+
+// 生成订阅频控 key
+export function buildSubscribeType(sceneKey: string, bizId?: string | number) {
+  if (bizId === undefined || bizId === null || bizId === '') {
+    return sceneKey;
+  }
+  return `${sceneKey}_${bizId}`;
+}
+
+// 执行通用订阅流程(失败/拒绝不影响主流程)
+export async function runSubscribeScene(config: SubscribeSceneConfig, bizId?: string | number) {
+  try {
+    const type = buildSubscribeType(config.sceneKey, bizId);
+    const canSubscribe = await addSubscribeCount(type, config.tmplIds, config.limit, config.needLogin);
+    if (!canSubscribe) return;
+    await new Promise<void>((resolve) => {
+      Taro.requestSubscribeMessage({
+        tmplIds: config.tmplIds,
+        success: (result) => {
+          checkSubscribeStatus(type, config.tmplIds, result, config.limit);
+          resolve();
+        },
+        fail: () => {
+          resolve();
+        },
+        complete: () => {
+          resolve();
+        }
+      });
+    });
+  } catch (error) {
+  }
+}
+
+// 下单成功后订阅消息
+export async function subscribeAfterOrderSuccess(orderId: string) {
+  if (!orderId) return;
+  await runSubscribeScene(ORDER_SUCCESS_SUBSCRIBE_CONFIG, orderId);
+}
+
+// 运营活动 + 券效期 + 券到账三合一模板订阅
+export async function subscribeOperationCouponGroup(bizId?: string | number) {
+  await runSubscribeScene(OPERATION_COUPON_SUBSCRIBE_CONFIG, bizId);
+}

--
Gitblit v1.7.1