卢春龙
2026-08-11 75dd09e8dd00780b37d3fc79e6b84a75c652ed2a
Merge branch 'lucl/animation-0810' into 'feature_v2.4.0_ui_animation'

feat: 动效过程中,隐藏空调、香氛等按钮,不触发申请蓝牙权限弹窗

See merge request robotaxi1/xp-robotaxi-miniprogram!260
6个文件已添加
11个文件已修改
659 ■■■■■ 已修改文件
src/robotaxi/constants/assets.ts 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/constants/preload-assets.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/fragrance-control/index.tsx 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/music-control/index.tsx 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car.tsx 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/debug-transition-panel.tsx 173 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/dispatch-transition-layer.tsx 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/trip-finished-fireworks/index.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-bluetooth-access.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-defer-until-dispatch-settled.ts 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-dispatch-transition.ts 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-transition-asset-preload.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/preload-assets.ts 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/constants/assets.ts
New file
@@ -0,0 +1,31 @@
/**
 * 素材链接单一数据源(Single Source of Truth)。
 *
 * 所有跨组件复用、且被预加载逻辑(constants/preload-assets.ts)引用的素材 URL 统一在此声明,
 * 组件与预加载清单都从这里 import,杜绝「组件改了链接、预加载还指向旧链接」导致的静默失效。
 *
 * 依赖方向:组件 → assets.ts ← 预加载清单(assets.ts 处于最底层,不反向依赖任何页面模块)。
 */
/** 派单成功转场主视频(mp4)。 */
export const VEHICLE_APPROACHING_VIDEO = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_v1_file_w_1128@h_1268.mp4';
/** 派单成功转场视频封面(jpg)。 */
export const VEHICLE_APPROACHING_POSTER = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_poster_v1_file_w_1128@h_1268.jpg';
/** 常态行程卡片背景图(派单成功落定 / 接驾 / 行程中)。 */
export const CARD_BG_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/card-bg-20260714.png';
/** 默认车辆图(无实拍车图时的兜底)。 */
export const DEFAULT_VEHICLE_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/default-vehicle-img-20260724-v2.png';
/** 到达终点 / 行程结束卡片背景图。 */
export const TRIP_FINISHED_BG_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/trip-finished-20260703.png';
/**
 * 全局字体链接。
 * ⚠️ 必须与 robotaxi/style/ui-variables.less 中的 @font-face src 保持一致;
 *    less 无法 import JS 常量,两处为约定同步(改一处需同步另一处)。
 */
export const FONT_XPSANS_BOLD = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/font/XPSANS-Bold.otf';
export const FONT_XPSANS_REGULAR = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/font/XPSANS-Regular.otf';
src/robotaxi/constants/preload-assets.ts
New file
@@ -0,0 +1,37 @@
import { PreloadAsset } from '../../utils/preload-assets';
import {
  VEHICLE_APPROACHING_POSTER,
  CARD_BG_IMG,
  DEFAULT_VEHICLE_IMG,
  TRIP_FINISHED_BG_IMG,
  FONT_XPSANS_BOLD,
  FONT_XPSANS_REGULAR,
} from './assets';
/**
 * 转场动效预加载素材清单,按行程状态分组。
 *
 * 所有 URL 均来自 constants/assets.ts 单一数据源,组件与本清单引用同一常量,
 * 组件改链接时预加载自动跟随,不会失效。
 *
 * ⚠️ wx.preloadAssets 仅支持 image / font,**不支持视频**:
 *    派单转场视频(mp4)无法预热,此处仅预加载其封面图(poster)。
 */
/** 派单成功转场素材(呼叫中/发布中 → 派单成功)。 */
export const DISPATCH_SUCCESS_ASSETS: PreloadAsset[] = [
  { type: 'image', src: VEHICLE_APPROACHING_POSTER }, // 转场视频封面(视频本身不支持预热)
  { type: 'image', src: CARD_BG_IMG }, // 转场落定后的常态卡片背景
  { type: 'image', src: DEFAULT_VEHICLE_IMG }, // 车模兜底图
];
/** 行程结束转场素材(送驾中 → 行程结束)。礼花为纯 canvas 绘制,无素材。 */
export const TRIP_FINISHED_ASSETS: PreloadAsset[] = [
  { type: 'image', src: TRIP_FINISHED_BG_IMG }, // 到达终点卡片背景
];
/** 全局字体(与 robotaxi/style/ui-variables.less 中 @font-face 对齐)。 */
export const GLOBAL_FONTS: PreloadAsset[] = [
  { type: 'font', src: FONT_XPSANS_BOLD },
  { type: 'font', src: FONT_XPSANS_REGULAR },
];
src/robotaxi/pages/begin-route/components/calling-car-modules/fragrance-control/index.tsx
@@ -47,6 +47,8 @@
    pageDidShowVersion?: number;
    /** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */
    hasPreviousOrder?: boolean;
    /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */
    active?: boolean;
};
/**
@@ -85,7 +87,7 @@
 * 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。
 * 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。
 */
export function useFragranceControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes {
export function useFragranceControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false, active = true }: Props): ControlNodes {
    // 车端真实状态
    const [switchOn, setSwitchOn] = useState(false);
    const [scents, setScents] = useState<FragranceScent[]>([]);
@@ -111,6 +113,10 @@
    // 进入接驾态查询香氛状态;连环单不查询、不展示
    useEffect(() => {
        // 门槛未就绪(如派单成功转场动效未完成):暂不查询,active 就绪后重跑再查询一次。
        if (!active) {
            return;
        }
        if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
            // 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。
            queriedOrderIdRef.current = '';
@@ -145,7 +151,7 @@
            setScents([]);
            setSelectedChannelId('');
        });
    }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]);
    }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active]);
    // 点击卡片:切换面板开合
    const togglePanel = () => {
src/robotaxi/pages/begin-route/components/calling-car-modules/music-control/index.tsx
@@ -40,6 +40,8 @@
    pageDidShowVersion?: number;
    /** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */
    hasPreviousOrder?: boolean;
    /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */
    active?: boolean;
};
/**
@@ -65,7 +67,7 @@
 * 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。
 * 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。
 */
