| | |
| | | QueryOrderPaymentResultResponse, |
| | | QueryLedgerCreditOrderResultResponse |
| | | } from './types/begin-route'; |
| | | import { withApiLog } from '../utils/api-logger'; |
| | | |
| | | // 导航-获取路线和费用 |
| | | export const queryRouteAndFare = async (payload: QueryRouteAndFarePayload): Promise<QueryRouteAndFareResponse> => { |
| | |
| | | |
| | | // 设置温度 |
| | | export const setTemperature = async (payload: SetTemperaturePayload): Promise<SetTemperatureResponse> => { |
| | | return withApiLog('设置空调温度', payload, request({ |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/vehicleConvoy/api/v1/mini-program/order/ac/set-temperature', |
| | | method: 'POST', |
| | | payload, |
| | | showToast: false |
| | | }, true)); |
| | | }, true); |
| | | } |
| | | |
| | | // 设置温度并跳过业务异常拦截,供初始化越界温度纠正场景使用 |
| | | export const setTemperatureIgnoreBusinessError = async (payload: SetTemperaturePayload): Promise<SetTemperatureResponse> => { |
| | | return withApiLog('设置空调温度(忽略业务异常)', payload, request({ |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/vehicleConvoy/api/v1/mini-program/order/ac/set-temperature', |
| | | method: 'POST', |
| | | payload, |
| | | skipException: true, |
| | | showToast: false |
| | | }, true)); |
| | | }, true); |
| | | } |
| | | |
| | | // 解锁车辆 |
| | |
| | | export const queryTemperatureByOrderId = async ( |
| | | payload: OrderIdPayload |
| | | ): Promise<QueryTemperatureByOrderIdResponse> => { |
| | | return withApiLog('查询车辆温度', payload, request({ |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/vehicleConvoy/api/v1/mini-program/order/queryTemperatureByOrderId', |
| | | method: 'GET', |
| | | payload |
| | | }, true)); |
| | | }, true); |
| | | } |
| | | |
| | | // 查询车辆空调开关状态 |
| | | export const queryVehicleAirConditionerSwitch = async ( |
| | | payload: OrderIdPayload |
| | | ): Promise<QueryVehicleAirConditionerSwitchResponse> => { |
| | | return withApiLog('查询空调开关状态', payload, request({ |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/vehicleConvoy/api/v1/mini-program/order/queryVehicleAirConditionerSwitch', |
| | | method: 'GET', |
| | | payload |
| | | }, true)); |
| | | }, true); |
| | | } |
| | | |
| | | // 车辆空调开关控制 |
| | | export const airConditionerUnlock = async (payload: AirConditionerUnlockPayload): Promise<void> => { |
| | | return withApiLog('空调开关控制', payload, request({ |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/vehicleConvoy/api/v1/mini-program/order/airConditionerUnlock', |
| | | method: 'POST', |
| | | payload, |
| | | showToast: false |
| | | }, true)); |
| | | }, true); |
| | | } |
| | | |
| | | // 查询后备箱开启状态 |
| | |
| | | import request from '../../utils/request'; |
| | | import { _ROBOTAXI_HOST } from '../../constants/api'; |
| | | import { withApiLog } from '../utils/api-logger'; |
| | | import type { |
| | | QueryCabinFragranceResponse, |
| | | SetCabinFragrancePayload |
| | |
| | | export const queryCabinFragrance = async ( |
| | | orderId: string |
| | | ): Promise<QueryCabinFragranceResponse> => { |
| | | const payload = { orderId }; |
| | | return withApiLog('查询车载香氛状态', payload, request({ |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/vehicleConvoy/base/v1/mini-program/cabin/fragrance', |
| | | method: 'GET', |
| | | payload |
| | | }, true)); |
| | | payload: { orderId } |
| | | }, true); |
| | | }; |
| | | |
| | | // 设置车载香氛(聚合下发开关状态,开启时同时下发香型通道) |
| | | export const setCabinFragrance = async ( |
| | | payload: SetCabinFragrancePayload |
| | | ): Promise<void> => { |
| | | return withApiLog('设置车载香氛', payload, request({ |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/vehicleConvoy/base/v1/mini-program/cabin/fragrance', |
| | | method: 'POST', |
| | | payload, |
| | | skipException: true |
| | | }, true)); |
| | | }, true); |
| | | }; |
| New file |
| | |
| | | import request from '../../utils/request'; |
| | | import { _ROBOTAXI_HOST } from '../../constants/api'; |
| | | import type { |
| | | QueryOrderGenreResponse, |
| | | SetOrderGenrePayload |
| | | } from './types/genre'; |
| | | |
| | | const GENRE_API_URL = _ROBOTAXI_HOST + '/vehicleConvoy/api/v1/mini-program/order/genre'; |
| | | |
| | | // 查询当前订单曲风状态(音乐开关、当前曲风、已启用曲风列表) |
| | | export const queryOrderGenre = async ( |
| | | orderId: string |
| | | ): Promise<QueryOrderGenreResponse> => { |
| | | return request({ |
| | | url: GENRE_API_URL, |
| | | method: 'GET', |
| | | payload: { orderId } |
| | | }, true); |
| | | }; |
| | | |
| | | // 设置当前订单曲风(下发音乐开关状态,开启时同时下发曲风 ID) |
| | | export const setOrderGenre = async ( |
| | | payload: SetOrderGenrePayload |
| | | ): Promise<void> => { |
| | | return request({ |
| | | url: GENRE_API_URL, |
| | | method: 'POST', |
| | | payload, |
| | | skipException: true |
| | | }, true); |
| | | }; |
| | |
| | | import request from '../../utils/request'; |
| | | import { _ROBOTAXI_HOST } from '../../constants/api'; |
| | | import { withApiLog } from '../utils/api-logger'; |
| | | import type { QuerySeatHvResponse, SetSeatHvPayload } from './types/seat-hv'; |
| | | |
| | | // 查询座椅通风/加热状态 |
| | | export const querySeatHv = async (orderId: string): Promise<QuerySeatHvResponse> => { |
| | | const payload = { orderId }; |
| | | return withApiLog('查询座椅通风加热状态', payload, request({ |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/vehicleConvoy/base/v1/mini-program/cabin/seat/hv', |
| | | method: 'GET', |
| | | payload |
| | | }, true)); |
| | | payload: { orderId } |
| | | }, true); |
| | | }; |
| | | |
| | | // 设置座椅通风/加热(seats 仅含被改动的座椅,通风与加热一起下发) |
| | | export const setSeatHv = async (payload: SetSeatHvPayload): Promise<void> => { |
| | | return withApiLog('设置座椅通风加热', payload, request({ |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/vehicleConvoy/base/v1/mini-program/cabin/seat/hv', |
| | | method: 'POST', |
| | | payload, |
| | | skipException: true |
| | | }, true)); |
| | | }, true); |
| | | }; |
| New file |
| | |
| | | import type { OrderId } from './begin-route'; |
| | | |
| | | /** 单个曲风。 */ |
| | | export interface GenreItem { |
| | | /** 曲风唯一标识 / 曲风 ID(下发时使用)。 */ |
| | | id: string; |
| | | /** 曲风显示名称。 */ |
| | | name: string; |
| | | /** 曲风描述(可选,后端未下发时前端可留空)。 */ |
| | | desc?: string; |
| | | } |
| | | |
| | | /** 订单曲风状态对象。 */ |
| | | export interface OrderGenreState { |
| | | /** 音乐开关状态。 */ |
| | | switchOn: boolean; |
| | | /** 当前选中曲风 ID(关闭时可为空)。 */ |
| | | currentGenreId?: string; |
| | | /** 后台已启用曲风列表(动态),空数组表示暂无可选曲风。 */ |
| | | availableGenres?: GenreItem[]; |
| | | } |
| | | |
| | | /** 查询订单曲风状态响应对象。 */ |
| | | export interface QueryOrderGenreResponse { |
| | | /** 曲风状态。 */ |
| | | genre?: OrderGenreState; |
| | | } |
| | | |
| | | /** 设置订单曲风请求。 */ |
| | | export interface SetOrderGenrePayload { |
| | | /** 订单 ID。 */ |
| | | orderId: OrderId; |
| | | /** 目标开关状态。 */ |
| | | switchOn: boolean; |
| | | /** 目标曲风 ID;switchOn 为 true 时必传,false 时可不传。 */ |
| | | genreId?: string; |
| | | } |
| | |
| | | import { getOrderId } from '../../../../../utils/order'; |
| | | import { TripStatus } from '../../../../../constants/trip-status'; |
| | | import useBottomPopupAnimation from '../../hooks/use-bottom-popup-animation'; |
| | | import AcSwitch from '../ac-switch'; |
| | | import '../../css-folder/fragrance-control.less'; |
| | | |
| | | const FC_HEADER_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/fragrance-icon-20260716.png'; |
| | |
| | | const cnNum = scentCount <= 10 ? CN_NUMBERS[scentCount] : String(scentCount); |
| | | return `可选${cnNum}款香型`; |
| | | } |
| | | const FRAGRANCE_OPEN_SUCCESS_TEXT = '已开启香氛'; // 开启香氛成功提示文案 |
| | | const FRAGRANCE_OPEN_PREFIX = '已开启香氛:'; // 开启/切换香氛成功提示前缀(后接香氛名称) |
| | | const CLOSE_SUCCESS_TEXT = '已关闭香氛'; |
| | | const PANEL_CLOSE_DELAY_MS = 150; // 下发成功后延迟关闭弹窗,优化交互体验 |
| | | const APPLY_FAIL_TEXT = '网络异常,请重试'; |
| | | |
| | | /** 香调描述本地兜底表:接口未返回 desc 时按 id 取用。 */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * 计算车端开启态下当前生效的香型通道。 |
| | | * 计算初始选中香型通道:优先车端当前香型,其次列表首项(关闭态也保留选中,用于卡片展示当前香氛)。 |
| | | */ |
| | | function resolveActiveChannelId(scents: FragranceScent[], switchOn: boolean, currentScent?: string): string { |
| | | if (!switchOn || !scents.length) { |
| | | return ''; |
| | | function resolveInitialChannelId(scents: FragranceScent[], currentScent?: string): string { |
| | | if (currentScent) { |
| | | const matched = scents.find((item) => item.id === currentScent); |
| | | if (matched) { |
| | | return matched.channelId; |
| | | } |
| | | } |
| | | const matched = currentScent ? scents.find((item) => item.id === currentScent) : undefined; |
| | | return (matched || scents[0]).channelId; |
| | | return scents.length ? scents[0].channelId : ''; |
| | | } |
| | | |
| | | export default function FragranceControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props) { |
| | | // 车端真实状态 |
| | | const [switchOn, setSwitchOn] = useState(false); |
| | | const [scents, setScents] = useState<FragranceScent[]>([]); |
| | | // 当前生效香型通道(关闭时为空) |
| | | const [activeChannelId, setActiveChannelId] = useState(''); |
| | | // 卡片主标题(开启时展示香型名,关闭时展示"已关闭") |
| | | const [cardTitle, setCardTitle] = useState(CLOSED_TITLE); |
| | | // 当前选中香型通道(关闭态也保留,用于卡片展示当前香氛) |
| | | const [selectedChannelId, setSelectedChannelId] = useState(''); |
| | | // 面板显隐 + 下发锁 |
| | | const [panelVisible, setPanelVisible] = useState(false); |
| | | const [applying, setApplying] = useState(false); |
| | |
| | | |
| | | const { animationData, shouldRender } = useBottomPopupAnimation(panelVisible); |
| | | const hasScents = scents.length > 0; |
| | | const selectedScent = findScentByChannel(scents, selectedChannelId); |
| | | // 卡片主标题:始终展示当前选中香型名(关闭态由样式置灰),无香型时兜底"已关闭" |
| | | const cardTitle = selectedScent?.name || (hasScents ? scents[0].name : CLOSED_TITLE); |
| | | // 开关 loading 仅在由开关(而非香型磁贴)触发下发时展示 |
| | | const switchLoading = applying && applyingChannelId === ''; |
| | | |
| | | // 进入接驾态查询香氛状态;连环单不查询、不展示 |
| | | useEffect(() => { |
| | |
| | | const fragrance = res || {}; |
| | | const nextSwitchOn = fragrance.switchOn === true; |
| | | const nextScents: FragranceScent[] = Array.isArray(fragrance.availableScents) ? fragrance.availableScents : []; |
| | | const nextActiveChannelId = resolveActiveChannelId(nextScents, nextSwitchOn, fragrance.currentScent); |
| | | setSwitchOn(nextSwitchOn); |
| | | setScents(nextScents); |
| | | setActiveChannelId(nextActiveChannelId); |
| | | const activeName = findScentByChannel(nextScents, nextActiveChannelId)?.name || ''; |
| | | setCardTitle(nextSwitchOn && activeName ? activeName : CLOSED_TITLE); |
| | | setSelectedChannelId(resolveInitialChannelId(nextScents, fragrance.currentScent)); |
| | | }).catch(() => { |
| | | if (querySeqRef.current !== snapshotSeq) { |
| | | return; |
| | |
| | | // 查询失败按「已关闭」兜底 |
| | | setSwitchOn(false); |
| | | setScents([]); |
| | | setActiveChannelId(''); |
| | | setCardTitle(CLOSED_TITLE); |
| | | setSelectedChannelId(''); |
| | | }); |
| | | }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]); |
| | | |
| | |
| | | setPanelVisible(false); |
| | | }; |
| | | |
| | | // 点击香型即时下发:点选中项 → 关闭香氛;点其它项 → 开启并切换香型 |
| | | const handleScentClick = async (channelId: string) => { |
| | | if (applying) { |
| | | // 点击卡片:只有一个香氛时卡片本体不可点击(仅右上角开关控制);多个香氛时打开选择面板 |
| | | const handleCardClick = () => { |
| | | if (scents.length <= 1) { |
| | | return; |
| | | } |
| | | togglePanel(); |
| | | }; |
| | | |
| | | // 点击香型即时下发:点选中项 → 关闭香氛;点其它项 → 开启并切换香型 |
| | | /** |
| | | * 下发香氛设置到车端;成功后同步开关与选中香型、关闭弹窗并 toast,失败提示重试。 |
| | | * @param loadingChannelId 触发下发的香型通道 ID(用于在该磁贴展示 loading);由开关触发时传空串。 |
| | | */ |
| | | const applyFragrance = async (nextSwitchOn: boolean, channelId: string, loadingChannelId = '') => { |
| | | const targetOrderId = getOrderId(orderId); |
| | | if (!targetOrderId) { |
| | | return; |
| | | } |
| | | // 点击当前生效香型 → 关闭;否则 → 开启该香型 |
| | | const turningOff = switchOn && channelId === activeChannelId; |
| | | const nextSwitchOn = !turningOff; |
| | | setApplying(true); |
| | | setApplyingChannelId(channelId); |
| | | setApplyingChannelId(loadingChannelId); |
| | | try { |
| | | await setCabinFragrance({ |
| | | orderId: targetOrderId, |
| | | switchOn: nextSwitchOn, |
| | | ...(nextSwitchOn ? { channelId } : {}) |
| | | }); |
| | | // 下发成功后同步车端真实状态与卡片主标题 |
| | | // 下发成功后同步车端真实状态(关闭态保留选中香型不重置) |
| | | setSwitchOn(nextSwitchOn); |
| | | const nextActiveChannelId = nextSwitchOn ? channelId : ''; |
| | | setActiveChannelId(nextActiveChannelId); |
| | | const activeName = findScentByChannel(scents, nextActiveChannelId)?.name || ''; |
| | | setCardTitle(nextSwitchOn && activeName ? activeName : CLOSED_TITLE); |
| | | // 开启/关闭香氛均使用通用 Toast 组件提示(success),并关闭香氛设置弹窗 |
| | | showToast({ title: nextSwitchOn ? FRAGRANCE_OPEN_SUCCESS_TEXT : CLOSE_SUCCESS_TEXT, icon: 'success' }); |
| | | closePanel(); |
| | | if (nextSwitchOn && channelId) { |
| | | setSelectedChannelId(channelId); |
| | | } |
| | | // 开启/关闭香氛:通用 Toast 提示(success),并延迟 150ms 关闭香氛设置弹窗(优化交互体验) |
| | | const appliedScentName = findScentByChannel(scents, channelId)?.name || ''; |
| | | showToast({ title: nextSwitchOn ? `${FRAGRANCE_OPEN_PREFIX}${appliedScentName}` : CLOSE_SUCCESS_TEXT, icon: 'success' }); |
| | | setTimeout(() => closePanel(), PANEL_CLOSE_DELAY_MS); |
| | | } catch (error) { |
| | | showToast({ title: APPLY_FAIL_TEXT, icon: 'none' }); |
| | | } finally { |
| | | setApplying(false); |
| | | setApplyingChannelId(''); |
| | | } |
| | | }; |
| | | |
| | | // 点击香型磁贴:点当前生效香型 → 关闭;点其它 → 开启并切换到该香型 |
| | | const handleScentClick = (channelId: string) => { |
| | | if (applying) { |
| | | return; |
| | | } |
| | | const turningOff = switchOn && channelId === selectedChannelId; |
| | | applyFragrance(!turningOff, channelId, channelId); |
| | | }; |
| | | |
| | | // 切换总开关(卡片主开关 / 弹窗底部开关共用):打开→下发当前选中香型;关闭→下发关闭并保留选中 |
| | | const handleToggleSwitch = () => { |
| | | if (applying || !hasScents) { |
| | | return; |
| | | } |
| | | const nextSwitchOn = !switchOn; |
| | | const targetChannelId = selectedChannelId || scents[0]?.channelId || ''; |
| | | applyFragrance(nextSwitchOn, targetChannelId, ''); |
| | | }; |
| | | |
| | | // 连环单或非接驾态不展示香氛卡片 |
| | |
| | | {/* 香氛入口卡片(复用 tf-card 视觉):点击打开面板 */} |
| | | <View |
| | | className={`tf-card tf-card-fragrance ${fragranceCardStateClass}`} |
| | | onClick={togglePanel} |
| | | onClick={handleCardClick} |
| | | > |
| | | <View className='tf-card-header'> |
| | | <View className='tf-card-header-left'> |
| | | <Image className='tf-card-header-icon' src={FC_HEADER_ICON} /> |
| | | <Text className='tf-card-header-title'>香氛</Text> |
| | | </View> |
| | | <AcSwitch checked={switchOn} loading={switchLoading} onChange={handleToggleSwitch} /> |
| | | </View> |
| | | <View className='tf-card-bottom'> |
| | | <View className='tf-card-fragrance-name'>{cardTitle}</View> |
| | | <View className='tf-card-footer'> |
| | | <Text className='tf-card-footer-text'>{resolveCardSubtitle(scents.length)}</Text> |
| | | <Image className='tf-card-footer-arrow' src={ARROW_ICON_URL} /> |
| | | {scents.length > 1 && <Image className='tf-card-footer-arrow' src={ARROW_ICON_URL} />} |
| | | </View> |
| | | </View> |
| | | </View> |
| | |
| | | {hasScents ? ( |
| | | <View className='popup-scent-list'> |
| | | {scents.map((item) => { |
| | | const isActive = switchOn && item.channelId === activeChannelId; |
| | | const isActive = switchOn && item.channelId === selectedChannelId; |
| | | const isLoading = applyingChannelId === item.channelId; |
| | | const desc = resolveScentDesc(item); |
| | | return ( |
| New file |
| | |
| | | import { View, Text, Image } from '@tarojs/components'; |
| | | import { useEffect, useRef, useState } from 'react'; |
| | | import { queryOrderGenre, setOrderGenre } from '../../../../../api/genre'; |
| | | import type { GenreItem } from '../../../../../api/types/genre'; |
| | | import { getOrderId } from '../../../../../utils/order'; |
| | | import { TripStatus } from '../../../../../constants/trip-status'; |
| | | import useBottomPopupAnimation from '../../hooks/use-bottom-popup-animation'; |
| | | import AcSwitch from '../ac-switch'; |
| | | import { showToast } from '../../../../../components/toast'; |
| | | import '../../css-folder/music-control.less'; |
| | | |
| | | const GENRE_HEADER_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/genre-0731@2x.png'; |
| | | const ARROW_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/temperature-fragrance-arrow-20260706.png'; |
| | | const CLOSE_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/close-icon.png'; |
| | | const LOADING_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/T-switch-loading-20260715.png'; |
| | | |
| | | const CARD_DEFAULT_TITLE = '车载音乐'; // 无可选曲风时卡片主标题兜底 |
| | | const NO_GENRE_TIP = '当前暂无可选曲风'; // 空曲风列表占位 |
| | | const PLAY_TIP_TEXT = '音乐将在您上车后开始播放'; // 弹窗底部开关文案 |
| | | const OPEN_PLAY_PREFIX = '上车后将自动播放:'; // 开启/切换曲风成功提示前缀(后接曲风名称) |
| | | const CLOSE_SUCCESS_TEXT = '音乐已关闭'; |
| | | const PANEL_CLOSE_DELAY_MS = 150; // 下发成功后延迟关闭弹窗,优化交互体验 |
| | | const APPLY_FAIL_TEXT = '网络异常,请重试'; |
| | | // 车端下发结果失败标识:接口 code 可能为 200,但 data 返回 'FAILED' 表示车端下发失败 |
| | | const APPLY_FAIL_RESULT = 'FAILED'; |
| | | |
| | | // 卡片副标题轮播文案(上下滚动轮播,动效由 CSS 实现) |
| | | const SUBTITLE_TEXTS = [ |
| | | '下一站,跟着节奏出发', |
| | | '好音乐,陪你出发', |
| | | '长风相送,雅韵随行', |
| | | '带上音乐,轻松启程', |
| | | '给旅程多一些旋律' |
| | | ]; |
| | | |
| | | type Props = { |
| | | tripStatus: TripStatus; |
| | | orderId?: string; |
| | | pageDidShowVersion?: number; |
| | | /** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */ |
| | | hasPreviousOrder?: boolean; |
| | | }; |
| | | |
| | | /** |
| | | * 从曲风列表中按 ID 查找曲风。 |
| | | */ |
| | | function findGenreById(genres: GenreItem[], genreId: string): GenreItem | undefined { |
| | | return genres.find((item) => item.id === genreId); |
| | | } |
| | | |
| | | /** |
| | | * 计算初始选中曲风:优先车端当前曲风,其次列表首项。 |
| | | */ |
| | | function resolveInitialGenreId(genres: GenreItem[], currentGenreId?: string): string { |
| | | if (currentGenreId && findGenreById(genres, currentGenreId)) { |
| | | return currentGenreId; |
| | | } |
| | | return genres.length ? genres[0].id : ''; |
| | | } |
| | | |
| | | export default function MusicControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props) { |
| | | // 车端真实开关状态 |
| | | const [switchOn, setSwitchOn] = useState(false); |
| | | // 后台配置曲风列表 |
| | | const [genres, setGenres] = useState<GenreItem[]>([]); |
| | | // 当前选中曲风 ID(关闭态下也可有值,仅本地记录,不下发) |
| | | const [selectedGenreId, setSelectedGenreId] = useState(''); |
| | | // 面板显隐 + 下发锁 |
| | | const [panelVisible, setPanelVisible] = useState(false); |
| | | const [applying, setApplying] = useState(false); |
| | | // 正在下发的曲风磁贴 ID(该磁贴显示 loading),空串表示由开关触发(loading 落在开关上) |
| | | const [applyingGenreId, setApplyingGenreId] = useState(''); |
| | | const querySeqRef = useRef(0); |
| | | // 记录本次接驾态周期内已查询过曲风状态的订单 ID,避免重复请求(同一订单在初始化恢复 |
| | | // 流程中 tripStatus/orderId/hasPreviousOrder 会分批异步更新,触发本 effect 多次)。 |
| | | const queriedOrderIdRef = useRef(''); |
| | | |
| | | const { animationData, shouldRender } = useBottomPopupAnimation(panelVisible); |
| | | const hasGenres = genres.length > 0; |
| | | const selectedGenre = findGenreById(genres, selectedGenreId); |
| | | // 卡片主标题:始终展示曲风名(关闭态由样式置灰),无曲风时兜底 |
| | | const cardTitle = selectedGenre?.name || (hasGenres ? genres[0].name : CARD_DEFAULT_TITLE); |
| | | // 开关 loading 仅在由开关(而非磁贴)触发下发时展示 |
| | | const switchLoading = applying && applyingGenreId === ''; |
| | | |
| | | // 进入接驾态查询曲风状态;连环单不查询、不展示 |
| | | useEffect(() => { |
| | | if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) { |
| | | // 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。 |
| | | queriedOrderIdRef.current = ''; |
| | | return; |
| | | } |
| | | const targetOrderId = getOrderId(orderId); |
| | | if (!targetOrderId) { |
| | | return; |
| | | } |
| | | // 去重:同一订单在本次接驾态周期内只查询一次。 |
| | | if (queriedOrderIdRef.current === targetOrderId) { |
| | | return; |
| | | } |
| | | queriedOrderIdRef.current = targetOrderId; |
| | | const snapshotSeq = ++querySeqRef.current; |
| | | queryOrderGenre(targetOrderId).then((res: any) => { |
| | | if (querySeqRef.current !== snapshotSeq) { |
| | | return; |
| | | } |
| | | const genre = res || {}; |
| | | const nextSwitchOn = genre.switchOn === true; |
| | | const nextGenres: GenreItem[] = Array.isArray(genre.availableGenres) ? genre.availableGenres : []; |
| | | setSwitchOn(nextSwitchOn); |
| | | setGenres(nextGenres); |
| | | setSelectedGenreId(resolveInitialGenreId(nextGenres, genre.currentGenreId)); |
| | | }).catch(() => { |
| | | if (querySeqRef.current !== snapshotSeq) { |
| | | return; |
| | | } |
| | | // 查询失败按「已关闭」兜底 |
| | | setSwitchOn(false); |
| | | setGenres([]); |
| | | setSelectedGenreId(''); |
| | | }); |
| | | }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]); |
| | | |
| | | // 点击卡片:切换面板开合 |
| | | const togglePanel = () => { |
| | | setPanelVisible((prev) => !prev); |
| | | }; |
| | | |
| | | const closePanel = () => { |
| | | setPanelVisible(false); |
| | | }; |
| | | |
| | | /** |
| | | * 下发曲风设置到车端;成功后同步开关状态、关闭弹窗并 toast,失败回滚并提示。 |
| | | * @param loadingGenreId 触发下发的曲风磁贴 ID(用于在该磁贴上展示 loading);由开关触发时传空串。 |
| | | */ |
| | | const applyGenre = async (nextSwitchOn: boolean, genreId: string, loadingGenreId = '') => { |
| | | const targetOrderId = getOrderId(orderId); |
| | | if (!targetOrderId) { |
| | | return; |
| | | } |
| | | setApplying(true); |
| | | setApplyingGenreId(loadingGenreId); |
| | | try { |
| | | const res: any = await setOrderGenre({ |
| | | orderId: targetOrderId, |
| | | switchOn: nextSwitchOn, |
| | | ...(nextSwitchOn ? { genreId } : {}) |
| | | }); |
| | | // skipException 下业务失败会以数据形式 resolve,需显式校验: |
| | | // ① HTTP 状态码非 200;② code 为 200 但 data 返回 'FAILED'(车端下发失败) |
| | | if (res && ((res.code !== undefined && res.code !== 200) || res.data === APPLY_FAIL_RESULT)) { |
| | | throw res; |
| | | } |
| | | setSwitchOn(nextSwitchOn); |
| | | if (nextSwitchOn && genreId) { |
| | | setSelectedGenreId(genreId); |
| | | } |
| | | // 打开/关闭成功:toast 提示,并延迟 150ms 关闭控制弹窗(优化交互体验) |
| | | const appliedGenreName = findGenreById(genres, genreId)?.name || ''; |
| | | showToast({ title: nextSwitchOn ? `${OPEN_PLAY_PREFIX}${appliedGenreName}` : CLOSE_SUCCESS_TEXT, icon: 'success' }); |
| | | setTimeout(() => setPanelVisible(false), PANEL_CLOSE_DELAY_MS); |
| | | } catch (error) { |
| | | // 下发失败:不改动本地状态(回滚),仅提示 |
| | | showToast({ title: APPLY_FAIL_TEXT, icon: 'none' }); |
| | | } finally { |
| | | setApplying(false); |
| | | setApplyingGenreId(''); |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 点击曲风磁贴:点当前生效曲风 → 关闭;点其它曲风 → 开启并切换到该曲风。 |
| | | * 该磁贴上方展示 loading,成功后同步底部总开关状态。 |
| | | */ |
| | | const handleGenreClick = (genreId: string) => { |
| | | if (applying) { |
| | | return; |
| | | } |
| | | const turningOff = switchOn && genreId === selectedGenreId; |
| | | applyGenre(!turningOff, genreId, genreId); |
| | | }; |
| | | |
| | | /** |
| | | * 切换总开关(卡片主开关 / 弹窗底部开关共用): |
| | | * - 打开:下发 switchOn=true + 当前选中曲风(无则取列表首项); |
| | | * - 关闭:下发 switchOn=false,保留已选曲风不重置。 |
| | | */ |
| | | const handleToggleSwitch = () => { |
| | | if (applying || !hasGenres) { |
| | | return; |
| | | } |
| | | const nextSwitchOn = !switchOn; |
| | | const targetGenreId = selectedGenreId || genres[0]?.id || ''; |
| | | applyGenre(nextSwitchOn, targetGenreId, ''); |
| | | }; |
| | | |
| | | // 连环单或非接驾态不展示音乐卡片 |
| | | if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) { |
| | | return null; |
| | | } |
| | | |
| | | // 音乐卡片背景随开关状态切换 |
| | | const musicCardStateClass = switchOn ? 'is-open' : 'is-close'; |
| | | |
| | | return ( |
| | | <View className='music-control'> |
| | | {/* 音乐入口卡片(复用 tf-card 视觉):点击打开面板 */} |
| | | <View |
| | | className={`tf-card tf-card-music ${musicCardStateClass}`} |
| | | onClick={togglePanel} |
| | | > |
| | | <View className='tf-card-header'> |
| | | <View className='tf-card-header-left'> |
| | | <Image className='tf-card-header-icon' src={GENRE_HEADER_ICON} /> |
| | | <Text className='tf-card-header-title'>音乐</Text> |
| | | </View> |
| | | <AcSwitch |
| | | checked={switchOn} |
| | | loading={switchLoading} |
| | | onChange={handleToggleSwitch} |
| | | /> |
| | | </View> |
| | | <View className='tf-card-bottom'> |
| | | <View className='tf-card-music-name'>{cardTitle}</View> |
| | | <View className='tf-card-footer'> |
| | | {/* 副标题上下滚动轮播:末尾克隆首条实现无缝循环,动效由 CSS 驱动 */} |
| | | <View className='tf-card-footer-text tf-card-subtitle'> |
| | | <View className='tf-card-subtitle-track'> |
| | | {SUBTITLE_TEXTS.map((text, idx) => ( |
| | | <View key={idx} className='tf-card-subtitle-item'>{text}</View> |
| | | ))} |
| | | <View className='tf-card-subtitle-item'>{SUBTITLE_TEXTS[0]}</View> |
| | | </View> |
| | | </View> |
| | | <Image className='tf-card-footer-arrow' src={ARROW_ICON_URL} /> |
| | | </View> |
| | | </View> |
| | | </View> |
| | | |
| | | {/* 控制面板(底部弹窗) */} |
| | | {shouldRender && ( |
| | | <View className={`music-popup ${panelVisible ? 'show' : ''}`} catchMove> |
| | | <View className='popup-mask' onClick={closePanel}></View> |
| | | <View className='popup-content' animation={animationData}> |
| | | <View className='popup-header'> |
| | | <Text className='popup-title'>车载音乐</Text> |
| | | <Image className='popup-close' src={CLOSE_ICON_URL} onClick={closePanel} /> |
| | | </View> |
| | | |
| | | <View className='popup-genre-area'> |
| | | {hasGenres ? ( |
| | | <View className='popup-genre-list'> |
| | | {genres.map((item) => { |
| | | const isActive = switchOn && item.id === selectedGenreId; |
| | | const isLoading = applyingGenreId === item.id; |
| | | return ( |
| | | <View |
| | | key={item.id} |
| | | className={`popup-genre-item ${isActive ? 'active' : ''} ${applying ? 'disabled' : ''}`} |
| | | onClick={() => handleGenreClick(item.id)} |
| | | > |
| | | { |
| | | isLoading ? |
| | | <Image className='popup-genre-loading' src={LOADING_ICON_URL} /> : |
| | | <Text className='popup-genre-name'>{item.name}</Text> |
| | | } |
| | | </View> |
| | | ); |
| | | })} |
| | | </View> |
| | | ) : ( |
| | | <View className='popup-genre-empty'>{NO_GENRE_TIP}</View> |
| | | )} |
| | | </View> |
| | | |
| | | {/* 底部播放开关 */} |
| | | <View className='popup-play-switch'> |
| | | <Text className='popup-play-text'>{PLAY_TIP_TEXT}</Text> |
| | | <AcSwitch checked={switchOn} loading={switchLoading} onChange={handleToggleSwitch} /> |
| | | </View> |
| | | </View> |
| | | </View> |
| | | )} |
| | | </View> |
| | | ); |
| | | } |
| | |
| | | import { TripStatus } from '../../../../../constants/trip-status'; |
| | | import '../../css-folder/seat-hv-control.less'; |
| | | |
| | | const SEAT_BG_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/seat_bg_0803.webp'; |
| | | const SEAT_BG_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/seat_bg_0805@2x.png'; |
| | | const IMG_BASE = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/'; |
| | | const LOADING_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/T-switch-loading-20260715.png'; |
| | | const NO_SEAT_TIP = '座椅调节暂不可用'; |
| | |
| | | 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 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'; // 费用明细卡片 |
| | |
| | | /> |
| | | |
| | | { |
| | | /* 行程操作栏:所有状态下均紧随状态卡、位于蓝牙提示条上方(对齐设计稿) */ |
| | | [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 && |
| | | <BluetoothOperateCard |
| | | description={bluetoothCardDescription} |
| | |
| | | <View className='car-card'> |
| | | <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} /> |
| | | </View> |
| | | |
| | |
| | | /> |
| | | </View> |
| | | } |
| | | { |
| | | [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} |
| | | /> |
| | | } |
| | | |
| | | <DoorTrunkControl |
| | | tripStatus={tripStatus === TripStatus.ArrivedDestination ? TripStatus.ArrivedDestination : -1} |
| | | orderId={orderId} |
| | |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | padding: 32px 8px 0 8px; |
| | | margin-top: 8px; |
| | | padding: 32px 8px; |
| | | } |
| | | .trip-action-bar-item { |
| | | display: flex; |
| | |
| | | position: relative; |
| | | z-index: 2; |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | justify-content: space-between; |
| | | gap: 16px; |
| | | margin-top: 16px; |
| | | gap: 28px; |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | /* 空调/香氛入口卡片开关 loading 旋转动画 */ |
| | |
| | | box-sizing: border-box; |
| | | width: 312px; |
| | | height: 240px; |
| | | padding: 24px; |
| | | padding: 8px 24px 24px; |
| | | border-radius: 24px; |
| | | overflow: hidden; |
| | | display: flex; |
| | |
| | | |
| | | .tf-card-header-icon { |
| | | flex-shrink: 0; |
| | | width: 28px; |
| | | height: 28px; |
| | | width: 32px; |
| | | height: 32px; |
| | | margin-right: 8px; |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | /* 香氛入口卡片 tf-card 样式(自包含,迁移自 temperature-fragrance-control.less) */ |
| | | /* 香氛入口卡片:tf-card 基础样式(盒模型 + header/footer 等)统一由 calling-car.less 的全局 .tf-card 提供,此处只保留香氛专属样式 */ |
| | | .fragrance-control { |
| | | .tf-card { |
| | | flex-shrink: 0; |
| | | position: relative; |
| | | box-sizing: border-box; |
| | | width: 312px; |
| | | height: 240px; |
| | | padding: 24px; |
| | | border-radius: 24px; |
| | | overflow: hidden; |
| | | display: flex; |
| | | flex-direction: column; |
| | | justify-content: space-between; |
| | | background-repeat: no-repeat; |
| | | background-position: center; |
| | | background-size: 100% 100%; |
| | | } |
| | | |
| | | .tf-card-bottom { |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .tf-card-header { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | } |
| | | |
| | | .tf-card-header-left { |
| | | display: flex; |
| | | align-items: center; |
| | | } |
| | | |
| | | .tf-card-header-icon { |
| | | flex-shrink: 0; |
| | | width: 28px; |
| | | height: 28px; |
| | | margin-right: 8px; |
| | | } |
| | | |
| | | .tf-card-header-title { |
| | | font-size: 24px; |
| | | font-weight: 500; |
| | | line-height: normal; |
| | | letter-spacing: normal; |
| | | color: @black-text-main; |
| | | } |
| | | |
| | | /* 香氛名称,大小类似温度显示 */ |
| | | .tf-card-fragrance-name { |
| | | font-size: 40px; |
| | |
| | | white-space: nowrap; |
| | | text-overflow: ellipsis; |
| | | text-align: left; |
| | | } |
| | | |
| | | .tf-card-footer { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | } |
| | | |
| | | .tf-card-footer-text { |
| | | font-size: 20px; |
| | | line-height: normal; |
| | | letter-spacing: normal; |
| | | color: rgba(20, 30, 39, 0.5); |
| | | } |
| | | |
| | | .tf-card-footer-arrow { |
| | | flex-shrink: 0; |
| | | width: 20px; |
| | | height: 20px; |
| | | } |
| | | |
| | | /* 香氛入口卡片:背景随开关状态切换 */ |
| | |
| | | .popup-scent-list { |
| | | display: flex; |
| | | align-items: stretch; |
| | | justify-content: space-between; |
| | | justify-content: center; |
| | | gap: 48px; |
| | | |
| | | // 香型卡片:竖排香型名 + 香调描述,背景使用设计切图(选中/未选) |
| | | .popup-scent-item { |
| New file |
| | |
| | | @import '../../../../style/ui-variables.less'; |
| | | |
| | | // 曲风磁贴下发 loading 旋转动画 |
| | | @keyframes music-genre-spin { |
| | | from { |
| | | transform: rotate(0deg); |
| | | } |
| | | |
| | | to { |
| | | transform: rotate(360deg); |
| | | } |
| | | } |
| | | |
| | | // 副标题上下滚动轮播(5 条文案 + 末尾克隆首条实现无缝循环,单条行高 20px) |
| | | @keyframes music-subtitle-roll { |
| | | 0%, 16% { |
| | | transform: translateY(0); |
| | | } |
| | | 20%, 36% { |
| | | transform: translateY(-20px); |
| | | } |
| | | 40%, 56% { |
| | | transform: translateY(-40px); |
| | | } |
| | | 60%, 76% { |
| | | transform: translateY(-60px); |
| | | } |
| | | 80%, 96% { |
| | | transform: translateY(-80px); |
| | | } |
| | | 100% { |
| | | transform: translateY(-100px); |
| | | } |
| | | } |
| | | |
| | | /* 音乐入口卡片:tf-card 基础样式(盒模型 + header/footer 等)统一由 calling-car.less 的全局 .tf-card 提供,此处只保留音乐专属样式 */ |
| | | .music-control { |
| | | /* 音符图标(暂用字形占位,待设计切图后替换为 <Image>) */ |
| | | .tf-card-header-note { |
| | | flex-shrink: 0; |
| | | width: 28px; |
| | | height: 28px; |
| | | margin-right: 8px; |
| | | font-size: 28px; |
| | | line-height: 28px; |
| | | text-align: center; |
| | | color: @black-text-main; |
| | | } |
| | | |
| | | /* 曲风名称,大小类似温度/香氛名 */ |
| | | .tf-card-music-name { |
| | | font-size: 40px; |
| | | font-weight: normal; |
| | | line-height: 64px; |
| | | color: @black-text-main; |
| | | overflow: hidden; |
| | | white-space: nowrap; |
| | | text-overflow: ellipsis; |
| | | text-align: left; |
| | | } |
| | | |
| | | // 副标题上下滚动轮播:仅露出一行(20px),轨道竖排承载多条文案 |
| | | .tf-card-subtitle { |
| | | flex: 1; |
| | | min-width: 0; |
| | | height: 20px; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .tf-card-subtitle-track { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: flex-start; |
| | | animation: music-subtitle-roll 25s ease infinite; |
| | | } |
| | | |
| | | .tf-card-subtitle-item { |
| | | height: 20px; |
| | | line-height: 20px; |
| | | overflow: hidden; |
| | | white-space: nowrap; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | /* 音乐入口卡片:背景随开关状态切换 */ |
| | | .tf-card-music { |
| | | &.is-open { |
| | | background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/genre-close-0731@2x.png'); |
| | | } |
| | | |
| | | &.is-close { |
| | | background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/genre-open-0731@2x.png'); |
| | | |
| | | // 关闭态卡片文字统一弱化 |
| | | .tf-card-header-note, |
| | | .tf-card-header-title, |
| | | .tf-card-music-name, |
| | | .tf-card-footer-text { |
| | | color: rgba(20, 30, 39, 0.3); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 底部弹窗面板 |
| | | .music-popup { |
| | | position: fixed; |
| | | top: 0; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | z-index: 1000; |
| | | display: flex; |
| | | flex-direction: column; |
| | | justify-content: flex-end; |
| | | pointer-events: none; |
| | | |
| | | &.show { |
| | | pointer-events: auto; |
| | | } |
| | | |
| | | .popup-mask { |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | width: 100%; |
| | | height: 100%; |
| | | background: rgba(0, 0, 0, 0.4); |
| | | z-index: 102; |
| | | } |
| | | |
| | | .popup-content { |
| | | position: relative; |
| | | z-index: 103; |
| | | background: #F1F5FA; |
| | | border-radius: 60px 60px 0 0; |
| | | width: 100%; |
| | | padding: 0 0 calc(48px + env(safe-area-inset-bottom)); |
| | | box-sizing: border-box; |
| | | display: flex; |
| | | flex-direction: column; |
| | | transform: translateY(100%); |
| | | } |
| | | |
| | | .popup-header { |
| | | height: 112px; |
| | | padding-top: 8px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | position: relative; |
| | | flex-shrink: 0; |
| | | |
| | | .popup-title { |
| | | font-size: 32px; |
| | | font-weight: 500; |
| | | line-height: 32px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | } |
| | | |
| | | .popup-close { |
| | | position: absolute; |
| | | right: 32px; |
| | | top: 50%; |
| | | transform: translateY(-50%); |
| | | width: 40px; |
| | | height: 40px; |
| | | } |
| | | } |
| | | |
| | | // 曲风磁贴区(每行 3 个自动换行) |
| | | .popup-genre-area { |
| | | margin-top: 24px; |
| | | padding: 0 40px; |
| | | } |
| | | |
| | | .popup-genre-list { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 22px; |
| | | } |
| | | |
| | | // 曲风磁贴 |
| | | .popup-genre-item { |
| | | position: relative; |
| | | box-sizing: border-box; |
| | | width: calc((100% - 22px) / 2); |
| | | height: 152px; |
| | | flex-shrink: 0; |
| | | border-radius: 32px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | background-repeat: no-repeat; |
| | | background-position: center; |
| | | background-size: 100% 100%; |
| | | background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/genre-item-close-0731@2x.png'); |
| | | |
| | | // 下发中 loading 图标(叠加于磁贴上方,水平居中,旋转) |
| | | .popup-genre-loading { |
| | | //position: absolute; |
| | | //top: 50%; |
| | | //left: 50%; |
| | | width: 64px; |
| | | height: 64px; |
| | | //transform: translate(-50%, -50%); |
| | | //z-index: 1; |
| | | animation: music-genre-spin 1s linear infinite; |
| | | } |
| | | |
| | | .popup-genre-name { |
| | | font-size: 28px; |
| | | font-weight: 500; |
| | | line-height: 40px; |
| | | color: rgba(20, 30, 39, 0.3); |
| | | overflow: hidden; |
| | | white-space: nowrap; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | // 选中态:高亮描边 + 主题渐变底 |
| | | &.active { |
| | | background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/genre-item-open-0731@2x.png'); |
| | | |
| | | .popup-genre-name { |
| | | color: #fff; |
| | | } |
| | | } |
| | | |
| | | // 下发中禁止重复点击 |
| | | &.disabled { |
| | | opacity: 0.6; |
| | | } |
| | | } |
| | | |
| | | .popup-genre-empty { |
| | | padding: 96px 0; |
| | | text-align: center; |
| | | font-size: 28px; |
| | | line-height: 40px; |
| | | color: rgba(0, 0, 0, 0.4); |
| | | } |
| | | |
| | | // 底部播放开关行 |
| | | .popup-play-switch { |
| | | margin-top: 48px; |
| | | padding: 0 48px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | position: relative; |
| | | |
| | | .popup-play-text { |
| | | font-size: 24px; |
| | | line-height: 40px; |
| | | color: rgba(20, 30, 39, 0.5); |
| | | } |
| | | |
| | | .ac-switch { |
| | | position: absolute; |
| | | bottom: -12px; |
| | | right: 40px; |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | }, 300); |
| | | }; |
| | | |
| | | /** |
| | | * 立即置为接驾态并暂停:一键把页面冻结在「车辆即将到达(4)」态,便于纯静态调 UI/样式。 |
| | | * |
| | | * 原理:新建模拟器后调用 start(),其内部 enterApproaching() 会同步注入 |
| | | * order_status(4) + 全量接驾路线事件;随即调用 stop() 清除定时器, |
| | | * 使车辆停在接驾路线起点、剩余路线全量绘制,画面固定在接驾态不再向后推进。 |
| | | */ |
| | | const handleFreezeApproaching = () => { |
| | | // 1) 绑定 socket 业务监听,确保注入事件能被消费(无真实连接也可) |
| | | props.bindSocketListeners(); |
| | | |
| | | // 2) 确保订单 ID 一致(守卫 isSameOrder) |
| | | const orderId = resolveOrderId(); |
| | | ensureSimulationOrderId(orderId); |
| | | props.setOrderId(orderId); |
| | | |
| | | // 3) 由上/下车点生成双腿路线 |
| | | const { approachRoute, serviceRoute } = buildDefaultRoutes(props.startSite, props.endSite); |
| | | const preset = SPEED_PRESETS[speedKey]; |
| | | |
| | | // 4) 新建模拟器,start() 后立即 stop(),冻结在接驾态初始帧 |
| | | simulatorRef.current?.reset(); |
| | | const simulator = new OrderFlowSimulator({ |
| | | orderId, |
| | | approachRoute, |
| | | serviceRoute, |
| | | tickIntervalMs: preset.tickIntervalMs, |
| | | pointsPerTick: preset.pointsPerTick, |
| | | onState: (state) => setSimState(state), |
| | | }); |
| | | simulatorRef.current = simulator; |
| | | setRunning(false); |
| | | // 延迟一帧等待订单 ID 写入页面 deps,再进入接驾态并立即暂停 |
| | | setTimeout(() => { |
| | | simulator.start(); // enterApproaching:注入 status(4) + 全量接驾路线 |
| | | simulator.stop(); // 立即停止推进,冻结在接驾态 |
| | | }, 300); |
| | | }; |
| | | |
| | | /** 停止模拟(保留当前画面)。 */ |
| | | const handleStop = () => { |
| | | simulatorRef.current?.stop(); |
| | |
| | | </View> |
| | | <View style={{ display: 'flex', marginBottom: '4px' }}> |
| | | <View |
| | | onClick={handleFreezeApproaching} |
| | | style={buttonStyle('#1d9bf0')} |
| | | > |
| | | 接驾态并暂停(4) |
| | | </View> |
| | | </View> |
| | | <View style={{ display: 'flex', marginBottom: '4px' }}> |
| | | <View |
| | | onClick={handleJumpToTripFinished} |
| | | style={buttonStyle('#0e9f6e')} |
| | | > |
| | |
| | | onClose={handleCloseCreditPayPopup} |
| | | onConfirm={handleConfirmCreditPayPopup} |
| | | /> |
| | | {/* <DebugOrderSimulatorPanel |
| | | {/* 模拟器入口已关闭,如需本地调试可取消下方注释 |
| | | <DebugOrderSimulatorPanel |
| | | orderId={orderId} |
| | | setOrderId={setOrderId} |
| | | setTripStatus={setTripStatus} |
| | |
| | | setShowCancelPopup={setShowCancelPopup} |
| | | setShowModifyDestinationFailPopup={setShowModifyDestinationFailPopup} |
| | | setModifyDestinationFailMessage={setModifyDestinationFailMessage} |
| | | /> */} |
| | | /> |
| | | */} |
| | | </> |
| | | ) |
| | | } |