Merge branch 'feature_v2.4.0_ui_animation' into 'feature_2.6.0'
feat: UI 焕新加动效
See merge request robotaxi1/xp-robotaxi-miniprogram!265
| | |
| | | .history |
| | | key/ |
| | | .swc/ |
| | | .tmp_frames/ |
| | | .qoder/ |
| | | # project.private.config.json |
| | |
| | | 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' |
| | |
| | | }, {}); |
| | | setIsAgree(initState); |
| | | }).catch(() => { |
| | | Taro.showToast({ title: '协议获取失败,请重试', icon: 'none' }); |
| | | showToast({ title: '协议获取失败,请重试', icon: 'none' }); |
| | | }); |
| | | }; |
| | | |
| | |
| | | |
| | | const handleAgree = () => { |
| | | if (!isAllAgree) { |
| | | Taro.showToast({ title: '请先同意全部协议', icon: 'none' }); |
| | | showToast({ title: '请先同意全部协议', icon: 'none' }); |
| | | return |
| | | }; |
| | | const selectedType = Object.keys(isAgree) |
| | |
| | | privicyShowManager.setHasShow(true) |
| | | onAgree?.(); |
| | | }).catch(() => { |
| | | Taro.showToast({ title: '提交失败,请重试', icon: 'none' }); |
| | | showToast({ title: '提交失败,请重试', icon: 'none' }); |
| | | }); |
| | | }; |
| | | |
| | |
| | | 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>; |
| | |
| | | */ |
| | | const handleFloatActivityClick = () => { |
| | | if (!floatActivityUrl) { |
| | | Taro.showToast({ title: '活动页面建设中', icon: 'none' }); |
| | | showToast({ title: '活动页面建设中', icon: 'none' }); |
| | | return; |
| | | } |
| | | deps.toH5(floatActivityUrl, true); |
| | |
| | | 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 = { |
| | | /** 是否未登录 */ |
| | |
| | | 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; |
| | | } |
| | | }; |
| | |
| | | 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) { |
| | |
| | | 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); |
| | |
| | | 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({ |
| | |
| | | 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; |
| | |
| | | deps.setPolygons([]); |
| | | } |
| | | if (!nextPolygons || nextPolygons.length === 0) { |
| | | Taro.showToast({ title: '此城市暂未开通运营区域', icon: 'none' }); |
| | | showToast({ title: '此城市暂未开通运营区域', icon: 'none' }); |
| | | return; |
| | | } |
| | | const points = extractPointsFromPolygons(nextPolygons); |
| | |
| | | }); |
| | | }, 600); |
| | | } else { |
| | | Taro.showToast({ title: '此城市暂未开通运营区域', icon: 'none' }); |
| | | showToast({ title: '此城市暂未开通运营区域', icon: 'none' }); |
| | | } |
| | | }; |
| | | |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { showToast } from '../../../../robotaxi/components/toast'; |
| | | |
| | | type Deps = { |
| | | /** 是否拒绝定位 */ |
| | |
| | | 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; |
| | |
| | | OrderDistanceConfig |
| | | } from '../../../../robotaxi/utils/order-distance'; |
| | | import { hasActiveTripOrderInStorage } from '../../../../robotaxi/utils/order'; |
| | | import { showToast } from '../../../../robotaxi/components/toast'; |
| | | |
| | | type CapsuleSite = { |
| | | id: string | number; |
| | |
| | | */ |
| | | 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(); |
| | |
| | | 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); |
| | |
| | | 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, |
| | |
| | | ); |
| | | } |
| | | |
| | | export default withRobotaxiWhitelist(Home); |
| | | export default withRobotaxiWhitelist(withToast(Home)); |
| | |
| | | import Taro from '@tarojs/taro' |
| | | import { PureComponent } from 'react' |
| | | import { View, Text } from '@tarojs/components' |
| | | import './index.less' |
| | |
| | | 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> |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | .xp-fireworks-canvas { |
| | | position: fixed; |
| | | left: 0; |
| | | top: 0; |
| | | width: 100vw; |
| | | height: 100vh; |
| | | z-index: 999; |
| | | pointer-events: none; |
| | | } |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | # 礼花(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)低端机型帧率可接受,无明显卡顿。 |
| 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'; |
| | | |
| | | /** 派单成功转场视频尾帧(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'; |
| New file |
| | |
| | | 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 }, // 到达终点卡片背景 |
| | | ]; |
| | |
| | | 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; |
| | | // 右侧区域渲染规则: |
| | | // - 不可点击(如车端钥匙下发失败)→ 不渲染 |
| | |
| | | // - 其他可点击场景("去授权" / 已授权)→ 渲染容器;文字仅在有 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> |
| | |
| | | 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'; |
| | | |
| | |
| | | |
| | | // 获取解锁请求所需定位参数,优先高精定位,失败后降级普通定位 |
| | | 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) { |
| | |
| | | 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; |
| | | }; |
| | | |
| | |
| | | const actionLoadingText = doorType === 'trunk' |
| | | ? (isCloseAction ? '正在关闭后备箱' : '正在开启后备箱') |
| | | : '正在解锁车门'; |
| | | // Taro.showLoading({ title: actionLoadingText, mask: true }); |
| | | // showToast({ title: actionLoadingText, icon: 'loading', mask: true }); |
| | | unlockVehicle({ |
| | | orderId: targetOrderId, |
| | | doorType, |
| | |
| | | ? Number(res?.data) == 1 |
| | | : !!res?.data; |
| | | if (!unlockSucceeded) { |
| | | // Taro.hideLoading(); |
| | | // hideToast(); |
| | | const failMsg = res?.msg || '解锁失败,请重试或联系客服'; |
| | | stopLoading(doorType, () => { |
| | | showToast({ title: failMsg, icon: 'fail' }); |
| | |
| | | trunkPollTimerRef.current = setTimeout(doPoll, pollInterval); |
| | | } else { |
| | | stopPolling(); |
| | | // Taro.hideLoading(); |
| | | // hideToast(); |
| | | stopLoading(doorType, () => { |
| | | showToast({ |
| | | title: failToastText, |
| | |
| | | const isOpen = !isCloseAction; |
| | | setTrunkStatus(isOpen); |
| | | onSuccess?.(); |
| | | // Taro.hideLoading(); |
| | | // hideToast(); |
| | | stopLoading(doorType, () => { |
| | | showToast({ |
| | | title: successToastText, |
| | |
| | | } |
| | | |
| | | if (doorType === 'door') { |
| | | // Taro.hideLoading(); |
| | | // hideToast(); |
| | | syncDoorUnlockButtonUI(); |
| | | onSuccess?.(); |
| | | stopLoading(doorType, () => { |
| | |
| | | }); |
| | | } |
| | | }).catch(() => { |
| | | // Taro.hideLoading(); |
| | | // hideToast(); |
| | | stopLoading(doorType, () => { |
| | | showToast({ title: '解锁失败,请重试或联系客服', icon: 'fail' }); |
| | | }); |
| | |
| | | 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) { |
| | |
| | | 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 (!isMusicControlEnabled() || 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 { 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'; // 车辆信息卡片 |
| | |
| | | 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; |
| | | /** 上车点超时后的回调 */ |
| | |
| | | |
| | | export default function CallingCar({ |
| | | tripStatus = TripStatus.CallingCar, |
| | | playDispatchTransition = false, |
| | | playEvaluateEnter = false, |
| | | playFinishedEnter = false, |
| | | onDispatchTransitionSettled, |
| | | handleOpenCancelPopup, |
| | | handleCancelTimeout, |
| | | handleFinishTimeout, |
| | |
| | | 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) */ |
| | |
| | | useEffect(() => { |
| | | setDetailExpanded(false); |
| | | }, [resetDetailVersion, tripStatus]); |
| | | |
| | | // 转场动效完成(或本次未播放转场)后通知父层:与车控卡片同步放行页面级蓝牙初始化。 |
| | | useEffect(() => { |
| | | if (contentReady) { |
| | | onDispatchTransitionSettled?.(); |
| | | } |
| | | }, [contentReady, onDispatchTransitionSettled]); |
| | | |
| | | // 拦截费用明细展开/收起回调:本地记录展开态后再透传给外部 |
| | | const handleFareToggle = (expanded: boolean, hasFare: boolean) => { |
| | |
| | | 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'> |
| | |
| | | /> |
| | | |
| | | { |
| | | /* 行程操作栏:紧跟行程状态之下、蓝牙卡片之上(对齐设计稿)。 |
| | | 到达终点/已支付状态下 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 || (() => { })} |
| | |
| | | </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 无此问题)。 |
| | |
| | | } |
| | | } |
| | | |
| | | // 结束态背景图(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; |
| | |
| | | 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 { |
| | |
| | | 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; |
| | |
| | | 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 } 同特异性且在其之前, |
| | |
| | | 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; |
| 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> |
| | | ); |
| | | } |
| New file |
| | |
| | | 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} |
| | | /> |
| | | )} |
| | | </> |
| | | ); |
| | | } |
| New file |
| | |
| | | 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} |
| | | /> |
| | | ); |
| | | } |
| | |
| | | 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 () => { |
| | |
| | | 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 = { |
| | |
| | | pollingOrderIdRef.current = ''; |
| | | clearPollingTimer(); |
| | | if (hideLoading) { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | } |
| | | }, [clearPollingTimer]); |
| | | |
| | |
| | | return; |
| | | } |
| | | |
| | | Taro.showLoading({ title: '授权结果查询中', mask: true }); |
| | | showToast({ title: '授权结果查询中', icon: 'loading', mask: true }); |
| | | |
| | | const maxPollingCount = 120; |
| | | let currentPollingCount = 0; |
| | |
| | | |
| | | if (status === 'DOING' || result?.code === 20710) { |
| | | stopPollingSession(true); |
| | | Taro.showToast({ title: '授权成功', icon: 'none' }); |
| | | showToast({ title: '授权成功', icon: 'none' }); |
| | | void deps.onAuthorizedReturn(queryId); |
| | | return; |
| | | } |
| | |
| | | if (errorCode === '20710') { |
| | | console.log(`[信用分授权查询遇到20710] 第${currentPollingCount + 1}次,当作授权成功处理`); |
| | | stopPollingSession(true); |
| | | Taro.showToast({ title: '授权成功', icon: 'none' }); |
| | | showToast({ title: '授权成功', icon: 'none' }); |
| | | void deps.onAuthorizedReturn(queryId); |
| | | return; |
| | | } |
| 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 }; |
| | | } |
| New file |
| | |
| | | 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, |
| | | }; |
| | | } |
| | |
| | | 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'; |
| | |
| | | try { |
| | | setIsConfirmingModifyDestination(true); |
| | | setFlowState(FLOW_STATES.CONFIRMING); |
| | | Taro.showLoading({ title: '正在修改目的地', mask: true }); // 修改目的地请求进行中全局 loading |
| | | showToast({ title: '正在修改目的地', icon: 'loading', mask: true }); // 修改目的地请求进行中全局 loading |
| | | |
| | | // 如果后端需要字符串格式可以显式转String,如果后端支持number直接传值,保留原始格式防止隐式转换截断 |
| | | // 如果原本因为 Number() 导致大整数丢失精度,去掉 Number() 即可 |
| | |
| | | |
| | | exitModifyDestinationReview({ shouldRestorePreviousEndSite: false }); |
| | | |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | showToast({ |
| | | title: '行程已更新', |
| | | icon: 'none' |
| | |
| | | deps.onModifySuccess(oldRoutePoints); |
| | | } |
| | | } catch (e: any) { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | throw e; |
| | | } finally { |
| | | setIsConfirmingModifyDestination(false); |
| | |
| | | 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'; |
| | | |
| | |
| | | return; |
| | | } |
| | | |
| | | Taro.showLoading({ title: '支付结果查询中' }); |
| | | showToast({ title: '支付结果查询中', icon: 'loading' }); |
| | | try { |
| | | const result = await queryOrderPaymentResultFromOrderId(deps.orderId); |
| | | const nextStatus = Number(result?.status); |
| | |
| | | } catch (error) { |
| | | showToast({ title: '支付结果查询失败,请稍后重试', icon: 'none' }); |
| | | } finally { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | } |
| | | }, [deps.onPaySuccess, deps.orderId]); |
| | | |
| | |
| | | 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 { |
| | |
| | | * 根据订单详情状态决定结束行程后的页面态。 |
| | | */ |
| | | const resolvePostEndOrderTripStatus = async (targetOrderId: string) => { |
| | | Taro.showLoading({ |
| | | showToast({ |
| | | title: '订单明细查询中', |
| | | icon: 'loading', |
| | | mask: true |
| | | }); |
| | | try { |
| | |
| | | nextTripStatus: TripStatus.TripFinished |
| | | }; |
| | | } finally { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | } |
| | | }; |
| | | |
| | |
| | | return; |
| | | } |
| | | paySubmittingRef.current = true; |
| | | Taro.showLoading({ |
| | | showToast({ |
| | | title: '支付中', |
| | | icon: 'loading', |
| | | mask: false |
| | | }); |
| | | try { |
| | |
| | | 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); |
| | |
| | | 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; |
| | | } |
| | | }; |
| | |
| | | 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'; |
| | |
| | | * 查询长期签约回跳后的最新支付分状态,并决定是否继续信用下单。 |
| | | */ |
| | | 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; |
| | |
| | | return null; |
| | | } finally { |
| | | if (shouldHideLoading) { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | } |
| | | } |
| | | }, []); |
| | |
| | | const nextCreditPaySubmitMode = await resolveCreditPayModeAfterSignReturn(); |
| | | if (!nextCreditPaySubmitMode) { |
| | | stopCallSubmitting(); |
| | | Taro.showToast({ title: '支付分授权未完成,请重试', icon: 'none' }); |
| | | showToast({ title: '支付分授权未完成,请重试', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | |
| | | onLaunchFail: () => { |
| | | waitingCreditPayReturnRef.current = false; |
| | | stopCallSubmitting(); |
| | | Taro.showToast({ title: '拉起签约失败', icon: 'none' }); |
| | | showToast({ title: '拉起签约失败', icon: 'none' }); |
| | | } |
| | | }); |
| | | |
| | |
| | | || submitMode === 'credit_pay_skip_auth_submit'; |
| | | |
| | | if (isCreditPaySubmit) { |
| | | Taro.showLoading({ title: '加载中', mask: true }); |
| | | showToast({ title: '加载中', icon: 'loading', mask: true }); |
| | | } |
| | | |
| | | const submitResult = isCreditPaySubmit |
| | |
| | | : await submitOrder(startSite, openCityId); |
| | | |
| | | if (!submitResult?.orderId) { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | stopCallSubmitting(); |
| | | return; |
| | | } |
| | |
| | | if (isCreditPaySubmit) { |
| | | Taro.setStorageSync('creditPayOrderId', submitResult.orderId); |
| | | if (submitMode === 'credit_pay_skip_auth_submit') { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | // status=2 代表当前已是免确认模式,信用下单成功后直接进入发单态。 |
| | | deps.setTripStatus(TripStatus.Publishing); |
| | | return; |
| | |
| | | |
| | | const creditPayPackageValue = String(submitResult.creditPayPackageValue || '').trim(); |
| | | if (!creditPayPackageValue) { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | showToast({ title: '支付参数缺失,请稍后重试', icon: 'none' }); |
| | | return; |
| | | } |
| | |
| | | packageValue: creditPayPackageValue |
| | | }, submitResult.orderId); |
| | | |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | |
| | | // 只有拉起失败才立刻进入发单状态(或者依靠 abort 逻辑终止) |
| | | if (!launched) { |
| | |
| | | } |
| | | 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(); |
| New file |
| | |
| | | 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]); |
| | | } |
| | |
| | | isAndroid: boolean; |
| | | /** 行程状态 */ |
| | | tripStatus: TripStatus; |
| | | /** 派单成功转场动效完成(或本次未播放转场)时回调,用于放行页面级蓝牙初始化 */ |
| | | onDispatchTransitionSettled?: () => void; |
| | | /** 地图 markers */ |
| | | markers: any[]; |
| | | /** 是否显示等待雷达 */ |
| | |
| | | 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), |
| | |
| | | ) { |
| | | return ( |
| | | <CallingCar |
| | | key={deps.tripStatus} |
| | | playDispatchTransition={playDispatchTransition} |
| | | playEvaluateEnter={playEvaluateEnter} |
| | | playFinishedEnter={playFinishedEnter} |
| | | onDispatchTransitionSettled={deps.onDispatchTransitionSettled} |
| | | orderId={deps.orderId} |
| | | hasBeforeUserEndSite={deps.hasBeforeUserEndSite} |
| | | hasPreviousOrder={deps.hasPreviousOrder} |
| | |
| | | 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 事件编排 |
| | |
| | | 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'; |
| | |
| | | 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; |
| | |
| | | waitingRadarUrl |
| | | } |
| | | }); // 地图气泡资源预加载 |
| | | |
| | | // 转场动效素材预加载(按行程状态空闲预载:呼叫中→派单成功素材、送驾中→行程结束素材) |
| | | useTransitionAssetPreload({ tripStatus }); |
| | | const { |
| | | openService, |
| | | toCoupon, |
| | |
| | | |
| | | try { |
| | | setIsConfirmingPickupRouteChange(true); |
| | | Taro.showLoading({ |
| | | showToast({ |
| | | title: '正在修改路线', |
| | | icon: 'loading', |
| | | mask: true |
| | | }); |
| | | await selectNavigationRoute({ |
| | |
| | | setIsPickupRouteChangeVisible(false); |
| | | pickupRouteChangeSnapshotRef.current = null; |
| | | routeRequestOrderRoute(orderId, tripStatus); |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | setTimeout(() => { |
| | | showToast({ |
| | | title: '切换成功,将按新路线行驶', |
| | |
| | | }); |
| | | }, 0); |
| | | } catch (error) { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | } finally { |
| | | setIsConfirmingPickupRouteChange(false); |
| | | } |
| | |
| | | } |
| | | creditPayFallbackActiveOrderIdRef.current = ''; |
| | | if (hideLoading) { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | } |
| | | }, []); |
| | | |
| | |
| | | |
| | | clearCreditPayFallbackRuntime(false); |
| | | creditPayFallbackActiveOrderIdRef.current = resolvedOrderId; |
| | | Taro.showLoading({ title: '扣款结果查询中,请勿重复支付', mask: true }); |
| | | showToast({ title: '扣款结果查询中,请勿重复支付', icon: 'loading', mask: true }); |
| | | |
| | | const checkCreditPayFallback = async () => { |
| | | try { |
| | |
| | | Taro.removeStorageSync('creditPayOrderId'); |
| | | clearCreditPayFallbackRuntime(); |
| | | setCreditPayFallbackRefreshVersion((prev) => prev + 1); |
| | | Taro.showToast({ title: '免密扣款失败,请手动支付', icon: 'none' }); |
| | | showToast({ title: '免密扣款失败,请手动支付', icon: 'none' }); |
| | | } |
| | | } catch (error) { |
| | | } |
| | |
| | | |
| | | 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 { |
| | |
| | | |
| | | try { |
| | | await cancelOrder(targetOrderId); |
| | | // Taro.showToast({ |
| | | // showToast({ |
| | | // title: reason === 'launch_failed' ? '拉起信用分失败,已取消发单' : '未完成信用分授权,已取消发单', |
| | | // icon: 'none' |
| | | // }); |
| | |
| | | reason, |
| | | error |
| | | }); |
| | | Taro.showToast({ title: '取消订单失败,请重试', icon: 'none' }); |
| | | showToast({ title: '取消订单失败,请重试', icon: 'none' }); |
| | | } |
| | | }, [orderId, syncWaitingSignedCreditPayResult]); |
| | | |
| | |
| | | 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, |
| | | markers: markers, |
| | | isRouteSelectExpanded, |
| | | isPickupRouteChangeVisible, |
| | |
| | | onClose={handleCloseCreditPayPopup} |
| | | onConfirm={handleConfirmCreditPayPopup} |
| | | /> |
| | | <TripFinishedFireworks tripStatus={tripStatus} /> |
| | | {/* 转场动效调试面板 — 固定在地图上方,始终可见(临时开启,调试完成后请重新注释) */} |
| | | {/* <DebugTransitionPanel |
| | | orderId={orderId} |
| | | setOrderId={setOrderId} |
| | | setTripStatus={setTripStatus} |
| | | bindSocketListeners={bindSocketListeners} |
| | | /> */} |
| | | {/* 模拟器入口已关闭,如需本地调试可取消下方注释 |
| | | <DebugOrderSimulatorPanel |
| | | orderId={orderId} |
| | |
| | | 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() { |
| | |
| | | |
| | | const openPopup = async (item: CouponItem) => { |
| | | try { |
| | | Taro.showLoading({ title: "加载中..." }); |
| | | showToast({ title: "加载中...", icon: 'loading' }); |
| | | const detail = await getCouponDetail(item.couponId); |
| | | detail.couponDesc = decodeURIComponent(detail.couponDesc || ""); |
| | | |
| | |
| | | ); |
| | | } |
| | | setCurrentCoupon(detail); |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | |
| | | const animation = Taro.createAnimation({ |
| | | duration: 300, |
| | |
| | | setAnimationData(animation.export()); |
| | | setVisible(true); |
| | | } catch (error) { |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | showToast({ title: "获取详情失败", icon: "none" }); |
| | | } |
| | | }; |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useCallback, useMemo, useState } from 'react'; |
| | | import { showToast } from '../../../components/toast'; |
| | | import { getStorage } from '../../../../utils/tools'; |
| | | |
| | | /** |
| | |
| | | ...item, |
| | | status: 'failed' |
| | | })); |
| | | Taro.showToast({ |
| | | showToast({ |
| | | title: error?.message || '上传失败,请重试', |
| | | icon: 'none' |
| | | }); |
| | |
| | | const chooseAndUpload = useCallback(async () => { |
| | | const remainCount = Math.max(0, options.maxCount - items.length); |
| | | if (remainCount <= 0) { |
| | | Taro.showToast({ |
| | | showToast({ |
| | | title: `最多可上传${options.maxCount}个图片或视频`, |
| | | icon: 'none' |
| | | }); |
| | |
| | | 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' |
| | | }); |
| | |
| | | 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'; |
| | |
| | | } |
| | | |
| | | isSubmittingRef.current = true; |
| | | Taro.showLoading({ title: '提交中...', mask: true }); |
| | | showToast({ title: '提交中...', icon: 'loading', mask: true }); |
| | | try { |
| | | const networkTypeRes = await Taro.getNetworkType(); |
| | | if (networkTypeRes.networkType === 'none') { |
| | |
| | | showFeedbackToast(errorTitle); |
| | | } finally { |
| | | isSubmittingRef.current = false; |
| | | Taro.hideLoading(); |
| | | hideToast(); |
| | | } |
| | | }; |
| | | |
| | |
| | | ); |
| | | } |
| | | |
| | | export default withRobotaxiWhitelist(FeedbackPage); |
| | | export default withRobotaxiWhitelist(withToast(FeedbackPage)); |
| | |
| | | try { |
| | | const activeOrder = await getOrderActive(); |
| | | if (activeOrder) { |
| | | Taro.showToast({ |
| | | showToast({ |
| | | title: '有进行中的订单,暂无法关闭服务', |
| | | icon: 'none' |
| | | }); |
| | |
| | | } |
| | | return true; |
| | | } catch (error) { |
| | | Taro.showToast({ |
| | | showToast({ |
| | | title: '查询订单状态失败,请稍后重试', |
| | | icon: 'none' |
| | | }); |
| | |
| | | return; |
| | | } |
| | | |
| | | Taro.showToast({ title: "功能建设中", icon: "none" }); |
| | | showToast({ title: "功能建设中", icon: "none" }); |
| | | }; |
| | | |
| | | return ( |
| New file |
| | |
| | | /** |
| | | * 视频预下载缓存:用 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; |
| 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; |