export function useMusicControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes {
export function useMusicControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false, active = true }: Props): ControlNodes {
    // 车端真实开关状态
    const [switchOn, setSwitchOn] = useState(false);
    // 后台配置曲风列表
@@ -92,6 +94,10 @@
    // 进入接驾态查询曲风状态;连环单不查询、不展示
    useEffect(() => {
        // 门槛未就绪(如派单成功转场动效未完成):暂不查询,active 就绪后重跑再查询一次。
        if (!active) {
            return;
        }
        if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
            // 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。
            queriedOrderIdRef.current = '';
@@ -126,7 +132,7 @@
            setGenres([]);
            setSelectedGenreId('');
        });
    }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]);
    }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active]);
    // 点击卡片:切换面板开合
    const togglePanel = () => {
src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx
@@ -40,6 +40,8 @@
    pageDidShowVersion?: number;
    /** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */
    hasPreviousOrder?: boolean;
    /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */
    active?: boolean;
};
/**
@@ -48,7 +50,7 @@
 * 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。
 * 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。
 */
export function useAcControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes {
export function useAcControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false, active = true }: Props): ControlNodes {
    const [tempStatus, setTempStatus] = useState<number | null>(null);
    const [acSwitchOn, setAcSwitchOn] = useState(false);
    const [acStatusLoaded, setAcStatusLoaded] = useState(false);
@@ -290,6 +292,11 @@
    };
    useEffect(() => {
        // 门槛未就绪(如派单成功转场动效未完成):暂不查询/下发,避免转场中提前开空调、下发指令。
        // 不重置去重标记,active 就绪后本 effect 重跑再正常查询一次。
        if (!active) {
            return;
        }
        if (tripStatus !== TripStatus.VehicleApproaching) {
            // 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。
            queriedOrderIdRef.current = '';
@@ -323,7 +330,7 @@
        }
        handleFirstTimeInit(targetOrderId);
    }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]);
    }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active]);
    useEffect(() => {
        lastConfirmedTempRef.current = tempStatus;
src/robotaxi/pages/begin-route/components/calling-car.tsx
@@ -6,6 +6,7 @@
import { useDispatchTransition } from '../hooks/use-dispatch-transition';
import DispatchTransitionLayer from './dispatch-transition-layer';
import { TripStatus } from '../../../constants/trip-status';
import { CARD_BG_IMG, DEFAULT_VEHICLE_IMG, TRIP_FINISHED_BG_IMG } from '../../../constants/assets';
import { BLUETOOTH_ICON_OFF } from '../hooks/use-bluetooth-access'; // 蓝牙未开/未授权图标
import TripStatusHeader from './calling-car-modules/trip-status-header'; // 行程状态头
import VehicleInfoCard from './calling-car-modules/vehicle-info-card'; // 车辆信息卡片
@@ -25,6 +26,10 @@
    tripStatus?: TripStatus;
    /** 是否在本次挂载时播放"派单成功"转场(由父层捕捉首次进入派单成功(4)边沿传入,因本组件被 key 重挂载无法自判) */
    playDispatchTransition?: boolean;
    /** 是否在本次挂载时播放"直达待评价"入场(由父层捕捉 送驾中(6)→待评价(9) 边沿传入,内部免费订单跳过待支付时使用) */
    playEvaluateEnter?: boolean;
    /** 派单成功转场动效完成(或本次不播放转场)时回调,用于放行页面级蓝牙初始化 */
    onDispatchTransitionSettled?: () => void;
    /** 打开取消弹窗,入参为预计到达展示文案 */
    handleOpenCancelPopup?: (durationDisplay: any) => void;
    /** 上车点超时后的回调 */
@@ -116,6 +121,8 @@
export default function CallingCar({
    tripStatus = TripStatus.CallingCar,
    playDispatchTransition = false,
    playEvaluateEnter = false,
    onDispatchTransitionSettled,
    handleOpenCancelPopup,
    handleCancelTimeout,
    handleFinishTimeout,
@@ -182,21 +189,31 @@
        transitionDone,
        shouldPlayEnter,
        handleDispatchVideoTimeUpdate,
        handleDispatchVideoEnded,
        enterSettle,
    } = useDispatchTransition(playDispatchTransition);
    // 派单成功转场门槛:未播转场时立即就绪;播转场时等动效完成(transitionDone)。
    // 用于延迟车控/蓝牙卡/操作栏的「展示」与「逻辑执行」,避免转场中触发弹窗与提前下发指令。
    const contentReady = !shouldPlayEnter || transitionDone;
    /** 是否为“到达终点/待支付”状态(图一、图二) */
    const isTripFinished = tripStatus === TripStatus.TripFinished;
    const isCallingCar = tripStatus === TripStatus.CallingCar;
    /**
     * 「直达待评价」入场快照:与 useDispatchTransition 里的 shouldPlayEnter 同理,
     * 父层的 playEvaluateEnter 在下一次渲染即翻 false,直接绑定会把动画类中途卸掉、动效被扼杀,
     * 故在挂载时快照一次,组件生命周期内不变(本组件由 key={tripStatus} 保证每次状态切换都重挂载)。
     */
    const [shouldPlayEvaluateEnter] = useState(playEvaluateEnter);
    /**
     * 车控(空调 / 音乐 / 香氛):卡片与弹窗分开挂载。
     * 卡片放进横向滑动的 ScrollView;弹窗是 fixed 全屏浮层,必须挂在 ScrollView 之外,
     * 否则 iOS(WKWebView)下滚动容器会成为 fixed 的包含块,弹窗被限制在卡片滑动区内。
     * 三个 hook 内部均按 tripStatus / hasPreviousOrder 守卫,非接驾态不发请求、返回空节点。
     */
    const acControl = useAcControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder });
    const musicControl = useMusicControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder });
    const fragranceControl = useFragranceControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder });
    const acControl = useAcControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active: contentReady });
    const musicControl = useMusicControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active: contentReady });
    const fragranceControl = useFragranceControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active: contentReady });
    /**
     * 车控卡片当前横向滚动位置。
     * 弹窗开合与其弹出动画会驱动本组件重渲染,weapp 的 scroll-view 在重渲染 / 重建后会回到
@@ -217,6 +234,13 @@
        setDetailExpanded(false);
    }, [resetDetailVersion, tripStatus]);
    // 转场动效完成(或本次未播放转场)后通知父层:与车控卡片同步放行页面级蓝牙初始化。
    useEffect(() => {
        if (contentReady) {
            onDispatchTransitionSettled?.();
        }
    }, [contentReady, onDispatchTransitionSettled]);
    // 拦截费用明细展开/收起回调:本地记录展开态后再透传给外部
    const handleFareToggle = (expanded: boolean, hasFare: boolean) => {
        setDetailExpanded(expanded);
@@ -230,7 +254,7 @@
        return <Image className='calling-car-bg carlling-bg'  src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/top-card-bg-20260722.png' />
      }
      if(isTripFinished) {
        return <Image className='calling-car-bg finished-bg'  src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/trip-finished-20260703.png' />
        return <Image className='calling-car-bg finished-bg'  src={TRIP_FINISHED_BG_IMG} />
      }
      // 派单转场中(挂载首帧 ~ 视频播放完成前):不返回任何背景图,杜绝转场期间出现静态背景图。
      // 常态背景图改在 DispatchTransitionLayer 中不可见预载。settle 起在此返回背景图(视频层仍盖在其上,
@@ -238,10 +262,10 @@
      if (shouldPlayEnter && !transitionDone && dispatchPhase !== 'settle') {
        return null;
      }
      return  <Image className='calling-car-bg' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/card-bg-20260714.png' />
      return  <Image className='calling-car-bg' src={CARD_BG_IMG} />
    }
    // 根节点状态类:呼叫中覆盖 + 待支付态 + 明细展开态 + 派单转场阶段 + 整卡升起入场
    const rootClassName = `calling-car${isCallingCar ? ' calling-status-car' : ''}${isTripFinished ? ' calling-car--finished' : ''}${isTripFinished && detailExpanded ? ' is-detail-expanded' : ''}${dispatchPhase !== 'idle' ? ` dispatch-${dispatchPhase}` : ''}${shouldPlayEnter ? ' calling-car--enter' : ''}`;
    // 根节点状态类:呼叫中覆盖 + 待支付态 + 明细展开态 + 派单转场阶段 + 整卡升起入场 + 直达待评价入场
    const rootClassName = `calling-car${isCallingCar ? ' calling-status-car' : ''}${isTripFinished ? ' calling-car--finished' : ''}${isTripFinished && detailExpanded ? ' is-detail-expanded' : ''}${dispatchPhase !== 'idle' ? ` dispatch-${dispatchPhase}` : ''}${shouldPlayEnter ? ' calling-car--enter' : ''}${shouldPlayEvaluateEnter ? ' calling-car--evaluate-enter' : ''}${!contentReady ? ' dispatch-defer-content' : ''}`;
    // 按状态组合渲染行程卡片模块
    return (
@@ -253,14 +277,14 @@
            dispatchPhase={dispatchPhase}
            vehicleInfo={vehicleInfo}
            onTimeUpdate={handleDispatchVideoTimeUpdate}
            onEnded={handleDispatchVideoEnded}
            onError={handleDispatchVideoEnded}
            onEnded={enterSettle}
            onError={enterSettle}
          />
          {
            // 常驻车图:接驾/行程中/支付成功且有车牌时展示。转场期间车模兜底由 DispatchTransitionLayer 渲染。
            ([TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.PaymentSuccess].includes(tripStatus) && vehicleInfo?.plateNumber) && <Image
              className='calling-car-vehicle-pic'
              src={safeParse(vehicleInfo?.vehiclePictures, {})?.url || 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/default-vehicle-img-20260724-v2.png'}
              src={safeParse(vehicleInfo?.vehiclePictures, {})?.url || DEFAULT_VEHICLE_IMG}
            />
          }
          <View className='calling-car-content-box'>
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less
@@ -104,14 +104,16 @@
    }
  }
  // 阶段三:视频结束 → 功能卡回升归位、车模缩小回落到最终停靠位、视频切回图片背景
  // --dispatch-settle-duration 与 use-dispatch-transition 中 BG_SHRINK_LEAD_SEC 保持一致
  &.dispatch-settle {
    .calling-car-content-box { transform: translateY(0); transition: transform 0.4s ease; }
    --dispatch-settle-duration: 1.055s;
    .calling-car-content-box { transform: translateY(0); transition: transform var(--dispatch-settle-duration) ease; }
    // 车模从居中态回落到常驻停靠位(translateX 回 0 即基础态 right:116px 位置),并缩小归位。
    // transform 函数列表与 video 阶段对齐(translateY/translateX/scale),保证平滑插值、无瞬跳。
    .calling-car-vehicle-pic {
      transform: translateY(0) translateX(0) scale(1);
      opacity: 1;
      transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
      transition: transform var(--dispatch-settle-duration) cubic-bezier(0.22, 1, 0.36, 1), opacity var(--dispatch-settle-duration) ease;
    }
  }
}
@@ -134,6 +136,70 @@
    transform: translateY(0);
  }
}
// 「送驾中(6) 直达待评价(9)」整卡入场:内部免费订单跳过待支付(8),原本是无动效硬切。
// 卡片容器为底部锚定(.container position:fixed bottom:0),待评价态内容更高会瞬间向上顶,
// 故从「相当于送驾态卡顶」的位置升起,用运动吃掉高度差;同时做轻微透明度渐变,柔化内容替换。
//
// 起升距离 = 待评价态与送驾态的卡片高度差。
// 实测依据(同机型两态截图,designWidth 750 下 px 即设计稿单位):以两态共有的首个内容块
// VehicleInfoCard(“小鹏GX”行)到屏幕底部的距离比对,送驾态约 288px、待评价态约 659px,
// 即待评价态多出约 370px(新增费用明细两行 + 评价卡,减去状态条 + 操作栏)。
// 用 LESS 变量而非 CSS 自定义属性:keyframes 内的 var() 在旧版 WKWebView 上存在不生效的风险,
// LESS 变量在编译期就展开为字面量,按真机观感调这一处即可。
@evaluate-enter-rise: 380px;
@keyframes calling-car-evaluate-rise {
  from {
    transform: translateY(@evaluate-enter-rise);
    opacity: 0.4;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}
// 待评价态新增内容(费用明细卡 / 评价卡)分层浮现:轻微上移淡入,手法对齐 finished-distance-fade。
@keyframes calling-car-evaluate-content-fade {
  from {
    transform: translateY(16px);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}
.calling-car--evaluate-enter {
  // 位移行程约 380px(大于派单入场的 170px),时长相应放到 0.7s,保持速度感与派单入场一致。
  animation: calling-car-evaluate-rise 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both;
  will-change: transform;
  // 分层:整卡升起过半后,新增的费用明细卡与评价卡再依次浮现,进一步掩盖高度差。
  .fare-box-card {
    animation: calling-car-evaluate-content-fade 600ms ease-out 420ms both;
  }
  .car-card-evaluate {
    animation: calling-car-evaluate-content-fade 600ms ease-out 560ms both;
  }
}
// 派单成功转场期间:蓝牙卡 / 车控卡 / 底部操作栏隐藏但保留占位(opacity),逻辑另在各组件延迟执行;
// 动效完成后移除 .dispatch-defer-content,随 opacity 过渡淡入。用 opacity 而非 visibility,保证淡入过渡生效。
.calling-car {
  .bluetooth-operate-card,
  .temp-fragrance-scroll,
  .trip-action-bar {
    transition: opacity 0.3s ease;
  }
  &.dispatch-defer-content {
    .bluetooth-operate-card,
    .temp-fragrance-scroll,
    .trip-action-bar {
      opacity: 0;
      pointer-events: none;
    }
  }
}
// 到达终点/待支付态(图一、图二):背景透明化 + 到达目的地头部 + 白色费用卡
.calling-car--finished {
  padding-top: 0;
src/robotaxi/pages/begin-route/components/debug-transition-panel.tsx
New file
@@ -0,0 +1,173 @@
import { View, Text } from '@tarojs/components';
import { TripStatus } from '../../../constants/trip-status';
import { ensureSimulationOrderId } from '../dev/order-flow-simulator';
type Props = {
  /** 当前页面订单 ID(为空时自动生成)。 */
  orderId: string;
  /** 设置页面订单 ID。 */
  setOrderId: (orderId: string) => void;
  /** 设置行程状态。 */
  setTripStatus: (status: TripStatus) => void;
  /** 绑定 socket 业务监听。 */
  bindSocketListeners: () => void;
};
/**
 * 转场动效调试面板(仅 dev 构建可见)。
 *
 * ## 用途
 * 提供「呼叫中(3)→派单成功(4)」和「送驾中(6)→行程结束(8)/待评价(9)」两个关键转场的独立调试入口,
 * 固定在地图上方,始终可见,方便反复验证转场动效。
 *
 * ## 使用方法
 * 1. 点击「呼叫中(3)」/「派单成功(4)」/「送驾中(6)」/「行程结束(8)」直接跳到指定态。
 * 2. 点击「重放转场」自动执行序列:先设前一态 → 400ms 后设后一态,稳定触发转场动效。
 * 3. 点击「去评价」执行 送驾中(6) → 400ms → 待评价(9),用于验证内部免费订单
 *    行程结束后跳过待支付、直接进入评价态的转场。
 * 4. 打开页面前需先清缓存,避免被 redirectTo 首页。
 */
