卢春龙
2026-08-14 1751755c34b83a5e65cd69a6393156caf04ae503
Merge branch 'feature_v2.4.0_ui_animation' into 'feature_2.6.0'

feat: UI 焕新加动效

See merge request robotaxi1/xp-robotaxi-miniprogram!265
14个文件已添加
30个文件已修改
2132 ■■■■■ 已修改文件
.gitignore 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/components/privacy-popup/index.tsx 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/hooks/use-marketing.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/hooks/use-navigation.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/hooks/use-origin-action.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/hooks/use-quick-capsule.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/index.tsx 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/demo/index.tsx 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/fireworks/error-boundary.tsx 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/fireworks/index.less 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/fireworks/index.tsx 436 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/fireworks/礼花效果.md 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/constants/assets.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/constants/preload-assets.ts 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/evaluation-card.tsx 8 ●●●●● 补丁 | 查看 | 原始文档 | 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 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/debug-order-simulator-panel.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/debug-transition-panel.tsx 173 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/dispatch-transition-layer.tsx 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/trip-finished-fireworks/index.tsx 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-bluetooth-access.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-credit-pay-return.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | 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 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-modify-destination-flow.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-order-payment-result-query.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-order-payment.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-order-submit.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-transition-asset-preload.ts 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/coupon/index.tsx 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/feedback/hooks/use-media-upload.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/feedback/index.tsx 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/payment-setting-detail/index.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/personal-center/index.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/utils/video-preload.ts 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/preload-assets.ts 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -7,5 +7,6 @@
.history
key/
.swc/
.tmp_frames/
.qoder/
# project.private.config.json
src/pages/robotaxi/home/components/privacy-popup/index.tsx
@@ -4,6 +4,7 @@
import './index.less';
import { privicyShowManager } from './utils'
import { submitAgreement } from '../../../../../robotaxi/api/home'
import { showToast } from '../../../../../robotaxi/components/toast'
import { getNormalizedLoginAgreementList, type LoginAgreementItem } from '../../../../../robotaxi/utils/login-agreement'
import { reportEvent } from '../../../../../utils/Robotaxi-public-method'
import { PROTOCOL_CLICK_EVENT_ID } from '../../../../../robotaxi/constants/report-events'
@@ -32,7 +33,7 @@
      }, {});
      setIsAgree(initState);
    }).catch(() => {
      Taro.showToast({ title: '协议获取失败,请重试', icon: 'none' });
      showToast({ title: '协议获取失败,请重试', icon: 'none' });
    });
  };
@@ -97,7 +98,7 @@
  const handleAgree = () => {
    if (!isAllAgree) {
      Taro.showToast({ title: '请先同意全部协议', icon: 'none' });
      showToast({ title: '请先同意全部协议', icon: 'none' });
      return
    };
    const selectedType = Object.keys(isAgree)
@@ -113,7 +114,7 @@
      privicyShowManager.setHasShow(true)
      onAgree?.();
    }).catch(() => {
      Taro.showToast({ title: '提交失败,请重试', icon: 'none' });
      showToast({ title: '提交失败,请重试', icon: 'none' });
    });
  };
src/pages/robotaxi/home/hooks/use-marketing.ts
@@ -1,5 +1,5 @@
import Taro from '@tarojs/taro';
import { useState } from 'react';
import { showToast } from '../../../../robotaxi/components/toast';
import { FLOAT_ACTIVITY_DEFAULT_IMAGE } from '../constants';
type MarketingData = Record<string, any>;
@@ -163,7 +163,7 @@
   */
  const handleFloatActivityClick = () => {
    if (!floatActivityUrl) {
      Taro.showToast({ title: '活动页面建设中', icon: 'none' });
      showToast({ title: '活动页面建设中', icon: 'none' });
      return;
    }
    deps.toH5(floatActivityUrl, true);
src/pages/robotaxi/home/hooks/use-navigation.ts
@@ -24,6 +24,7 @@
  writeConfirmRideSiteSourceType
} from '../../../../robotaxi/constants/confirm-ride-report';
import { resolveHomeStartSiteDisplaySite } from '../../../../robotaxi/hooks/use-home-start-site-display';
import { showToast } from '../../../../robotaxi/components/toast';
type Deps = {
  /** 是否未登录 */
@@ -124,12 +125,12 @@
    try {
      const res = await Taro.getNetworkType();
      if (res?.networkType === 'none') {
        Taro.showToast({ title: '网络未连接', icon: 'none' });
        showToast({ title: '网络未连接', icon: 'none' });
        return false;
      }
      return true;
    } catch (e) {
      Taro.showToast({ title: '网络未连接', icon: 'none' });
      showToast({ title: '网络未连接', icon: 'none' });
      return false;
    }
  };
@@ -176,11 +177,11 @@
    const ok = await ensureNetwork();
    if (!ok) return;
    if (deps.isLocationAuthDenied) {
      Taro.showToast({ title: '请先开启定位权限', icon: 'none' });
      showToast({ title: '请先开启定位权限', icon: 'none' });
      return;
    }
    if (!deps.electronicAreaId) {
      Taro.showToast({ title: '请先移动到运营区内', icon: 'none' });
      showToast({ title: '请先移动到运营区内', icon: 'none' });
      return;
    }
    if (isToMapStart) {
@@ -197,7 +198,7 @@
      const preferredStartSite = resolveHomeStartSiteDisplaySite();
      const effectiveStartSite = preferredStartSite || deps.currentSite;
      if (!effectiveStartSite || !effectiveStartSite.id) {
        Taro.showToast({ title: '请先选择起点站点', icon: 'none' });
        showToast({ title: '请先选择起点站点', icon: 'none' });
        return;
      }
      Taro.setStorageSync('startSite', effectiveStartSite);
@@ -223,7 +224,7 @@
    const startLongitude = hasValidSiteCoord ? siteLongitude : (hasValidMapCoord ? mapLongitude : null);
    const startLatitude = hasValidSiteCoord ? siteLatitude : (hasValidMapCoord ? mapLatitude : null);
    if (startLongitude === null || startLatitude === null) {
      Taro.showToast({ title: '请先移动到运营区内', icon: 'none' });
      showToast({ title: '请先移动到运营区内', icon: 'none' });
      return;
    }
    Taro.navigateTo({
@@ -321,7 +322,7 @@
    if (!ok) return;
    if (deps.isLocationAuthDenied) return;
    if (!deps.selectedCity?.code) {
      Taro.showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
      showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
      return;
    }
    let nextPolygons = deps.polygons;
@@ -341,7 +342,7 @@
      deps.setPolygons([]);
    }
    if (!nextPolygons || nextPolygons.length === 0) {
      Taro.showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
      showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
      return;
    }
    const points = extractPointsFromPolygons(nextPolygons);
@@ -358,7 +359,7 @@
        });
      }, 600);
    } else {
      Taro.showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
      showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
    }
  };
src/pages/robotaxi/home/hooks/use-origin-action.ts
@@ -1,4 +1,5 @@
import Taro from '@tarojs/taro';
import { showToast } from '../../../../robotaxi/components/toast';
type Deps = {
  /** 是否拒绝定位 */
@@ -37,7 +38,7 @@
  const toOrigin = async () => {
    const systemSetting = await Promise.resolve((Taro as any).getSystemSetting?.());
    if (systemSetting?.locationEnabled === false) {
      Taro.showToast({ title: '需开启设备定位服务开关', icon: 'none' });
      showToast({ title: '需开启设备定位服务开关', icon: 'none' });
      return;
    }
    if (deps.isLocationAuthDenied) return;
src/pages/robotaxi/home/hooks/use-quick-capsule.ts
@@ -14,6 +14,7 @@
  OrderDistanceConfig
} from '../../../../robotaxi/utils/order-distance';
import { hasActiveTripOrderInStorage } from '../../../../robotaxi/utils/order';
import { showToast } from '../../../../robotaxi/components/toast';
type CapsuleSite = {
  id: string | number;
@@ -142,16 +143,16 @@
   */
  const handleQuickCapsuleClick = async () => {
    if (hasActiveTripOrderInStorage()) {
      Taro.showToast({ title: '您已有进行中的订单', icon: 'none' });
      showToast({ title: '您已有进行中的订单', icon: 'none' });
      return;
    }
    const targetStartSite = resolveValidStartSite();
    if (!targetStartSite) {
      Taro.showToast({ title: '请先选择起点站点', icon: 'none' });
      showToast({ title: '请先选择起点站点', icon: 'none' });
      return;
    }
    if (!capsuleSite) {
      Taro.showToast({ title: '暂无快捷目的地', icon: 'none' });
      showToast({ title: '暂无快捷目的地', icon: 'none' });
      return;
    }
    const distanceConfig = await ensureDistanceConfigReady();
@@ -160,7 +161,7 @@
      endSite: capsuleSite,
      minDistance: distanceConfig?.minDistance
    })) {
      Taro.showToast({ title: ORDER_DISTANCE_MIN_DISTANCE_TOAST, icon: 'none' });
      showToast({ title: ORDER_DISTANCE_MIN_DISTANCE_TOAST, icon: 'none' });
      return;
    }
    Taro.setStorageSync('endSite', capsuleSite);
src/pages/robotaxi/home/index.tsx
@@ -41,6 +41,7 @@
import { checkOtp, toH5 } from '../../../utils/tools';
import { getCityLocation, reportEvent } from '../../../utils/Robotaxi-public-method';
import { withRobotaxiWhitelist } from '../../../utils/whitelist';
import { withToast } from '../../../robotaxi/components/toast/with-toast';
import {
  HOME_MAP_ID,
  HOME_ORDER_STATUS,
@@ -636,4 +637,4 @@
  );
}
export default withRobotaxiWhitelist(Home);
export default withRobotaxiWhitelist(withToast(Home));
src/robotaxi/components/demo/index.tsx
@@ -1,4 +1,3 @@
import Taro from '@tarojs/taro'
import { PureComponent } from 'react'
import { View, Text } from '@tarojs/components'
import './index.less'
@@ -164,8 +163,8 @@
            fareEstimate={DEMO_MODIFY_FARE_ESTIMATE}
            routeOptions={DEMO_MODIFY_ROUTE_OPTIONS}
            selectedRouteIndex={0}
            onEditDestination={() => Taro.showToast({ title: '编辑目的地(Demo)', icon: 'none' })}
            onConfirm={() => Taro.showToast({ title: '确认修改(Demo)', icon: 'none' })}
            onEditDestination={() => showToast({ title: '编辑目的地(Demo)', icon: 'none' })}
            onConfirm={() => showToast({ title: '确认修改(Demo)', icon: 'none' })}
          />
        </View>
      </View>
