src/robotaxi/pages/begin-route/components/calling-car-modules/fragrance-control/index.tsx
@@ -7,6 +7,7 @@ import { getOrderId } from '../../../../../utils/order'; import { TripStatus } from '../../../../../constants/trip-status'; import useBottomPopupAnimation from '../../hooks/use-bottom-popup-animation'; import type { ControlNodes } from '../types'; import AcSwitch from '../ac-switch'; import '../../css-folder/fragrance-control.less'; @@ -78,7 +79,13 @@ return scents.length ? scents[0].channelId : ''; } export default function FragranceControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props) { /** * 香氛控制 Hook:返回「入口卡片」与「控制弹窗」两个可分别挂载的节点。 * 拆分原因:卡片要放进横向滑动的 ScrollView,而弹窗是 position: fixed 全屏浮层; * 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。 * 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。 */ export function useFragranceControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes { // 车端真实状态 const [switchOn, setSwitchOn] = useState(false); const [scents, setScents] = useState<FragranceScent[]>([]); @@ -213,13 +220,13 @@ // 连环单或非接驾态不展示香氛卡片 if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) { return null; return { card: null, popup: null }; } // 香氛卡片背景随开关状态切换(复用 tf-card 视觉) const fragranceCardStateClass = switchOn ? 'is-open' : 'is-close'; return ( const card = ( <View className='fragrance-control'> {/* 香氛入口卡片(复用 tf-card 视觉):点击打开面板 */} <View @@ -241,10 +248,13 @@ </View> </View> </View> </View> ); {/* 控制面板(底部弹窗) */} {shouldRender && ( <View className={`fragrance-popup ${panelVisible ? 'show' : ''}`} catchMove> // 控制面板(底部弹窗):与卡片分开挂载,必须渲染在 ScrollView 之外,否则 iOS 上会被滚动容器裁切 const popup = shouldRender ? ( <View className='fragrance-control'> <View className={`fragrance-popup ${panelVisible ? 'show' : ''}`} catchMove> <View className='popup-mask' onClick={closePanel}></View> <View className='popup-content' animation={animationData}> <View className='popup-header'> @@ -276,8 +286,15 @@ )} </View> </View> </View> )} </View> </View> ); ) : null; return { card, popup }; } /** 兼容旧用法:卡片与弹窗一起渲染(不在 ScrollView 内的场景可直接使用本组件) */ export default function FragranceControl(props: Props) { const { card, popup } = useFragranceControl(props); return <>{card}{popup}</>; } src/robotaxi/pages/begin-route/components/calling-car-modules/music-control/index.tsx
@@ -5,6 +5,7 @@ import { getOrderId } from '../../../../../utils/order'; import { TripStatus } from '../../../../../constants/trip-status'; import useBottomPopupAnimation from '../../hooks/use-bottom-popup-animation'; import type { ControlNodes } from '../types'; import AcSwitch from '../ac-switch'; import { showToast } from '../../../../../components/toast'; import '../../css-folder/music-control.less'; @@ -58,7 +59,13 @@ return genres.length ? genres[0].id : ''; } export default function MusicControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props) { /** * 音乐控制 Hook:返回「入口卡片」与「控制弹窗」两个可分别挂载的节点。 * 拆分原因:卡片要放进横向滑动的 ScrollView,而弹窗是 position: fixed 全屏浮层; * 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。 * 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。 */ export function useMusicControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes { // 车端真实开关状态 const [switchOn, setSwitchOn] = useState(false); // 后台配置曲风列表 @@ -197,13 +204,13 @@ // 连环单或非接驾态不展示音乐卡片 if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) { return null; return { card: null, popup: null }; } // 音乐卡片背景随开关状态切换 const musicCardStateClass = switchOn ? 'is-open' : 'is-close'; return ( const card = ( <View className='music-control'> {/* 音乐入口卡片(复用 tf-card 视觉):点击打开面板 */} <View @@ -237,10 +244,13 @@ </View> </View> </View> </View> ); {/* 控制面板(底部弹窗) */} {shouldRender && ( <View className={`music-popup ${panelVisible ? 'show' : ''}`} catchMove> // 控制面板(底部弹窗):与卡片分开挂载,必须渲染在 ScrollView 之外,否则 iOS 上会被滚动容器裁切 const popup = shouldRender ? ( <View className='music-control'> <View className={`music-popup ${panelVisible ? 'show' : ''}`} catchMove> <View className='popup-mask' onClick={closePanel}></View> <View className='popup-content' animation={animationData}> <View className='popup-header'> @@ -280,8 +290,15 @@ <AcSwitch checked={switchOn} loading={switchLoading} onChange={handleToggleSwitch} /> </View> </View> </View> )} </View> </View> ); ) : null; return { card, popup }; } /** 兼容旧用法:卡片与弹窗一起渲染(不在 ScrollView 内的场景可直接使用本组件) */ export default function MusicControl(props: Props) { const { card, popup } = useMusicControl(props); return <>{card}{popup}</>; } src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx
@@ -14,6 +14,7 @@ import '../css-folder/temperature-control.less'; import { getOrderId } from '../../../../utils/order'; import { TripStatus } from '../../../../constants/trip-status'; import type { ControlNodes } from './types'; import { MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY } from '../../../../constants/modify-destination'; import { getAcStorage, saveAcStorage } from './ac-storage'; import useMinimumSpinLoading from './use-minimum-spin-loading'; @@ -41,7 +42,13 @@ hasPreviousOrder?: boolean; }; export default function TemperatureControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props) { /** * 空调控制 Hook:返回「入口卡片」与「温度调节弹窗」两个可分别挂载的节点。 * 拆分原因:卡片要放进横向滑动的 ScrollView,而弹窗是 position: fixed 全屏浮层; * 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。 * 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。 */ export function useAcControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes { const [tempStatus, setTempStatus] = useState<number | null>(null); const [acSwitchOn, setAcSwitchOn] = useState(false); const [acStatusLoaded, setAcStatusLoaded] = useState(false); @@ -451,10 +458,10 @@ // 连环单(车还在执行上一单)时不展示空调卡片 if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) { return null; return { card: null, popup: null }; } return ( const card = ( <View className='ac-control'> {/* 空调入口卡片(复用 tf-card 视觉):点击打开温度调节面板;右上开关直接切空调 */} <View @@ -481,10 +488,14 @@ </View> </View> </View> </View> ); {/* 温度调节面板(底部弹窗,fixed 定位覆盖全屏) */} {shouldRender && ( <View className={`ac-popup ${panelVisible ? 'show' : ''}`} catchMove> // 温度调节面板(底部弹窗,fixed 定位覆盖全屏):与卡片分开挂载, // 必须渲染在 ScrollView 之外,否则 iOS 上会被滚动容器裁切 const popup = shouldRender ? ( <View className='ac-control'> <View className={`ac-popup ${panelVisible ? 'show' : ''}`} catchMove> <View className='popup-mask' onClick={closePanel}></View> <View className='popup-content' animation={animationData}> {/* 座椅通风/加热区(顶部满宽,关闭按钮浮于右上角) */} @@ -536,8 +547,15 @@ </View> </View> </View> </View> )} </View> </View> ); ) : null; return { card, popup }; } /** 兼容旧用法:卡片与弹窗一起渲染(不在 ScrollView 内的场景可直接使用本组件) */ export default function TemperatureControl(props: Props) { const { card, popup } = useAcControl(props); return <>{card}{popup}</>; } src/robotaxi/pages/begin-route/components/calling-car-modules/types.ts
New file @@ -0,0 +1,16 @@ import type { ReactNode } from 'react'; /** * 车控组件(空调 / 音乐 / 香氛)拆分后的两个挂载点。 * * 卡片需要放进横向滑动的 ScrollView,而弹窗是 position: fixed 的全屏浮层。 * iOS(WKWebView)下 scroll-view 的滚动容器会成为 fixed 子元素的包含块, * 导致弹窗被限制在滚动区域内并被裁切(Android 正常),因此两者必须分开挂载: * card 放进 ScrollView,popup 放在 ScrollView 之外。 */ export type ControlNodes = { /** 入口卡片节点:放进横向滑动容器 */ card: ReactNode; /** 控制弹窗节点:必须挂在 ScrollView 之外 */ popup: ReactNode; }; src/robotaxi/pages/begin-route/components/calling-car.tsx
@@ -1,5 +1,5 @@ import { View, Image, ScrollView } 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'; @@ -7,9 +7,9 @@ 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'; // 车辆信息卡片 import TemperatureControl from './calling-car-modules/temperature-control'; // 空调温控(含温度调节面板 + 座椅通风加热) import FragranceControl from './calling-car-modules/fragrance-control'; // 车载香氛控制 import MusicControl from './calling-car-modules/music-control'; // 车载音乐曲风控制 import { useAcControl } from './calling-car-modules/temperature-control'; // 空调温控(含温度调节面板 + 座椅通风加热) import { useFragranceControl } from './calling-car-modules/fragrance-control'; // 车载香氛控制 import { useMusicControl } from './calling-car-modules/music-control'; // 车载音乐曲风控制 import TripActionBar from './calling-car-modules/trip-action-bar'; // 行程操作栏 import DoorTrunkControl from './calling-car-modules/door-trunk-control'; // 门箱控制 import FareDetailCard from './calling-car-modules/fare-detail-card'; // 费用明细卡片 @@ -174,6 +174,25 @@ /** 是否为“到达终点/待支付”状态(图一、图二) */ const isTripFinished = tripStatus === TripStatus.TripFinished; const isCallingCar = tripStatus === TripStatus.CallingCar; /** * 车控(空调 / 音乐 / 香氛):卡片与弹窗分开挂载。 * 卡片放进横向滑动的 ScrollView;弹窗是 fixed 全屏浮层,必须挂在 ScrollView 之外, * 否则 iOS(WKWebView)下滚动容器会成为 fixed 的包含块,弹窗被限制在卡片滑动区内。 * 三个 hook 内部均按 tripStatus / hasPreviousOrder 守卫,非接驾态不发请求、返回空节点。 */ const acControl = useAcControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder }); const musicControl = useMusicControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder }); const fragranceControl = useFragranceControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder }); /** * 车控卡片当前横向滚动位置。 * 弹窗开合与其弹出动画会驱动本组件重渲染,weapp 的 scroll-view 在重渲染 / 重建后会回到 * 初始位置(最左),表现为「弹窗一出现卡片就滑回去了」。这里用 ref 记录实时位置并回传 * scroll-left,使重渲染后停留在原处;用 ref 而非 state 是为了滚动过程中不触发额外渲染。 */ const cardScrollLeftRef = useRef(0); const handleCardScroll = (e: any) => { cardScrollLeftRef.current = e?.detail?.scrollLeft ?? 0; }; /** 费用明细是否展开(拦截 FareDetailCard 的回调本地记录,用于切换背景与卡片布局) */ const [detailExpanded, setDetailExpanded] = useState(false); /** 大号里程文案:取全程里程,单位参考 formatTripDistance(米→M,公里→KM) */ @@ -276,14 +295,21 @@ { [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService].includes(tripStatus) && <View className='car-card'> {/* 车控卡片横向滑动(仅接驾态展示):weapp 中普通 View 的 overflow 滚动无法响应拖动,必须用 ScrollView */} {/* 车控卡片横向滑动(仅接驾态展示):weapp 中普通 View 的 overflow 滚动无法响应拖动,必须用 ScrollView。 注意:这里只放卡片,三个控制弹窗在下方 ScrollView 之外单独挂载(iOS fixed 会被滚动容器裁切) */} { TripStatus.VehicleApproaching === tripStatus && <ScrollView className='temp-fragrance-scroll' scrollX showScrollbar={false}> <ScrollView className='temp-fragrance-scroll' scrollX showScrollbar={false} scrollLeft={cardScrollLeftRef.current} onScroll={handleCardScroll} > <View className='temp-fragrance-control'> <TemperatureControl tripStatus={tripStatus} orderId={orderId} pageDidShowVersion={pageDidShowVersion} hasPreviousOrder={hasPreviousOrder} /> <MusicControl tripStatus={tripStatus} orderId={orderId} pageDidShowVersion={pageDidShowVersion} hasPreviousOrder={hasPreviousOrder} /> <FragranceControl tripStatus={tripStatus} orderId={orderId} pageDidShowVersion={pageDidShowVersion} hasPreviousOrder={hasPreviousOrder} /> {acControl.card} {musicControl.card} {fragranceControl.card} </View> </ScrollView> } @@ -310,6 +336,15 @@ /> </View> } {/* 车控弹窗(空调 / 音乐 / 香氛):必须挂在横向滑动 ScrollView 之外。 iOS(WKWebView)下 scroll-view 的滚动容器会成为 fixed 子元素的包含块, 弹窗若留在 ScrollView 内会被限制、裁切在卡片滑动区里(Android 无此问题)。 非接驾态时三个 hook 均返回 null,无额外渲染开销。 */} {acControl.popup} {musicControl.popup} {fragranceControl.popup} { [TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus) && <View className='car-card fare-box-card'> src/robotaxi/pages/begin-route/components/css-folder/music-control.less
@@ -55,7 +55,7 @@ /* 曲风名称,大小类似温度/香氛名 */ .tf-card-music-name { font-size: 40px; font-weight: normal; font-weight: 500; line-height: 64px; color: @black-text-main; overflow: hidden;