export default function DebugTransitionPanel(props: Props) {
  if (process.env.NODE_ENV === 'production') {
    return null;
  }
  /** 生成或复用订单 ID。 */
  const resolveOrderId = (): string => {
    const existing = String(props.orderId || '').trim();
    if (existing) {
      return existing;
    }
    return `SIM-${Date.now()}`;
  };
  const prepOrder = () => {
    props.bindSocketListeners();
    const orderId = resolveOrderId();
    ensureSimulationOrderId(orderId);
    props.setOrderId(orderId);
  };
  /** 呼叫中(3) */
  const handleSetCallingCar = () => {
    prepOrder();
    props.setTripStatus(TripStatus.CallingCar);
  };
  /** 派单成功(4) */
  const handleSetVehicleApproaching = () => {
    prepOrder();
    props.setTripStatus(TripStatus.VehicleApproaching);
  };
  /** 重放 呼叫中→派单成功:3 → 400ms → 4 */
  const handleReplayCallingToDispatch = () => {
    prepOrder();
    props.setTripStatus(TripStatus.CallingCar);
    setTimeout(() => {
      props.setTripStatus(TripStatus.VehicleApproaching);
    }, 400);
  };
  /** 送驾中(6) */
  const handleSetInService = () => {
    prepOrder();
    props.setTripStatus(TripStatus.InService);
  };
  /** 行程结束(8) */
  const handleSetTripFinished = () => {
    prepOrder();
    props.setTripStatus(TripStatus.TripFinished);
  };
  /**
   * 重放 送驾中→待评价:6 → 400ms → 9。
   *
   * 用于测试内部免费订单(无需支付)行程结束后直接进入待评价态的转场,
   * 跳过 行程结束(8) 待支付中间态。
   */
  const handleReplayInServiceToEvaluate = () => {
    prepOrder();
    props.setTripStatus(TripStatus.InService);
    setTimeout(() => {
      props.setTripStatus(TripStatus.PaymentSuccess);
    }, 400);
  };
  const btn = (bg: string) =>
    ({
      flex: 1,
      textAlign: 'center' as const,
      padding: '7px 0',
      margin: '0 3px',
      background: bg,
      color: '#fff',
      fontSize: '12px',
      borderRadius: '6px',
    } as const);
  return (
    <View
      catchMove
      style={{
        position: 'fixed',
        left: '8px',
        right: '8px',
        top: '8px',
        zIndex: 9998,
        padding: '8px 10px',
        background: 'rgba(20,20,20,0.88)',
        color: '#fff',
        borderRadius: '10px',
        boxShadow: '0 2px 12px rgba(0,0,0,0.35)',
      }}
    >
      {/* 呼叫派单转场 */}
      <View
        style={{
          fontSize: '11px',
          lineHeight: '1.5',
          marginBottom: '4px',
          opacity: 0.85,
        }}
      >
        <Text style={{ fontWeight: 'bold' }}>呼叫派单转场(呼叫中 → 派单成功)</Text>
      </View>
      <View style={{ display: 'flex', marginBottom: '6px' }}>
        <View onClick={handleSetCallingCar} style={btn('#4b9fd6')}>
          呼叫中(3)
        </View>
        <View onClick={handleSetVehicleApproaching} style={btn('#36b37e')}>
          派单成功(4)
        </View>
        <View onClick={handleReplayCallingToDispatch} style={btn('#ff7a45')}>
          重放转场
        </View>
      </View>
      {/* 送驾到达转场 */}
      <View
        style={{
          fontSize: '11px',
          lineHeight: '1.5',
          marginBottom: '4px',
          opacity: 0.85,
        }}
      >
        <Text style={{ fontWeight: 'bold' }}>送驾到达转场(送驾中 → 行程结束 / 待评价)</Text>
      </View>
      <View style={{ display: 'flex' }}>
        <View onClick={handleSetInService} style={btn('#4b9fd6')}>
          送驾中(6)
        </View>
        <View onClick={handleSetTripFinished} style={btn('#36b37e')}>
          行程结束(8)
        </View>
        <View onClick={handleReplayInServiceToEvaluate} style={btn('#ff7a45')}>
          去评价
        </View>
      </View>
    </View>
  );
}
src/robotaxi/pages/begin-route/components/dispatch-transition-layer.tsx
@@ -1,6 +1,7 @@
import { Image, Video } from "@tarojs/components";
import { safeParse } from '../../../../utils/Robotaxi-public-method';
import { DispatchPhase, DISPATCH_VIDEO_SRC, DISPATCH_VIDEO_POSTER } from '../hooks/use-dispatch-transition';
import { CARD_BG_IMG, DEFAULT_VEHICLE_IMG } from '../../../constants/assets';
type Props = {
  shouldPlayEnter: boolean;
@@ -35,7 +36,7 @@
  if (!shouldPlayEnter || transitionDone) return null;
  const vehiclePicSrc = safeParse(vehicleInfo?.vehiclePictures, {})?.url
    || 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/default-vehicle-img-20260724-v2.png';
    || DEFAULT_VEHICLE_IMG;
  return (
    <>
@@ -43,7 +44,7 @@
           勿用内联 opacity:0——Taro 序列化会丢弃数字 0 导致其可见。 */}
      <Image
        className='calling-car-bg bg-preload'
        src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/card-bg-20260714.png'
        src={CARD_BG_IMG}
      />
      <Video
        className='calling-car-video'
src/robotaxi/pages/begin-route/components/trip-finished-fireworks/index.tsx
@@ -41,7 +41,7 @@
        ticks: 180,
        colors: ['#FFD666', '#F9A76A', '#FFF1B8', '#7FE6F2', '#95CF29'],
      });
    }, 180);
    }, 60);
    return () => clearTimeout(timer);
  }, [tripStatus]);