src/robotaxi/components/fireworks/error-boundary.tsx
New file
@@ -0,0 +1,35 @@
import { Component, ReactNode } from 'react';
// 通用错误边界:捕获子树在「渲染 / 生命周期」阶段抛出的错误,出错后静默渲染为空,
// 避免动效等非核心 UI 的异常向上冒泡拖垮整个页面。
// 注意:错误边界无法捕获异步回调(requestAnimationFrame、Promise、事件处理器)中的错误,
// 那部分需在对应异步逻辑内自行 try/catch 兜底。
type Props = {
  children: ReactNode;
  /** 出错时的兜底 UI,默认不显示任何内容 */
  fallback?: ReactNode;
};
type State = {
  hasError: boolean;
};
export default class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };
  static getDerivedStateFromError(): State {
    return { hasError: true };
  }
  componentDidCatch(error: any) {
    // eslint-disable-next-line no-console
    console.warn('[Fireworks] 已忽略渲染错误:', error);
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? null;
    }
    return this.props.children;
  }
}
src/robotaxi/components/fireworks/index.less
New file
@@ -0,0 +1,9 @@
.xp-fireworks-canvas {
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
  z-index: 999;
  pointer-events: none;
}
src/robotaxi/components/fireworks/index.tsx
New file
@@ -0,0 +1,436 @@
import Taro from '@tarojs/taro';
import { Canvas } from '@tarojs/components';
import {
  forwardRef,
  useEffect,
  useImperativeHandle,
  useRef,
} from 'react';
import ErrorBoundary from './error-boundary';
import './index.less';
// 粒子礼花动效:小程序原生 Canvas 2D 自绘,复刻 react-canvas-confetti 的 fireworks 预设。
// 参数命名与 canvas-confetti 对齐,方便与 H5 侧对照调参。
export type FireworkShape = 'square' | 'circle';
export type FireworksOptions = {
  /** 单簇粒子数量(连发时随剩余时间递减) */
  particleCount?: number;
  /** 散布角度(度),360 即向四周炸开 */
  spread?: number;
  /** 发射方向角度(度),90 表示向上(spread=360 时无影响) */
  angle?: number;
  /** 初始速度 */
  startVelocity?: number;
  /** 重力(每帧对 vy 的增量) */
  gravity?: number;
  /** 横向漂移 */
  drift?: number;
  /** 每帧速度衰减(空气阻力) */
  decay?: number;
  /** 粒子大小缩放(方片边长 = 6 * scalar) */
  scalar?: number;
  /** 粒子存活帧数 */
  ticks?: number;
  /** 颜色数组 */
  colors?: string[];
  /** 形状(单个),与 shapes 二选一 */
  shape?: FireworkShape;
  /** 形状数组,随机取一,优先级高于 shape */
  shapes?: FireworkShape[];
  /** 发射中心点,取值 0~1 的相对坐标(x 向右、y 向下) */
  origin?: { x?: number; y?: number };
  /** 连发总时长(ms),仅 play 连发时生效 */
  duration?: number;
  /** 两次发射间隔(ms),仅 play 连发时生效 */
  interval?: number;
};
export type FireworksRef = {
  /** 连发礼花(在 duration 内每隔 interval 在随机位置发射一簇) */
  play: (options?: FireworksOptions) => void;
  /** 单次发射一簇礼花(等价 canvas-confetti 的 confetti(options)) */
  shoot: (options?: FireworksOptions) => void;
  /** 立即停止并清空 */
  stop: () => void;
};
type Props = {
  /** canvas 层级 */
  zIndex?: number;
};
const DEFAULTS = {
  particleCount: 80,
  spread: 360,
  angle: 90,
  startVelocity: 28,
  gravity: 0.85,
  drift: 0,
  decay: 0.9,
  scalar: 1,
  ticks: 150,
  colors: ['#7FE6F2', '#F9A76A', '#95CF29'],
  shapes: ['square'] as FireworkShape[],
  origin: { x: 0.5, y: 0.5 },
  duration: 2500,
  interval: 250,
};
type NormalizedOptions = {
  particleCount: number;
  spread: number;
  angle: number;
  startVelocity: number;
  gravity: number;
  drift: number;
  decay: number;
  scalar: number;
  ticks: number;
  colors: string[];
  shapes: FireworkShape[];
  origin: { x: number; y: number };
  duration: number;
  interval: number;
};
// 合并默认值 + 兼容 shape 单值写法,产出归一化配置
function normalize(options: FireworksOptions = {}): NormalizedOptions {
  const shapes = options.shapes && options.shapes.length
    ? options.shapes
    : options.shape
      ? [options.shape]
      : DEFAULTS.shapes;
  return {
    particleCount: options.particleCount ?? DEFAULTS.particleCount,
    spread: options.spread ?? DEFAULTS.spread,
    angle: options.angle ?? DEFAULTS.angle,
    startVelocity: options.startVelocity ?? DEFAULTS.startVelocity,
    gravity: options.gravity ?? DEFAULTS.gravity,
    drift: options.drift ?? DEFAULTS.drift,
    decay: options.decay ?? DEFAULTS.decay,
    scalar: options.scalar ?? DEFAULTS.scalar,
    ticks: options.ticks ?? DEFAULTS.ticks,
    colors: options.colors ?? DEFAULTS.colors,
    shapes,
    origin: {
      x: options.origin?.x ?? DEFAULTS.origin.x,
      y: options.origin?.y ?? DEFAULTS.origin.y,
    },
    duration: options.duration ?? DEFAULTS.duration,
    interval: options.interval ?? DEFAULTS.interval,
  };
}
function rand(min: number, max: number) {
  return Math.random() * (max - min) + min;
}
// 粒子模型对齐 canvas-confetti:每片纸屑带 wobble(飘动)+ tiltAngle(翻转),
// 因此绘制成会抖动、翻面的四边形,而非静止方块,视觉上更接近真实纸屑。
type Particle = {
  x: number;
  y: number;
  velocity: number; // 爆发初速(逐帧按 decay 衰减)
  angle2D: number; // 发射方向(弧度)
  wobble: number; // 飘动相位
  wobbleSpeed: number;
  wobbleX: number;
  wobbleY: number;
  tiltAngle: number; // 翻转角
  gravity: number; // 每帧恒定下落量(= opt.gravity * 3)
  decay: number;
  drift: number;
  scalar: number;
  color: string;
  shape: FireworkShape;
  tick: number;
  totalTicks: number;
};
// 生成一簇粒子。angle 为发射主方向(度,90 向上),spread 为散布角度。
// angle2D = -angle ± spread/2,屏幕 y 向下,故 angle=90 时 sin(angle2D)=-1 表现为向上。
// 初速取 sqrt(random)×startVelocity:用平方根让粒子按「面积」均匀分布,
// 避免大量粒子挤在中心(若用线性 random,低速粒子过多会在中心结团);
// 同时最小值仍接近 0,保留少量粒子填充中心,整体从中心向四周均匀铺满屏幕。
function createBurst(cx: number, cy: number, particleCount: number, opt: NormalizedOptions) {
  const list: Particle[] = [];
  const radAngle = (opt.angle * Math.PI) / 180;
  const radSpread = (opt.spread * Math.PI) / 180;
  for (let i = 0; i < particleCount; i++) {
    list.push({
      x: cx,
      y: cy,
      velocity: Math.sqrt(Math.random()) * opt.startVelocity,
      angle2D: -radAngle + (0.5 * radSpread - Math.random() * radSpread),
      wobble: Math.random() * 10,
      wobbleSpeed: Math.min(0.11, Math.random() * 0.1 + 0.05),
      wobbleX: 0,
      wobbleY: 0,
      tiltAngle: (Math.random() * 0.5 + 0.25) * Math.PI,
      gravity: opt.gravity * 3,
      decay: opt.decay,
      drift: opt.drift,
      // 每片纸屑大小在 scalar 基础上随机(0.5×~1.8×),大的一端更大,呈现有大有小的层次感
      scalar: opt.scalar * rand(0.5, 1.8),
      color: opt.colors[(Math.random() * opt.colors.length) | 0],
      shape: opt.shapes[(Math.random() * opt.shapes.length) | 0],
      tick: 0,
      totalTicks: opt.ticks,
    });
  }
  return list;
}
// canvas 节点 id 前缀。真正的 id 会在每个实例内拼接自增序号 + 随机串,保证同页多个
// <Fireworks> 实例不会因固定 id 冲突而 createSelectorQuery 选错节点(class 仍固定用于样式)。
const CANVAS_ID_PREFIX = 'xp-fireworks-canvas';
// 实例自增序号:模块级,配合随机串生成唯一 canvas id
let fireworksInstanceSeq = 0;
const FireworksInner = forwardRef<FireworksRef, Props>((props, ref) => {
  const canvasRef = useRef<any>(null);
  const ctxRef = useRef<any>(null);
  const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
  const particlesRef = useRef<Particle[]>([]);
  const runningRef = useRef(false);
  // 世代号:stop() 递增,loop 每帧比对——若世代变化则自终止,解决 stop() 停不掉已排队的 rAF
  const generationRef = useRef(0);
  // 当前发射器:play 设 fireRef,shoot 设 null;运行中更换 fireRef 即可热替换发射逻辑,解决吞新 play()
  const fireRef = useRef<((now: number) => boolean) | null>(null);
  // 本实例唯一的 canvas id(惰性生成一次,跨渲染稳定)
  const canvasIdRef = useRef<string>('');
  if (!canvasIdRef.current) {
    fireworksInstanceSeq += 1;
    canvasIdRef.current = `${CANVAS_ID_PREFIX}-${fireworksInstanceSeq}-${Math.random().toString(36).slice(2, 8)}`;
  }
  // 统一的错误吞噬:礼花只是锦上添花的动效,任何异常都不应外溢影响页面,出错即静默不显示
  const swallow = (err: unknown) => {
    runningRef.current = false;
    // eslint-disable-next-line no-console
    console.warn('[Fireworks] 已忽略动效内部错误:', err);
  };
  // 包裹异步入口方法:错误边界捕获不到 rAF/Promise 等异步栈里的错误,这里统一兜底吞掉
  const safe = <A extends any[]>(fn: (...args: A) => void | Promise<void>) => (...args: A) => {
    try {
      const ret = fn(...args);
      if (ret && typeof (ret as Promise<void>).catch === 'function') {
        (ret as Promise<void>).catch(swallow);
      }
    } catch (err) {
      swallow(err);
    }
  };
  // 初始化 canvas 节点(Canvas 2D)。同步异常会 reject 由 safe 兜底;
  // exec 回调是异步栈,safe 捕获不到,故回调内单独 try/catch。
  const initCanvas = () =>
    new Promise<void>((resolve) => {
      if (canvasRef.current) {
        resolve();
        return;
      }
      const query = Taro.createSelectorQuery();
      query
        .select(`#${canvasIdRef.current}`)
        .fields({ node: true, size: true })
        .exec((res) => {
          try {
            if (!res || !res[0] || !res[0].node) {
              resolve();
              return;
            }
            const canvas = res[0].node;
            const ctx = canvas.getContext('2d');
            const dpr = (Taro.getSystemInfoSync() || {}).pixelRatio || 1;
            canvas.width = res[0].width * dpr;
            canvas.height = res[0].height * dpr;
            ctx.scale(dpr, dpr);
            canvasRef.current = canvas;
            ctxRef.current = ctx;
            sizeRef.current = { w: res[0].width, h: res[0].height };
            resolve();
          } catch (err) {
            swallow(err);
            resolve();
          }
        });
    });
  // 逐帧更新并绘制单个粒子,返回 false 表示已消亡。逻辑对齐 canvas-confetti 的 updateFizz。
  const updateAndDraw = (ctx: any, p: Particle) => {
    // 位置:沿 angle2D 前进(速度按 decay 衰减)+ 恒定重力下落 + 横向漂移
    p.x += Math.cos(p.angle2D) * p.velocity + p.drift;
    p.y += Math.sin(p.angle2D) * p.velocity + p.gravity;
    p.velocity *= p.decay;
    // wobble:让粒子飘动,绘制成随时间抖动的四边形
    p.wobble += p.wobbleSpeed;
    p.wobbleX = p.x + 10 * p.scalar * Math.cos(p.wobble);
    p.wobbleY = p.y + 10 * p.scalar * Math.sin(p.wobble);
    // tiltAngle:让纸屑翻面(宽度周期性收缩,模拟 3D 翻转)
    p.tiltAngle += 0.1;
    const tiltSin = Math.sin(p.tiltAngle);
    const tiltCos = Math.cos(p.tiltAngle);
    const random = Math.random() + 2; // 对齐 canvas-confetti,决定纸片另一维的尺寸
    const x1 = p.x + random * tiltCos;
    const y1 = p.y + random * tiltSin;
    const x2 = p.wobbleX + random * tiltCos;
    const y2 = p.wobbleY + random * tiltSin;
    const progress = p.tick / p.totalTicks;
    p.tick++;
    if (p.tick >= p.totalTicks) return false;
    ctx.globalAlpha = 1 - progress;
    ctx.fillStyle = p.color;
    ctx.beginPath();
    if (p.shape === 'circle') {
      // 椭圆:随 wobble/tilt 改变长短轴,呈现翻转的圆片(ovalScalar=0.6)
      ctx.ellipse(
        p.x,
        p.y,
        Math.abs(x2 - x1) * 0.6,
        Math.abs(y2 - y1) * 0.6,
        (Math.PI / 10) * p.wobble,
        0,
        2 * Math.PI,
      );
    } else {
      // 方片:由 4 个随 wobble/tilt 抖动的顶点构成的四边形,会飘动翻面
      ctx.moveTo(Math.floor(p.x), Math.floor(p.y));
      ctx.lineTo(Math.floor(p.wobbleX), Math.floor(y1));
      ctx.lineTo(Math.floor(x2), Math.floor(y2));
      ctx.lineTo(Math.floor(x1), Math.floor(p.wobbleY));
    }
    ctx.closePath();
    ctx.fill();
    return true;
  };
  // 逐帧渲染循环:读取 fireRef 驱动发射、比对 generationRef 自终止,无粒子且无发射时自动停止 rAF
  const startLoop = () => {
    const canvas = canvasRef.current;
    const ctx = ctxRef.current;
    if (!canvas || !ctx) return;
    if (runningRef.current) return; // 循环唯一,发射器靠 fireRef 热替换,新 play 无需重启循环
    runningRef.current = true;
    const { w, h } = sizeRef.current;
    const myGen = generationRef.current;
    const loop = () => {
      // 单帧出错时吞掉并终止循环,清空粒子,避免错误反复外溢
      try {
        // 世代号比对:stop() 递增后,已排队的下一帧检测到变化即自终止
        if (myGen !== generationRef.current) {
          runningRef.current = false;
          return;
        }
        const now = Date.now();
        const keepFiring = fireRef.current ? fireRef.current(now) : false;
        ctx.clearRect(0, 0, w, h);
        particlesRef.current = particlesRef.current.filter((p) => updateAndDraw(ctx, p));
        ctx.globalAlpha = 1;
        if (particlesRef.current.length || keepFiring) {
          canvas.requestAnimationFrame(loop);
        } else {
          runningRef.current = false;
          fireRef.current = null;
        }
      } catch (err) {
        particlesRef.current = [];
        try {
          ctx.clearRect(0, 0, w, h);
        } catch (_) {
          // 清屏也失败则忽略
        }
        swallow(err);
      }
    };
    canvas.requestAnimationFrame(loop);
  };
  // 单次发射一簇礼花(safe 包裹,异步异常统一吞掉)
  const shoot = safe(async (options: FireworksOptions = {}) => {
    await initCanvas();
    if (!canvasRef.current || !ctxRef.current) return;
    const opt = normalize(options);
    const { w, h } = sizeRef.current;
    const cx = opt.origin.x * w;
    const cy = opt.origin.y * h;
    fireRef.current = null;
    particlesRef.current.push(...createBurst(cx, cy, opt.particleCount, opt));
    startLoop();
  });
  // 连发礼花:在 duration 内每隔 interval 在随机位置发射一簇。
  // fireRef 先设后调 startLoop——即使循环已在运行也能热替换发射逻辑,不再吞新 play()。
  const play = safe(async (options: FireworksOptions = {}) => {
    await initCanvas();
    if (!canvasRef.current || !ctxRef.current) return;
    const opt = normalize(options);
    const { w, h } = sizeRef.current;
    const end = Date.now() + opt.duration;
    let lastFire = 0;
    fireRef.current = (now) => {
      if (now <= end && now - lastFire >= opt.interval) {
        lastFire = now;
        const remain = (end - now) / opt.duration;
        const count = Math.max(1, Math.ceil(opt.particleCount * remain));
        const cx = rand(0.2, 0.8) * w;
        const cy = rand(0.35, 0.5) * h;
        particlesRef.current.push(...createBurst(cx, cy, count, opt));
      }
      return now <= end;
    };
    startLoop();
  });
  const stop = safe(() => {
    generationRef.current += 1;
    runningRef.current = false;
    fireRef.current = null;
    particlesRef.current = [];
    const ctx = ctxRef.current;
    const { w, h } = sizeRef.current;
    if (ctx) ctx.clearRect(0, 0, w, h);
  });
  useImperativeHandle(ref, () => ({ play, shoot, stop }));
  useEffect(() => {
    return () => {
      generationRef.current += 1;
      runningRef.current = false;
      fireRef.current = null;
      particlesRef.current = [];
    };
  }, []);
  return (
    <Canvas
      type='2d'
      id={canvasIdRef.current}
      className='xp-fireworks-canvas'
      style={props.zIndex != null ? { zIndex: props.zIndex } : undefined}
    />
  );
});
// 用错误边界包裹:捕获渲染/生命周期阶段的错误(异步错误由内部 safe 兜底),出错静默渲染为空。
// ref 透传给内部组件,外部照常拿到 play / shoot / stop。
const Fireworks = forwardRef<FireworksRef, Props>((props, ref) => (
  <ErrorBoundary>
    <FireworksInner {...props} ref={ref} />
  </ErrorBoundary>
));
export default Fireworks;
src/robotaxi/components/fireworks/礼花效果.md
New file
@@ -0,0 +1,139 @@
# 礼花(Fireworks)动效组件
> 小程序原生 **Canvas 2D** 自绘的粒子礼花组件,复刻 [`canvas-confetti`](https://github.com/catdad/canvas-confetti) / [`react-canvas-confetti`](https://github.com/ulitcos/react-canvas-confetti) 的 `confetti`/`fireworks` 观感,**不依赖任何第三方库**。
> 参数命名与 `canvas-confetti` 对齐,方便与 H5 侧对照调参。
组件位置:`src/robotaxi/components/fireworks/`
```
fireworks/
├── index.tsx          # 组件主体(forwardRef,暴露 play/shoot/stop)
├── error-boundary.tsx # 通用错误边界(吞掉渲染/生命周期错误)
├── index.less         # 全屏、置顶、pointer-events:none 的 canvas 样式
└── 礼花效果.md         # 本文档
```
---
## 1. 背景
现有 H5 工程的撒花动效基于 `react-canvas-confetti`(底层 `canvas-confetti`),强依赖浏览器 DOM/BOM(`document`、`window`、DOM canvas)。**小程序没有 DOM,canvas 是专有组件**,无法直接复用,因此在小程序侧用**原生 Canvas 2D 自绘粒子系统**复刻同样的效果。
## 2. 对外 API
组件通过 `forwardRef` + `useImperativeHandle` 暴露 3 个命令式方法(`FireworksRef`):
| 方法 | 说明 |
|------|------|
| `shoot(options?)` | **单次发射一簇**礼花,等价 `canvas-confetti` 的 `confetti(options)`。到达结束态即用此方法。 |
| `play(options?)` | **连发**:在 `duration` 内每隔 `interval` 在随机位置发射一簇,`particleCount` 随剩余时间递减。 |
| `stop()` | 立即停止并清空画布。 |
Props:
| Prop | 类型 | 说明 |
|------|------|------|
| `zIndex` | `number?` | canvas 层级;不传则用样式默认 `999`。 |
### 用法
```tsx
import Fireworks, { FireworksRef } from '@/robotaxi/components/fireworks';
const ref = useRef<FireworksRef | null>(null);
// 挂载
<Fireworks ref={(node) => { ref.current = node; }} zIndex={99999} />
// 单簇(到达庆祝)
ref.current?.shoot({
  origin: { x: 0.5, y: 0.4 },
  angle: 90,
  particleCount: 260,
  spread: 360,
  startVelocity: 42,
  gravity: 0.9,
  scalar: 0.9,
  ticks: 180,
  colors: ['#FFD666', '#F9A76A', '#FFF1B8', '#7FE6F2', '#95CF29'],
});
// 连发
ref.current?.play({ duration: 2500, interval: 250 });
```
### 当前落地场景
- **到达结束态庆祝**:`src/robotaxi/pages/begin-route/index.tsx` 中,检测 `tripStatus` 由非结束态切到 `TripFinished(8)` 的上升沿,延迟约 180ms 后调用 `shoot` 发射一簇(与背景卡片升起同步呈现,只发一次避免重复观感)。
- **调参 Demo**:`src/robotaxi/pages/fireworks-demo/index.tsx`(仅调试用),点击按钮调用 `shoot` 绽放一簇。
## 3. 参数(`FireworksOptions`)与默认值
| 参数 | 含义 | 默认值 |
|------|------|--------|
| `particleCount` | 单簇粒子数量(连发时随剩余时间递减) | `80` |
| `spread` | 散布角度(度),`360` 即向四周炸开 | `360` |
| `angle` | 发射主方向(度),`90` 向上(`spread=360` 时无影响) | `90` |
| `startVelocity` | 初始速度(实际按 `sqrt(random)` 分布,见下) | `28` |
| `gravity` | 重力(每帧恒定下落量 = `gravity * 3`) | `0.85` |
| `drift` | 横向漂移 | `0` |
| `decay` | 每帧速度衰减(空气阻力) | `0.9` |
| `scalar` | 粒子大小缩放(每片再随机 `0.5×~1.8×`) | `1` |
| `ticks` | 粒子存活帧数 | `150` |
| `colors` | 颜色数组 | `['#7FE6F2', '#F9A76A', '#95CF29']` |
| `shape` / `shapes` | 形状:`'square'`(方片)/ `'circle'`(椭圆片),`shapes` 优先 | `['square']` |
| `origin` | 发射中心,相对坐标 `0~1`(x 向右、y 向下) | `{ x: 0.5, y: 0.5 }` |
| `duration` | 连发总时长(ms),仅 `play` 生效 | `2500` |
| `interval` | 两次发射间隔(ms),仅 `play` 生效 | `250` |
## 4. 粒子模型与算法
粒子模型对齐 `canvas-confetti`:每片纸屑带 `wobble`(飘动)与 `tiltAngle`(翻转),绘制成**会抖动、翻面的四边形/椭圆**,而非静止方块,视觉上更接近真实纸屑。
**发射(`createBurst`)**
- 主方向 `angle2D = -radAngle + (0.5*radSpread - random*radSpread)`;屏幕 y 向下,故 `angle=90` 时向上。
- 初速 `velocity = sqrt(random) * startVelocity`:用平方根让粒子按「面积」均匀分布,避免大量粒子挤在中心结团;同时保留少量低速粒子填充中心,整体从中心向四周均匀铺满。
- 每片大小 `scalar * rand(0.5, 1.8)`:有大有小的层次感。
**每帧更新(`updateAndDraw`)**
```
x += cos(angle2D) * velocity + drift
y += sin(angle2D) * velocity + gravity   // gravity 为每帧恒定下落量
velocity *= decay                         // 速度衰减
wobble   += wobbleSpeed                    // 飘动相位,决定四边形顶点抖动
tiltAngle += 0.1                           // 翻面(宽度周期性收缩,模拟 3D 翻转)
alpha = 1 - tick / totalTicks              // 线性淡出
tick++ ; if (tick >= totalTicks) → 移除
```
- 方片:由 4 个随 wobble/tilt 抖动的顶点构成的四边形。
- 椭圆:随 wobble/tilt 改变长短轴(ovalScalar=0.6),呈现翻转的圆片。
**渲染循环(`startLoop`)**:单实例只跑一个 `requestAnimationFrame` 循环,新发射的粒子追加进同一循环;无存活粒子且无待发射时自动停止 rAF,避免空转耗电。
## 5. 健壮性设计(三层容错)
礼花只是锦上添花的动效,任何异常都不应拖垮页面,出错即静默不显示:
1. **`ErrorBoundary`**:捕获子树在渲染/生命周期阶段抛出的错误,出错渲染为空。
2. **`safe()` 包裹**:包裹 `shoot`/`play`/`stop` 等异步入口,兜底 rAF/Promise 等异步栈里错误边界捕获不到的错误。
3. **逐帧 `try/catch`**:单帧出错即清空粒子并终止循环,避免错误反复外溢。
## 6. 关键实现细节
- **Canvas 2D(`type="2d"`)**:新版 canvas,性能优于旧版 `createCanvasContext`。
- **DPR 适配**:`canvas.width = 逻辑宽 * pixelRatio` 并 `ctx.scale(dpr, dpr)`,否则高清屏发虚。
- **唯一 canvas id**:每个组件实例惰性生成 `xp-fireworks-canvas-{seq}-{random}` 作为 canvas id(`className` 仍固定 `xp-fireworks-canvas` 用于样式),避免同页多个 `<Fireworks>` 实例因固定 id 导致 `createSelectorQuery` 选错节点。
- **点击穿透**:canvas `position:fixed` 全屏置顶 + `pointer-events:none`,不阻挡按钮/支付等交互。
- **卸载清理**:`useEffect` 卸载时停循环、清粒子;到达页对 `<Fireworks>` 做条件渲染,离开结束态即卸载。
## 7. 验收标准
- [ ] 到达结束态(6→8)时,随背景卡片升起绽放一簇礼花,粒子四散、受重力下落并淡出,仅播一次。
- [ ] 配色/形状/密度可通过 `FireworksOptions` 配置。
- [ ] 动画期间不阻塞页面交互(点击穿透正常)。
- [ ] 动画结束后 rAF 停止、canvas 无残留、CPU 回落。
- [ ] 组件内部任何异常都被静默吞掉,不影响页面主流程。
- [ ] 真机(iOS + Android)低端机型帧率可接受,无明显卡顿。
src/robotaxi/constants/assets.ts
New file
@@ -0,0 +1,62 @@
/**
 * 素材链接单一数据源(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';
/** 派单成功转场视频尾帧(jpeg):结尾遮挡层用,挡住原生 <video> 卸载黑帧。 */
export const VEHICLE_APPROACHING_END_POSTER = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_end_poster_v1_file_w_1128@h_1268.jpeg';
/** 常态行程卡片背景图(派单成功落定 / 接驾 / 行程中)。 */
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';
/**
 * 是否使用接口(queryVehicleByOrderId)动态返回的车图。
 *
 * 背景:当前线上只有一种车型,产品要求统一用默认车图 DEFAULT_VEHICLE_IMG,
 *      暂不使用接口返回的动态车图(vehicleInfo.vehiclePictures.url)。
 *
 * 后续有新车型、需要按车型展示实拍车图时,把此开关改回 true 即可完全恢复动态车图逻辑,
 * 无需改动其余代码(车图渲染处 calling-car.tsx、dispatch-transition-layer.tsx 均已按此开关分流;
 * 默认车图 DEFAULT_VEHICLE_IMG 已在 constants/preload-assets.ts 静态预加载,展示无闪烁)。
 */
export const USE_DYNAMIC_VEHICLE_IMG = false;
/** 到达终点 / 行程结束卡片背景图。 */
export const TRIP_FINISHED_BG_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/trip-finished-20260703.png';
/** 发布中/呼叫车中顶部卡片背景图(release-be-being、calling-car 呼叫态共用)。 */
export const TOP_CARD_BG_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/top-card-bg-20260722.png';
/** 站点完成态图标(金色:起点,黑色:其余站点)。 */
export const ROUTE_STATE_ICON_GOLDEN = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/route-state-icon-golden-20260702.png';
export const ROUTE_STATE_ICON_BLACK = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/route-state-icon-black-20260702.png';
/** 站点连接虚线。 */
export const DOTTED_LINE_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/dotted-line.png';
/** 接驾中/行程中状态栏「车正在赶来」图标。 */
export const COME_ICON_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/come-icon-20260728.webp';
/** 发布中进度图标。 */
export const PUBLISH_PROGRESS_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/publish-progress-20260715.webp';
/**
 * 全局字体链接。
 * ⚠️ 必须与 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,46 @@
import { PreloadAsset } from '../../utils/preload-assets';
import {
  VEHICLE_APPROACHING_POSTER,
  VEHICLE_APPROACHING_END_POSTER,
  CARD_BG_IMG,
  DEFAULT_VEHICLE_IMG,
  TRIP_FINISHED_BG_IMG,
  TOP_CARD_BG_IMG,
  ROUTE_STATE_ICON_GOLDEN,
  ROUTE_STATE_ICON_BLACK,
  DOTTED_LINE_IMG,
  COME_ICON_IMG,
  PUBLISH_PROGRESS_IMG,
} 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: VEHICLE_APPROACHING_END_POSTER }, // 转场视频尾帧(结尾遮挡层,挡视频卸载黑帧)
  { type: 'image', src: CARD_BG_IMG }, // 转场落定后的常态卡片背景
  { type: 'image', src: DEFAULT_VEHICLE_IMG }, // 车模兜底图
  { type: 'image', src: TOP_CARD_BG_IMG }, // 发布中/呼叫中顶部卡片背景
  { type: 'image', src: ROUTE_STATE_ICON_GOLDEN }, // 起点完成态图标
  { type: 'image', src: ROUTE_STATE_ICON_BLACK }, // 其余站点完成态图标
  { type: 'image', src: DOTTED_LINE_IMG }, // 站点连接虚线
  { type: 'image', src: COME_ICON_IMG }, // 接驾中/行程中「车正在赶来」图标
  { type: 'image', src: PUBLISH_PROGRESS_IMG }, // 发布中进度图标
];
/** 行程结束转场素材(送驾中 → 行程结束)。礼花为纯 canvas 绘制,无素材。 */
export const TRIP_FINISHED_ASSETS: PreloadAsset[] = [
  { type: 'image', src: TRIP_FINISHED_BG_IMG }, // 到达终点卡片背景
];
src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx
@@ -10,9 +10,11 @@
    descriptionColor?: string;
    /** 是否允许点击(默认 true)。false 时不绑定 onClick */
    clickable?: boolean;
    /** 蓝牙状态首轮判定是否完成(默认 true)。false 时占位保留高度、内容淡出且不可点,避免异步就绪撑高卡片产生跳变 */
    ready?: boolean;
};
export default function BluetoothOperateCard({ description, actionText = '', onClick, iconSrc, descriptionColor = '', clickable = true }: Props) {
export default function BluetoothOperateCard({ description, actionText = '', onClick, iconSrc, descriptionColor = '', clickable = true, ready = true }: Props) {
    const descriptionStyle = descriptionColor ? { color: descriptionColor } : undefined;
    // 右侧区域渲染规则:
    // - 不可点击(如车端钥匙下发失败)→ 不渲染
@@ -20,7 +22,10 @@
    // - 其他可点击场景("去授权" / 已授权)→ 渲染容器;文字仅在有 actionText 时显示,箭头始终显示
    const shouldRenderRight = clickable && actionText !== '去开启';
    return (
        <View className="bluetooth-operate-card" onClick={clickable ? onClick : undefined}>
        <View
            className={`bluetooth-operate-card${ready ? '' : ' bluetooth-operate-card--pending'}`}
            onClick={ready && clickable ? onClick : undefined}
        >
            <View className="bluetooth-operate-card-left">
                <Image src={iconSrc} />
                <View style={descriptionStyle}>{description}</View>
src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx
@@ -4,7 +4,7 @@
import { unlockVehicle, queryVehicleTrunkOpenStatus } from '../../../../api/begin-route';
import { getOrderId } from '../../../../utils/order';
import { TripStatus } from '../../../../constants/trip-status';
import { showToast } from '../../../../components/toast';
import { showToast, hideToast } from '../../../../components/toast';
import useMinimumSpinLoading from './use-minimum-spin-loading';
import TrunkConfirmPopup from './trunk-confirm-popup';
@@ -93,13 +93,13 @@
    // 获取解锁请求所需定位参数,优先高精定位,失败后降级普通定位
    const getUnlockLocationPayload = async () => {
        Taro.showLoading({ title: '正在获取位置...', mask: true });
        showToast({ title: '正在获取位置...', icon: 'loading', mask: true });
        try {
            const location = await Taro.getLocation({ type: 'gcj02', isHighAccuracy: true });
            const longitude = Number(location?.longitude);
            const latitude = Number(location?.latitude);
            if (!Number.isNaN(longitude) && !Number.isNaN(latitude)) {
                Taro.hideLoading();
                hideToast();
                return { longitude, latitude };
            }
        } catch (error) {
@@ -111,14 +111,14 @@
            const longitude = Number(location?.longitude);
            const latitude = Number(location?.latitude);
            if (!Number.isNaN(longitude) && !Number.isNaN(latitude)) {
                Taro.hideLoading();
                hideToast();
                return { longitude, latitude };
            }
        } catch (error) {
            console.error('普通定位获取失败:', error);
        }
        Taro.hideLoading();
        hideToast();
        return null;
    };
@@ -144,7 +144,7 @@
        const actionLoadingText = doorType === 'trunk'
            ? (isCloseAction ? '正在关闭后备箱' : '正在开启后备箱')
            : '正在解锁车门';
        // Taro.showLoading({ title: actionLoadingText, mask: true });
        // showToast({ title: actionLoadingText, icon: 'loading', mask: true });
        unlockVehicle({
            orderId: targetOrderId,
            doorType,
@@ -157,7 +157,7 @@
                ? Number(res?.data) == 1
                : !!res?.data;
            if (!unlockSucceeded) {
                // Taro.hideLoading();
                // hideToast();
                const failMsg = res?.msg || '解锁失败,请重试或联系客服';
                stopLoading(doorType, () => {
                    showToast({ title: failMsg, icon: 'fail' });
@@ -187,7 +187,7 @@
                        trunkPollTimerRef.current = setTimeout(doPoll, pollInterval);
                    } else {
                        stopPolling();
                        // Taro.hideLoading();
                        // hideToast();
                        stopLoading(doorType, () => {
                            showToast({
                                title: failToastText,
@@ -222,7 +222,7 @@
                            const isOpen = !isCloseAction;
                            setTrunkStatus(isOpen);
                            onSuccess?.();
                            // Taro.hideLoading();
                            // hideToast();
                            stopLoading(doorType, () => {
                                showToast({
                                    title: successToastText,
@@ -240,7 +240,7 @@
            }
            if (doorType === 'door') {
                // Taro.hideLoading();
                // hideToast();
                syncDoorUnlockButtonUI();
                onSuccess?.();
                stopLoading(doorType, () => {
@@ -248,7 +248,7 @@
                });
            }
        }).catch(() => {
            // Taro.hideLoading();
            // hideToast();
            stopLoading(doorType, () => {
                showToast({ title: '解锁失败,请重试或联系客服', icon: 'fail' });
            });
src/robotaxi/pages/begin-route/components/calling-car-modules/evaluation-card.tsx
@@ -33,6 +33,14 @@
    const [isEvaluated, setIsEvaluated] = useState(false); // 是否已完成评价
    const skipEvaluateResetRef = useRef(false); // 跳过关闭重置的一次性标记
    // 订单切换时重置评价态:去掉 CallingCar 的 key 后本组件不再随状态重挂载,
    // 需显式按 orderId 复位,避免上一单「已评价」状态串到下一单。
    useEffect(() => {
        setActiveIndex(0);
        setIsEvaluated(false);
        skipEvaluateResetRef.current = false;
    }, [orderId]);
    // 外部提交成功后同步评价状态
    useEffect(() => {
        if (typeof evaluateSubmitVersion === 'number' && evaluateSubmitVersion > 0) {
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
@@ -41,6 +41,8 @@
    pageDidShowVersion?: number;
    /** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */
    hasPreviousOrder?: boolean;
    /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */
    active?: boolean;
};
/**
@@ -66,7 +68,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);
    // 后台配置曲风列表
@@ -93,6 +95,10 @@
    // 进入接驾态查询曲风状态;功能开关关闭、连环单不查询、不展示
    useEffect(() => {
        // 门槛未就绪(如派单成功转场动效未完成):暂不查询,active 就绪后重跑再查询一次。
        if (!active) {
            return;
        }
        if (!isMusicControlEnabled() || tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
            // 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。
            queriedOrderIdRef.current = '';
@@ -127,7 +133,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
@@ -43,6 +43,8 @@
    pageDidShowVersion?: number;
    /** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */
    hasPreviousOrder?: boolean;
    /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */
    active?: boolean;
};
/**
@@ -51,7 +53,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);
@@ -293,6 +295,11 @@
    };
    useEffect(() => {
        // 门槛未就绪(如派单成功转场动效未完成):暂不查询/下发,避免转场中提前开空调、下发指令。
        // 不重置去重标记,active 就绪后本 effect 重跑再正常查询一次。
        if (!active) {
            return;
        }
        if (tripStatus !== TripStatus.VehicleApproaching) {
            // 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。
            queriedOrderIdRef.current = '';
@@ -326,7 +333,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
@@ -1,9 +1,12 @@
import { View, Image } from "@tarojs/components";
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import './css-folder/calling-car.less'
import { formatRemainingDurationDisplay } from '../../../utils/format';
import { safeParse, formatTripDistance } from '../../../../utils/Robotaxi-public-method';
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, USE_DYNAMIC_VEHICLE_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'; // 车辆信息卡片
@@ -21,6 +24,14 @@
type Props = {
    /** 行程状态:3呼叫中、4即将到达、5已到达上车点、6行程中、7到达终点、8待支付、9已支付 */
    tripStatus?: TripStatus;
    /** 是否在本次挂载时播放"派单成功"转场(由父层捕捉首次进入派单成功(4)边沿传入,因本组件被 key 重挂载无法自判) */
    playDispatchTransition?: boolean;
    /** 是否在本次挂载时播放"直达待评价"入场(由父层捕捉 送驾中(6)→待评价(9) 边沿传入,内部免费订单跳过待支付时使用) */
    playEvaluateEnter?: boolean;
    /** 是否在本次挂载时播放"行程结束"入场(背景卡升起 + 里程渐显);由父层捕捉进入行程结束(8)边沿传入,恢复态不播放 */
    playFinishedEnter?: boolean;
    /** 派单成功转场动效完成(或本次不播放转场)时回调,用于放行页面级蓝牙初始化 */
    onDispatchTransitionSettled?: () => void;
    /** 打开取消弹窗,入参为预计到达展示文案 */
    handleOpenCancelPopup?: (durationDisplay: any) => void;
    /** 上车点超时后的回调 */
@@ -113,6 +124,10 @@
export default function CallingCar({
    tripStatus = TripStatus.CallingCar,
    playDispatchTransition = false,
    playEvaluateEnter = false,
    playFinishedEnter = false,
    onDispatchTransitionSettled,
    handleOpenCancelPopup,
    handleCancelTimeout,
    handleFinishTimeout,
@@ -175,17 +190,78 @@
    const showCancelTrip = forceShowCancelTrip
        || ([TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus) && !doorUnlockedInCurrentPage);
    // 派单成功转场状态机:phase 状态、定时器、视频事件均由 useDispatchTransition 管理
    const {
        dispatchPhase,
        transitionDone,
        shouldPlayEnter,
        handleDispatchVideoTimeUpdate,
        enterSettle,
    } = useDispatchTransition(playDispatchTransition);
    // 派单成功转场门槛:未播转场时立即就绪;播转场时等动效完成(transitionDone)。
    // 用于延迟车控/蓝牙卡/操作栏的「展示」与「逻辑执行」,避免转场中触发弹窗与提前下发指令。
    const contentReady = !shouldPlayEnter || transitionDone;
    /** 是否为“到达终点/待支付”状态(图一、图二) */
    const isTripFinished = tripStatus === TripStatus.TripFinished;
    const isCallingCar = tripStatus === TripStatus.CallingCar;
    /**
     * 「直达待评价」入场:与 useDispatchTransition 里的 shouldPlayEnter 同理,
     * 父层的 playEvaluateEnter 在下一次渲染即翻 false,直接绑定会把动画类中途卸掉、动效被扼杀。
     * 过去依赖 key={tripStatus} 重挂载做挂载快照;去 key 后组件不再重挂载,
     * 改为在渲染期同步捕捉 playEvaluateEnter 上升沿置类(避免先渲染一帧静止态再跳到位移起点的闪跳),
     * 播放一次后按动画时长(0.7s)清除类名——最终态与静止态一致无跳变,且使下一次 6→9 能重新触发。
     */
    const [shouldPlayEvaluateEnter, setShouldPlayEvaluateEnter] = useState(false);
    const prevPlayEvaluateRef = useRef(false);
    const evaluateEnterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
    if (playEvaluateEnter && !prevPlayEvaluateRef.current) {
        prevPlayEvaluateRef.current = true;
        if (!shouldPlayEvaluateEnter) setShouldPlayEvaluateEnter(true);
    } else if (!playEvaluateEnter && prevPlayEvaluateRef.current) {
        prevPlayEvaluateRef.current = false;
    }
    useEffect(() => {
        if (!shouldPlayEvaluateEnter) return;
        if (evaluateEnterTimerRef.current) clearTimeout(evaluateEnterTimerRef.current);
        evaluateEnterTimerRef.current = setTimeout(() => setShouldPlayEvaluateEnter(false), 750);
        return () => {
            if (evaluateEnterTimerRef.current) clearTimeout(evaluateEnterTimerRef.current);
        };
    }, [shouldPlayEvaluateEnter]);
    /**
     * 「行程结束」入场:与上面的 shouldPlayEvaluateEnter 完全同理(渲染期捕捉上升沿 → 置类 → 按动画时长清类)。
     * 父层 playFinishedEnter 仅在真实 live 跃迁进入行程结束(8)时为真;恢复态(从首页进入即已是结束态)恒为 false,
     * 此时不加 calling-car--finished-enter 类,结束态背景卡与里程直接显示最终态、不再弹起。
     * 背景卡与里程升起:延迟 700ms + 时长 1s = 1700ms 才结束,故清类须 ≥1700ms,否则会中途移除类
     * 导致动画被截断、瞬间 snap 到最终态(观感为「快结束时卡顿」)。取 1850ms 留缓冲。
     */
    const [shouldPlayFinishedEnter, setShouldPlayFinishedEnter] = useState(false);
    const prevPlayFinishedRef = useRef(false);
    const finishedEnterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
    if (playFinishedEnter && !prevPlayFinishedRef.current) {
        prevPlayFinishedRef.current = true;
        if (!shouldPlayFinishedEnter) setShouldPlayFinishedEnter(true);
    } else if (!playFinishedEnter && prevPlayFinishedRef.current) {
        prevPlayFinishedRef.current = false;
    }
    useEffect(() => {
        if (!shouldPlayFinishedEnter) return;
        if (finishedEnterTimerRef.current) clearTimeout(finishedEnterTimerRef.current);
        finishedEnterTimerRef.current = setTimeout(() => setShouldPlayFinishedEnter(false), 1850);
        return () => {
            if (finishedEnterTimerRef.current) clearTimeout(finishedEnterTimerRef.current);
        };
    }, [shouldPlayFinishedEnter]);
    /**
     * 车控(空调 / 音乐 / 香氛):卡片与弹窗分开挂载。
     * 卡片放进 2 列网格容器;弹窗是 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 });
    /** 费用明细是否展开(拦截 FareDetailCard 的回调本地记录,用于切换背景与卡片布局) */
    const [detailExpanded, setDetailExpanded] = useState(false);
    /** 大号里程文案:取全程里程,单位参考 formatTripDistance(米→M,公里→KM) */
@@ -195,6 +271,13 @@
    useEffect(() => {
        setDetailExpanded(false);
    }, [resetDetailVersion, tripStatus]);
    // 转场动效完成(或本次未播放转场)后通知父层:与车控卡片同步放行页面级蓝牙初始化。
    useEffect(() => {
        if (contentReady) {
            onDispatchTransitionSettled?.();
        }
    }, [contentReady, onDispatchTransitionSettled]);
    // 拦截费用明细展开/收起回调:本地记录展开态后再透传给外部
    const handleFareToggle = (expanded: boolean, hasFare: boolean) => {
@@ -209,22 +292,39 @@
        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} />
      }
      return  <Image className='calling-car-bg' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/card-bg-20260714.png' />
      // 派单转场中(挂载首帧 ~ 视频播放完成前):不返回任何背景图,杜绝转场期间出现静态背景图。
      // 常态背景图改在 DispatchTransitionLayer 中不可见预载。settle 起在此返回背景图(视频层仍盖在其上,
      // 600ms 后随 transitionDone 卸载、直切露出背景图,此时图片已预载、无加载间隙)。
      if (shouldPlayEnter && !transitionDone && dispatchPhase !== 'settle') {
        return null;
      }
      return  <Image className='calling-car-bg' src={CARD_BG_IMG} />
    }
    // 根节点状态类:待支付态 + 明细展开态
    const rootClassName = `calling-car${isTripFinished ? ' calling-car--finished' : ''}${isTripFinished && detailExpanded ? ' is-detail-expanded' : ''} ${isCallingCar ? 'calling-status-car' : ''}`;
    // 根节点状态类:呼叫中覆盖 + 待支付态 + 明细展开态 + 派单转场阶段 + 整卡升起入场 + 直达待评价入场
    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' : ''}${shouldPlayFinishedEnter ? ' calling-car--finished-enter' : ''}${!contentReady ? ' dispatch-defer-content' : ''}`;
    // 按状态组合渲染行程卡片模块
    return (
        <View className={rootClassName}>
          {getCardBgImg()}
          <DispatchTransitionLayer
            shouldPlayEnter={shouldPlayEnter}
            transitionDone={transitionDone}
            dispatchPhase={dispatchPhase}
            vehicleInfo={vehicleInfo}
            onTimeUpdate={handleDispatchVideoTimeUpdate}
            onEnded={enterSettle}
            onError={enterSettle}
          />
          {
            [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.PaymentSuccess].includes(tripStatus) && vehicleInfo?.plateNumber &&
            <Image
            // 常驻车图:接驾/行程中/支付成功且有车牌时展示。转场期间车模兜底由 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'}
              // 当前只有一种车型,产品要求统一用默认车图(USE_DYNAMIC_VEHICLE_IMG=false);
              // 后续有新车型时把该开关改回 true 即恢复接口动态车图。详见 constants/assets.ts。
              src={USE_DYNAMIC_VEHICLE_IMG ? (safeParse(vehicleInfo?.vehiclePictures, {})?.url || DEFAULT_VEHICLE_IMG) : DEFAULT_VEHICLE_IMG}
            />
          }
          <View className='calling-car-content-box'>
@@ -257,24 +357,9 @@
            />
            {
                /* 行程操作栏:紧跟行程状态之下、蓝牙卡片之上(对齐设计稿)。
                   到达终点/已支付状态下 TripActionBar 内部按钮集合为空、自动返回 null,不占位。 */
                [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus) &&
                <TripActionBar
                  tripStatus={tripStatus}
                  handleCancelTrip={handleCancelTrip}
                  showCancelTrip={showCancelTrip}
                  showShareTrip={showShareTrip}
                  handleModifyDestination={handleModifyDestination}
                  handleModifyDestinationDisabledClick={handleModifyDestinationDisabledClick}
                  showModifyDestination={showModifyDestination}
                  handleShareTrip={handleShareTrip}
                />
            }
            {
                [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus) && bluetoothStatusReady &&
                [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus) &&
                <BluetoothOperateCard
                  ready={bluetoothStatusReady}
                  description={bluetoothCardDescription}
                  actionText={bluetoothCardActionText}
                  onClick={handleBluetoothCardClick || (() => { })}
@@ -338,6 +423,22 @@
                </View>
            }
            {
                /* 行程操作栏:位于车控卡片之后、门箱控制之前(对齐设计稿)。
                   到达终点/已支付状态下 TripActionBar 内部按钮集合为空、自动返回 null,不占位。 */
                [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus) &&
                <TripActionBar
                  tripStatus={tripStatus}
                  handleCancelTrip={handleCancelTrip}
                  showCancelTrip={showCancelTrip}
                  showShareTrip={showShareTrip}
                  handleModifyDestination={handleModifyDestination}
                  handleModifyDestinationDisabledClick={handleModifyDestinationDisabledClick}
                  showModifyDestination={showModifyDestination}
                  handleShareTrip={handleShareTrip}
                />
            }
            {/* 车控弹窗(空调 / 音乐 / 香氛):必须挂在横向滑动 ScrollView 之外。
                iOS(WKWebView)下 scroll-view 的滚动容器会成为 fixed 子元素的包含块,
                弹窗若留在 ScrollView 内会被限制、裁切在卡片滑动区里(Android 无此问题)。
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less
@@ -10,6 +10,19 @@
  }
}
// 结束态背景图(calling-car-bg)「缓缓升起」进场:
// height 恒为 844px(不改变高度、不拉伸图片),仅用 translateY 从「相当于 282px 露出」的位置升到最终位置(translateY 0)。
// 起始位移 = 844 - 282 = 562px,随组件挂载触发,结束停在 translateY(0) 的正确位置。
@keyframes finished-bg-rise {
  from {
    transform: translateY(562px);
  }
  to {
    transform: translateY(0);
  }
}
.calling-car {
  position: relative;
  padding: 148px 0px 0px 0px;
@@ -31,11 +44,16 @@
    left: 0;
    width: 100%;
    height: 282px;
    // 转场期间的常态背景图预载副本:仅提前触发加载、全程不可见。
    // 必须用类隐藏——内联 style 的 opacity:0 会被 Taro 当 falsy 值丢弃,导致副本可见盖住封面图。
    &.bg-preload { opacity: 0; }
    &.carlling-bg {
      height: 276px;
    }
    &.finished-bg {
      height: 844px;
      // 背景卡升起动画仅在真实 live 跃迁进入结束态时播放(见 .calling-car--finished-enter);
      // 恢复态(从首页进入即已是结束态)不加该类,此处默认静态停在最终位(translateY(0)),不再弹起。
    }
  }
  .calling-car-vehicle-pic {
@@ -46,10 +64,159 @@
    height: 294px;
    z-index: 1;
  }
  // 派单成功转场:视频层(覆盖背景卡位置)
  .calling-car-video {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: auto;
    aspect-ratio: 1128 / 1268;
    // 修复:原生 video 方角会露出黑色,给视频自身加与卡片一致的顶部圆角并裁剪
    border-radius: 60px 60px 0 0;
    overflow: hidden;
    z-index: 0;
    // 注:不做淡入淡出——模拟器上原生 video 不受 CSS opacity 控制,挂了也无效;挂载即显示 poster、自然过渡到播放。
  }
  // 派单成功转场:poster 覆盖层(起始)+ 结尾遮挡层(结束)。均盖在视频之上用于消除黑帧:
  // - poster:视频真正播放前盖住,消除「加载完成→首帧绘制」间的起始黑帧;
  // - end-cover:settle 阶段盖住,先于视频上屏、视频卸载后仍在,挡住原生 <video> 卸载
  //   (原生合成层销毁 → webview 重绘间隙)产生的结尾黑帧。
  // 尺寸/位置/圆角与 .calling-car-video 完全一致;z-index 与视频同为 0、低于车模(z-index:1),
  // 保证 settle 期间车模归位动画照常可见。
  .calling-car-poster,
  .calling-car-end-cover {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: auto;
    aspect-ratio: 1128 / 1268;
    border-radius: 60px 60px 0 0;
    overflow: hidden;
    z-index: 0;
  }
  // 阶段一/二:视频展示 + 双卡反向位移。视频原地展示、不淡入(模拟器上原生 video 不受 opacity 控制)。
  &.dispatch-video {
    // 功能面板缓动下移,直到蓝牙/空调/香氛卡片移出可视区
    .calling-car-content-box { transform: translateY(360px); transition: transform 0.8s ease; }
    // 车模在视频区域内放大展示并居中。全程仅 transform 参与过渡,不切换 left/right 避免瞬跳。
    .calling-car-vehicle-pic {
      transform: translateY(70%) translateX(-88px) scale(1.5);
      opacity: 1;
      transition: transform 0.8s ease, opacity 0.5s ease;
      z-index: 0;
    }
  }
  // 阶段三:视频结束 → 功能卡回升归位、车模缩小回落到最终停靠位、视频切回图片背景
  // --dispatch-settle-duration 与 use-dispatch-transition 中 BG_SHRINK_LEAD_SEC 保持一致
  &.dispatch-settle {
    --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 var(--dispatch-settle-duration) cubic-bezier(0.22, 1, 0.36, 1), opacity var(--dispatch-settle-duration) ease;
    }
  }
}
// 派单成功整卡入场:整卡从「呼叫中」卡片位置缓缓升起到位。
// 用 keyframes 挂载自动播放(weapp 实测可靠);弃用 JS 两帧 + transition:起始态类依赖首帧提交才可能触发过渡,
// 且会被父层重渲染中途卸掉导致整卡直接切换、无升起。
.calling-car--enter {
  // 缓动必须慢起步:入场升起与面板下移是同时进行的父子叠加运动,
  // 慢起步让面板下移速度全程大于升起速度,面板绝对位置单调下降、无方向反转。
  animation: calling-car-rise 0.8s cubic-bezier(0.45, 0, 0.2, 1);
  will-change: transform;
}
// 起升距离 = 呼叫中与派单成功两态卡片顶部高度差。不能用 translateY(100%)(卡高 1500+px 会从屏外开始升)。
@keyframes calling-car-rise {
  from {
    transform: translateY(170px);
  }
  to {
    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;
  }
  // 透明度单独提前收口:0.4 → 1 用 0.2s 走完(0.7s 总时长的 28.57% 处),
  // 之后 transform 继续独立完成剩余位移,不再受 opacity 影响。
  28.57% {
    opacity: 1;
  }
  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;
}
// 「行程结束」入场:仅在真实 live 跃迁进入结束态时(父层 playFinishedEnter 上升沿)加此类,
// 承载背景卡与里程「一起升起」动画;恢复态不加此类,直接显示最终态、不弹起。
//
// 入场时序:礼花先播放(见 trip-finished-fireworks),延迟 @finished-enter-delay 后卡片与里程
// 才开始升起——让礼花播到中段时卡片才登场,避免二者同时抢镜(延迟解耦,不与礼花播放硬关联)。
@finished-enter-delay: 700ms;
.calling-car--finished-enter {
  .calling-car-bg.finished-bg {
    // 背景卡(背景图)缓缓升起:延迟至礼花中段后触发,时长 1s
    animation: finished-bg-rise 1s cubic-bezier(0.22, 0.61, 0.36, 1) @finished-enter-delay both;
    will-change: transform;
  }
  // 里程简化:与背景卡「同延迟、同时长、同曲线」纯位移升起(复用 finished-bg-rise),
  // 与背景卡锁步移动,视觉上如同印在卡面上一起升起;不再做淡入淡出。
  // 整块头部(到达目的地 + 里程)一起动,避免只动里程导致标题脱节。
  .trip-finished-header {
    animation: finished-bg-rise 1s cubic-bezier(0.22, 0.61, 0.36, 1) @finished-enter-delay both;
    will-change: transform;
  }
}
// 派单成功转场期间:蓝牙卡 / 车控卡 / 底部操作栏隐藏但保留占位(opacity),逻辑另在各组件延迟执行;
// 动效完成后移除 .dispatch-defer-content,随 opacity 过渡淡入。用 opacity 而非 visibility,保证淡入过渡生效。
.calling-car {
  .bluetooth-operate-card,
  .temp-fragrance-control,
  .trip-action-bar {
    transition: opacity 0.3s ease;
  }
  &.dispatch-defer-content {
    .bluetooth-operate-card,
    .temp-fragrance-control,
    .trip-action-bar {
      opacity: 0;
      pointer-events: none;
    }
  }
}
// 到达终点/待支付态(图一、图二):背景透明化 + 到达目的地头部 + 白色费用卡
.calling-car--finished {
  padding-top: 0;
  // 结束态整卡背景透明:避免背景图升起时顶部露出 .calling-car 的 @gary-theme 底色
  background: transparent;
  .calling-car-content-box {
    background: transparent;
    padding: 0;
@@ -74,6 +241,8 @@
    font-family: @font-xpsans-bold;
    letter-spacing: normal;
    color: #FFFFFF;
    // 里程渐显动画仅在真实 live 跃迁进入结束态时播放(见 .calling-car--finished-enter);
    // 恢复态默认静态显示最终态(opacity:1、translateY(0))。
  }
  // 白色费用卡(横向内边距由卡片自身承担,因 content-box 已透明化)
  // 提升特异性:与 .calling-car .fare-box-card { padding-bottom: 0 } 同特异性且在其之前,
@@ -834,9 +1003,17 @@
  padding: 24px 0px;
  border-radius: 16px;
  background: @gary-theme;
  // 就绪后内容淡入;未就绪时占位保留高度、内容淡出,避免蓝牙状态异步就绪撑高卡片产生跳变
  transition: opacity 0.3s ease;
  &--pending {
    opacity: 0;
    pointer-events: none;
  }
  .bluetooth-operate-card-left {
    display: flex;
    align-items: center;
    // 与右侧箭头容器 line-height:40px 对齐,保证「未就绪(无箭头)」与「就绪(有箭头)」两态行高一致、零跳变
    min-height: 40px;
    gap: 4px;
    font-size: 24px;
    font-weight: normal;
src/robotaxi/pages/begin-route/components/debug-order-simulator-panel.tsx
@@ -538,4 +538,4 @@
      </View>
    </View>
  );
}
}
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
New file
@@ -0,0 +1,141 @@
import { useState, useRef, useEffect } from 'react';
import { Image, Video } from "@tarojs/components";
import { safeParse } from '../../../../utils/Robotaxi-public-method';
import { DispatchPhase, DISPATCH_VIDEO_SRC, DISPATCH_VIDEO_POSTER, DISPATCH_VIDEO_END_POSTER } from '../hooks/use-dispatch-transition';
import { CARD_BG_IMG, DEFAULT_VEHICLE_IMG, USE_DYNAMIC_VEHICLE_IMG } from '../../../constants/assets';
import { getPreloadedVideoSrc } from '../../../utils/video-preload';
type Props = {
  shouldPlayEnter: boolean;
  transitionDone: boolean;
  dispatchPhase: DispatchPhase;
  vehicleInfo?: {
    vehiclePictures?: string;
    vehicleCode?: string;
    vehicleColor?: string;
    plateNumber?: string;
  };
  onTimeUpdate: (e: any) => void;
  onEnded: () => void;
  onError: () => void;
};
/**
 * 派单成功转场视觉层:视频 + poster 覆盖层 + 背景预载 + 车模兜底。
 * 转场期间 getCardBgImg 不返回背景图,此处通过 bg-preload 不可见预载常态背景图,
 * 保证 settle 切回背景图时无加载间隙。
 *
 * ## 黑帧消除(核心)
 * 微信原生 <video> 的 poster 属性无法可靠衔接「加载完成 → 首帧绘制」之间的空档:
 * poster 撤下后、首帧绘制前会露出约一帧黑屏。且视频 mp4 无法通过 wx.preloadAssets 预热。
 * 双重方案:
 * 1)视频预下载:进入下单流程即用 wx.downloadFile 把 mp4 下到本地临时文件(见 utils/video-preload),
 *    此处经 getPreloadedVideoSrc 优先用本地路径作 src——本地文件无网络缓冲,首帧秒出,从根源缩短黑帧空档。
 * 2)poster 覆盖层兜底:在视频之上叠一张真实 <Image>(同一 poster,已在 preload-assets 中预载,命中缓存秒开),
 *    视频「真正开始播放」(首个 currentTime>0 的 timeupdate)前始终盖住视频区域,首帧到达后再撤下。
 * 两者叠加,整个加载→首帧空档全程无黑屏。
 * 注:本页 video 已启用同层渲染(车模 Image 能盖在 video 上即为佐证),故普通 Image 可覆盖 video。
 */
export default function DispatchTransitionLayer({
  shouldPlayEnter,
  transitionDone,
  dispatchPhase,
  vehicleInfo,
  onTimeUpdate,
  onEnded,
  onError,
}: Props) {
  // 视频是否已真正开始播放(首帧已绘制)。false 时用 poster 覆盖层遮挡视频,杜绝黑帧。
  const [videoStarted, setVideoStarted] = useState(false);
  // 视频是否已卸载。settle 阶段结尾遮挡层挂上后延一帧置真,先撤视频——保证遮挡先上屏、视频后拆,杜绝卸载黑帧。
  const [videoRemoved, setVideoRemoved] = useState(false);
  // 每轮转场开始(shouldPlayEnter 上升沿)在渲染期同步复位,使二次派单重新用 poster 遮挡、不透出黑帧。
  // 采用渲染期 latch 而非 useEffect:effect 在绘制后才执行,会漏掉新一轮首帧导致闪黑。
  const prevShouldPlayRef = useRef(false);
  if (shouldPlayEnter && !prevShouldPlayRef.current) {
    prevShouldPlayRef.current = true;
    if (videoStarted) setVideoStarted(false);
    if (videoRemoved) setVideoRemoved(false);
  } else if (!shouldPlayEnter && prevShouldPlayRef.current) {
    prevShouldPlayRef.current = false;
  }
  // 进入 settle(视频卸载前的落定窗口):本次渲染已挂上结尾遮挡层(盖在视频之上),
  // 延一帧再卸原生视频——遮挡先上屏、视频后拆,原生层销毁的黑帧被遮挡层挡住,杜绝结尾黑帧。
  useEffect(() => {
    if (dispatchPhase === 'settle' && !videoRemoved) {
      const t = setTimeout(() => setVideoRemoved(true), 50);
      return () => clearTimeout(t);
    }
  }, [dispatchPhase, videoRemoved]);
  if (!shouldPlayEnter || transitionDone) return null;
  // 当前只有一种车型,产品要求统一用默认车图(USE_DYNAMIC_VEHICLE_IMG=false);
  // 后续有新车型时把该开关改回 true 即恢复接口动态车图。详见 constants/assets.ts。
  const vehiclePicSrc = USE_DYNAMIC_VEHICLE_IMG
    ? (safeParse(vehicleInfo?.vehiclePictures, {})?.url || DEFAULT_VEHICLE_IMG)
    : DEFAULT_VEHICLE_IMG;
  // 优先用预下载好的本地视频路径(downloadFile 落地,无网络缓冲);未就绪回退 CDN URL。
  const videoSrc = getPreloadedVideoSrc(DISPATCH_VIDEO_SRC);
  // 视频真正开始播放(首个 currentTime>0 的 timeupdate):撤下 poster 覆盖层,交由视频接管。
  const handleTimeUpdate = (e: any) => {
    if (!videoStarted && (e?.detail?.currentTime ?? 0) > 0) setVideoStarted(true);
    onTimeUpdate(e);
  };
  return (
    <>
      {/* 预载常态背景图:转场期间不可见(bg-preload 类),保证 settle 切回背景图时无加载间隙。
           勿用内联 opacity:0——Taro 序列化会丢弃数字 0 导致其可见。 */}
      <Image
        className='calling-car-bg bg-preload'
        src={CARD_BG_IMG}
      />
      {!videoRemoved && (
        <Video
          className='calling-car-video'
          src={videoSrc}
          poster={DISPATCH_VIDEO_POSTER}
          autoplay
          muted
          loop={false}
          controls={false}
          showCenterPlayBtn={false}
          showPlayBtn={false}
          showFullscreenBtn={false}
          showProgress={false}
          objectFit='cover'
          onTimeUpdate={handleTimeUpdate}
          onEnded={onEnded}
          onError={onError}
        />
      )}
      {/* Poster 覆盖层:视频真正开始播放前,用预载封面图盖住视频区域,消除「加载完成→首帧绘制」间的黑帧。
           首帧到达(首个 currentTime>0 的 timeupdate)后撤下,交由视频接管。 */}
      {!videoStarted && (
        <Image
          className='calling-car-poster'
          src={DISPATCH_VIDEO_POSTER}
        />
      )}
      {/* 结尾遮挡层:settle 阶段用预载尾帧图盖在视频之上(z-index 与 poster 同为 0),先于视频上屏、
           视频卸载后仍在,挡住原生 <video> 卸载黑帧;transitionDone 时随本层卸载,露出已预载的常态背景图。 */}
      {dispatchPhase === 'settle' && (
        <Image
          className='calling-car-end-cover'
          src={DISPATCH_VIDEO_END_POSTER}
        />
      )}
      {/* 转场期间车模兜底渲染,承接视频卸载后与静态背景图的衔接 */}
      {dispatchPhase !== 'idle' && (
        <Image
          className='calling-car-vehicle-pic'
          src={vehiclePicSrc}
        />
      )}
    </>
  );
}
src/robotaxi/pages/begin-route/components/trip-finished-fireworks/index.tsx
New file
@@ -0,0 +1,60 @@
import { useEffect, useRef } from 'react';
import Fireworks, { FireworksRef } from '../../../../components/fireworks';
import { TripStatus } from '../../../../constants/trip-status';
type Props = {
  /** 当前行程状态,由父页面传入 */
  tripStatus: TripStatus;
};
/**
 * 到达结束态全屏礼花组件(begin-route 页面专有)。
 *
 * ## 职责
 * - 监听 tripStatus 变化,检测「送驾中(6) → 行程结束(8)」上升沿
 * - 进入结束态后尽快发射一簇礼花(礼花先行播放),卡片/里程延迟约 700ms 才升起
 * - 仅发射一次,prevStatus 去重防重复播放
 * - tripStatus 非 TripFinished 时卸载 canvas(条件渲染)
 */
export default function TripFinishedFireworks({ tripStatus }: Props) {
  const fireworksRef = useRef<FireworksRef | null>(null);
  const prevStatusRef = useRef<TripStatus>(tripStatus);
  // 送驾(6) → 结束(8) 转场:进入到达结束态时播放一次全屏礼花(顶层、pointer-events:none 不阻挡操作)
  useEffect(() => {
    const prevStatus = prevStatusRef.current;
    prevStatusRef.current = tripStatus;
    if (tripStatus !== TripStatus.TripFinished || prevStatus === TripStatus.TripFinished) {
      return;
    }
    // 礼花先行:进入结束态后尽快发射(仅留极短延迟等 canvas 挂载),卡片随后延迟升起
    const timer = setTimeout(() => {
      // 仅发射一簇(single burst),避免连发造成「播放多次」的观感
      fireworksRef.current?.shoot({
        origin: { x: 0.5, y: 0.4 },
        angle: 90,
        particleCount: 180,
        spread: 360,
        startVelocity: 42,
        gravity: 1.1,
        scalar: 0.9,
        // ticks 控制粒子存活帧数(≈ticks/60 秒)。卡片动效约 1.7s 收尾,
        // 礼花缩短到约 1.5s(ticks 90)先行结束,避免动效结束后礼花还在飘。
        ticks: 90,
        colors: ['#FFD666', '#F9A76A', '#FFF1B8', '#7FE6F2', '#95CF29'],
      });
    }, 60);
    return () => clearTimeout(timer);
  }, [tripStatus]);
  if (tripStatus !== TripStatus.TripFinished) {
    return null;
  }
  return (
    <Fireworks
      ref={(node) => { fireworksRef.current = node; }}
      zIndex={99999}
    />
  );
}
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-credit-pay-return.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react';
import Taro from '@tarojs/taro';
import { showToast } from '../../../components/toast';
import { showToast, hideToast } from '../../../components/toast';
import { queryLedgerCreditOrderResult } from '../../../api/begin-route';
type Deps = {
@@ -61,7 +61,7 @@
    pollingOrderIdRef.current = '';
    clearPollingTimer();
    if (hideLoading) {
      Taro.hideLoading();
      hideToast();
    }
  }, [clearPollingTimer]);
@@ -143,7 +143,7 @@
        return;
      }
      Taro.showLoading({ title: '授权结果查询中', mask: true });
      showToast({ title: '授权结果查询中', icon: 'loading', mask: true });
      const maxPollingCount = 120;
      let currentPollingCount = 0;
@@ -169,7 +169,7 @@
          if (status === 'DOING' || result?.code === 20710) {
            stopPollingSession(true);
            Taro.showToast({ title: '授权成功', icon: 'none' });
            showToast({ title: '授权成功', icon: 'none' });
            void deps.onAuthorizedReturn(queryId);
            return;
          }
@@ -205,7 +205,7 @@
          if (errorCode === '20710') {
            console.log(`[信用分授权查询遇到20710] 第${currentPollingCount + 1}次,当作授权成功处理`);
            stopPollingSession(true);
            Taro.showToast({ title: '授权成功', icon: 'none' });
            showToast({ title: '授权成功', icon: 'none' });
            void deps.onAuthorizedReturn(queryId);
            return;
          }
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
New file
@@ -0,0 +1,147 @@
import { useState, useEffect, useRef } from 'react';
import { VEHICLE_APPROACHING_VIDEO, VEHICLE_APPROACHING_POSTER, VEHICLE_APPROACHING_END_POSTER } from '../../../constants/assets';
/** 派单成功转场视频(源自 constants/assets.ts 单一数据源) */
export const DISPATCH_VIDEO_SRC = VEHICLE_APPROACHING_VIDEO;
export const DISPATCH_VIDEO_POSTER = VEHICLE_APPROACHING_POSTER;
/** 转场视频尾帧:settle 阶段作结尾遮挡层,挡住原生 video 卸载黑帧 */
export const DISPATCH_VIDEO_END_POSTER = VEHICLE_APPROACHING_END_POSTER;
/** 兜底超时(ms):防卡死安全阀——视频加载/播放失败时 onTimeUpdate/onEnded 都不触发,超过此时长强制归位 */
export const DISPATCH_FALLBACK_MS = 10000;
export type DispatchPhase = 'idle' | 'video' | 'settle';
/**
 * 派单成功转场状态机:管理视频播放阶段、定时器、事件处理。
 * 挂载时先保持 idle(面板处于最终位),下一帧再切 video 才能让面板下移产生缓动过渡。
 * 父层已捕捉边沿并通过 playDispatchTransition 传入(本组件被 key 重挂载,无法自判边沿)。
 */
export function useDispatchTransition(playDispatchTransition: boolean) {
  const [dispatchPhase, setDispatchPhase] = useState<DispatchPhase>('idle');
  const [transitionDone, setTransitionDone] = useState(false);
  // shouldPlayEnter 控制转场期间的内容延迟、背景图门槛与整卡升起 keyframe。
  // 过去依赖 CallingCar 的 key={tripStatus} 重挂载做「挂载快照」,去 key 后组件不再重挂载,
  // 改为在渲染期同步捕捉 playDispatchTransition 的上升沿(false→true)置真——
  // 同步置真而非放到 effect,是为避免整卡升起前先渲染一帧静止态导致的位移闪跳;
  // 又不能直接绑定 playDispatchTransition(其在父层下一次渲染即翻 false,会把 keyframe 类中途卸掉扼杀动画),
  // 故用独立状态 latch,转场完成后回落 false,使同一实例内下一单再次进入派单成功时能重新触发。
  const [shouldPlayEnter, setShouldPlayEnter] = useState(false);
  const prevPlayRef = useRef(false);
  // 渲染期同步捕捉上升沿并置类(React 官方「prop 变化时调整 state」模式,重渲染早于绘制,无中间帧)
  if (playDispatchTransition && !prevPlayRef.current) {
    prevPlayRef.current = true;
    if (!shouldPlayEnter) setShouldPlayEnter(true);
  } else if (!playDispatchTransition && prevPlayRef.current) {
    prevPlayRef.current = false;
  }
  const enterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  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 enterSettle = () => {
    if (settleTriggeredRef.current) return;
    settleTriggeredRef.current = true;
    if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
    setDispatchPhase('settle');
    if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
    settleTimerRef.current = setTimeout(() => {
      setDispatchPhase('idle');
      setTransitionDone(true);
      // 回落转场门槛:--enter keyframe 已在前 0.8s 播完(此时约 3.9s),清除类名不影响观感,
      // 且为同一实例内下一次上升沿重新触发 keyframe 留出空间(false→true 才会重播)。
      setShouldPlayEnter(false);
    }, 800);
  };
  /**
   * 自适应缩小定时器。每次 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;
    lastTimeSyncRef.current = { currentTime, wallClock: Date.now() };
    videoDurationRef.current = duration;
    scheduleShrinkCheck(currentTime);
  };
  // shouldPlayEnter 置真(上升沿)后启动转场状态机;复位所有一次性标记,保证同一实例内可重复播放。
  useEffect(() => {
    if (!shouldPlayEnter) 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);
    settleTriggeredRef.current = false;
    shrinkTriggeredRef.current = false;
    lastTimeSyncRef.current = { currentTime: 0, wallClock: 0 };
    videoDurationRef.current = 0;
    setTransitionDone(false);
    setDispatchPhase('idle');
    enterTimerRef.current = setTimeout(() => {
      // 视频已在 32ms 内报错/结束(极端)时勿覆盖 settle 阶段
      if (settleTriggeredRef.current) return;
      setDispatchPhase('video');
    }, 32);
    fallbackTimerRef.current = setTimeout(() => enterSettle(), DISPATCH_FALLBACK_MS);
  }, [shouldPlayEnter]);
  // 卸载时清理所有定时器
  useEffect(() => {
    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);
    };
  }, []);
  return {
    dispatchPhase,
    transitionDone,
    shouldPlayEnter,
    enterSettle,
    handleDispatchVideoTimeUpdate,
  };
}
src/robotaxi/pages/begin-route/hooks/use-modify-destination-flow.ts
@@ -1,5 +1,5 @@
import Taro from '@tarojs/taro';
import { showToast } from '../../../components/toast';
import { showToast, hideToast } from '../../../components/toast';
import { useEffect, useMemo, useState } from 'react';
import { orderChange } from '../../../api/begin-route';
import { TripStatus } from '../../../constants/trip-status';
@@ -385,7 +385,7 @@
    try {
      setIsConfirmingModifyDestination(true);
      setFlowState(FLOW_STATES.CONFIRMING);
      Taro.showLoading({ title: '正在修改目的地', mask: true }); // 修改目的地请求进行中全局 loading
      showToast({ title: '正在修改目的地', icon: 'loading', mask: true }); // 修改目的地请求进行中全局 loading
      // 如果后端需要字符串格式可以显式转String,如果后端支持number直接传值,保留原始格式防止隐式转换截断
      // 如果原本因为 Number() 导致大整数丢失精度,去掉 Number() 即可
@@ -416,7 +416,7 @@
      exitModifyDestinationReview({ shouldRestorePreviousEndSite: false });
      Taro.hideLoading();
      hideToast();
      showToast({
        title: '行程已更新',
        icon: 'none'
@@ -426,7 +426,7 @@
        deps.onModifySuccess(oldRoutePoints);
      }
    } catch (e: any) {
      Taro.hideLoading();
      hideToast();
      throw e;
    } finally {
      setIsConfirmingModifyDestination(false);
src/robotaxi/pages/begin-route/hooks/use-order-payment-result-query.ts
@@ -1,6 +1,5 @@
import { useCallback } from 'react';
import Taro from '@tarojs/taro';
import { showToast } from '../../../components/toast';
import { showToast, hideToast } from '../../../components/toast';
import { queryOrderPaymentResultFromOrderId } from '../../../api/begin-route';
import { RobotaxiOrderStatus } from '../../../api/types/begin-route';
@@ -24,7 +23,7 @@
      return;
    }
    Taro.showLoading({ title: '支付结果查询中' });
    showToast({ title: '支付结果查询中', icon: 'loading' });
    try {
      const result = await queryOrderPaymentResultFromOrderId(deps.orderId);
      const nextStatus = Number(result?.status);
@@ -43,7 +42,7 @@
    } catch (error) {
      showToast({ title: '支付结果查询失败,请稍后重试', icon: 'none' });
    } finally {
      Taro.hideLoading();
      hideToast();
    }
  }, [deps.onPaySuccess, deps.orderId]);
src/robotaxi/pages/begin-route/hooks/use-order-payment.ts
@@ -1,5 +1,5 @@
import Taro from '@tarojs/taro';
import { showToast } from '../../../components/toast';
import { showToast, hideToast } from '../../../components/toast';
import { useRef } from 'react';
import { reportEvent } from '../../../../utils/Robotaxi-public-method';
import {
@@ -56,8 +56,9 @@
   * 根据订单详情状态决定结束行程后的页面态。
   */
  const resolvePostEndOrderTripStatus = async (targetOrderId: string) => {
    Taro.showLoading({
    showToast({
      title: '订单明细查询中',
      icon: 'loading',
      mask: true
    });
    try {
@@ -79,7 +80,7 @@
        nextTripStatus: TripStatus.TripFinished
      };
    } finally {
      Taro.hideLoading();
      hideToast();
    }
  };
@@ -199,8 +200,9 @@
      return;
    }
    paySubmittingRef.current = true;
    Taro.showLoading({
    showToast({
      title: '支付中',
      icon: 'loading',
      mask: false
    });
    try {
@@ -214,7 +216,7 @@
      if (paymentStatus === RobotaxiPaymentEntryStatus.Unsigned) {
        const cashierToken = paymentResult?.value?.token;
        if (!cashierToken) {
          Taro.showToast({ title: '支付凭证缺失,请稍后重试', icon: 'none' });
          showToast({ title: '支付凭证缺失,请稍后重试', icon: 'none' });
          return;
        }
        await deps.handleUnsignedPayment(cashierToken);
@@ -224,18 +226,18 @@
      if (paymentStatus === RobotaxiPaymentEntryStatus.Signed) {
        const signedPaymentValue = paymentResult?.value as SignedOrderPaymentValue;
        if (!signedPaymentValue?.packageValue) {
          Taro.showToast({ title: '支付参数缺失,请稍后重试', icon: 'none' });
          showToast({ title: '支付参数缺失,请稍后重试', icon: 'none' });
          return;
        }
        await deps.handleSignedCreditPayment(signedPaymentValue);
        return;
      }
      Taro.showToast({ title: '暂不支持当前支付方式', icon: 'none' });
      showToast({ title: '暂不支持当前支付方式', icon: 'none' });
    } catch (error) {
      showToast({ title: '支付失败,请重试', icon: 'none' });
    } finally {
      Taro.hideLoading();
      hideToast();
      paySubmittingRef.current = false;
    }
  };
src/robotaxi/pages/begin-route/hooks/use-order-submit.ts
@@ -1,5 +1,5 @@
import Taro from '@tarojs/taro';
import { showToast } from '../../../components/toast';
import { showToast, hideToast } from '../../../components/toast';
import { useCallback, useRef } from 'react';
import { formatDateTime, debounce, reportEvent } from '../../../../utils/Robotaxi-public-method';
import { createCreditPayOrder, saveOrder, routeStaleCheck, queryCreditPaySign } from '../../../api/begin-route';
@@ -141,16 +141,16 @@
   * 查询长期签约回跳后的最新支付分状态,并决定是否继续信用下单。
   */
  const resolveCreditPayModeAfterSignReturn = useCallback(async () => {
    Taro.showLoading({ title: '授权结果查询中', mask: true });
    showToast({ title: '授权结果查询中', icon: 'loading', mask: true });
    let shouldHideLoading = true;
    try {
      const signResult = await queryCreditPaySign();
      const latestStatus = signResult?.data?.status ?? signResult?.status;
      const resolvedCreditPaySubmitMode = resolveCreditPaySubmitMode(latestStatus);
      if (resolvedCreditPaySubmitMode) {
        Taro.hideLoading();
        hideToast();
        shouldHideLoading = false;
        Taro.showToast({ title: '授权成功', icon: 'none' });
        showToast({ title: '授权成功', icon: 'none' });
        return resolvedCreditPaySubmitMode;
      }
      return resolvedCreditPaySubmitMode;
@@ -158,7 +158,7 @@
      return null;
    } finally {
      if (shouldHideLoading) {
        Taro.hideLoading();
        hideToast();
      }
    }
  }, []);
@@ -499,7 +499,7 @@
      const nextCreditPaySubmitMode = await resolveCreditPayModeAfterSignReturn();
      if (!nextCreditPaySubmitMode) {
        stopCallSubmitting();
        Taro.showToast({ title: '支付分授权未完成,请重试', icon: 'none' });
        showToast({ title: '支付分授权未完成,请重试', icon: 'none' });
        return;
      }
@@ -511,7 +511,7 @@
    onLaunchFail: () => {
      waitingCreditPayReturnRef.current = false;
      stopCallSubmitting();
      Taro.showToast({ title: '拉起签约失败', icon: 'none' });
      showToast({ title: '拉起签约失败', icon: 'none' });
    }
  });
@@ -640,7 +640,7 @@
        || submitMode === 'credit_pay_skip_auth_submit';
      if (isCreditPaySubmit) {
        Taro.showLoading({ title: '加载中', mask: true });
        showToast({ title: '加载中', icon: 'loading', mask: true });
      }
      const submitResult = isCreditPaySubmit
@@ -648,7 +648,7 @@
        : await submitOrder(startSite, openCityId);
      if (!submitResult?.orderId) {
        Taro.hideLoading();
        hideToast();
        stopCallSubmitting();
        return;
      }
@@ -663,7 +663,7 @@
      if (isCreditPaySubmit) {
        Taro.setStorageSync('creditPayOrderId', submitResult.orderId);
        if (submitMode === 'credit_pay_skip_auth_submit') {
          Taro.hideLoading();
          hideToast();
          // status=2 代表当前已是免确认模式,信用下单成功后直接进入发单态。
          deps.setTripStatus(TripStatus.Publishing);
          return;
@@ -671,7 +671,7 @@
        const creditPayPackageValue = String(submitResult.creditPayPackageValue || '').trim();
        if (!creditPayPackageValue) {
          Taro.hideLoading();
          hideToast();
          showToast({ title: '支付参数缺失,请稍后重试', icon: 'none' });
          return;
        }
@@ -684,7 +684,7 @@
          packageValue: creditPayPackageValue
        }, submitResult.orderId);
        Taro.hideLoading();
        hideToast();
        // 只有拉起失败才立刻进入发单状态(或者依靠 abort 逻辑终止)
        if (!launched) {
@@ -694,10 +694,10 @@
      }
      deps.setTripStatus(TripStatus.Publishing);
    } catch (error) {
      Taro.hideLoading();
      hideToast();
      const errorMessage = String((error as any)?.msg || (error as any)?.message || '下单失败,请重试');
      if (submitMode === 'credit_pay_submit' || submitMode === 'credit_pay_skip_auth_submit') {
        Taro.showToast({ title: errorMessage, icon: 'none' });
        showToast({ title: errorMessage, icon: 'none' });
      }
      reportPayResultEvent(resolvePayResultStatus(errorMessage));
      stopCallSubmitting();
src/robotaxi/pages/begin-route/hooks/use-transition-asset-preload.ts
New file
@@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react';
import { TripStatus } from '../../../constants/trip-status';
import { preloadAssets } from '../../../../utils/preload-assets';
import { preloadVideo } from '../../../utils/video-preload';
import { VEHICLE_APPROACHING_VIDEO } from '../../../constants/assets';
import {
  DISPATCH_SUCCESS_ASSETS,
  TRIP_FINISHED_ASSETS,
} from '../../../constants/preload-assets';
/** Hook 入参。 */
type Params = {
  /** 当前行程状态。 */
  tripStatus: TripStatus;
};
/**
 * 转场动效素材预加载(按需触发)。
 *
 * ## 触发策略(必要时才预载,避免无谓消耗)
 * - 进入「发布行程(1) / 发布中(2) / 呼叫中(3)」→ 预载「派单成功」素材 + 转场视频(downloadFile 落地本地)
 *   (越早进入下单流程越早预热,第三次之后动效明显更顺畅正是因为素材已有缓存;
 *    提前到发布行程即开始,尽量把预热窗口拉满)。
 * - 进入「送驾中(6)」→ 预载「行程结束」素材(距行程结束有整段行程)。
 *
 * ## 视频预下载(黑帧消除关键)
 * wx.preloadAssets 不支持视频,转场 mp4 直接用 CDN URL 播放时首帧要经网络缓冲、露出黑帧。
 * 此处额外用 preloadVideo(wx.downloadFile)把 mp4 提前下到本地临时文件,转场组件优先用本地路径播放。
 *
 * ## 去重
 * 各阶段用 ref 标记「已发起预载」,状态反复切换不重复调用。
 * 预加载底层失败静默(见 preloadAssets),此处无需错误处理。
 */
export default function useTransitionAssetPreload(params: Params): void {
  const { tripStatus } = params;
  const orderingPreloadedRef = useRef(false); // 派单成功素材是否已发起预载
  const finishedPreloadedRef = useRef(false); // 行程结束素材是否已发起预载
  useEffect(() => {
    // 进入下单流程(发布行程/发布中/呼叫中):预载派单成功素材 + 转场视频
    if ([TripStatus.PublishRoute, TripStatus.Publishing, TripStatus.CallingCar].includes(tripStatus) && !orderingPreloadedRef.current) {
      orderingPreloadedRef.current = true;
      preloadAssets(DISPATCH_SUCCESS_ASSETS);
      preloadVideo(VEHICLE_APPROACHING_VIDEO); // downloadFile 提前落地本地,转场首帧无网络缓冲、消除黑帧
    }
    // 进入送驾中:预载行程结束素材
    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;
  /** 地图 markers */
  markers: any[];
  /** 是否显示等待雷达 */
@@ -311,6 +313,34 @@
    priceUpdateVersion: deps.priceUpdateVersion,
    hasPickupException: deps.hasPickupException
  });
  // 捕捉"首次进入派单成功(4)"边沿:CallingCar 用 key={tripStatus} 会随状态切换重挂载,
  // 组件内部 ref 会被重置而无法自判边沿,故在不会重挂载的父 hook 中记录上一状态,
  // 通过 playDispatchTransition 告知 CallingCar 挂载时是否播放派单成功转场。
  // 触发条件:进入 VehicleApproaching(4) 且上一态不是 VehicleApproaching(覆盖正常单/快速派单/改派)。
  // 用「真实初始状态」初始化 prev(tripStatus 在页面挂载时已同步从缓存读入,见 index useState 初值),
  // 使「进入即已是某状态」时首帧为 B→B 无边沿、不播动效;仅页内真实 A→B 流转才触发。
  const prevTripStatusRef = useRef<TripStatus | undefined>(deps.tripStatus);
  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 也为该态、不会误播。
  const playEvaluateEnter =
    deps.tripStatus === TripStatus.PaymentSuccess &&
    prevTripStatusRef.current === TripStatus.InService;
  // 捕捉「进入行程结束(8)」边沿:控制结束态背景卡升起(finished-bg-rise)与里程渐显(finished-distance-fade)。
  // 进入即已是结束态时首帧为 8→8 无边沿、不播升起;仅真实 live 跃迁到 8 才播放。
  const playFinishedEnter =
    deps.tripStatus === TripStatus.TripFinished &&
    prevTripStatusRef.current !== TripStatus.TripFinished;
  useEffect(() => {
    prevTripStatusRef.current = deps.tripStatus;
  }, [deps.tripStatus]);
  /** 时长文案 */
  const durationDisplay = useMemo(
    () => formatRemainingDurationDisplay(deps.routeDuration),
@@ -1019,7 +1049,10 @@
    ) {
      return (
        <CallingCar
          key={deps.tripStatus}
          playDispatchTransition={playDispatchTransition}
          playEvaluateEnter={playEvaluateEnter}
          playFinishedEnter={playFinishedEnter}
          onDispatchTransitionSettled={deps.onDispatchTransitionSettled}
          orderId={deps.orderId}
          hasBeforeUserEndSite={deps.hasBeforeUserEndSite}
          hasPreviousOrder={deps.hasPreviousOrder}
src/robotaxi/pages/begin-route/index.tsx
@@ -10,6 +10,10 @@
import CreditPayPopup from './components/credit-pay-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';
// 弹窗状态管理
import usePopupManager from './hooks/use-popup-manager';
// WebSocket 事件编排
@@ -56,8 +60,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';
@@ -89,7 +96,7 @@
  resetConfirmRideSelectionContext
} from '../../constants/confirm-ride-report';
import { withRobotaxiWhitelist } from '../../../utils/whitelist';
import { showToast } from '../../components/toast';
import { showToast, hideToast } from '../../components/toast';
import type { PickupExceptionInfo } from './pickup-exception';
const MODIFY_DESTINATION_PICKUP_STATUS_GUARD_MS = 5000;
@@ -428,6 +435,9 @@
      waitingRadarUrl
    }
  }); // 地图气泡资源预加载
  // 转场动效素材预加载(按行程状态空闲预载:呼叫中→派单成功素材、送驾中→行程结束素材)
  useTransitionAssetPreload({ tripStatus });
  const {
    openService,
    toCoupon,
@@ -882,8 +892,9 @@
    try {
      setIsConfirmingPickupRouteChange(true);
      Taro.showLoading({
      showToast({
        title: '正在修改路线',
        icon: 'loading',
        mask: true
      });
      await selectNavigationRoute({
@@ -894,7 +905,7 @@
      setIsPickupRouteChangeVisible(false);
      pickupRouteChangeSnapshotRef.current = null;
      routeRequestOrderRoute(orderId, tripStatus);
      Taro.hideLoading();
      hideToast();
      setTimeout(() => {
        showToast({
          title: '切换成功,将按新路线行驶',
@@ -902,7 +913,7 @@
        });
      }, 0);
    } catch (error) {
      Taro.hideLoading();
      hideToast();
    } finally {
      setIsConfirmingPickupRouteChange(false);
    }
@@ -1619,7 +1630,7 @@
    }
    creditPayFallbackActiveOrderIdRef.current = '';
    if (hideLoading) {
      Taro.hideLoading();
      hideToast();
    }
  }, []);
@@ -1638,7 +1649,7 @@
    clearCreditPayFallbackRuntime(false);
    creditPayFallbackActiveOrderIdRef.current = resolvedOrderId;
    Taro.showLoading({ title: '扣款结果查询中,请勿重复支付', mask: true });
    showToast({ title: '扣款结果查询中,请勿重复支付', icon: 'loading', mask: true });
    const checkCreditPayFallback = async () => {
      try {
@@ -1647,7 +1658,7 @@
          Taro.removeStorageSync('creditPayOrderId');
          clearCreditPayFallbackRuntime();
          setCreditPayFallbackRefreshVersion((prev) => prev + 1);
          Taro.showToast({ title: '免密扣款失败,请手动支付', icon: 'none' });
          showToast({ title: '免密扣款失败,请手动支付', icon: 'none' });
        }
      } catch (error) {
      }
@@ -1672,8 +1683,13 @@
  useEffect(() => {
    if (tripStatus === TripStatus.TripFinished) {
      void handleCreditPayTripFinished(orderId);
      // 等行程结束动效(礼花 + 卡片/里程升起,约 700ms 延迟 + 1s 升起 ≈ 1.7s)播完再调起免密支付,
      // 避免「扣款结果查询中」Toast/遮罩在动效期间弹出打断观感。
      const payTimer = setTimeout(() => {
        void handleCreditPayTripFinished(orderId);
      }, 2100);
      return () => {
        clearTimeout(payTimer);
        clearCreditPayFallbackRuntime();
      };
    } else {
@@ -1709,7 +1725,7 @@
    try {
      await cancelOrder(targetOrderId);
      // Taro.showToast({
      // showToast({
      //   title: reason === 'launch_failed' ? '拉起信用分失败,已取消发单' : '未完成信用分授权,已取消发单',
      //   icon: 'none'
      // });
@@ -1726,7 +1742,7 @@
        reason,
        error
      });
      Taro.showToast({ title: '取消订单失败,请重试', icon: 'none' });
      showToast({ title: '取消订单失败,请重试', icon: 'none' });
    }
  }, [orderId, syncWaitingSignedCreditPayResult]);
@@ -1861,6 +1877,9 @@
    tripStatus
  });
  const shouldSkipBluetoothAuth = doorUnlockedInCurrentPage || bluetoothDoorUnlockSuccess;
  // 页面级派单转场延后闸门:进入接驾中(4)暂缓蓝牙初始化,避免转场动画中弹系统授权弹窗;
  // 动效完成由 CallingCar 的 onDispatchTransitionSettled 回调 markSettled 放行。
  const { dispatchTransitionActive, markSettled: handleDispatchTransitionSettled } = useDeferUntilDispatchSettled(tripStatus);
  const {
    bluetoothStatusReady,
    bluetoothCardDescription,
@@ -1880,7 +1899,8 @@
    bluetoothAuthSuccess,
    startBluetoothAuth,
    skipBluetoothAuth: shouldSkipBluetoothAuth,
    unlockTokenFailed
    unlockTokenFailed,
    dispatchTransitionActive
  });
  const { handleConfirmRequoteIfNeeded } = useModifyDestinationPreviewRoute({
    tripStatus,
@@ -2363,6 +2383,7 @@
  const { renderMapOverlays, renderCarCalloutOverlay, renderTripCard, fareBreakdown } = useViewDelegates({
    isAndroid,
    tripStatus,
    onDispatchTransitionSettled: handleDispatchTransitionSettled,
    markers: markers,
    isRouteSelectExpanded,
    isPickupRouteChangeVisible,
@@ -2648,6 +2669,14 @@
        onClose={handleCloseCreditPayPopup}
        onConfirm={handleConfirmCreditPayPopup}
      />
      <TripFinishedFireworks tripStatus={tripStatus} />
      {/* 转场动效调试面板 — 固定在地图上方,始终可见(临时开启,调试完成后请重新注释) */}
      {/* <DebugTransitionPanel
        orderId={orderId}
        setOrderId={setOrderId}
        setTripStatus={setTripStatus}
        bindSocketListeners={bindSocketListeners}
      /> */}
      {/* 模拟器入口已关闭,如需本地调试可取消下方注释
      <DebugOrderSimulatorPanel
        orderId={orderId}
src/robotaxi/pages/coupon/index.tsx
@@ -12,7 +12,7 @@
  CouponDetail
} from "../../../robotaxi/api/coupon";
import { withRobotaxiWhitelist } from "../../../utils/whitelist";
import { showToast } from "../../components/toast";
import { showToast, hideToast } from "../../components/toast";
import { withToast } from "../../components/toast/with-toast";
function CouponPage() {
@@ -126,7 +126,7 @@
  const openPopup = async (item: CouponItem) => {
    try {
      Taro.showLoading({ title: "加载中..." });
      showToast({ title: "加载中...", icon: 'loading' });
      const detail = await getCouponDetail(item.couponId);
      detail.couponDesc = decodeURIComponent(detail.couponDesc || "");
@@ -137,7 +137,7 @@
        );
      }
      setCurrentCoupon(detail);
      Taro.hideLoading();
      hideToast();
      const animation = Taro.createAnimation({
        duration: 300,
@@ -147,7 +147,7 @@
      setAnimationData(animation.export());
      setVisible(true);
    } catch (error) {
      Taro.hideLoading();
      hideToast();
      showToast({ title: "获取详情失败", icon: "none" });
    }
  };
src/robotaxi/pages/feedback/hooks/use-media-upload.ts
@@ -1,5 +1,6 @@
import Taro from '@tarojs/taro';
import { useCallback, useMemo, useState } from 'react';
import { showToast } from '../../../components/toast';
import { getStorage } from '../../../../utils/tools';
/**
@@ -162,7 +163,7 @@
        ...item,
        status: 'failed'
      }));
      Taro.showToast({
      showToast({
        title: error?.message || '上传失败,请重试',
        icon: 'none'
      });
@@ -186,7 +187,7 @@
  const chooseAndUpload = useCallback(async () => {
    const remainCount = Math.max(0, options.maxCount - items.length);
    if (remainCount <= 0) {
      Taro.showToast({
      showToast({
        title: `最多可上传${options.maxCount}个图片或视频`,
        icon: 'none'
      });
@@ -207,7 +208,7 @@
      for (const file of files) {
        const mediaType: MediaType = file.fileType === 'video' ? 'video' : 'image';
        if (mediaType === 'video' && Number(file.size || 0) > videoSizeLimitBytes) {
          Taro.showToast({
          showToast({
            title: '视频不能超过20MB',
            icon: 'none'
          });
src/robotaxi/pages/feedback/index.tsx
@@ -3,6 +3,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import CustomNavbar from '../../../components/custom-navbar';
import { withRobotaxiWhitelist } from '../../../utils/whitelist';
import { showToast, hideToast } from '../../components/toast';
import { withToast } from '../../components/toast/with-toast';
import { getFeedbackTypeList, submitComplaint } from '../../api/feedback';
import { _ROBOTAXI_HOST } from '../../../constants/api';
import useMediaUpload from './hooks/use-media-upload';
@@ -149,7 +151,7 @@
    }
    isSubmittingRef.current = true;
    Taro.showLoading({ title: '提交中...', mask: true });
    showToast({ title: '提交中...', icon: 'loading', mask: true });
    try {
      const networkTypeRes = await Taro.getNetworkType();
      if (networkTypeRes.networkType === 'none') {
@@ -191,7 +193,7 @@
      showFeedbackToast(errorTitle);
    } finally {
      isSubmittingRef.current = false;
      Taro.hideLoading();
      hideToast();
    }
  };
@@ -447,4 +449,4 @@
  );
}
export default withRobotaxiWhitelist(FeedbackPage);
export default withRobotaxiWhitelist(withToast(FeedbackPage));
src/robotaxi/pages/payment-setting-detail/index.tsx
@@ -41,7 +41,7 @@
    try {
      const activeOrder = await getOrderActive();
      if (activeOrder) {
        Taro.showToast({
        showToast({
          title: '有进行中的订单,暂无法关闭服务',
          icon: 'none'
        });
@@ -49,7 +49,7 @@
      }
      return true;
    } catch (error) {
      Taro.showToast({
      showToast({
        title: '查询订单状态失败,请稍后重试',
        icon: 'none'
      });
src/robotaxi/pages/personal-center/index.tsx
@@ -145,7 +145,7 @@
      return;
    }
    Taro.showToast({ title: "功能建设中", icon: "none" });
    showToast({ title: "功能建设中", icon: "none" });
  };
  return (
src/robotaxi/utils/video-preload.ts
New file
@@ -0,0 +1,69 @@
/**
 * 视频预下载缓存:用 wx.downloadFile 提前把转场 mp4 落地为本地临时文件,
 * 转场时改用本地路径作 <video src>,消除首帧网络缓冲导致的黑帧。
 *
 * ## 背景
 * wx.preloadAssets 仅支持 image / font,**不支持视频**。派单转场 mp4 直接用 CDN URL 作
 * <video src> 时,首帧需先经网络请求 + 缓冲,「加载完成 → 首帧绘制」间会露出约一帧黑屏。
 * 方案:进入下单流程(发布中/呼叫中)即 downloadFile 把 mp4 下到本地临时路径,转场时用本地
 * 路径播放——本地文件无网络缓冲,首帧秒出。
 *
 * ## 使用
 * - preloadVideo(url):幂等发起下载,成功后缓存 `CDN URL → 本地 tempFilePath`。
 * - getPreloadedVideoSrc(url):取本地路径;未就绪(下载中/失败/非 weapp)则回退原 CDN URL,不阻塞。
 *
 * 参考:https://developers.weixin.qq.com/miniprogram/dev/api/network/download/wx.downloadFile.html
 */
/** 判断当前是否为微信小程序环境。 */
const isWeapp = (): boolean => process.env.TARO_ENV === 'weapp';
/** CDN URL → 本地临时文件路径(下载成功后写入)。 */
const localPathCache: Record<string, string> = {};
/** 已发起下载的 URL(下载中/已完成均登记,避免重复请求;失败时移除以允许重试)。 */
const pendingSet = new Set<string>();
/**
 * 幂等预下载视频。已缓存或正在下载则直接返回;非 weapp / 低版本无 downloadFile 时静默跳过。
 * 下载失败不报错、不缓存,转场时自动回退 CDN URL。
 *
 * @param url 视频 CDN 地址
 */
export const preloadVideo = (url: string): void => {
  try {
    if (!isWeapp() || !url) return;
    if (localPathCache[url] || pendingSet.has(url)) return;
    // @ts-ignore 微信小程序全局 API,低版本走 typeof 兜底跳过
    if (typeof wx === 'undefined' || typeof wx.downloadFile !== 'function') return;
    pendingSet.add(url);
    // @ts-ignore
    wx.downloadFile({
      url,
      success: (res: any) => {
        if (res && res.statusCode === 200 && res.tempFilePath) {
          localPathCache[url] = res.tempFilePath;
        } else {
          // 非 200:不缓存,允许后续重试
          pendingSet.delete(url);
        }
      },
      fail: () => {
        // 下载失败:不缓存、允许后续重试,转场时回退 CDN URL(不影响功能)
        pendingSet.delete(url);
      },
    });
  } catch (e) {
    console.error('[preloadVideo] 视频预下载异常:', e);
    // 兜底:绝不抛出
  }
};
/**
 * 取预下载好的本地视频路径;未就绪则回退原 CDN URL。
 *
 * @param url 视频 CDN 地址
 * @returns 本地临时路径(已就绪)或原 CDN URL
 */
export const getPreloadedVideoSrc = (url: string): string => localPathCache[url] || url;
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;