feat(begin-route): 增加行程中修改目的地功能
- 新增修改目的地API接口orderChange
- 新增修改目的地流程管理hook useModifyDestinationFlow
- 新增目的地修改确认组件ModifyDestinationReview
- 新增路线选择面板组件RouteSelectionPanel并抽取复用
- 在站点搜索页支持修改目的地候选点存储逻辑
- 扩展地图标记和路线管理逻辑以支持修改目的地状态
- 在行程状态4-9时显示修改目的地入口
| | |
| | | }, true); |
| | | } |
| | | |
| | | // 修改目的地 |
| | | export const orderChange = async (payload) => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/itinerary/api/v1/mini-program/order/orderChange', |
| | | method: 'POST', |
| | | payload |
| | | }, true); |
| | | } |
| | | |
| | | // 查询订单费用明细 |
| | | export const queryFareBreakdown = async (payload) => { |
| | | return request({ |
| | |
| | | packUpIcon: string; |
| | | onSelectRouteClick: () => void; |
| | | isRouteSelectExpanded: boolean; |
| | | canToggleRouteSelect: boolean; |
| | | walkIcon: string; |
| | | renderTripCard: () => JSX.Element | null; |
| | | showCustomerService: boolean; |
| | |
| | | packUpIcon, |
| | | onSelectRouteClick, |
| | | isRouteSelectExpanded, |
| | | canToggleRouteSelect, |
| | | walkIcon, |
| | | renderTripCard, |
| | | showCustomerService, |
| | |
| | | <View className='origin-box' style={{ opacity: originBoxHidden ? 0 : originOpacity, transition: 'opacity 0.2s ease-out', pointerEvents: originBoxHidden ? 'none' : 'auto' }}> |
| | | <Image onClick={toOrigin} className='origin-icon' src={originIcon} /> |
| | | { |
| | | tripStatus == 1 && |
| | | canToggleRouteSelect && |
| | | <View> |
| | | { |
| | | isRouteSelectExpanded ? |
| New file |
| | |
| | | .modify-destination-review-container { |
| | | position: relative; |
| | | width: 100vw; |
| | | } |
| | | |
| | | .modify-destination-review { |
| | | position: relative; |
| | | width: 100%; |
| | | border-radius: 40px 40px 0 0; |
| | | background: transparent; |
| | | z-index: 10; |
| | | overflow: hidden; |
| | | transition: height 0.3s ease; |
| | | |
| | | &-inner { |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | width: 100%; |
| | | } |
| | | |
| | | // &-route-panel { |
| | | // margin: 0 32px 16px 32px; |
| | | // } |
| | | |
| | | &-content-wrapper { |
| | | position: relative; |
| | | padding-bottom: 180px; |
| | | background: #f2f3f7 url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/site-top-bg.png') no-repeat top center; |
| | | background-size: 100% auto; |
| | | border-top: 2px solid #ffffff; |
| | | border-radius: 40px 40px 0 0; |
| | | } |
| | | |
| | | &-content { |
| | | position: relative; |
| | | padding: 40px 32px 40px; |
| | | z-index: 10; |
| | | } |
| | | |
| | | &-title { |
| | | position: relative; |
| | | z-index: 1; |
| | | font-size: 28px; |
| | | font-weight: 500; |
| | | margin-bottom: 32px; |
| | | } |
| | | |
| | | &-sites { |
| | | position: relative; |
| | | z-index: 1; |
| | | padding: 24px; |
| | | border-radius: 24px; |
| | | background: #ffffff; |
| | | box-sizing: border-box; |
| | | border: 2px solid #ffffff; |
| | | backdrop-filter: blur(20px); |
| | | box-shadow: 0 40px 60px 0 rgba(87, 99, 147, 0.1); |
| | | } |
| | | |
| | | &-site-item { |
| | | display: flex; |
| | | align-items: center; |
| | | min-height: 92px; |
| | | padding: 0 24px; |
| | | border-radius: 20px; |
| | | |
| | | & + & { |
| | | margin-top: 12px; |
| | | } |
| | | } |
| | | |
| | | &-site-item-active { |
| | | background: #F5F6FA; |
| | | } |
| | | |
| | | &-site-dot { |
| | | width: 12px; |
| | | height: 12px; |
| | | margin-right: 20px; |
| | | border-radius: 50%; |
| | | flex-shrink: 0; |
| | | |
| | | &-old { |
| | | background: #768aff; |
| | | } |
| | | |
| | | &-new { |
| | | background: #9cc14e; |
| | | } |
| | | } |
| | | |
| | | &-site-text { |
| | | flex: 1; |
| | | font-size: 30px; |
| | | line-height: 42px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | word-break: break-all; |
| | | } |
| | | |
| | | &-footer { |
| | | position: fixed; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | z-index: 11; |
| | | height: 180px; |
| | | box-sizing: border-box; |
| | | padding: 24px 38px 0 32px; |
| | | background: #fefeff; |
| | | display: flex; |
| | | // align-items: center; |
| | | justify-content: space-between; |
| | | gap: 24px; |
| | | } |
| | | |
| | | &-fare { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | &-fare-main { |
| | | display: flex; |
| | | align-items: flex-end; |
| | | font-size: 24px; |
| | | line-height: 36px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | } |
| | | |
| | | &-fare-num { |
| | | margin: 0 6px; |
| | | font-size: 56px; |
| | | line-height: 56px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | } |
| | | |
| | | &-fare-discount { |
| | | margin-top: 8px; |
| | | font-size: 24px; |
| | | line-height: 34px; |
| | | color: #f58900; |
| | | } |
| | | |
| | | &-fare-original { |
| | | margin-top: 6px; |
| | | font-size: 24px; |
| | | line-height: 34px; |
| | | color: rgba(0, 0, 0, 0.4); |
| | | text-decoration: line-through; |
| | | } |
| | | |
| | | &-confirm { |
| | | flex-shrink: 0; |
| | | width: 296px; |
| | | height: 94px; |
| | | border-radius: 20px; |
| | | background: linear-gradient(102deg, #8475ff 0%, #7dd6ff 102%); |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 28px; |
| | | line-height: 40px; |
| | | color: #ffffff; |
| | | } |
| | | } |
| New file |
| | |
| | | .route-select { |
| | | border-radius: 24px; |
| | | background: #FFFFFF; |
| | | box-sizing: border-box; |
| | | border: 1px solid #FFFFFF; |
| | | margin: 0 32px 16px 32px; |
| | | padding: 16px 24px 24px 24px; |
| | | |
| | | .route-select-type { |
| | | display: flex; |
| | | align-items: center; |
| | | |
| | | .route-select-title { |
| | | font-size: 24px; |
| | | font-weight: 500; |
| | | line-height: 44px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | } |
| | | |
| | | .route-select-preference { |
| | | display: flex; |
| | | align-items: center; |
| | | |
| | | .route-select-preference-text { |
| | | font-size: 24px; |
| | | font-weight: 500; |
| | | line-height: 40px; |
| | | color: #6D7FD9; |
| | | } |
| | | |
| | | image { |
| | | flex-shrink: 0; |
| | | margin-left: 9.25px; |
| | | height: 11.97px; |
| | | width: 17.5px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .route-select-content { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | gap: 10px; |
| | | margin-top: 16px; |
| | | |
| | | .route-select-content-item { |
| | | flex: 1; |
| | | padding: 16px 0 24px 24px; |
| | | border-radius: 16px; |
| | | background: #F5F6FA; |
| | | border: 1px solid #F2F3F7; |
| | | |
| | | .route-select-content-item-title { |
| | | font-size: 20px; |
| | | line-height: 20px; |
| | | color: rgba(0, 0, 0, 0.4); |
| | | } |
| | | |
| | | .route-select-content-item-time { |
| | | margin-top: 10px; |
| | | font-size: 28px; |
| | | font-weight: 500; |
| | | line-height: 40px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | } |
| | | |
| | | .route-select-content-item-distance { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12px; |
| | | margin-top: 12px; |
| | | font-size: 20px; |
| | | line-height: 20px; |
| | | color: rgba(0, 0, 0, 0.4); |
| | | |
| | | .traffic-signal-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 4px; |
| | | |
| | | .traffic-signal-icon { |
| | | flex-shrink: 0; |
| | | width: 20px; |
| | | height: 20px; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .active-item { |
| | | background: linear-gradient(97deg, rgba(132, 117, 255, 0.2) 0%, rgba(125, 214, 255, 0.2) 103%) !important; |
| | | box-sizing: border-box; |
| | | position: relative; |
| | | |
| | | .route-select-content-item-title { |
| | | color: #6D7FD9; |
| | | } |
| | | |
| | | &::after { |
| | | content: ""; |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | border-radius: 16px; |
| | | padding: 1px; |
| | | background: linear-gradient(180deg, #AAA8FF -10%, rgba(41, 159, 255, 0.0469) 108%, rgba(0, 0, 0, 0) 108%); |
| | | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); |
| | | -webkit-mask-composite: xor; |
| | | mask-composite: exclude; |
| | | pointer-events: none; |
| | | } |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | import { View, Text } from '@tarojs/components'; |
| | | import Taro from '@tarojs/taro'; |
| | | import { useEffect, useState, useRef } from 'react'; |
| | | import './css-folder/modify-destination-review.less'; |
| | | import RouteSelectionPanel from './route-selection-panel'; |
| | | |
| | | type Props = { |
| | | previousEndSite: any; |
| | | nextEndSite: any; |
| | | previousFareEstimate?: any; |
| | | fareEstimate: any; |
| | | routeOptions?: any[]; |
| | | selectedRouteIndex?: number; |
| | | isRouteSelectExpanded?: boolean; |
| | | onToggleRouteSelect?: (expanded: boolean) => void; |
| | | handleSelectRoute?: (index: number) => void; |
| | | onEditDestination?: () => void; |
| | | onConfirm: () => void; |
| | | }; |
| | | |
| | | export default function ModifyDestinationReview({ |
| | | previousEndSite, |
| | | nextEndSite, |
| | | previousFareEstimate, |
| | | fareEstimate, |
| | | routeOptions = [], |
| | | selectedRouteIndex = 0, |
| | | isRouteSelectExpanded = false, |
| | | onToggleRouteSelect, |
| | | handleSelectRoute, |
| | | onEditDestination, |
| | | onConfirm |
| | | }: Props) { |
| | | // 高度统一使用 rpx,'auto' 表示由内容撑开 |
| | | const [boxHeightRpx, setBoxHeightRpx] = useState<number | 'auto'>('auto'); |
| | | |
| | | const isDragging = useRef(false); |
| | | const startY = useRef(0); |
| | | const startHeight = useRef(0); |
| | | const windowWidth = useRef(375); |
| | | |
| | | useEffect(() => { |
| | | const sysInfo = Taro.getSystemInfoSync(); |
| | | windowWidth.current = sysInfo.windowWidth; |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | if (!isDragging.current) { |
| | | if (isRouteSelectExpanded) { |
| | | // 提取数字,兼容之前可能带有 'px' 的情况 |
| | | setBoxHeightRpx(378); // 展开时高度 |
| | | } else { |
| | | setBoxHeightRpx('auto'); // 正常高度自适应 |
| | | } |
| | | } |
| | | }, [isRouteSelectExpanded]); |
| | | |
| | | const pxToRpx = (px: number) => { |
| | | return (px * 750) / windowWidth.current; |
| | | }; |
| | | |
| | | const handleTouchStart = (e: any) => { |
| | | if (!isRouteSelectExpanded) return; |
| | | isDragging.current = true; |
| | | startY.current = e.touches[0].clientY; |
| | | // 如果是 auto,我们在拖拽开始时需要获取当前真实高度并转换为 rpx |
| | | // 但为了流畅,通常展开时已经是具体的 rpx 数字了 |
| | | startHeight.current = typeof boxHeightRpx === 'number' ? boxHeightRpx : 378; |
| | | }; |
| | | |
| | | const handleTouchMove = (e: any) => { |
| | | if (!isRouteSelectExpanded || !isDragging.current) return; |
| | | const deltaYPx = e.touches[0].clientY - startY.current; |
| | | const deltaYRpx = pxToRpx(deltaYPx); |
| | | |
| | | // 往下拉 deltaY > 0,高度变小;往上拉 deltaY < 0,高度变大 |
| | | let newHeightRpx = startHeight.current - deltaYRpx; |
| | | |
| | | // 限制拖拽范围 |
| | | if (newHeightRpx < 150) newHeightRpx = 150; |
| | | if (newHeightRpx > 800) newHeightRpx = 800; |
| | | |
| | | setBoxHeightRpx(newHeightRpx); |
| | | }; |
| | | |
| | | const handleTouchEnd = () => { |
| | | if (!isRouteSelectExpanded || !isDragging.current) return; |
| | | isDragging.current = false; |
| | | |
| | | // 不自动回弹:让高度停留在滑动结束的位置 |
| | | // 但如果滑得太低,当做收起 |
| | | if (typeof boxHeightRpx === 'number' && boxHeightRpx <= 200) { |
| | | onToggleRouteSelect && onToggleRouteSelect(false); |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <View className="modify-destination-review-container"> |
| | | {isRouteSelectExpanded && routeOptions.length > 0 && ( |
| | | <View className='modify-destination-review-route-panel'> |
| | | <RouteSelectionPanel |
| | | routeOptions={routeOptions} |
| | | selectedRouteIndex={selectedRouteIndex} |
| | | handleSelectRoute={handleSelectRoute} |
| | | /> |
| | | </View> |
| | | )} |
| | | |
| | | <View |
| | | className='modify-destination-review' |
| | | style={{ |
| | | height: boxHeightRpx === 'auto' ? 'auto' : Taro.pxTransform(boxHeightRpx as number), |
| | | transition: isDragging.current ? 'none' : 'height 0.3s ease' |
| | | }} |
| | | catchMove={isRouteSelectExpanded} |
| | | onTouchStart={(e) => { |
| | | if (!isRouteSelectExpanded) return; |
| | | e.stopPropagation(); |
| | | handleTouchStart(e); |
| | | }} |
| | | onTouchMove={(e) => { |
| | | if (!isRouteSelectExpanded) return; |
| | | e.stopPropagation(); |
| | | handleTouchMove(e); |
| | | }} |
| | | onTouchEnd={(e) => { |
| | | if (!isRouteSelectExpanded) return; |
| | | e.stopPropagation(); |
| | | handleTouchEnd(); |
| | | }} |
| | | > |
| | | <View className='modify-destination-review-content-wrapper'> |
| | | <View className='modify-destination-review-content'> |
| | | <View className='modify-destination-review-title'> |
| | | 由于目的地变更,本次行程费用已更新 |
| | | </View> |
| | | <View className='modify-destination-review-sites'> |
| | | <View className='modify-destination-review-site-item'> |
| | | <View className='modify-destination-review-site-dot modify-destination-review-site-dot-old' /> |
| | | <View className='modify-destination-review-site-text'>{previousEndSite?.name || '-'}</View> |
| | | </View> |
| | | <View |
| | | className='modify-destination-review-site-item modify-destination-review-site-item-active' |
| | | onClick={() => onEditDestination?.()} |
| | | > |
| | | <View className='modify-destination-review-site-dot modify-destination-review-site-dot-new' /> |
| | | <View className='modify-destination-review-site-text'>{nextEndSite?.name || '-'}</View> |
| | | </View> |
| | | </View> |
| | | </View> |
| | | |
| | | <View className='modify-destination-review-footer'> |
| | | <View className='modify-destination-review-fare'> |
| | | <View className='modify-destination-review-fare-main'> |
| | | 预估 |
| | | <Text className='modify-destination-review-fare-num'>{fareEstimate?.finalFareStr || '-'}</Text> |
| | | 元 |
| | | </View> |
| | | {Number(fareEstimate?.discountAmount) > 0 && ( |
| | | <View className='modify-destination-review-fare-discount'> |
| | | 已优惠{fareEstimate.discountAmount}元 |
| | | </View> |
| | | )} |
| | | {previousFareEstimate?.finalFareStr && previousFareEstimate?.finalFareStr !== fareEstimate?.finalFareStr && ( |
| | | <View className='modify-destination-review-fare-original'> |
| | | 原预估{previousFareEstimate.finalFareStr}元 |
| | | </View> |
| | | )} |
| | | </View> |
| | | <View className='modify-destination-review-confirm' onClick={onConfirm}> |
| | | 确认修改 |
| | | </View> |
| | | </View> |
| | | </View> |
| | | </View> |
| | | </View> |
| | | ); |
| | | } |
| | |
| | | import './css-folder/release-route.less' |
| | | import tipBlueIcon from '../../../assets/tip-blue-icon.png' |
| | | import tipDefaultIcon from '../../../assets/tip-default-icon.png' |
| | | import selectLineIcon from '../../../assets/select-line.png' |
| | | import trafficSignalIcon from '../../../assets/traffic-signal.png' |
| | | import RoutePreferencePopup, { RoutePreferenceOption } from './route-preference-popup'; |
| | | import { saveRoutePreference, queryRoutePreferences } from '../../../api/begin-route'; |
| | | import { formatDurationDisplay, formatTripDistance } from '../../../../utils/Robotaxi-public-method'; |
| | | import RouteSelectionPanel from './route-selection-panel'; |
| | | |
| | | type Props = { |
| | | startSite: any; |
| | |
| | | const dragFrame = useRef<any>(null); |
| | | const pendingHeight = useRef<number | null>(null); |
| | | |
| | | const [showPreferencePopup, setShowPreferencePopup] = useState(false); |
| | | const [preference, setPreference] = useState<RoutePreferenceOption>({ label: '智能推荐', value: '智能推荐', code: '智能推荐' }); |
| | | const [preferenceOptions, setPreferenceOptions] = useState<RoutePreferenceOption[]>([]); |
| | | const openCityId = Number(Taro.getStorageSync('selectedCity')?.id || 0); |
| | | const selectedRoute = routeOptions?.[selectedRouteIndex]; |
| | | const isOperateFree = Number(selectedRoute?.operateFree) === 1; |
| | | |
| | | useEffect(() => { |
| | | let cancelled = false; |
| | | queryRoutePreferences({ openCityId }).then((res: any) => { |
| | | if (cancelled) return; |
| | | const list = Array.isArray(res) ? res : (Array.isArray(res?.list) ? res.list : (Array.isArray(res?.data) ? res.data : [])); |
| | | const normalized = list.map((item: any) => { |
| | | const label = item?.label || item?.name || item?.displayName || item?.preferenceName || item?.value; |
| | | const value = item?.value ?? item?.code ?? item?.preferenceCode ?? label; |
| | | const code = item?.code ?? item?.preferenceCode ?? item?.value ?? label; |
| | | if (!label || value === undefined || value === null) return null; |
| | | return { label: String(label), value: String(value), code: String(code) }; |
| | | }).filter(Boolean) as RoutePreferenceOption[]; |
| | | if (normalized.length > 0) { |
| | | setPreferenceOptions(normalized); |
| | | } |
| | | }).catch(() => {}); |
| | | return () => { cancelled = true; }; |
| | | }, [openCityId]); |
| | | |
| | | // Sync preference label when route selection changes externally (e.g. clicking map or route card) |
| | | useEffect(() => { |
| | | if (routeOptions && routeOptions[selectedRouteIndex]) { |
| | | const currentRoute = routeOptions[selectedRouteIndex]; |
| | | |
| | | // Match label based on preference from API response |
| | | let mappedLabel = '智能推荐'; |
| | | if (preferenceOptions.length > 0 && currentRoute.preference !== undefined && currentRoute.preference !== null) { |
| | | const matchedOption = preferenceOptions.find(opt => String(opt.code) === String(currentRoute.preference)); |
| | | if (matchedOption) { |
| | | mappedLabel = matchedOption.label; |
| | | } else if (currentRoute.routePlanName) { |
| | | // Fallback to routePlanName if preference matching fails |
| | | const name = currentRoute.routePlanName; |
| | | mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | } |
| | | } else if (currentRoute.routePlanName) { |
| | | // Fallback to routePlanName if preference matching fails |
| | | const name = currentRoute.routePlanName; |
| | | mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | } |
| | | |
| | | setPreference(prev => { |
| | | if (prev.label !== mappedLabel) { |
| | | const newCode = currentRoute.preference !== undefined && currentRoute.preference !== null ? String(currentRoute.preference) : mappedLabel; |
| | | return { |
| | | label: mappedLabel, |
| | | value: newCode, |
| | | code: newCode |
| | | }; |
| | | } |
| | | return prev; |
| | | }); |
| | | } |
| | | }, [selectedRouteIndex, routeOptions, preferenceOptions]); |
| | | |
| | | useEffect(() => { |
| | | if (!isRouteSelectExpanded && boxHeight !== null && minHeight.current > 0) { |
| | |
| | | } |
| | | return `${safeMinutes}分钟`; |
| | | } |
| | | const handleSelectPreference = async (item: RoutePreferenceOption) => { |
| | | setPreference(item); |
| | | |
| | | if (routeOptions && routeOptions.length > 0 && handleSelectRoute) { |
| | | const targetIndex = routeOptions.findIndex(route => { |
| | | // Exact match by preference code |
| | | if (route.preference !== undefined && route.preference !== null && String(route.preference) === String(item.code)) { |
| | | return true; |
| | | } |
| | | |
| | | // Fallback match by name |
| | | if (route.routePlanName) { |
| | | const name = route.routePlanName; |
| | | const mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | if (mappedLabel === item.label) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | return false; |
| | | }); |
| | | |
| | | if (targetIndex !== -1) { |
| | | handleSelectRoute(targetIndex); |
| | | } |
| | | } |
| | | |
| | | try { |
| | | await saveRoutePreference({ |
| | | code: item.code |
| | | }); |
| | | } catch (error) { |
| | | // keep local selection state to avoid interrupting the popup interaction |
| | | } |
| | | } |
| | | |
| | | return ( |
| | | <View className="release-route-container"> |
| | |
| | | > |
| | | <View className='release-route-content'> |
| | | {isRouteSelectExpanded && ( |
| | | <View className="route-select"> |
| | | <View className="route-select-type"> |
| | | <View className="route-select-title">路线选择:</View> |
| | | <View className="route-select-preference" onClick={() => setShowPreferencePopup(true)}> |
| | | <View className="route-select-preference-text">{preference.label}</View> |
| | | <Image src={selectLineIcon} /> |
| | | </View> |
| | | </View> |
| | | <View className="route-select-content"> |
| | | { |
| | | routeOptions.map((item, index) => { |
| | | const routeData = item.route || {}; |
| | | const time = formatDurationDisplay(routeData.duration || 0); |
| | | const dist = formatTripDistance(routeData.distance || 0); |
| | | const isSelected = index === selectedRouteIndex; |
| | | |
| | | return ( |
| | | <View |
| | | className={`route-select-content-item ${isSelected ? 'active-item' : ''}`} |
| | | key={index} |
| | | onClick={() => handleSelectRoute && handleSelectRoute(index)} |
| | | > |
| | | <View className="route-select-content-item-title">{item.label || (index === 0 ? '智能推荐' : `路线${index + 1}`)}</View> |
| | | <View className="route-select-content-item-time">{time.value}{time.unit}</View> |
| | | <View className="route-select-content-item-distance"> |
| | | <View>{dist}</View> |
| | | {typeof routeData.trafficLights !== 'undefined' && ( |
| | | <View className="traffic-signal-item"> |
| | | <Image className="traffic-signal-icon" src={trafficSignalIcon} /> |
| | | <Text>{routeData.trafficLights}个</Text> |
| | | </View> |
| | | )} |
| | | </View> |
| | | </View> |
| | | ); |
| | | }) |
| | | } |
| | | </View> |
| | | </View> |
| | | <RouteSelectionPanel |
| | | routeOptions={routeOptions} |
| | | selectedRouteIndex={selectedRouteIndex} |
| | | handleSelectRoute={handleSelectRoute} |
| | | /> |
| | | )} |
| | | <View className="select-site"> |
| | | <Image className="site-top-bg" src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/site-top-bg.png' /> |
| | |
| | | </View> |
| | | </View> |
| | | </View> |
| | | <RoutePreferencePopup |
| | | show={showPreferencePopup} |
| | | value={preference.value} |
| | | openCityId={openCityId} |
| | | onClose={() => setShowPreferencePopup(false)} |
| | | onSelect={handleSelectPreference} |
| | | /> |
| | | </View> |
| | | ) |
| | | } |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useEffect, useState } from 'react'; |
| | | import { View, Text, Image } from '@tarojs/components'; |
| | | import selectLineIcon from '../../../assets/select-line.png'; |
| | | import trafficSignalIcon from '../../../assets/traffic-signal.png'; |
| | | import RoutePreferencePopup, { RoutePreferenceOption } from './route-preference-popup'; |
| | | import { queryRoutePreferences, saveRoutePreference } from '../../../api/begin-route'; |
| | | import { formatDurationDisplay, formatTripDistance } from '../../../../utils/Robotaxi-public-method'; |
| | | import './css-folder/route-selection-panel.less'; |
| | | |
| | | type Props = { |
| | | routeOptions?: any[]; |
| | | selectedRouteIndex?: number; |
| | | handleSelectRoute?: (index: number) => void; |
| | | }; |
| | | |
| | | export default function RouteSelectionPanel({ |
| | | routeOptions = [], |
| | | selectedRouteIndex = 0, |
| | | handleSelectRoute |
| | | }: Props) { |
| | | const [showPreferencePopup, setShowPreferencePopup] = useState(false); |
| | | const [preference, setPreference] = useState<RoutePreferenceOption>({ |
| | | label: '智能推荐', |
| | | value: '智能推荐', |
| | | code: '智能推荐' |
| | | }); |
| | | const [preferenceOptions, setPreferenceOptions] = useState<RoutePreferenceOption[]>([]); |
| | | const openCityId = Number(Taro.getStorageSync('selectedCity')?.id || 0); |
| | | |
| | | useEffect(() => { |
| | | let cancelled = false; |
| | | queryRoutePreferences({ openCityId }).then((res: any) => { |
| | | if (cancelled) return; |
| | | const list = Array.isArray(res) ? res : (Array.isArray(res?.list) ? res.list : (Array.isArray(res?.data) ? res.data : [])); |
| | | const normalized = list.map((item: any) => { |
| | | const label = item?.label || item?.name || item?.displayName || item?.preferenceName || item?.value; |
| | | const value = item?.value ?? item?.code ?? item?.preferenceCode ?? label; |
| | | const code = item?.code ?? item?.preferenceCode ?? item?.value ?? label; |
| | | if (!label || value === undefined || value === null) return null; |
| | | return { label: String(label), value: String(value), code: String(code) }; |
| | | }).filter(Boolean) as RoutePreferenceOption[]; |
| | | if (normalized.length > 0) { |
| | | setPreferenceOptions(normalized); |
| | | } |
| | | }).catch(() => {}); |
| | | |
| | | return () => { |
| | | cancelled = true; |
| | | }; |
| | | }, [openCityId]); |
| | | |
| | | useEffect(() => { |
| | | if (!routeOptions || !routeOptions[selectedRouteIndex]) return; |
| | | const currentRoute = routeOptions[selectedRouteIndex]; |
| | | |
| | | let mappedLabel = '智能推荐'; |
| | | if (preferenceOptions.length > 0 && currentRoute.preference !== undefined && currentRoute.preference !== null) { |
| | | const matchedOption = preferenceOptions.find(opt => String(opt.code) === String(currentRoute.preference)); |
| | | if (matchedOption) { |
| | | mappedLabel = matchedOption.label; |
| | | } else if (currentRoute.routePlanName) { |
| | | const name = currentRoute.routePlanName; |
| | | mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | } |
| | | } else if (currentRoute.routePlanName) { |
| | | const name = currentRoute.routePlanName; |
| | | mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | } |
| | | |
| | | setPreference(prev => { |
| | | if (prev.label !== mappedLabel) { |
| | | const newCode = currentRoute.preference !== undefined && currentRoute.preference !== null ? String(currentRoute.preference) : mappedLabel; |
| | | return { |
| | | label: mappedLabel, |
| | | value: newCode, |
| | | code: newCode |
| | | }; |
| | | } |
| | | return prev; |
| | | }); |
| | | }, [selectedRouteIndex, routeOptions, preferenceOptions]); |
| | | |
| | | const handleSelectPreference = async (item: RoutePreferenceOption) => { |
| | | setPreference(item); |
| | | |
| | | if (routeOptions.length > 0 && handleSelectRoute) { |
| | | const targetIndex = routeOptions.findIndex(route => { |
| | | if (route.preference !== undefined && route.preference !== null && String(route.preference) === String(item.code)) { |
| | | return true; |
| | | } |
| | | |
| | | if (route.routePlanName) { |
| | | const name = route.routePlanName; |
| | | const mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | if (mappedLabel === item.label) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | return false; |
| | | }); |
| | | |
| | | if (targetIndex !== -1) { |
| | | handleSelectRoute(targetIndex); |
| | | } |
| | | } |
| | | |
| | | try { |
| | | await saveRoutePreference({ code: item.code }); |
| | | } catch (error) { |
| | | // Keep local state even if persistence fails. |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <View className='route-select'> |
| | | <View className='route-select-type'> |
| | | <View className='route-select-title'>路线选择:</View> |
| | | <View className='route-select-preference' onClick={() => setShowPreferencePopup(true)}> |
| | | <View className='route-select-preference-text'>{preference.label}</View> |
| | | <Image src={selectLineIcon} /> |
| | | </View> |
| | | </View> |
| | | <View className='route-select-content'> |
| | | {routeOptions.map((item, index) => { |
| | | const routeData = item.route || {}; |
| | | const time = formatDurationDisplay(routeData.duration || 0); |
| | | const dist = formatTripDistance(routeData.distance || 0); |
| | | const isSelected = index === selectedRouteIndex; |
| | | |
| | | return ( |
| | | <View |
| | | className={`route-select-content-item ${isSelected ? 'active-item' : ''}`} |
| | | key={index} |
| | | onClick={() => handleSelectRoute && handleSelectRoute(index)} |
| | | > |
| | | <View className='route-select-content-item-title'>{item.label || (index === 0 ? '智能推荐' : `路线${index + 1}`)}</View> |
| | | <View className='route-select-content-item-time'>{time.value}{time.unit}</View> |
| | | <View className='route-select-content-item-distance'> |
| | | <View>{dist}</View> |
| | | {typeof routeData.trafficLights !== 'undefined' && ( |
| | | <View className='traffic-signal-item'> |
| | | <Image className='traffic-signal-icon' src={trafficSignalIcon} /> |
| | | <Text>{routeData.trafficLights}个</Text> |
| | | </View> |
| | | )} |
| | | </View> |
| | | </View> |
| | | ); |
| | | })} |
| | | </View> |
| | | |
| | | <RoutePreferencePopup |
| | | show={showPreferencePopup} |
| | | value={preference.value} |
| | | openCityId={openCityId} |
| | | onClose={() => setShowPreferencePopup(false)} |
| | | onSelect={handleSelectPreference} |
| | | /> |
| | | </View> |
| | | ); |
| | | } |
| New file |
| | |
| | | export const MODIFY_DESTINATION_SESSION_KEY = 'modifyDestinationSession'; |
| | | export const MODIFY_DESTINATION_CANDIDATE_KEY = 'modifyDestinationCandidate'; |
| | | export const MODIFY_DESTINATION_OVERRIDE_KEY = 'modifyDestinationOverride'; |
| | | export const MODIFY_DESTINATION_ROUTE_POINTS_KEY = 'modifyDestinationRoutePoints'; |
| | |
| | | tripStatus: number; |
| | | statusRule: any; |
| | | isAndroid: boolean; |
| | | showTrip1StyleEndpoint: boolean; |
| | | startSite: any; |
| | | endSite: any; |
| | | vehicleLocation: { latitude: number; longitude: number } | null; |
| | |
| | | tripStatus: 1, |
| | | statusRule: { showPickup: true, showDropoff: true, showCar: false }, |
| | | isAndroid: false, |
| | | showTrip1StyleEndpoint: false, |
| | | startSite: null, |
| | | endSite: null, |
| | | vehicleLocation: null, |
| | |
| | | ...(useAndroidNativeTrip1Callout |
| | | ? { |
| | | callout: { |
| | | content: deps.tripStatus === 1 ? `${pickupSite?.name || '上车点'} >` : (pickupSite?.name || '上车点'), |
| | | content: (deps.tripStatus === 1 || deps.showTrip1StyleEndpoint) ? `${pickupSite?.name || '上车点'} >` : (pickupSite?.name || '上车点'), |
| | | color: '#000000', |
| | | fontSize: 12, |
| | | bgColor: '#FFFFFF', |
| | |
| | | ...(useAndroidNativeTrip1Callout || deps.tripStatus == 6 |
| | | ? { |
| | | callout: { |
| | | content: deps.tripStatus === 1 ? `${dropoffName} >` : dropoffName, |
| | | content: (deps.tripStatus === 1 || deps.showTrip1StyleEndpoint) ? `${dropoffName} >` : dropoffName, |
| | | color: '#000000', |
| | | fontSize: 12, |
| | | bgColor: '#FFFFFF', |
| | |
| | | useEffect(() => { |
| | | const pickupSite = deps.startSite?.latitude && deps.startSite?.longitude ? deps.startSite : Taro.getStorageSync('startSite'); |
| | | const dropoffSite = deps.endSite?.latitude && deps.endSite?.longitude ? deps.endSite : Taro.getStorageSync('endSite'); |
| | | const useAndroidNativeTrip1Callout = deps.isAndroid && [1, 5, 8, 9, 10].includes(deps.tripStatus); |
| | | const useAndroidNativeTrip1Callout = deps.isAndroid && ([1, 5, 8, 9, 10].includes(deps.tripStatus) || deps.showTrip1StyleEndpoint); |
| | | const pickupMarker = createPickupMarker(pickupSite, useAndroidNativeTrip1Callout); |
| | | const dropoffMarker = createDropoffMarker(dropoffSite, useAndroidNativeTrip1Callout); |
| | | const carMarker = createCarMarker(); |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useEffect, useMemo, useState } from 'react'; |
| | | import { orderChange } from '../../../api/begin-route'; |
| | | import { getOrderActive } from '../../../api/home'; |
| | | import { |
| | | MODIFY_DESTINATION_CANDIDATE_KEY, |
| | | MODIFY_DESTINATION_OVERRIDE_KEY, |
| | | MODIFY_DESTINATION_ROUTE_POINTS_KEY, |
| | | MODIFY_DESTINATION_SESSION_KEY |
| | | } from '../constants/modify-destination'; |
| | | |
| | | type Deps = { |
| | | tripStatus: number; |
| | | orderId: string; |
| | | endSite: any; |
| | | setEndSite: (site: any) => void; |
| | | toSelectSite: (pageType: 'start' | 'end') => void; |
| | | onModifySuccess?: () => void; |
| | | }; |
| | | |
| | | const isFiniteCoordinate = (value: any) => Number.isFinite(Number(value)); |
| | | |
| | | const isValidSite = (site: any) => { |
| | | if (!site) return false; |
| | | return isFiniteCoordinate(site.latitude) && isFiniteCoordinate(site.longitude); |
| | | }; |
| | | |
| | | const isSameSite = (left: any, right: any) => { |
| | | if (!left || !right) return false; |
| | | if (left.id && right.id) { |
| | | return String(left.id) === String(right.id); |
| | | } |
| | | return String(left.name || '') === String(right.name || '') |
| | | && Number(left.latitude) === Number(right.latitude) |
| | | && Number(left.longitude) === Number(right.longitude); |
| | | }; |
| | | |
| | | const clearModifyDestinationSession = () => { |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_SESSION_KEY); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_CANDIDATE_KEY); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_ROUTE_POINTS_KEY); |
| | | }; |
| | | |
| | | export default function useModifyDestinationFlow(deps: Deps) { |
| | | const [previewEndSite, setPreviewEndSite] = useState<any>(null); |
| | | const [previousEndSite, setPreviousEndSite] = useState<any>(null); |
| | | const [previousFareEstimate, setPreviousFareEstimate] = useState<any>(null); |
| | | const [previousRoutePoints, setPreviousRoutePoints] = useState<any[]>([]); |
| | | const [overrideEndSite, setOverrideEndSite] = useState<any>(() => { |
| | | const cached = Taro.getStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | return isValidSite(cached) ? cached : null; |
| | | }); |
| | | |
| | | useEffect(() => { |
| | | if (!deps.orderId || ![4, 5, 6, 7, 8, 9].includes(deps.tripStatus)) { |
| | | setPreviewEndSite(null); |
| | | setPreviousEndSite(null); |
| | | setPreviousFareEstimate(null); |
| | | setPreviousRoutePoints([]); |
| | | if (![4, 5, 6, 7, 8, 9].includes(deps.tripStatus)) { |
| | | clearModifyDestinationSession(); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | setOverrideEndSite(null); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | const session = Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY); |
| | | const candidate = Taro.getStorageSync(MODIFY_DESTINATION_CANDIDATE_KEY); |
| | | const cachedOverride = Taro.getStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | const cachedRoutePoints = Taro.getStorageSync(MODIFY_DESTINATION_ROUTE_POINTS_KEY); |
| | | |
| | | if (isValidSite(cachedOverride)) { |
| | | setOverrideEndSite(cachedOverride); |
| | | } |
| | | |
| | | if (session?.active && isValidSite(candidate)) { |
| | | if (isSameSite(session.previousEndSite, candidate)) { |
| | | clearModifyDestinationSession(); |
| | | setPreviewEndSite(null); |
| | | return; |
| | | } |
| | | setPreviousEndSite(session.previousEndSite || deps.endSite || null); |
| | | setPreviousFareEstimate(session.previousFareEstimate || null); |
| | | setPreviousRoutePoints(Array.isArray(cachedRoutePoints) ? cachedRoutePoints : []); |
| | | setPreviewEndSite(candidate); |
| | | return; |
| | | } |
| | | |
| | | if (session?.active && !isValidSite(candidate)) { |
| | | clearModifyDestinationSession(); |
| | | } |
| | | }, [ |
| | | deps.tripStatus, |
| | | deps.orderId, |
| | | deps.endSite?.id, |
| | | deps.endSite?.name, |
| | | deps.endSite?.latitude, |
| | | deps.endSite?.longitude, |
| | | ]); |
| | | |
| | | useEffect(() => { |
| | | if (!isValidSite(overrideEndSite)) return; |
| | | if (isSameSite(overrideEndSite, deps.endSite)) return; |
| | | deps.setEndSite(overrideEndSite); |
| | | }, [ |
| | | overrideEndSite, |
| | | deps.endSite?.id, |
| | | deps.endSite?.name, |
| | | deps.endSite?.latitude, |
| | | deps.endSite?.longitude |
| | | ]); |
| | | |
| | | const handleStartModifyDestination = (fareEstimate?: any, routePoints?: any[]) => { |
| | | Taro.setStorageSync(MODIFY_DESTINATION_SESSION_KEY, { |
| | | active: true, |
| | | previousEndSite: deps.endSite || null, |
| | | previousFareEstimate: fareEstimate || null, |
| | | orderId: deps.orderId || '', |
| | | tripStatus: deps.tripStatus, |
| | | updatedAt: Date.now() |
| | | }); |
| | | Taro.setStorageSync(MODIFY_DESTINATION_ROUTE_POINTS_KEY, Array.isArray(routePoints) ? routePoints : []); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_CANDIDATE_KEY); |
| | | deps.toSelectSite('end'); |
| | | }; |
| | | |
| | | const clearLocalDestinationOverride = () => { |
| | | setOverrideEndSite(null); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | }; |
| | | |
| | | const handleConfirmModifyDestination = async (selectedRoute?: any) => { |
| | | if (!isValidSite(previewEndSite)) return; |
| | | |
| | | try { |
| | | Taro.showLoading({ title: '正在修改...', mask: true }); |
| | | |
| | | let targetOrderId: string | number = deps.orderId || ''; |
| | | const activeOrderRes: any = await getOrderActive(); |
| | | targetOrderId = activeOrderRes?.id ? activeOrderRes.id : targetOrderId; |
| | | |
| | | // 如果后端需要字符串格式可以显式转String,如果后端支持number直接传值,保留原始格式防止隐式转换截断 |
| | | // 如果原本因为 Number() 导致大整数丢失精度,去掉 Number() 即可 |
| | | await orderChange({ |
| | | endPointId: previewEndSite.id || 0, |
| | | orderId: targetOrderId, |
| | | selectedRouteId: selectedRoute?.route?.routeID || selectedRoute?.routeId || '', |
| | | selectedRoutePlanRule: selectedRoute?.amapStrategy || '' |
| | | }); |
| | | Taro.hideLoading(); |
| | | |
| | | // 清除本地修改状态,不再作为“本地覆盖”存在,因为订单已真正变更 |
| | | // 同步更新缓存中新的终点信息 |
| | | const newEndSite = { |
| | | ...deps.endSite, |
| | | ...previewEndSite, |
| | | name: previewEndSite.name, |
| | | latitude: previewEndSite.latitude, |
| | | longitude: previewEndSite.longitude, |
| | | id: previewEndSite.id |
| | | }; |
| | | |
| | | Taro.setStorageSync('endSite', newEndSite); |
| | | deps.setEndSite(newEndSite); |
| | | |
| | | setPreviewEndSite(null); |
| | | setPreviousEndSite(null); |
| | | setPreviousFareEstimate(null); |
| | | setPreviousRoutePoints([]); |
| | | clearModifyDestinationSession(); |
| | | clearLocalDestinationOverride(); |
| | | |
| | | Taro.showToast({ |
| | | title: '目的地已更新', |
| | | icon: 'none' |
| | | }); |
| | | |
| | | if (typeof deps.onModifySuccess === 'function') { |
| | | deps.onModifySuccess(); |
| | | } |
| | | } catch (e: any) { |
| | | Taro.hideLoading(); |
| | | Taro.showToast({ |
| | | title: e?.message || '修改失败,请重试', |
| | | icon: 'none' |
| | | }); |
| | | } |
| | | }; |
| | | |
| | | const isModifyDestinationReviewVisible = isValidSite(previewEndSite); |
| | | const hasLocalDestinationOverride = isValidSite(overrideEndSite); |
| | | const effectiveEndSite = useMemo(() => ( |
| | | previewEndSite || overrideEndSite || deps.endSite |
| | | ), [previewEndSite, overrideEndSite, deps.endSite]); |
| | | |
| | | return { |
| | | effectiveEndSite, |
| | | previewEndSite, |
| | | previousEndSite, |
| | | previousFareEstimate, |
| | | previousRoutePoints, |
| | | isModifyDestinationReviewVisible, |
| | | hasLocalDestinationOverride, |
| | | clearLocalDestinationOverride, |
| | | handleStartModifyDestination, |
| | | handleConfirmModifyDestination |
| | | }; |
| | | } |
| | |
| | | tripStatusRef: React.MutableRefObject<number>; |
| | | statusRule: any; |
| | | shouldShowRoute: boolean; |
| | | allowSelectableRoutes: boolean; |
| | | startSite: any; |
| | | endSite?: any; |
| | | setStartSite?: (site: any) => void; |
| | |
| | | tripStatusRef: { current: 1 }, |
| | | statusRule: { showWalking: false }, |
| | | shouldShowRoute: false, |
| | | allowSelectableRoutes: false, |
| | | startSite: null, |
| | | resolveSafeTopInset: () => 0, |
| | | computeBottomPadding: () => 280, |
| | |
| | | newEndLng = routeData.destination.longitude ?? newEndLng; |
| | | } |
| | | |
| | | if (Number.isFinite(newStartLat) && Number.isFinite(newStartLng) && typeof deps.setStartSite === 'function') { |
| | | if (deps.tripStatusRef.current === 1 && Number.isFinite(newStartLat) && Number.isFinite(newStartLng) && typeof deps.setStartSite === 'function') { |
| | | deps.setStartSite({ |
| | | ...deps.startSite, |
| | | latitude: newStartLat, |
| | |
| | | }); |
| | | } |
| | | |
| | | if (Number.isFinite(newEndLat) && Number.isFinite(newEndLng) && typeof deps.setEndSite === 'function') { |
| | | if (deps.tripStatusRef.current === 1 && Number.isFinite(newEndLat) && Number.isFinite(newEndLng) && typeof deps.setEndSite === 'function') { |
| | | deps.setEndSite({ |
| | | ...deps.endSite, |
| | | latitude: newEndLat, |
| | |
| | | |
| | | if (Array.isArray(points) && points.length > 0) { |
| | | applyRoutePoints(points); |
| | | if (deps.tripStatusRef.current === 1) { |
| | | // 在 tripStatus === 1 时,我们需要包含所有路线的点,以便自适应视野 |
| | | // 如果允许选择路线(当前业务下未发单及修改目的地确认态时 allowSelectableRoutes 为 true) |
| | | if (deps.allowSelectableRoutes) { |
| | | // 在 tripStatus === 1 或 修改目的地确认态 时,我们需要包含所有路线的点,以便自适应视野 |
| | | const allRoutesPoints: any[] = []; |
| | | allRoutePoints.forEach(pts => { |
| | | if (Array.isArray(pts)) { |
| | |
| | | }; |
| | | |
| | | const resolveRouteIndexByTap = (e: any) => { |
| | | // 确保当前处于发单状态(有路线选项),并且有路线可供选择 |
| | | if (deps.tripStatusRef.current !== 1 || allRoutePoints.length <= 1) return -1; |
| | | // 确保允许选择路线并且有多条路线可供选择 |
| | | if (!deps.allowSelectableRoutes || allRoutePoints.length <= 1) return -1; |
| | | |
| | | // 优先使用 polylineId;如果运行时未返回 id,则退化为用点击坐标匹配最近路线 |
| | | let targetIndex = -1; |
| | |
| | | const points = Array.isArray(route?.polyline) ? route.polyline : []; |
| | | if (Array.isArray(points) && points.length > 0) { |
| | | applyRoutePoints(points); |
| | | if (deps.tripStatusRef.current === 1) { |
| | | // 在 tripStatus === 1 时,我们需要包含所有路线的点,以便自适应视野 |
| | | if (deps.allowSelectableRoutes) { |
| | | const allRoutesPoints: any[] = []; |
| | | allPointsArr.forEach(pts => { |
| | | if (Array.isArray(pts)) { |
| | |
| | | const buildPolylineLines = (displayRoutePoints: any[]) => { |
| | | const lines: any[] = []; |
| | | |
| | | // 如果有多条路线且在未发单状态(tripStatus === 1),渲染所有路线 |
| | | if (deps.tripStatusRef.current === 1 && allRoutePoints.length > 1) { |
| | | // 如果有多条路线且在未发单状态或修改目的地状态(allowSelectableRoutes === true),渲染所有路线 |
| | | if (deps.allowSelectableRoutes && allRoutePoints.length > 1) { |
| | | allRoutePoints.forEach((points, index) => { |
| | | if (!points || points.length === 0) return; |
| | | |
| | |
| | | import ReleaseRoute from '../components/release-route'; |
| | | import ReleaseBeBeing from '../components/release-be-being'; |
| | | import CallingCar from '../components/calling-car'; |
| | | import ModifyDestinationReview from '../components/modify-destination-review'; |
| | | import { formatDurationDisplay } from '../../../../utils/Robotaxi-public-method'; |
| | | |
| | | type Deps = { |
| | |
| | | routeDuration: number | null; |
| | | /** 自定义气泡图标 */ |
| | | customCalloutIconSrc: string; |
| | | /** 是否使用下单前端点气泡样式 */ |
| | | showTrip1StyleEndpoint: boolean; |
| | | /** 上车点 */ |
| | | startSite: any; |
| | | /** 下车点 */ |
| | |
| | | selectedRouteIndex: number; |
| | | /** 切换路线回调 */ |
| | | handleSelectRoute: (index: number) => void; |
| | | /** 地图点击 */ |
| | | handleMapTap?: (e: any) => void; |
| | | /** 修改目的地确认态 */ |
| | | isModifyDestinationReviewVisible?: boolean; |
| | | /** 修改前目的地 */ |
| | | previousEndSite?: any; |
| | | /** 修改前费用 */ |
| | | previousFareEstimate?: any; |
| | | /** 修改后目的地 */ |
| | | previewEndSite?: any; |
| | | /** 确认修改目的地 */ |
| | | handleConfirmModifyDestination?: (selectedRoute?: any) => void; |
| | | }; |
| | | |
| | | /** |
| | |
| | | <CoverView className={`custom-callout-content ${deps.isAndroid && 'txt-center '}`} style={{ zIndex: 1 }}> |
| | | <CoverView style={{ lineHeight: '22px', display: 'flex', alignItems: 'center' }}>{item.name}</CoverView> |
| | | {deps.isAndroid && <CoverView style={{ width: '2px' }}></CoverView>} |
| | | {deps.tripStatus == 1 && <CoverImage className='custom-callout-icon' src={deps.customCalloutIconSrc} />} |
| | | {(deps.tripStatus == 1 || deps.showTrip1StyleEndpoint) && <CoverImage className='custom-callout-icon' src={deps.customCalloutIconSrc} />} |
| | | </CoverView> |
| | | )} |
| | | {(item.name === '车辆位置' && [4, 6].includes(deps.tripStatus) && !deps.isAndroid) && ( |
| | |
| | | /> |
| | | ); |
| | | } |
| | | if (deps.isModifyDestinationReviewVisible) { |
| | | return ( |
| | | <ModifyDestinationReview |
| | | previousEndSite={deps.previousEndSite} |
| | | nextEndSite={deps.previewEndSite} |
| | | previousFareEstimate={deps.previousFareEstimate} |
| | | fareEstimate={deps.fareEstimate} |
| | | routeOptions={deps.routeOptions} |
| | | selectedRouteIndex={deps.selectedRouteIndex} |
| | | isRouteSelectExpanded={deps.isRouteSelectExpanded} |
| | | handleSelectRoute={deps.handleSelectRoute} |
| | | onEditDestination={deps.handleModifyDestination} |
| | | onConfirm={() => deps.handleConfirmModifyDestination?.(deps.routeOptions?.[deps.selectedRouteIndex || 0])} |
| | | /> |
| | | ); |
| | | } |
| | | if (deps.tripStatus == 2) { |
| | | return ( |
| | | <ReleaseBeBeing |
| | |
| | | import useOrderActions from './hooks/use-order-actions'; |
| | | // 视图委托渲染编排 |
| | | import useViewDelegates from './hooks/use-view-delegates'; |
| | | import useModifyDestinationFlow from './hooks/use-modify-destination-flow'; |
| | | |
| | | import backIcon from '../../../robotaxi/assets/back-icon.png' |
| | | import personalCenter from '../../../robotaxi/assets/personal-center.png' |
| | |
| | | 9: { showPickup: true, showDropoff: true, showCar: false, showRoute: true, showWalking: false, viewport: 'order_detail', translateCar: false }, |
| | | 10: { showPickup: true, showDropoff: false, showCar: false, showRoute: false, showWalking: false, viewport: 'pickup', translateCar: false }, |
| | | }; |
| | | const statusRule = useMemo(() => STATUS_RULES[tripStatus] || STATUS_RULES[1], [tripStatus]); // 当前行程状态对应的显示规则 |
| | | const shouldShowRoute = statusRule.showRoute; // 当前状态是否显示路线 |
| | | const orderDetailRouteRequestIdRef = useRef(0); // 订单详情路线请求ID |
| | | const isCheckedEnter = useRef(false); // 是否已检查页面进入状态 |
| | | const isAndroid = useMemo(() => { |
| | | try { |
| | | const info = Taro.getSystemInfoSync(); |
| | |
| | | }, []); |
| | | const shouldShowWaitingRadar = isAndroid && [2, 3].includes(tripStatus); // 是否显示等待雷达动画(Android且状态2/3) |
| | | const vehicleMarkerId = !isAndroid && tripStatus === 6 ? 30 : 3; // 车辆标记ID,iOS状态6使用不同ID |
| | | const { |
| | | openService, |
| | | toCoupon, |
| | | toOrder, |
| | | toBack, |
| | | handleCalloutTap, |
| | | toSelectSite, |
| | | guardPrivacyThenNavigate |
| | | } = usePageActions({ |
| | | tripStatus, |
| | | startSite, |
| | | getCheckedEnter: () => isCheckedEnter.current, |
| | | setCheckedEnter: (checked) => { |
| | | isCheckedEnter.current = checked; |
| | | } |
| | | }); // 页面动作编排 |
| | | const { |
| | | effectiveEndSite, |
| | | previewEndSite, |
| | | previousEndSite, |
| | | previousFareEstimate, |
| | | previousRoutePoints, |
| | | isModifyDestinationReviewVisible, |
| | | hasLocalDestinationOverride, |
| | | clearLocalDestinationOverride, |
| | | handleStartModifyDestination, |
| | | handleConfirmModifyDestination |
| | | } = useModifyDestinationFlow({ |
| | | tripStatus, |
| | | orderId, |
| | | endSite, |
| | | setEndSite, |
| | | toSelectSite, |
| | | onModifySuccess: () => { |
| | | // 成功修改后触发路线重新获取 |
| | | // 下一次渲染时 isModifyDestinationReviewVisible 变为 false |
| | | // 我们需要强制让选路状态退出,同时获取后端的新行程路线 |
| | | setIsRouteSelectExpanded(false); |
| | | setRouteOptions([]); |
| | | setRoutePoints([]); |
| | | setFareEstimate({}); |
| | | routeRequestOrderRoute(orderId, tripStatus); |
| | | } |
| | | }); |
| | | const statusRule = useMemo(() => { |
| | | const baseRule = STATUS_RULES[tripStatus] || STATUS_RULES[1]; |
| | | if (isModifyDestinationReviewVisible || hasLocalDestinationOverride) { |
| | | return { |
| | | ...baseRule, |
| | | showPickup: true, |
| | | showDropoff: true, |
| | | showCar: false, |
| | | showRoute: true, |
| | | showWalking: false, |
| | | viewport: 'route' |
| | | }; |
| | | } |
| | | return baseRule; |
| | | }, [tripStatus, isModifyDestinationReviewVisible, hasLocalDestinationOverride]); |
| | | const shouldShowRoute = statusRule.showRoute; // 当前状态是否显示路线 |
| | | const allowSelectableRoutes = tripStatus === 1 || isModifyDestinationReviewVisible || hasLocalDestinationOverride; |
| | | const showTrip1StyleEndpoint = isModifyDestinationReviewVisible || hasLocalDestinationOverride; |
| | | const vehicleState = useVehicleTracking({ |
| | | statusRule, |
| | | vehicleMarkerId |
| | |
| | | tripStatus, |
| | | statusRule, |
| | | isAndroid, |
| | | showTrip1StyleEndpoint, |
| | | startSite, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | vehicleLocation, |
| | | vehicleDirection, |
| | | vehicleMarkerId |
| | |
| | | isAndroid, |
| | | tripStatus, |
| | | startSite, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | vehicleLocation, |
| | | longitude, |
| | | latitude |
| | |
| | | tripStatusRef, |
| | | statusRule, |
| | | shouldShowRoute, |
| | | allowSelectableRoutes, |
| | | startSite, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | setStartSite, |
| | | setEndSite, |
| | | updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled, |
| | |
| | | requestOrderRoute: routeRequestOrderRoute, |
| | | updateWalkingRouteForStart: routeUpdateWalkingRouteForStart |
| | | } = routeState; // 路线状态解构 |
| | | const displayPolyline = useMemo(() => { |
| | | if (!(isModifyDestinationReviewVisible || hasLocalDestinationOverride) || !Array.isArray(previousRoutePoints) || previousRoutePoints.length === 0) { |
| | | return polyline; |
| | | } |
| | | return [ |
| | | { |
| | | id: 900001, |
| | | points: previousRoutePoints, |
| | | color: '#D7DDEF', |
| | | width: 8, |
| | | dottedLine: false, |
| | | arrowLine: true, |
| | | clickable: false |
| | | }, |
| | | ...polyline |
| | | ]; |
| | | }, [polyline, previousRoutePoints, isModifyDestinationReviewVisible, hasLocalDestinationOverride]); |
| | | const routeSummaryMarker = useMemo(() => { |
| | | if (tripStatus !== 1 || !routeDistance || routeDuration === null || !Array.isArray(routePoints) || routePoints.length === 0) { |
| | | if (!allowSelectableRoutes || !routeDistance || routeDuration === null || !Array.isArray(routePoints) || routePoints.length === 0) { |
| | | return null; |
| | | } |
| | | const midpoint = routePoints[Math.max(0, Math.floor(routePoints.length / 2))]; |
| | |
| | | isRouteSummaryBubble: true, |
| | | customCallout: { anchorY: 0, anchorX: 0, display: 'ALWAYS' } |
| | | }; |
| | | }, [tripStatus, routeDistance, routeDuration, routePoints]); |
| | | }, [allowSelectableRoutes, routeDistance, routeDuration, routePoints]); |
| | | const markersWithRouteSummary = useMemo(() => { |
| | | if (!routeSummaryMarker) return markers; |
| | | return [...markers, routeSummaryMarker]; |
| | |
| | | routePoints, |
| | | walkingPoints, |
| | | startSite, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | vehicleLocation, |
| | | longitude, |
| | | latitude, |
| | |
| | | toOrigin, |
| | | getLocationInfo |
| | | } = viewportState; // 视口状态解构 |
| | | const isCheckedEnter = useRef(false); // 是否已检查页面进入状态 |
| | | const { checkDistanceAndCityReady } = useOrderDistanceConfig({ |
| | | endSite, |
| | | setTipType, |
| | |
| | | getCurrentLongitude: () => longitude, |
| | | getCurrentLatitude: () => latitude |
| | | }); // 页面恢复与初始化编排 |
| | | const { |
| | | openService, |
| | | toCoupon, |
| | | toOrder, |
| | | toBack, |
| | | handleCalloutTap, |
| | | toSelectSite, |
| | | guardPrivacyThenNavigate |
| | | } = usePageActions({ |
| | | tripStatus, |
| | | startSite, |
| | | getCheckedEnter: () => isCheckedEnter.current, |
| | | setCheckedEnter: (checked) => { |
| | | isCheckedEnter.current = checked; |
| | | } |
| | | }); // 页面动作编排 |
| | | const router = Taro.useRouter(); |
| | | const tripRuntime = useTripRuntime({ |
| | | waitingTimerStartKey: WAITING_TIMER_START_KEY, |
| | |
| | | currentFullRouteRef, |
| | | orderDetailRouteRequestIdRef, |
| | | routePoints, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | longitude, |
| | | latitude, |
| | | elapsedSeconds, |
| | |
| | | const orderActions = useOrderActions({ |
| | | tripStatus, |
| | | orderId, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | startSite, |
| | | fareEstimate, |
| | | routeOptions, |
| | |
| | | handleFinishCountdownTimeout, |
| | | handlePay |
| | | } = orderActions; |
| | | const modifyPreviewRouteKeyRef = useRef(''); |
| | | |
| | | useEffect(() => { |
| | | const shouldPreviewModifiedRoute = isModifyDestinationReviewVisible || hasLocalDestinationOverride; |
| | | const targetEndSite = shouldPreviewModifiedRoute ? effectiveEndSite : null; |
| | | const destLat = Number(targetEndSite?.latitude); |
| | | const destLng = Number(targetEndSite?.longitude); |
| | | |
| | | if (![4, 5, 6].includes(tripStatus) || !Number.isFinite(destLat) || !Number.isFinite(destLng)) { |
| | | modifyPreviewRouteKeyRef.current = ''; |
| | | return; |
| | | } |
| | | |
| | | const cachedStartSite = Taro.getStorageSync('startSite') || {}; |
| | | let originLat = Number(startSite?.latitude ?? cachedStartSite?.latitude); |
| | | let originLng = Number(startSite?.longitude ?? cachedStartSite?.longitude); |
| | | |
| | | if ([4, 5].includes(tripStatus)) { |
| | | if (!Number.isFinite(originLat) || !Number.isFinite(originLng)) { |
| | | return; |
| | | } |
| | | } else if (tripStatus === 6 && Number.isFinite(Number(vehicleLocation?.latitude)) && Number.isFinite(Number(vehicleLocation?.longitude))) { |
| | | originLat = Number(vehicleLocation?.latitude); |
| | | originLng = Number(vehicleLocation?.longitude); |
| | | } else { |
| | | return; |
| | | } |
| | | |
| | | if (!Number.isFinite(originLat) || !Number.isFinite(originLng)) { |
| | | return; |
| | | } |
| | | |
| | | const nextKey = `${tripStatus}|${Number(targetEndSite?.id || 0)}|${originLng},${originLat}-${destLng},${destLat}`; |
| | | if (nextKey === modifyPreviewRouteKeyRef.current) { |
| | | return; |
| | | } |
| | | modifyPreviewRouteKeyRef.current = nextKey; |
| | | routeRequestRouteAndFare(originLat, originLng, destLat, destLng, true); |
| | | }, [ |
| | | tripStatus, |
| | | isModifyDestinationReviewVisible, |
| | | hasLocalDestinationOverride, |
| | | effectiveEndSite?.latitude, |
| | | effectiveEndSite?.longitude, |
| | | startSite?.latitude, |
| | | startSite?.longitude, |
| | | vehicleLocation?.latitude, |
| | | vehicleLocation?.longitude, |
| | | latitude, |
| | | longitude |
| | | ]); |
| | | |
| | | useDidShow(() => { |
| | | handleDidShow(router.params?.skipPageFlag); |
| | |
| | | }, [isRouteSelectExpanded]); |
| | | const handleModifyDestination = () => { |
| | | isCheckedEnter.current = true; |
| | | toSelectSite('end'); |
| | | handleStartModifyDestination(fareEstimate, routePoints); |
| | | }; |
| | | const handleShareTrip = () => { |
| | | isCheckedEnter.current = true; |
| | |
| | | routeDistance, |
| | | routeDuration, |
| | | customCalloutIconSrc, |
| | | showTrip1StyleEndpoint, |
| | | startSite, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | callImmediately, |
| | | fareEstimate, |
| | | handleOpenPassengerNotice, |
| | |
| | | routeOptions, |
| | | selectedRouteIndex, |
| | | handleSelectRoute, |
| | | handleMapTap |
| | | handleMapTap, |
| | | isModifyDestinationReviewVisible, |
| | | previousEndSite, |
| | | previousFareEstimate, |
| | | previewEndSite, |
| | | handleConfirmModifyDestination |
| | | }); // 视图渲染委托 |
| | | const mapLongitude = longitude ?? 116.397128; |
| | | const mapLatitude = latitude ?? 39.916527; |
| | | const canToggleRouteSelect = allowSelectableRoutes && routeOptions.length > 1; |
| | | |
| | | const shouldShowOrigin = ![7, 8, 9].includes(tripStatus); |
| | | const handleServiceEntry = () => { |
| | |
| | | mapLatitude={mapLatitude} |
| | | mapScale={mapScale} |
| | | markers={markersWithRouteSummary} |
| | | polyline={polyline} |
| | | polyline={displayPolyline} |
| | | showLocation={!!longitude && showLocationFlag} |
| | | handleCalloutTap={handleCalloutTap} |
| | | handleRegionChange={handleRegionChange} |
| | |
| | | packUpIcon={packUpIcon} |
| | | onSelectRouteClick={() => setIsRouteSelectExpanded(!isRouteSelectExpanded)} |
| | | isRouteSelectExpanded={isRouteSelectExpanded} |
| | | canToggleRouteSelect={canToggleRouteSelect} |
| | | walkIcon={walkIcon} |
| | | originIcon={originIcon} |
| | | renderTripCard={renderTripCard} |
| | |
| | | showCancelPopup={showCancelPopup} |
| | | cancelType={cancelType} |
| | | startSite={startSite} |
| | | endSite={endSite} |
| | | endSite={effectiveEndSite} |
| | | fareEstimate={fareEstimate} |
| | | tripStatus={tripStatus} |
| | | routeDuration={routeDuration} |
| | |
| | | splitHighlightParts, |
| | | filterNearbyListByCurrentSite |
| | | } from './utils/site-search'; |
| | | import { |
| | | MODIFY_DESTINATION_CANDIDATE_KEY, |
| | | MODIFY_DESTINATION_SESSION_KEY |
| | | } from '../begin-route/constants/modify-destination'; |
| | | |
| | | |
| | | |
| | |
| | | } |
| | | } |
| | | } catch { } |
| | | Taro.setStorageSync('endSite', item) |
| | | const modifySession = Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY); |
| | | if (modifySession?.active) { |
| | | Taro.setStorageSync(MODIFY_DESTINATION_CANDIDATE_KEY, item); |
| | | } else { |
| | | Taro.setStorageSync('endSite', item); |
| | | } |
| | | Taro.reLaunch({ |
| | | url: Taro.getStorageSync('orderId') ? '/robotaxi/pages/begin-route/index?skipPageFlag=1' : '/robotaxi/pages/begin-route/index', |
| | | }) |