src/robotaxi/pages/begin-route/hooks/use-bluetooth-access.ts
@@ -13,6 +13,8 @@
  skipBluetoothAuth?: boolean;
  /** 车端钥匙下发失败(getUnlocktToken 接口非 200),蓝牙不可解锁 */
  unlockTokenFailed?: boolean;
  /** 派单成功转场动效是否进行中:为 true 时接驾中(4)暂缓初始化蓝牙,待动效完成后再初始化 */
  dispatchTransitionActive?: boolean;
};
type BluetoothAccessStatus = 'system_off' | 'unauthorized' | 'authorized';
@@ -148,7 +150,8 @@
  bluetoothAuthSuccess,
  startBluetoothAuth,
  skipBluetoothAuth = false,
  unlockTokenFailed = false
  unlockTokenFailed = false,
  dispatchTransitionActive = false
}: Deps) {
  const [status, setStatus] = useState<BluetoothAccessStatus>('system_off'); // 当前蓝牙访问态:系统未开 / 小程序未授权 / 已授权
  const [statusReady, setStatusReady] = useState(false); // 蓝牙状态是否已完成首轮判定
@@ -556,6 +559,12 @@
        logBluetoothAccess('等待 orderId 就绪');
        return;
      }
      // 接驾中(4)派单成功转场期间:与车控卡片一致,待转场动效完成后再初始化蓝牙,
      // 避免转场动画播放时触发系统蓝牙授权弹窗。到达上车点(5)不受此限制(需尽快可开门)。
      if (tripStatus === TripStatus.VehicleApproaching && dispatchTransitionActive) {
        logBluetoothAccess('派单成功转场动效未完成,暂缓初始化蓝牙');
        return;
      }
      logBluetoothAccess('进入蓝牙 UI 维护态', {
        tripStatus,
        orderId
@@ -588,7 +597,7 @@
    stopOpenSettingPollingRef.current();
    setShowAuthBluetoothPopup(false);
    setShowSuccessAuthPopup(false);
  }, [tripStatus, orderId, refreshBluetoothState]);
  }, [tripStatus, orderId, refreshBluetoothState, dispatchTransitionActive]);
  // 跳转系统蓝牙设置,并在返回后触发一次状态刷新
  const handleOpenSystemBluetoothSetting = useCallback(async () => {
src/robotaxi/pages/begin-route/hooks/use-defer-until-dispatch-settled.ts
New file
@@ -0,0 +1,51 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { TripStatus } from '../../../constants/trip-status';
/**
 * 页面级「派单成功转场延后闸门」。
 *
 * 背景:真正的转场动画状态机 `useDispatchTransition` 位于子组件 `CallingCar` 内(绑定视频、且随
 * `key={tripStatus}` 重挂载),页面级逻辑(如蓝牙初始化 `useBluetoothAccess`)读不到它的
 * `transitionDone`。本 hook 在页面级维护一个布尔闸门,作为「是否要暂缓某些页面级逻辑」的信号:
 *
 * - **开闸时机(本 hook 自算)**:进入接驾中(4)边沿时,在渲染期同步置 `active=true`
 *   (必须同步——确保早于同一提交里的页面级 effect(如蓝牙)读取,避免竞态导致转场中弹系统弹窗)。
 * - **关闸时机(外部告知)**:动画真正播完由子组件 `useDispatchTransition.transitionDone` 判定,
 *   经 `CallingCar` 的 `onDispatchTransitionSettled` 回调调用本 hook 的 `markSettled` 置回 false。
 * - 离开接驾态时自动复位为 false。
 *
 * 数据流:
 *   [子] useDispatchTransition.transitionDone
 *        └─(contentReady → markSettled)→ [页] useDeferUntilDispatchSettled.dispatchTransitionActive
 *        └─→ [页] useBluetoothAccess 据此决定是否暂缓初始化
 *
 * @param tripStatus 当前行程状态
 * @returns dispatchTransitionActive 转场是否进行中(true 时应暂缓页面级逻辑);markSettled 动效完成回调
 */
export function useDeferUntilDispatchSettled(tripStatus: TripStatus) {
  const [dispatchTransitionActive, setDispatchTransitionActive] = useState(false);
  const prevTripStatusRef = useRef<TripStatus | undefined>(undefined);
  // 渲染期同步置位:进入接驾中(4)边沿即开闸(早于 effect,规避竞态);离开接驾态则关闸复位。
  if (
    tripStatus === TripStatus.VehicleApproaching &&
    prevTripStatusRef.current !== TripStatus.VehicleApproaching
  ) {
    if (!dispatchTransitionActive) {
      setDispatchTransitionActive(true);
    }
  } else if (tripStatus !== TripStatus.VehicleApproaching && dispatchTransitionActive) {
    setDispatchTransitionActive(false);
  }
  useEffect(() => {
    prevTripStatusRef.current = tripStatus;
  }, [tripStatus]);
  // 转场动效完成(或本次未播放转场)时由子组件回调,放行被暂缓的页面级逻辑。
  const markSettled = useCallback(() => {
    setDispatchTransitionActive(false);
  }, []);
  return { dispatchTransitionActive, markSettled };
}
src/robotaxi/pages/begin-route/hooks/use-dispatch-transition.ts
@@ -1,10 +1,9 @@
import { useState, useEffect, useRef } from 'react';
import { VEHICLE_APPROACHING_VIDEO, VEHICLE_APPROACHING_POSTER } from '../../../constants/assets';
/** 派单成功转场视频 */
export const DISPATCH_VIDEO_SRC = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_v1_file_w_1128@h_1268.mp4';
export const DISPATCH_VIDEO_POSTER = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_poster_v1_file_w_1128@h_1268.jpg';
/** 距视频结束多久提前进入 settle 归位阶段(秒) */
export const DISPATCH_SETTLE_LEAD_SECONDS = 0.8;
/** 派单成功转场视频(源自 constants/assets.ts 单一数据源) */
export const DISPATCH_VIDEO_SRC = VEHICLE_APPROACHING_VIDEO;
export const DISPATCH_VIDEO_POSTER = VEHICLE_APPROACHING_POSTER;
/** 兜底超时(ms):防卡死安全阀——视频加载/播放失败时 onTimeUpdate/onEnded 都不触发,超过此时长强制归位 */
export const DISPATCH_FALLBACK_MS = 10000;
@@ -28,8 +27,19 @@
  const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const settleTriggeredRef = useRef(false);
  // 背景缩小精确计时:微信 video 的 timeupdate 间隔约 265ms,无法直接捕获目标时刻。
  // 方案:timeupdate 记录 {播放时间, 墙上时钟} 作同步锚点,自适应 setTimeout 在两次事件间
  // 通过墙上时钟插值估算播放进度,提前唤醒、递归逼近,最终触发精度约 4ms。
  const lastTimeSyncRef = useRef({ currentTime: 0, wallClock: 0 });
  const videoDurationRef = useRef(0);
  const shrinkTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const shrinkTriggeredRef = useRef(false);
  /** 背景缩小提前量(秒):在视频结束前触发 settle。视频全长 3.333s,即 currentTime ≈ 2.278s 处。 */
  const BG_SHRINK_LEAD_SEC = 1.055;
  // 进入归位落定阶段(幂等,只生效一次)
  const handleDispatchVideoEnded = () => {
  const enterSettle = () => {
    if (settleTriggeredRef.current) return;
    settleTriggeredRef.current = true;
    if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
@@ -41,13 +51,41 @@
    }, 600);
  };
  // 播放进度更新:距视频结束还剩 DISPATCH_SETTLE_LEAD_SECONDS 秒时提前进入 settle
  /**
   * 自适应缩小定时器。每次 timeupdate 或定时器到期时调用,计算距目标还有多久:
   * - 已到达 → 调用 enterSettle 进入 settle
   * - 未到达 → setTimeout 提前 10ms 唤醒,到期后用墙上时钟插值修正、递归检查
   */
  const scheduleShrinkCheck = (currentTime: number) => {
    if (shrinkTriggeredRef.current) return;
    const targetSec = videoDurationRef.current - BG_SHRINK_LEAD_SEC;
    const remaining = targetSec - currentTime;
    if (remaining <= 0) {
      shrinkTriggeredRef.current = true;
      if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
      enterSettle();
      return;
    }
    if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
    shrinkTimerRef.current = setTimeout(() => {
      const { currentTime: lastCT, wallClock: lastWC } = lastTimeSyncRef.current;
      if (lastWC === 0) return;
      const estimated = lastCT + (Date.now() - lastWC) / 1000;
      scheduleShrinkCheck(estimated);
    }, Math.max(remaining * 1000 - 10,30));
  };
  const handleDispatchVideoTimeUpdate = (e: any) => {
    const { currentTime, duration } = e?.detail || {};
    if (typeof currentTime !== 'number' || typeof duration !== 'number' || duration <= 0) return;
    if (duration - currentTime <= DISPATCH_SETTLE_LEAD_SECONDS) {
      handleDispatchVideoEnded();
    }
    lastTimeSyncRef.current = { currentTime, wallClock: Date.now() };
    videoDurationRef.current = duration;
    scheduleShrinkCheck(currentTime);
  };
  useEffect(() => {
@@ -57,12 +95,13 @@
        if (settleTriggeredRef.current) return;
        setDispatchPhase('video');
      }, 32);
      fallbackTimerRef.current = setTimeout(() => handleDispatchVideoEnded(), DISPATCH_FALLBACK_MS);
      fallbackTimerRef.current = setTimeout(() => enterSettle(), DISPATCH_FALLBACK_MS);
    }
    return () => {
      if (enterTimerRef.current) clearTimeout(enterTimerRef.current);
      if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
      if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
      if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
    };
  }, []);
