Merge branch 'lucl/animation-0810' into 'feature_v2.4.0_ui_animation'
feat: 动效过程中,隐藏空调、香氛等按钮,不触发申请蓝牙权限弹窗
See merge request robotaxi1/xp-robotaxi-miniprogram!260
| New file |
| | |
| | | /** |
| | | * 素材链接单一数据源(Single Source of Truth)。 |
| | | * |
| | | * 所有跨组件复用、且被预加载逻辑(constants/preload-assets.ts)引用的素材 URL 统一在此声明, |
| | | * 组件与预加载清单都从这里 import,杜绝「组件改了链接、预加载还指向旧链接」导致的静默失效。 |
| | | * |
| | | * 依赖方向:组件 → assets.ts ← 预加载清单(assets.ts 处于最底层,不反向依赖任何页面模块)。 |
| | | */ |
| | | |
| | | /** 派单成功转场主视频(mp4)。 */ |
| | | export const VEHICLE_APPROACHING_VIDEO = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_v1_file_w_1128@h_1268.mp4'; |
| | | |
| | | /** 派单成功转场视频封面(jpg)。 */ |
| | | export const VEHICLE_APPROACHING_POSTER = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_poster_v1_file_w_1128@h_1268.jpg'; |
| | | |
| | | /** 常态行程卡片背景图(派单成功落定 / 接驾 / 行程中)。 */ |
| | | export const CARD_BG_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/card-bg-20260714.png'; |
| | | |
| | | /** 默认车辆图(无实拍车图时的兜底)。 */ |
| | | export const DEFAULT_VEHICLE_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/default-vehicle-img-20260724-v2.png'; |
| | | |
| | | /** 到达终点 / 行程结束卡片背景图。 */ |
| | | export const TRIP_FINISHED_BG_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/trip-finished-20260703.png'; |
| | | |
| | | /** |
| | | * 全局字体链接。 |
| | | * ⚠️ 必须与 robotaxi/style/ui-variables.less 中的 @font-face src 保持一致; |
| | | * less 无法 import JS 常量,两处为约定同步(改一处需同步另一处)。 |
| | | */ |
| | | export const FONT_XPSANS_BOLD = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/font/XPSANS-Bold.otf'; |
| | | export const FONT_XPSANS_REGULAR = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/font/XPSANS-Regular.otf'; |
| New file |
| | |
| | | import { PreloadAsset } from '../../utils/preload-assets'; |
| | | import { |
| | | VEHICLE_APPROACHING_POSTER, |
| | | CARD_BG_IMG, |
| | | DEFAULT_VEHICLE_IMG, |
| | | TRIP_FINISHED_BG_IMG, |
| | | FONT_XPSANS_BOLD, |
| | | FONT_XPSANS_REGULAR, |
| | | } from './assets'; |
| | | |
| | | /** |
| | | * 转场动效预加载素材清单,按行程状态分组。 |
| | | * |
| | | * 所有 URL 均来自 constants/assets.ts 单一数据源,组件与本清单引用同一常量, |
| | | * 组件改链接时预加载自动跟随,不会失效。 |
| | | * |
| | | * ⚠️ wx.preloadAssets 仅支持 image / font,**不支持视频**: |
| | | * 派单转场视频(mp4)无法预热,此处仅预加载其封面图(poster)。 |
| | | */ |
| | | |
| | | /** 派单成功转场素材(呼叫中/发布中 → 派单成功)。 */ |
| | | export const DISPATCH_SUCCESS_ASSETS: PreloadAsset[] = [ |
| | | { type: 'image', src: VEHICLE_APPROACHING_POSTER }, // 转场视频封面(视频本身不支持预热) |
| | | { type: 'image', src: CARD_BG_IMG }, // 转场落定后的常态卡片背景 |
| | | { type: 'image', src: DEFAULT_VEHICLE_IMG }, // 车模兜底图 |
| | | ]; |
| | | |
| | | /** 行程结束转场素材(送驾中 → 行程结束)。礼花为纯 canvas 绘制,无素材。 */ |
| | | export const TRIP_FINISHED_ASSETS: PreloadAsset[] = [ |
| | | { type: 'image', src: TRIP_FINISHED_BG_IMG }, // 到达终点卡片背景 |
| | | ]; |
| | | |
| | | /** 全局字体(与 robotaxi/style/ui-variables.less 中 @font-face 对齐)。 */ |
| | | export const GLOBAL_FONTS: PreloadAsset[] = [ |
| | | { type: 'font', src: FONT_XPSANS_BOLD }, |
| | | { type: 'font', src: FONT_XPSANS_REGULAR }, |
| | | ]; |
| | |
| | | pageDidShowVersion?: number; |
| | | /** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */ |
| | | hasPreviousOrder?: boolean; |
| | | /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */ |
| | | active?: boolean; |
| | | }; |
| | | |
| | | /** |
| | |
| | | * 只要弹窗位于 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[]>([]); |
| | |
| | | |
| | | // 进入接驾态查询香氛状态;连环单不查询、不展示 |
| | | useEffect(() => { |
| | | // 门槛未就绪(如派单成功转场动效未完成):暂不查询,active 就绪后重跑再查询一次。 |
| | | if (!active) { |
| | | return; |
| | | } |
| | | if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) { |
| | | // 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。 |
| | | queriedOrderIdRef.current = ''; |
| | |
| | | setScents([]); |
| | | setSelectedChannelId(''); |
| | | }); |
| | | }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]); |
| | | }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active]); |
| | | |
| | | // 点击卡片:切换面板开合 |
| | | const togglePanel = () => { |
| | |
| | | pageDidShowVersion?: number; |
| | | /** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */ |
| | | hasPreviousOrder?: boolean; |
| | | /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */ |
| | | active?: boolean; |
| | | }; |
| | | |
| | | /** |
| | |
| | | * 只要弹窗位于 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); |
| | | // 后台配置曲风列表 |
| | |
| | | |
| | | // 进入接驾态查询曲风状态;连环单不查询、不展示 |
| | | useEffect(() => { |
| | | // 门槛未就绪(如派单成功转场动效未完成):暂不查询,active 就绪后重跑再查询一次。 |
| | | if (!active) { |
| | | return; |
| | | } |
| | | if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) { |
| | | // 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。 |
| | | queriedOrderIdRef.current = ''; |
| | |
| | | setGenres([]); |
| | | setSelectedGenreId(''); |
| | | }); |
| | | }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]); |
| | | }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active]); |
| | | |
| | | // 点击卡片:切换面板开合 |
| | | const togglePanel = () => { |
| | |
| | | pageDidShowVersion?: number; |
| | | /** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */ |
| | | hasPreviousOrder?: boolean; |
| | | /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */ |
| | | active?: boolean; |
| | | }; |
| | | |
| | | /** |
| | |
| | | * 只要弹窗位于 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); |
| | |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | // 门槛未就绪(如派单成功转场动效未完成):暂不查询/下发,避免转场中提前开空调、下发指令。 |
| | | // 不重置去重标记,active 就绪后本 effect 重跑再正常查询一次。 |
| | | if (!active) { |
| | | return; |
| | | } |
| | | if (tripStatus !== TripStatus.VehicleApproaching) { |
| | | // 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。 |
| | | queriedOrderIdRef.current = ''; |
| | |
| | | } |
| | | |
| | | handleFirstTimeInit(targetOrderId); |
| | | }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]); |
| | | }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active]); |
| | | |
| | | useEffect(() => { |
| | | lastConfirmedTempRef.current = tempStatus; |
| | |
| | | import { useDispatchTransition } from '../hooks/use-dispatch-transition'; |
| | | import DispatchTransitionLayer from './dispatch-transition-layer'; |
| | | import { TripStatus } from '../../../constants/trip-status'; |
| | | import { CARD_BG_IMG, DEFAULT_VEHICLE_IMG, TRIP_FINISHED_BG_IMG } from '../../../constants/assets'; |
| | | import { BLUETOOTH_ICON_OFF } from '../hooks/use-bluetooth-access'; // 蓝牙未开/未授权图标 |
| | | import TripStatusHeader from './calling-car-modules/trip-status-header'; // 行程状态头 |
| | | import VehicleInfoCard from './calling-car-modules/vehicle-info-card'; // 车辆信息卡片 |
| | |
| | | tripStatus?: TripStatus; |
| | | /** 是否在本次挂载时播放"派单成功"转场(由父层捕捉首次进入派单成功(4)边沿传入,因本组件被 key 重挂载无法自判) */ |
| | | playDispatchTransition?: boolean; |
| | | /** 是否在本次挂载时播放"直达待评价"入场(由父层捕捉 送驾中(6)→待评价(9) 边沿传入,内部免费订单跳过待支付时使用) */ |
| | | playEvaluateEnter?: boolean; |
| | | /** 派单成功转场动效完成(或本次不播放转场)时回调,用于放行页面级蓝牙初始化 */ |
| | | onDispatchTransitionSettled?: () => void; |
| | | /** 打开取消弹窗,入参为预计到达展示文案 */ |
| | | handleOpenCancelPopup?: (durationDisplay: any) => void; |
| | | /** 上车点超时后的回调 */ |
| | |
| | | export default function CallingCar({ |
| | | tripStatus = TripStatus.CallingCar, |
| | | playDispatchTransition = false, |
| | | playEvaluateEnter = false, |
| | | onDispatchTransitionSettled, |
| | | handleOpenCancelPopup, |
| | | handleCancelTimeout, |
| | | handleFinishTimeout, |
| | |
| | | transitionDone, |
| | | shouldPlayEnter, |
| | | handleDispatchVideoTimeUpdate, |
| | | handleDispatchVideoEnded, |
| | | enterSettle, |
| | | } = useDispatchTransition(playDispatchTransition); |
| | | |
| | | // 派单成功转场门槛:未播转场时立即就绪;播转场时等动效完成(transitionDone)。 |
| | | // 用于延迟车控/蓝牙卡/操作栏的「展示」与「逻辑执行」,避免转场中触发弹窗与提前下发指令。 |
| | | const contentReady = !shouldPlayEnter || transitionDone; |
| | | |
| | | /** 是否为“到达终点/待支付”状态(图一、图二) */ |
| | | const isTripFinished = tripStatus === TripStatus.TripFinished; |
| | | const isCallingCar = tripStatus === TripStatus.CallingCar; |
| | | /** |
| | | * 「直达待评价」入场快照:与 useDispatchTransition 里的 shouldPlayEnter 同理, |
| | | * 父层的 playEvaluateEnter 在下一次渲染即翻 false,直接绑定会把动画类中途卸掉、动效被扼杀, |
| | | * 故在挂载时快照一次,组件生命周期内不变(本组件由 key={tripStatus} 保证每次状态切换都重挂载)。 |
| | | */ |
| | | const [shouldPlayEvaluateEnter] = useState(playEvaluateEnter); |
| | | /** |
| | | * 车控(空调 / 音乐 / 香氛):卡片与弹窗分开挂载。 |
| | | * 卡片放进横向滑动的 ScrollView;弹窗是 fixed 全屏浮层,必须挂在 ScrollView 之外, |
| | | * 否则 iOS(WKWebView)下滚动容器会成为 fixed 的包含块,弹窗被限制在卡片滑动区内。 |
| | | * 三个 hook 内部均按 tripStatus / hasPreviousOrder 守卫,非接驾态不发请求、返回空节点。 |
| | | */ |
| | | const acControl = useAcControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder }); |
| | | const musicControl = useMusicControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder }); |
| | | const fragranceControl = useFragranceControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder }); |
| | | const acControl = useAcControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active: contentReady }); |
| | | const musicControl = useMusicControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active: contentReady }); |
| | | const fragranceControl = useFragranceControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active: contentReady }); |
| | | /** |
| | | * 车控卡片当前横向滚动位置。 |
| | | * 弹窗开合与其弹出动画会驱动本组件重渲染,weapp 的 scroll-view 在重渲染 / 重建后会回到 |
| | |
| | | setDetailExpanded(false); |
| | | }, [resetDetailVersion, tripStatus]); |
| | | |
| | | // 转场动效完成(或本次未播放转场)后通知父层:与车控卡片同步放行页面级蓝牙初始化。 |
| | | useEffect(() => { |
| | | if (contentReady) { |
| | | onDispatchTransitionSettled?.(); |
| | | } |
| | | }, [contentReady, onDispatchTransitionSettled]); |
| | | |
| | | // 拦截费用明细展开/收起回调:本地记录展开态后再透传给外部 |
| | | const handleFareToggle = (expanded: boolean, hasFare: boolean) => { |
| | | setDetailExpanded(expanded); |
| | |
| | | return <Image className='calling-car-bg carlling-bg' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/top-card-bg-20260722.png' /> |
| | | } |
| | | if(isTripFinished) { |
| | | return <Image className='calling-car-bg finished-bg' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/trip-finished-20260703.png' /> |
| | | return <Image className='calling-car-bg finished-bg' src={TRIP_FINISHED_BG_IMG} /> |
| | | } |
| | | // 派单转场中(挂载首帧 ~ 视频播放完成前):不返回任何背景图,杜绝转场期间出现静态背景图。 |
| | | // 常态背景图改在 DispatchTransitionLayer 中不可见预载。settle 起在此返回背景图(视频层仍盖在其上, |
| | |
| | | if (shouldPlayEnter && !transitionDone && dispatchPhase !== 'settle') { |
| | | return null; |
| | | } |
| | | return <Image className='calling-car-bg' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/card-bg-20260714.png' /> |
| | | return <Image className='calling-car-bg' src={CARD_BG_IMG} /> |
| | | } |
| | | // 根节点状态类:呼叫中覆盖 + 待支付态 + 明细展开态 + 派单转场阶段 + 整卡升起入场 |
| | | const rootClassName = `calling-car${isCallingCar ? ' calling-status-car' : ''}${isTripFinished ? ' calling-car--finished' : ''}${isTripFinished && detailExpanded ? ' is-detail-expanded' : ''}${dispatchPhase !== 'idle' ? ` dispatch-${dispatchPhase}` : ''}${shouldPlayEnter ? ' calling-car--enter' : ''}`; |
| | | // 根节点状态类:呼叫中覆盖 + 待支付态 + 明细展开态 + 派单转场阶段 + 整卡升起入场 + 直达待评价入场 |
| | | const rootClassName = `calling-car${isCallingCar ? ' calling-status-car' : ''}${isTripFinished ? ' calling-car--finished' : ''}${isTripFinished && detailExpanded ? ' is-detail-expanded' : ''}${dispatchPhase !== 'idle' ? ` dispatch-${dispatchPhase}` : ''}${shouldPlayEnter ? ' calling-car--enter' : ''}${shouldPlayEvaluateEnter ? ' calling-car--evaluate-enter' : ''}${!contentReady ? ' dispatch-defer-content' : ''}`; |
| | | |
| | | // 按状态组合渲染行程卡片模块 |
| | | return ( |
| | |
| | | dispatchPhase={dispatchPhase} |
| | | vehicleInfo={vehicleInfo} |
| | | onTimeUpdate={handleDispatchVideoTimeUpdate} |
| | | onEnded={handleDispatchVideoEnded} |
| | | onError={handleDispatchVideoEnded} |
| | | onEnded={enterSettle} |
| | | onError={enterSettle} |
| | | /> |
| | | { |
| | | // 常驻车图:接驾/行程中/支付成功且有车牌时展示。转场期间车模兜底由 DispatchTransitionLayer 渲染。 |
| | | ([TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.PaymentSuccess].includes(tripStatus) && vehicleInfo?.plateNumber) && <Image |
| | | className='calling-car-vehicle-pic' |
| | | src={safeParse(vehicleInfo?.vehiclePictures, {})?.url || 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/default-vehicle-img-20260724-v2.png'} |
| | | src={safeParse(vehicleInfo?.vehiclePictures, {})?.url || DEFAULT_VEHICLE_IMG} |
| | | /> |
| | | } |
| | | <View className='calling-car-content-box'> |
| | |
| | | } |
| | | } |
| | | // 阶段三:视频结束 → 功能卡回升归位、车模缩小回落到最终停靠位、视频切回图片背景 |
| | | // --dispatch-settle-duration 与 use-dispatch-transition 中 BG_SHRINK_LEAD_SEC 保持一致 |
| | | &.dispatch-settle { |
| | | .calling-car-content-box { transform: translateY(0); transition: transform 0.4s ease; } |
| | | --dispatch-settle-duration: 1.055s; |
| | | .calling-car-content-box { transform: translateY(0); transition: transform var(--dispatch-settle-duration) ease; } |
| | | // 车模从居中态回落到常驻停靠位(translateX 回 0 即基础态 right:116px 位置),并缩小归位。 |
| | | // transform 函数列表与 video 阶段对齐(translateY/translateX/scale),保证平滑插值、无瞬跳。 |
| | | .calling-car-vehicle-pic { |
| | | transform: translateY(0) translateX(0) scale(1); |
| | | opacity: 1; |
| | | transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease; |
| | | transition: transform var(--dispatch-settle-duration) cubic-bezier(0.22, 1, 0.36, 1), opacity var(--dispatch-settle-duration) ease; |
| | | } |
| | | } |
| | | } |
| | |
| | | transform: translateY(0); |
| | | } |
| | | } |
| | | // 「送驾中(6) 直达待评价(9)」整卡入场:内部免费订单跳过待支付(8),原本是无动效硬切。 |
| | | // 卡片容器为底部锚定(.container position:fixed bottom:0),待评价态内容更高会瞬间向上顶, |
| | | // 故从「相当于送驾态卡顶」的位置升起,用运动吃掉高度差;同时做轻微透明度渐变,柔化内容替换。 |
| | | // |
| | | // 起升距离 = 待评价态与送驾态的卡片高度差。 |
| | | // 实测依据(同机型两态截图,designWidth 750 下 px 即设计稿单位):以两态共有的首个内容块 |
| | | // VehicleInfoCard(“小鹏GX”行)到屏幕底部的距离比对,送驾态约 288px、待评价态约 659px, |
| | | // 即待评价态多出约 370px(新增费用明细两行 + 评价卡,减去状态条 + 操作栏)。 |
| | | // 用 LESS 变量而非 CSS 自定义属性:keyframes 内的 var() 在旧版 WKWebView 上存在不生效的风险, |
| | | // LESS 变量在编译期就展开为字面量,按真机观感调这一处即可。 |
| | | @evaluate-enter-rise: 380px; |
| | | |
| | | @keyframes calling-car-evaluate-rise { |
| | | from { |
| | | transform: translateY(@evaluate-enter-rise); |
| | | opacity: 0.4; |
| | | } |
| | | |
| | | to { |
| | | transform: translateY(0); |
| | | opacity: 1; |
| | | } |
| | | } |
| | | // 待评价态新增内容(费用明细卡 / 评价卡)分层浮现:轻微上移淡入,手法对齐 finished-distance-fade。 |
| | | @keyframes calling-car-evaluate-content-fade { |
| | | from { |
| | | transform: translateY(16px); |
| | | opacity: 0; |
| | | } |
| | | |
| | | to { |
| | | transform: translateY(0); |
| | | opacity: 1; |
| | | } |
| | | } |
| | | .calling-car--evaluate-enter { |
| | | // 位移行程约 380px(大于派单入场的 170px),时长相应放到 0.7s,保持速度感与派单入场一致。 |
| | | animation: calling-car-evaluate-rise 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both; |
| | | will-change: transform; |
| | | // 分层:整卡升起过半后,新增的费用明细卡与评价卡再依次浮现,进一步掩盖高度差。 |
| | | .fare-box-card { |
| | | animation: calling-car-evaluate-content-fade 600ms ease-out 420ms both; |
| | | } |
| | | .car-card-evaluate { |
| | | animation: calling-car-evaluate-content-fade 600ms ease-out 560ms both; |
| | | } |
| | | } |
| | | // 派单成功转场期间:蓝牙卡 / 车控卡 / 底部操作栏隐藏但保留占位(opacity),逻辑另在各组件延迟执行; |
| | | // 动效完成后移除 .dispatch-defer-content,随 opacity 过渡淡入。用 opacity 而非 visibility,保证淡入过渡生效。 |
| | | .calling-car { |
| | | .bluetooth-operate-card, |
| | | .temp-fragrance-scroll, |
| | | .trip-action-bar { |
| | | transition: opacity 0.3s ease; |
| | | } |
| | | &.dispatch-defer-content { |
| | | .bluetooth-operate-card, |
| | | .temp-fragrance-scroll, |
| | | .trip-action-bar { |
| | | opacity: 0; |
| | | pointer-events: none; |
| | | } |
| | | } |
| | | } |
| | | // 到达终点/待支付态(图一、图二):背景透明化 + 到达目的地头部 + 白色费用卡 |
| | | .calling-car--finished { |
| | | padding-top: 0; |
| New file |
| | |
| | | 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> |
| | | ); |
| | | } |
| | |
| | | import { Image, Video } from "@tarojs/components"; |
| | | import { safeParse } from '../../../../utils/Robotaxi-public-method'; |
| | | import { DispatchPhase, DISPATCH_VIDEO_SRC, DISPATCH_VIDEO_POSTER } from '../hooks/use-dispatch-transition'; |
| | | import { CARD_BG_IMG, DEFAULT_VEHICLE_IMG } from '../../../constants/assets'; |
| | | |
| | | type Props = { |
| | | shouldPlayEnter: boolean; |
| | |
| | | if (!shouldPlayEnter || transitionDone) return null; |
| | | |
| | | const vehiclePicSrc = safeParse(vehicleInfo?.vehiclePictures, {})?.url |
| | | || 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/default-vehicle-img-20260724-v2.png'; |
| | | || DEFAULT_VEHICLE_IMG; |
| | | |
| | | return ( |
| | | <> |
| | |
| | | 勿用内联 opacity:0——Taro 序列化会丢弃数字 0 导致其可见。 */} |
| | | <Image |
| | | className='calling-car-bg bg-preload' |
| | | src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/card-bg-20260714.png' |
| | | src={CARD_BG_IMG} |
| | | /> |
| | | <Video |
| | | className='calling-car-video' |
| | |
| | | ticks: 180, |
| | | colors: ['#FFD666', '#F9A76A', '#FFF1B8', '#7FE6F2', '#95CF29'], |
| | | }); |
| | | }, 180); |
| | | }, 60); |
| | | return () => clearTimeout(timer); |
| | | }, [tripStatus]); |
| | | |
| | |
| | | skipBluetoothAuth?: boolean; |
| | | /** 车端钥匙下发失败(getUnlocktToken 接口非 200),蓝牙不可解锁 */ |
| | | unlockTokenFailed?: boolean; |
| | | /** 派单成功转场动效是否进行中:为 true 时接驾中(4)暂缓初始化蓝牙,待动效完成后再初始化 */ |
| | | dispatchTransitionActive?: boolean; |
| | | }; |
| | | |
| | | type BluetoothAccessStatus = 'system_off' | 'unauthorized' | 'authorized'; |
| | |
| | | bluetoothAuthSuccess, |
| | | startBluetoothAuth, |
| | | skipBluetoothAuth = false, |
| | | unlockTokenFailed = false |
| | | unlockTokenFailed = false, |
| | | dispatchTransitionActive = false |
| | | }: Deps) { |
| | | const [status, setStatus] = useState<BluetoothAccessStatus>('system_off'); // 当前蓝牙访问态:系统未开 / 小程序未授权 / 已授权 |
| | | const [statusReady, setStatusReady] = useState(false); // 蓝牙状态是否已完成首轮判定 |
| | |
| | | logBluetoothAccess('等待 orderId 就绪'); |
| | | return; |
| | | } |
| | | // 接驾中(4)派单成功转场期间:与车控卡片一致,待转场动效完成后再初始化蓝牙, |
| | | // 避免转场动画播放时触发系统蓝牙授权弹窗。到达上车点(5)不受此限制(需尽快可开门)。 |
| | | if (tripStatus === TripStatus.VehicleApproaching && dispatchTransitionActive) { |
| | | logBluetoothAccess('派单成功转场动效未完成,暂缓初始化蓝牙'); |
| | | return; |
| | | } |
| | | logBluetoothAccess('进入蓝牙 UI 维护态', { |
| | | tripStatus, |
| | | orderId |
| | |
| | | stopOpenSettingPollingRef.current(); |
| | | setShowAuthBluetoothPopup(false); |
| | | setShowSuccessAuthPopup(false); |
| | | }, [tripStatus, orderId, refreshBluetoothState]); |
| | | }, [tripStatus, orderId, refreshBluetoothState, dispatchTransitionActive]); |
| | | |
| | | // 跳转系统蓝牙设置,并在返回后触发一次状态刷新 |
| | | const handleOpenSystemBluetoothSetting = useCallback(async () => { |
| New file |
| | |
| | | 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 }; |
| | | } |
| | |
| | | import { useState, useEffect, useRef } from 'react'; |
| | | import { VEHICLE_APPROACHING_VIDEO, VEHICLE_APPROACHING_POSTER } from '../../../constants/assets'; |
| | | |
| | | /** 派单成功转场视频 */ |
| | | export const DISPATCH_VIDEO_SRC = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_v1_file_w_1128@h_1268.mp4'; |
| | | export const DISPATCH_VIDEO_POSTER = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_poster_v1_file_w_1128@h_1268.jpg'; |
| | | /** 距视频结束多久提前进入 settle 归位阶段(秒) */ |
| | | export const DISPATCH_SETTLE_LEAD_SECONDS = 0.8; |
| | | /** 派单成功转场视频(源自 constants/assets.ts 单一数据源) */ |
| | | export const DISPATCH_VIDEO_SRC = VEHICLE_APPROACHING_VIDEO; |
| | | export const DISPATCH_VIDEO_POSTER = VEHICLE_APPROACHING_POSTER; |
| | | /** 兜底超时(ms):防卡死安全阀——视频加载/播放失败时 onTimeUpdate/onEnded 都不触发,超过此时长强制归位 */ |
| | | export const DISPATCH_FALLBACK_MS = 10000; |
| | | |
| | |
| | | const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| | | const settleTriggeredRef = useRef(false); |
| | | |
| | | // 背景缩小精确计时:微信 video 的 timeupdate 间隔约 265ms,无法直接捕获目标时刻。 |
| | | // 方案:timeupdate 记录 {播放时间, 墙上时钟} 作同步锚点,自适应 setTimeout 在两次事件间 |
| | | // 通过墙上时钟插值估算播放进度,提前唤醒、递归逼近,最终触发精度约 4ms。 |
| | | const lastTimeSyncRef = useRef({ currentTime: 0, wallClock: 0 }); |
| | | const videoDurationRef = useRef(0); |
| | | const shrinkTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| | | const shrinkTriggeredRef = useRef(false); |
| | | |
| | | /** 背景缩小提前量(秒):在视频结束前触发 settle。视频全长 3.333s,即 currentTime ≈ 2.278s 处。 */ |
| | | const BG_SHRINK_LEAD_SEC = 1.055; |
| | | |
| | | // 进入归位落定阶段(幂等,只生效一次) |
| | | const handleDispatchVideoEnded = () => { |
| | | const enterSettle = () => { |
| | | if (settleTriggeredRef.current) return; |
| | | settleTriggeredRef.current = true; |
| | | if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current); |
| | |
| | | }, 600); |
| | | }; |
| | | |
| | | // 播放进度更新:距视频结束还剩 DISPATCH_SETTLE_LEAD_SECONDS 秒时提前进入 settle |
| | | /** |
| | | * 自适应缩小定时器。每次 timeupdate 或定时器到期时调用,计算距目标还有多久: |
| | | * - 已到达 → 调用 enterSettle 进入 settle |
| | | * - 未到达 → setTimeout 提前 10ms 唤醒,到期后用墙上时钟插值修正、递归检查 |
| | | */ |
| | | const scheduleShrinkCheck = (currentTime: number) => { |
| | | if (shrinkTriggeredRef.current) return; |
| | | |
| | | const targetSec = videoDurationRef.current - BG_SHRINK_LEAD_SEC; |
| | | const remaining = targetSec - currentTime; |
| | | |
| | | if (remaining <= 0) { |
| | | shrinkTriggeredRef.current = true; |
| | | if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current); |
| | | enterSettle(); |
| | | return; |
| | | } |
| | | |
| | | if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current); |
| | | shrinkTimerRef.current = setTimeout(() => { |
| | | const { currentTime: lastCT, wallClock: lastWC } = lastTimeSyncRef.current; |
| | | if (lastWC === 0) return; |
| | | const estimated = lastCT + (Date.now() - lastWC) / 1000; |
| | | scheduleShrinkCheck(estimated); |
| | | }, Math.max(remaining * 1000 - 10,30)); |
| | | }; |
| | | |
| | | const handleDispatchVideoTimeUpdate = (e: any) => { |
| | | const { currentTime, duration } = e?.detail || {}; |
| | | if (typeof currentTime !== 'number' || typeof duration !== 'number' || duration <= 0) return; |
| | | if (duration - currentTime <= DISPATCH_SETTLE_LEAD_SECONDS) { |
| | | handleDispatchVideoEnded(); |
| | | } |
| | | |
| | | lastTimeSyncRef.current = { currentTime, wallClock: Date.now() }; |
| | | videoDurationRef.current = duration; |
| | | |
| | | scheduleShrinkCheck(currentTime); |
| | | }; |
| | | |
| | | useEffect(() => { |
| | |
| | | if (settleTriggeredRef.current) return; |
| | | setDispatchPhase('video'); |
| | | }, 32); |
| | | fallbackTimerRef.current = setTimeout(() => handleDispatchVideoEnded(), DISPATCH_FALLBACK_MS); |
| | | fallbackTimerRef.current = setTimeout(() => enterSettle(), DISPATCH_FALLBACK_MS); |
| | | } |
| | | return () => { |
| | | if (enterTimerRef.current) clearTimeout(enterTimerRef.current); |
| | | if (settleTimerRef.current) clearTimeout(settleTimerRef.current); |
| | | if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current); |
| | | if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current); |
| | | }; |
| | | }, []); |
| | | |
| | |
| | | dispatchPhase, |
| | | transitionDone, |
| | | shouldPlayEnter, |
| | | enterSettle, |
| | | handleDispatchVideoTimeUpdate, |
| | | handleDispatchVideoEnded, |
| | | }; |
| | | } |
| New file |
| | |
| | | import { useEffect, useRef } from 'react'; |
| | | import { TripStatus } from '../../../constants/trip-status'; |
| | | import { preloadAssets } from '../../../../utils/preload-assets'; |
| | | import { |
| | | DISPATCH_SUCCESS_ASSETS, |
| | | TRIP_FINISHED_ASSETS, |
| | | GLOBAL_FONTS, |
| | | } from '../../../constants/preload-assets'; |
| | | |
| | | /** Hook 入参。 */ |
| | | type Params = { |
| | | /** 当前行程状态。 */ |
| | | tripStatus: TripStatus; |
| | | }; |
| | | |
| | | /** |
| | | * 转场动效素材预加载(按需触发)。 |
| | | * |
| | | * ## 触发策略(必要时才预载,避免无谓消耗) |
| | | * - 进入「发布中(2) / 呼叫中(3)」→ 预载「派单成功」素材 + 全局字体 |
| | | * (距派单成功尚有等待派单时长,此时预热充分)。 |
| | | * - 进入「送驾中(6)」→ 预载「行程结束」素材(距行程结束有整段行程)。 |
| | | * - 未开始打车(发布行程(1))→ 不预载任何转场素材。 |
| | | * |
| | | * ## 去重 |
| | | * 各阶段用 ref 标记「已发起预载」,状态反复切换不重复调用。 |
| | | * 预加载底层失败静默(见 preloadAssets),此处无需错误处理。 |
| | | */ |
| | | export default function useTransitionAssetPreload(params: Params): void { |
| | | const { tripStatus } = params; |
| | | |
| | | const orderingPreloadedRef = useRef(false); // 派单成功素材 + 字体是否已发起预载 |
| | | const finishedPreloadedRef = useRef(false); // 行程结束素材是否已发起预载 |
| | | |
| | | useEffect(() => { |
| | | // 进入下单流程(发布中/呼叫中):预载派单成功素材 + 全局字体 |
| | | if ([TripStatus.Publishing, TripStatus.CallingCar].includes(tripStatus) && !orderingPreloadedRef.current) { |
| | | orderingPreloadedRef.current = true; |
| | | preloadAssets([...DISPATCH_SUCCESS_ASSETS, ...GLOBAL_FONTS]); |
| | | } |
| | | |
| | | // 进入送驾中:预载行程结束素材 |
| | | if (tripStatus === TripStatus.InService && !finishedPreloadedRef.current) { |
| | | finishedPreloadedRef.current = true; |
| | | preloadAssets(TRIP_FINISHED_ASSETS); |
| | | } |
| | | }, [tripStatus]); |
| | | } |
| | |
| | | isAndroid: boolean; |
| | | /** 行程状态 */ |
| | | tripStatus: TripStatus; |
| | | /** 派单成功转场动效完成(或本次未播放转场)时回调,用于放行页面级蓝牙初始化 */ |
| | | onDispatchTransitionSettled?: () => void; |
| | | /** 地图缩放等级 */ |
| | | mapScale: number; |
| | | /** 地图 markers */ |
| | |
| | | const playDispatchTransition = |
| | | deps.tripStatus === TripStatus.VehicleApproaching && |
| | | prevTripStatusRef.current !== TripStatus.VehicleApproaching; |
| | | // 捕捉「送驾中(6) 直达待评价(9)」边沿:内部免费订单无需支付,行程结束后跳过待支付(8)直接进入待评价。 |
| | | // 该路径原本没有任何入场动效,且待评价态内容比送驾态更高(多出费用明细卡与评价卡), |
| | | // 底部锚定容器(.container position:fixed bottom:0)会瞬间向上顶,观感生硬。 |
| | | // 与 playDispatchTransition 同理:CallingCar 被 key 重挂载无法自判边沿,由父层捕捉后传入, |
| | | // 组件内做挂载快照,再用挂载即播的 keyframes 实现整卡升起 + 内容分层浮现。 |
| | | // 限定上一态为 InService:不影响支付后 8→9 的既有表现;冷启动恢复时 prev 为 undefined 也不会误播。 |
| | | const playEvaluateEnter = |
| | | deps.tripStatus === TripStatus.PaymentSuccess && |
| | | prevTripStatusRef.current === TripStatus.InService; |
| | | useEffect(() => { |
| | | prevTripStatusRef.current = deps.tripStatus; |
| | | }, [deps.tripStatus]); |
| | |
| | | <CallingCar |
| | | key={deps.tripStatus} |
| | | playDispatchTransition={playDispatchTransition} |
| | | playEvaluateEnter={playEvaluateEnter} |
| | | onDispatchTransitionSettled={deps.onDispatchTransitionSettled} |
| | | orderId={deps.orderId} |
| | | hasBeforeUserEndSite={deps.hasBeforeUserEndSite} |
| | | hasPreviousOrder={deps.hasPreviousOrder} |
| | |
| | | import CouponSelectPopup from '../../components/coupon-select-popup'; |
| | | // 调试面板:模拟完整行程流转(仅 dev 构建可见) |
| | | // import DebugOrderSimulatorPanel from './components/debug-order-simulator-panel'; |
| | | // 转场动效调试面板:呼叫中→派单成功、送驾中→行程结束(仅 dev 构建可见) |
| | | import DebugTransitionPanel from './components/debug-transition-panel'; |
| | | // 到达结束态全屏礼花动效 |
| | | import TripFinishedFireworks from './components/trip-finished-fireworks'; |
| | | // 弹窗状态管理 |
| | |
| | | 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'; |
| | |
| | | waitingRadarUrl |
| | | } |
| | | }); // 地图气泡资源预加载 |
| | | |
| | | // 转场动效素材预加载(按行程状态空闲预载:呼叫中→派单成功素材、送驾中→行程结束素材) |
| | | useTransitionAssetPreload({ tripStatus }); |
| | | const { |
| | | openService, |
| | | toCoupon, |
| | |
| | | tripStatus |
| | | }); |
| | | const shouldSkipBluetoothAuth = doorUnlockedInCurrentPage || bluetoothDoorUnlockSuccess; |
| | | // 页面级派单转场延后闸门:进入接驾中(4)暂缓蓝牙初始化,避免转场动画中弹系统授权弹窗; |
| | | // 动效完成由 CallingCar 的 onDispatchTransitionSettled 回调 markSettled 放行。 |
| | | const { dispatchTransitionActive, markSettled: handleDispatchTransitionSettled } = useDeferUntilDispatchSettled(tripStatus); |
| | | const { |
| | | bluetoothStatusReady, |
| | | bluetoothCardDescription, |
| | |
| | | bluetoothAuthSuccess, |
| | | startBluetoothAuth, |
| | | skipBluetoothAuth: shouldSkipBluetoothAuth, |
| | | unlockTokenFailed |
| | | unlockTokenFailed, |
| | | dispatchTransitionActive |
| | | }); |
| | | const { handleConfirmRequoteIfNeeded } = useModifyDestinationPreviewRoute({ |
| | | tripStatus, |
| | |
| | | const { renderMapOverlays, renderCarCalloutOverlay, renderTripCard, fareBreakdown } = useViewDelegates({ |
| | | isAndroid, |
| | | tripStatus, |
| | | onDispatchTransitionSettled: handleDispatchTransitionSettled, |
| | | mapScale, |
| | | markers: markers, |
| | | isRouteSelectExpanded, |
| | |
| | | onConfirm={handleConfirmCreditPayPopup} |
| | | /> |
| | | <TripFinishedFireworks tripStatus={tripStatus} /> |
| | | {/* 转场动效调试面板 — 固定在地图上方,始终可见 */} |
| | | <DebugTransitionPanel |
| | | orderId={orderId} |
| | | setOrderId={setOrderId} |
| | | setTripStatus={setTripStatus} |
| | | bindSocketListeners={bindSocketListeners} |
| | | /> |
| | | {/* 模拟器入口已关闭,如需本地调试可取消下方注释 |
| | | <DebugOrderSimulatorPanel |
| | | orderId={orderId} |
| New file |
| | |
| | | /** |
| | | * 通用预加载素材描述。 |
| | | * |
| | | * 仅支持 `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; |