@@ -70,7 +109,7 @@
    dispatchPhase,
    transitionDone,
    shouldPlayEnter,
    enterSettle,
    handleDispatchVideoTimeUpdate,
    handleDispatchVideoEnded,
  };
}
src/robotaxi/pages/begin-route/hooks/use-transition-asset-preload.ts
New file
@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
import { TripStatus } from '../../../constants/trip-status';
import { preloadAssets } from '../../../../utils/preload-assets';
import {
  DISPATCH_SUCCESS_ASSETS,
  TRIP_FINISHED_ASSETS,
  GLOBAL_FONTS,
} from '../../../constants/preload-assets';
/** Hook 入参。 */
type Params = {
  /** 当前行程状态。 */
  tripStatus: TripStatus;
};
/**
 * 转场动效素材预加载(按需触发)。
 *
 * ## 触发策略(必要时才预载,避免无谓消耗)
 * - 进入「发布中(2) / 呼叫中(3)」→ 预载「派单成功」素材 + 全局字体
 *   (距派单成功尚有等待派单时长,此时预热充分)。
 * - 进入「送驾中(6)」→ 预载「行程结束」素材(距行程结束有整段行程)。
 * - 未开始打车(发布行程(1))→ 不预载任何转场素材。
 *
 * ## 去重
 * 各阶段用 ref 标记「已发起预载」,状态反复切换不重复调用。
 * 预加载底层失败静默(见 preloadAssets),此处无需错误处理。
 */
export default function useTransitionAssetPreload(params: Params): void {
  const { tripStatus } = params;
  const orderingPreloadedRef = useRef(false); // 派单成功素材 + 字体是否已发起预载
  const finishedPreloadedRef = useRef(false); // 行程结束素材是否已发起预载
  useEffect(() => {
    // 进入下单流程(发布中/呼叫中):预载派单成功素材 + 全局字体
    if ([TripStatus.Publishing, TripStatus.CallingCar].includes(tripStatus) && !orderingPreloadedRef.current) {
      orderingPreloadedRef.current = true;
      preloadAssets([...DISPATCH_SUCCESS_ASSETS, ...GLOBAL_FONTS]);
    }
    // 进入送驾中:预载行程结束素材
    if (tripStatus === TripStatus.InService && !finishedPreloadedRef.current) {
      finishedPreloadedRef.current = true;
      preloadAssets(TRIP_FINISHED_ASSETS);
    }
  }, [tripStatus]);
}
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
@@ -23,6 +23,8 @@
  isAndroid: boolean;
  /** 行程状态 */
  tripStatus: TripStatus;
  /** 派单成功转场动效完成(或本次未播放转场)时回调,用于放行页面级蓝牙初始化 */
  onDispatchTransitionSettled?: () => void;
  /** 地图缩放等级 */
  mapScale: number;
  /** 地图 markers */
@@ -319,6 +321,15 @@
  const playDispatchTransition =
    deps.tripStatus === TripStatus.VehicleApproaching &&
    prevTripStatusRef.current !== TripStatus.VehicleApproaching;
  // 捕捉「送驾中(6) 直达待评价(9)」边沿:内部免费订单无需支付,行程结束后跳过待支付(8)直接进入待评价。
  // 该路径原本没有任何入场动效,且待评价态内容比送驾态更高(多出费用明细卡与评价卡),
  // 底部锚定容器(.container position:fixed bottom:0)会瞬间向上顶,观感生硬。
  // 与 playDispatchTransition 同理:CallingCar 被 key 重挂载无法自判边沿,由父层捕捉后传入,
  // 组件内做挂载快照,再用挂载即播的 keyframes 实现整卡升起 + 内容分层浮现。
  // 限定上一态为 InService:不影响支付后 8→9 的既有表现;冷启动恢复时 prev 为 undefined 也不会误播。
  const playEvaluateEnter =
    deps.tripStatus === TripStatus.PaymentSuccess &&
    prevTripStatusRef.current === TripStatus.InService;
  useEffect(() => {
    prevTripStatusRef.current = deps.tripStatus;
  }, [deps.tripStatus]);
@@ -1033,6 +1044,8 @@
        <CallingCar
          key={deps.tripStatus}
          playDispatchTransition={playDispatchTransition}
          playEvaluateEnter={playEvaluateEnter}
          onDispatchTransitionSettled={deps.onDispatchTransitionSettled}
          orderId={deps.orderId}
          hasBeforeUserEndSite={deps.hasBeforeUserEndSite}
          hasPreviousOrder={deps.hasPreviousOrder}
src/robotaxi/pages/begin-route/index.tsx
@@ -11,6 +11,8 @@
import CouponSelectPopup from '../../components/coupon-select-popup';
// 调试面板:模拟完整行程流转(仅 dev 构建可见)
// import DebugOrderSimulatorPanel from './components/debug-order-simulator-panel';
// 转场动效调试面板:呼叫中→派单成功、送驾中→行程结束(仅 dev 构建可见)
import DebugTransitionPanel from './components/debug-transition-panel';
// 到达结束态全屏礼花动效
import TripFinishedFireworks from './components/trip-finished-fireworks';
// 弹窗状态管理
@@ -56,8 +58,11 @@
import useModifyDestinationFlow from './hooks/use-modify-destination-flow';
import useModifyDestinationPreviewRoute from './hooks/use-modify-destination-preview-route';
import usePreloadedMapAssets from './hooks/use-preloaded-map-assets';
import useTransitionAssetPreload from './hooks/use-transition-asset-preload';
// 蓝牙状态与弹窗编排
import useBluetoothAccess from './hooks/use-bluetooth-access';
// 页面级派单转场延后闸门(转场进行中暂缓蓝牙初始化)
import { useDeferUntilDispatchSettled } from './hooks/use-defer-until-dispatch-settled';
// 蓝牙车控连接与鉴权编排
import useBluetoothCarControl from './hooks/use-bluetooth-car-control';
import { toTripStatus, TripStatus } from '../../constants/trip-status';
@@ -462,6 +467,9 @@
      waitingRadarUrl
    }
  }); // 地图气泡资源预加载
  // 转场动效素材预加载(按行程状态空闲预载:呼叫中→派单成功素材、送驾中→行程结束素材)
  useTransitionAssetPreload({ tripStatus });
  const {
    openService,
    toCoupon,
@@ -1851,6 +1859,9 @@
    tripStatus
  });
  const shouldSkipBluetoothAuth = doorUnlockedInCurrentPage || bluetoothDoorUnlockSuccess;
  // 页面级派单转场延后闸门:进入接驾中(4)暂缓蓝牙初始化,避免转场动画中弹系统授权弹窗;
  // 动效完成由 CallingCar 的 onDispatchTransitionSettled 回调 markSettled 放行。
  const { dispatchTransitionActive, markSettled: handleDispatchTransitionSettled } = useDeferUntilDispatchSettled(tripStatus);
  const {
    bluetoothStatusReady,
    bluetoothCardDescription,
@@ -1870,7 +1881,8 @@
    bluetoothAuthSuccess,
    startBluetoothAuth,
    skipBluetoothAuth: shouldSkipBluetoothAuth,
    unlockTokenFailed
    unlockTokenFailed,
    dispatchTransitionActive
  });
  const { handleConfirmRequoteIfNeeded } = useModifyDestinationPreviewRoute({
    tripStatus,
@@ -2410,6 +2422,7 @@
  const { renderMapOverlays, renderCarCalloutOverlay, renderTripCard, fareBreakdown } = useViewDelegates({
    isAndroid,
    tripStatus,
    onDispatchTransitionSettled: handleDispatchTransitionSettled,
    mapScale,
    markers: markers,
    isRouteSelectExpanded,
@@ -2693,6 +2706,13 @@
        onConfirm={handleConfirmCreditPayPopup}
      />
      <TripFinishedFireworks tripStatus={tripStatus} />
      {/* 转场动效调试面板 — 固定在地图上方,始终可见 */}
      <DebugTransitionPanel
        orderId={orderId}
        setOrderId={setOrderId}
        setTripStatus={setTripStatus}
        bindSocketListeners={bindSocketListeners}
      />
      {/* 模拟器入口已关闭,如需本地调试可取消下方注释
      <DebugOrderSimulatorPanel
        orderId={orderId}
src/utils/preload-assets.ts
New file
@@ -0,0 +1,54 @@
/**
 * 通用预加载素材描述。
 *
 * 仅支持 `image` 与 `font`——微信 `wx.preloadAssets` 目前只支持这两类,
 * **不支持视频**(视频无法通过该 API 预热,转场视频仅能预热其封面图)。
 */
export type PreloadAsset = { type: 'image' | 'font'; src: string };
/** 判断当前是否为微信小程序环境。 */
const isWeapp = (): boolean => process.env.TARO_ENV === 'weapp';
/**
 * 通用预加载方法:图片、字体统一走 `wx.preloadAssets` 为视图层预热媒体资源。
 *
 * ## 特性
 * - 仅在微信小程序环境执行;非 weapp / 低版本(无 `wx.preloadAssets`)静默跳过。
 * - image / font 合批调用一次 `wx.preloadAssets`,组件仍用同一 CDN URL,命中缓存即秒开。
 * - **预加载失败不报错**:整体 try/catch + `fail` 空回调兜底。
 *
 * 参考:https://developers.weixin.qq.com/miniprogram/dev/api/base/performance/wx.preloadAssets.html
 * (基础库 2.22.1+ 支持,功能描述:为视图层预加载媒体资源文件,目前支持 font、image)
 *
 * @param assets 待预加载素材列表
 */
export const preloadAssets = (assets: PreloadAsset[]): void => {
  try {
    if (!isWeapp() || !Array.isArray(assets) || assets.length === 0) {
      return;
    }
    // @ts-ignore 微信基础库 2.22.1+ 能力,低版本走 typeof 兜底跳过
    if (typeof wx === 'undefined' || typeof wx.preloadAssets !== 'function') {
      return;
    }
    const data = assets
      .filter((asset) => asset && asset.src && (asset.type === 'image' || asset.type === 'font'))
      .map((asset) => ({ type: asset.type, src: asset.src }));
    if (data.length === 0) {
      return;
    }
    // @ts-ignore
    wx.preloadAssets({
      data,
      fail: () => { /* 预加载失败不报错,静默忽略 */ },
    });
  } catch (e) {
    console.error('[preloadAssets] 预加载素材异常:', e);
    // 兜底:绝不抛出
  }
};
export default preloadAssets;