Merge branch 'feature_v1.0' of http://gitlab.xiaopeng.local:18080/robotaxi1/xp-robotaxi-miniprogram into feature_v1.0
| | |
| | | export const HOME_MAP_ID = 'map'; |
| | | |
| | | /** 浮层默认上移距离。 */ |
| | | export const OVERLAY_ANCHOR_OFFSET = 32; |
| | | export const OVERLAY_ANCHOR_OFFSET = 8; |
| | | |
| | | /** 浮层初次量测延迟。 */ |
| | | export const OVERLAY_ANCHOR_INIT_DELAY = 80; |
| | |
| | | try { |
| | | const { isInside, newMarkers, sites } = await mapState.updateMapData(lat, lng, undefined, { commitMarkers: false }); |
| | | if (!isInside) { |
| | | siteState.clearCachedStartSiteSelection(); |
| | | commitQueriedMarkers(newMarkers, false, false); |
| | | settleRegionResult({ lat, lon: lng, isInside: false }); |
| | | siteState.setBounce(false); |
| | |
| | | try { |
| | | const { isInside, newMarkers, sites } = await mapState.updateMapData(lat, lng, undefined, { commitMarkers: false }); |
| | | if (!isInside) { |
| | | siteState.clearCachedStartSiteSelection(); |
| | | commitQueriedMarkers(newMarkers, false, false); |
| | | settleRegionResult({ lat, lon: lng, isInside: false }); |
| | | siteState.setBounce(false); |
| | |
| | | RECOMMENDED_CONFIRM_RIDE_SOURCE_TYPE, |
| | | writeConfirmRideSiteSourceType |
| | | } from '../../../../robotaxi/constants/confirm-ride-report'; |
| | | import { clearHomeStartSiteDisplayState } from '../../../../robotaxi/hooks/use-home-start-site-display'; |
| | | |
| | | /** |
| | | * 站点与吸附状态管理:统一吸附判断与逆地理地址回填。 |
| | |
| | | const [showToolTip, setShowToolTip] = useState(true); |
| | | const [bounce, setBounce] = useState(false); |
| | | const [adsorptionDistance, setAdsorptionDistance] = useState<number | null>(null); |
| | | |
| | | /** |
| | | * 清理首页已缓存的上车点选择,避免拖图未吸附时继续复用旧站点。 |
| | | */ |
| | | const clearCachedStartSiteSelection = () => { |
| | | Taro.removeStorageSync('startSite'); |
| | | clearHomeStartSiteDisplayState(); |
| | | }; |
| | | |
| | | /** |
| | | * 统一吸附判断,命中阈值则更新吸附站点并显示提示。 |
| | |
| | | */ |
| | | const resolveAddress = async (lat: number, lng: number) => { |
| | | setIsAdsorbed(false); |
| | | clearCachedStartSiteSelection(); |
| | | setCurrentSite({ name: '正在获取位置...' }); |
| | | try { |
| | | const data = await getAddressByLocation(lng, lat); |
| | |
| | | setShowToolTip, |
| | | setBounce, |
| | | setAdsorptionDistance, |
| | | clearCachedStartSiteSelection, |
| | | tryAdsorb, |
| | | resolveAddress, |
| | | }; |
| | |
| | | finalFare?: NumericLike; |
| | | /** 原价金额。 */ |
| | | originalFare?: NumericLike; |
| | | /** 线路报价返回的优惠券 ID。 */ |
| | | couponId?: OrderId | ''; |
| | | /** 兼容后端返回的用户优惠券 ID。 */ |
| | | userCouponId?: OrderId | ''; |
| | | } |
| | | |
| | | /** 单条路线选项。 */ |
| | |
| | | import { PureComponent } from 'react' |
| | | import { View, Text } from '@tarojs/components' |
| | | import './index.less' |
| | | // TODO(仅用于测试): 上线前删除以下空调测试相关引入及 DEMO_ORDER_ID |
| | | import TemperatureControl from '../../pages/begin-route/components/calling-car-modules/temperature-control'; |
| | | import { TripStatus } from '../../constants/trip-status'; |
| | | |
| | | const DEMO_ORDER_ID = '2063814874050772993'; |
| | | |
| | | type Props = { |
| | | showIcon: boolean |
| | | showTitle: boolean |
| | | } |
| | | type State = { |
| | | |
| | | tripStatus: TripStatus | number; |
| | | } |
| | | |
| | | export default class Loading extends PureComponent<Props, State> { |
| | |
| | | constructor (props) { |
| | | super(props) |
| | | this.state = { |
| | | |
| | | tripStatus: TripStatus.VehicleApproaching, |
| | | } |
| | | } |
| | | |
| | | componentDidMount() { |
| | | |
| | | // setTimeout(() => { |
| | | // this.setState({ tripStatus: 5 }); |
| | | // }, 3000); |
| | | } |
| | | |
| | | componentWillUnmount() { |
| | |
| | | render () { |
| | | return ( |
| | | <View className='loading'> |
| | | <Text>demo components</Text> |
| | | <Text>空调卡片 Demo</Text> |
| | | {/* TODO(仅用于测试): 上线前删除 TemperatureControl 测试渲染,.calling-car 包裹仅用于补全样式层级 */} |
| | | <View className='calling-car'> |
| | | <TemperatureControl |
| | | tripStatus={this.state.tripStatus as TripStatus} |
| | | orderId={DEMO_ORDER_ID} |
| | | /> |
| | | </View> |
| | | </View> |
| | | ) |
| | | } |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | |
| | | const AC_STORAGE_KEY_PREFIX = 'rt_control_'; |
| | | |
| | | export interface AcStorageData { |
| | | acOpen: boolean; |
| | | temperature: number; |
| | | } |
| | | |
| | | function getStorageKey(orderId: string): string { |
| | | return `${AC_STORAGE_KEY_PREFIX}${orderId}`; |
| | | } |
| | | |
| | | export function getAcStorage(orderId: string): AcStorageData | null { |
| | | try { |
| | | const raw = Taro.getStorageSync(getStorageKey(orderId)); |
| | | if (!raw) return null; |
| | | const data = typeof raw === 'string' ? JSON.parse(raw) : raw; |
| | | if (typeof data.acOpen === 'boolean' && typeof data.temperature === 'number') { |
| | | return data; |
| | | } |
| | | return null; |
| | | } catch { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | export function saveAcStorage(orderId: string, data: AcStorageData): void { |
| | | try { |
| | | Taro.setStorageSync(getStorageKey(orderId), JSON.stringify(data)); |
| | | } catch { |
| | | // 静默失败,不阻断交互 |
| | | } |
| | | } |
| | | |
| | | export function removeAcStorage(orderId: string): void { |
| | | try { |
| | | Taro.removeStorageSync(getStorageKey(orderId)); |
| | | } catch { |
| | | // 静默失败 |
| | | } |
| | | } |
| | |
| | | <View className="car-card-arrive"> |
| | | <View className={`car-card-arrive-item ${trunkStatus && 'close-btn-item'}`} onClick={() => handleUnlock('trunk', () => setTrunkStatus(!trunkStatus))}> |
| | | <Image className="car-card-arrive-item-img" src={trunkStatus ? 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-open.png' : 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-close.png'} /> |
| | | <View className="car-card-arrive-item-text">{trunkStatus ? '关闭后备箱' : '打开后备箱'}</View> |
| | | <View className="car-card-arrive-item-text">{trunkStatus ? '后备箱' : '后备箱'}</View> |
| | | </View> |
| | | <View className={`car-card-arrive-item ${lockStatus && 'close-btn-item'}`} onClick={() => handleUnlock('door')}> |
| | | <Image className="car-card-arrive-item-img" src={lockStatus ? LOCK_OPEN_ICON_URL : LOCK_CLOSE_ICON_URL} /> |
| | |
| | | <View className="car-card-reach-btn"> |
| | | <View className={`car-card-reach-btn-item ${trunkStatus && 'close-btn-item'}`} onClick={() => handleUnlock('trunk', () => setTrunkStatus(!trunkStatus))}> |
| | | <Image className="car-card-reach-btn-item-img" src={trunkStatus ? 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-open.png' : 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-close.png'} /> |
| | | <View className="car-card-reach-btn-item-text">{trunkStatus ? '关闭后备箱' : '打开后备箱'}</View> |
| | | <View className="car-card-reach-btn-item-text">{trunkStatus ? '后备箱' : '后备箱'}</View> |
| | | </View> |
| | | <View className="car-card-reach-btn-item" onClick={handleOpenOverRoutePopupWithDebounce}> |
| | | <Image className="car-card-reach-btn-item-img" src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/over-gps-icon.png" /> |
| | |
| | | return ( |
| | | <View> |
| | | <View className="car-card-end"> |
| | | <View className="car-card-line" style={tripStatus === TripStatus.TripFinished ? { margin: '12px 0' } : { margin: '24px 0' }}></View> |
| | | <View className="car-card-line" style={{ margin: '12px 0' }}></View> |
| | | <View className="car-card-end-particulars"> |
| | | <View className="car-card-end-particulars-label"> |
| | | 行程费用 |
| | |
| | | ))} |
| | | </View> |
| | | } |
| | | {Array.isArray(fareBreakdown?.duration) && fareBreakdown.duration.length > 0 && |
| | | <View className="particulars-detail-item"> |
| | | <View className="particulars-detail-item-title"> |
| | | <View>时长费</View> |
| | | <View>{formatMoney(fareBreakdown.duration.reduce((sum: number, item: any) => sum + (Number(item?.money) || 0), 0))}元</View> |
| | | </View> |
| | | {fareBreakdown.duration.filter((item: any) => item?.money || item?.duration).map((item: any, index: number) => ( |
| | | <View key={`duration-${index}`} className="particulars-detail-item-content"> |
| | | <View>{getTypeLabel(item?.type)}({formatTripDurationFromSeconds(item?.duration)})</View> |
| | | <View>{formatMoney(item?.money)}元</View> |
| | | </View> |
| | | ))} |
| | | </View> |
| | | } |
| | | {Array.isArray(fareBreakdown?.longDistance) && fareBreakdown.longDistance.length > 0 && |
| | | <View className="particulars-detail-item"> |
| | | <View className="particulars-detail-item-title"> |
| | |
| | | </View> |
| | | </View> |
| | | } |
| | | {Number(fareBreakdown?.waitMoney) > 0 && |
| | | <View className="particulars-detail-item"> |
| | | <View className="particulars-detail-item-title"> |
| | | <View>等待费</View> |
| | | <View>{formatMoney(fareBreakdown.waitMoney)}元</View> |
| | | </View> |
| | | </View> |
| | | } |
| | | {Number(fareBreakdown?.redPacketMoney) > 0 && |
| | | <View className="particulars-detail-item"> |
| | | <View className="particulars-detail-item-title"> |
| | |
| | | import Taro from "@tarojs/taro"; |
| | | import { View, Image } from "@tarojs/components"; |
| | | import { useState, useEffect, useRef } from "react"; |
| | | // TODO(仅用于测试): 上线前将此处改回从 '../../../../api/begin-route' 导入 './test-case/ac-mock' |
| | | import { |
| | | airConditionerUnlock, |
| | | queryTemperatureByOrderId, |
| | | queryVehicleAirConditionerSwitch, |
| | | setTemperature, |
| | | setTemperatureIgnoreBusinessError |
| | | setTemperature |
| | | } from '../../../../api/begin-route'; |
| | | // TODO(仅用于测试): 上线前删除以下 import |
| | | import '../css-folder/calling-car.less'; |
| | | import { getOrderId } from '../../../../utils/order'; |
| | | import { TripStatus } from '../../../../constants/trip-status'; |
| | | import { MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY } from '../../../../constants/modify-destination'; |
| | | import { getAcStorage, saveAcStorage, removeAcStorage } from './ac-storage'; |
| | | |
| | | const AC_SWITCH_OPEN_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-open.png'; // 空调关闭态展示的开启动作图标 |
| | | const AC_SWITCH_CLOSE_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-close.png'; // 空调开启态展示的关闭动作图标 |
| | | const AC_OPENING_TEXT = '开启中...'; // 开启空调时的 loading 文案 |
| | | const AC_CLOSING_TEXT = '关闭中...'; // 关闭空调时的 loading 文案 |
| | | const AC_QUERY_FAIL_TEXT = '空调状态查询失败,请稍后重试'; // 空调状态查询失败提示 |
| | | const AC_TOGGLE_FAIL_TEXT = '空调开关操作失败,请重试'; // 空调开关操作失败提示 |
| | | const AC_SWITCH_OPEN_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-open.png'; |
| | | const AC_SWITCH_CLOSE_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-close.png'; |
| | | const AC_SWITCH_LOADING_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-loading-v2.png'; |
| | | const AC_TOGGLE_FAIL_TEXT = '空调开关操作失败,请重试'; |
| | | const AC_TEMP_FAIL_TEXT = '温度设置失败,请重试'; |
| | | |
| | | type Props = { |
| | | tripStatus: TripStatus; |
| | | orderId?: string; |
| | | pageDidShowVersion?: number; |
| | | /** 车是否还在执行上一单 */ |
| | | hasBeforeUserEndSite?: boolean; |
| | | }; |
| | | |
| | | export default function TemperatureControl({ tripStatus, orderId, pageDidShowVersion = 0 }: Props) { |
| | | const [tempStatus, setTempStatus] = useState<number | null>(null); // 当前显示温度 |
| | | const [acSwitchOn, setAcSwitchOn] = useState(false); // 空调开关状态 |
| | | const [acStatusLoaded, setAcStatusLoaded] = useState(false); // 空调状态是否已完成查询 |
| | | const [isQueryingAc, setIsQueryingAc] = useState(false); // 空调状态查询中 |
| | | const [isQueryingTemp, setIsQueryingTemp] = useState(false); // 温度查询中 |
| | | const [isTogglingAc, setIsTogglingAc] = useState(false); // 空调开关操作中 |
| | | const tempTimerRef = useRef<any>(null); // 温度设置防抖定时器 |
| | | const lastConfirmedTempRef = useRef<number | null>(null); // 最近一次设置成功温度 |
| | | const acQuerySeqRef = useRef(0); // 空调状态查询请求序号,避免旧请求覆盖新状态 |
| | | const skipQueryOnThisCycleRef = useRef(false); // 修改目的地返回本次页面恢复是否跳过温度/空调查询 |
| | | const INITIAL_OUT_OF_RANGE_TARGET_TEMP = 22; // 初始化查询温度越界时回写的目标温度 |
| | | const TEMP_UPDATE_DEBOUNCE_MS = 1000; // 温度调整后提交接口的防抖时长 |
| | | export default function TemperatureControl({ tripStatus, orderId, pageDidShowVersion = 0, hasBeforeUserEndSite = false }: Props) { |
| | | const [tempStatus, setTempStatus] = useState<number | null>(null); |
| | | const [acSwitchOn, setAcSwitchOn] = useState(false); |
| | | const [acStatusLoaded, setAcStatusLoaded] = useState(false); |
| | | const [isTogglingAc, setIsTogglingAc] = useState(false); |
| | | const tempTimerRef = useRef<any>(null); |
| | | const lastConfirmedTempRef = useRef<number | null>(null); |
| | | const skipQueryOnThisCycleRef = useRef(false); |
| | | const userActionSeqRef = useRef(0); |
| | | const calibrationSeqRef = useRef(0); |
| | | const TEMP_MIN = 22; |
| | | const TEMP_MAX = 28; |
| | | const TEMP_UPDATE_DEBOUNCE_MS = 500; |
| | | const DEFAULT_FIRST_TEMP = 23.5; |
| | | |
| | | const TEMP_MIN = 22; // 空调最低温度 |
| | | const TEMP_MAX = 28; // 空调最高温度 |
| | | const canToggleSwitch = tripStatus === TripStatus.VehicleApproaching && !isQueryingAc && !isTogglingAc; // 开关是否可点击 |
| | | const canAdjustTemperature = canToggleSwitch && acStatusLoaded && acSwitchOn && typeof tempStatus === 'number' && !isQueryingTemp; // 温度是否可调整 |
| | | const tempButtonIconSuffix = acSwitchOn ? '' : '-no'; // 空调关闭时展示置灰的温度调节图标 |
| | | const tempTextDisabledClassName = acSwitchOn ? '' : 'color-000-25'; // 空调关闭时温度和文案置灰 |
| | | const canToggleSwitch = tripStatus === TripStatus.VehicleApproaching && !isTogglingAc; |
| | | const canAdjustTemperature = canToggleSwitch && acStatusLoaded && acSwitchOn && typeof tempStatus === 'number'; |
| | | const tempButtonIconSuffix = acSwitchOn ? '' : '-no'; |
| | | const tempTextDisabledClassName = acSwitchOn ? '' : 'color-000-25'; |
| | | |
| | | useEffect(() => { |
| | | const skipOnce = Number(Taro.getStorageSync(MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY)) === 1; // 修改目的地返回场景只跳过一次查询 |
| | | if (tripStatus === TripStatus.VehicleApproaching) { |
| | | return; |
| | | } |
| | | const targetOrderId = getOrderId(orderId); |
| | | if (targetOrderId) { |
| | | removeAcStorage(targetOrderId); |
| | | } |
| | | }, [tripStatus, orderId]); |
| | | |
| | | useEffect(() => { |
| | | const skipOnce = Number(Taro.getStorageSync(MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY)) === 1; |
| | | skipQueryOnThisCycleRef.current = skipOnce; |
| | | if (skipOnce) { |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY); |
| | | } |
| | | }, [pageDidShowVersion, tripStatus, orderId]); |
| | | |
| | | // 将空调状态回落到“默认关闭态” |
| | | const resetAcUiState = () => { |
| | | setAcSwitchOn(false); |
| | | setAcStatusLoaded(false); |
| | | }; |
| | | |
| | | // 解析接口返回的空调开关状态 |
| | | const parseAcSwitchState = (res: any) => { |
| | | const raw = res?.open ?? res?.isOpen ?? res?.switchStatus ?? res?.airConditionerSwitch ?? res; |
| | | if (typeof raw === 'boolean') { |
| | |
| | | return false; |
| | | }; |
| | | |
| | | // 查询空调开关状态 |
| | | const queryAcSwitchStatus = async (targetOrderId: string) => { |
| | | const nextSeq = acQuerySeqRef.current + 1; |
| | | acQuerySeqRef.current = nextSeq; |
| | | setIsQueryingAc(true); |
| | | const syncStorageToBackend = async (targetOrderId: string, acOpen: boolean, temperature: number) => { |
| | | try { |
| | | const res = await queryVehicleAirConditionerSwitch({ orderId: targetOrderId }); |
| | | if (nextSeq !== acQuerySeqRef.current) { |
| | | return; |
| | | await airConditionerUnlock({ orderId: targetOrderId, open: acOpen }); |
| | | } catch {} |
| | | try { |
| | | await setTemperature({ orderId: targetOrderId, targetTemperature: temperature }); |
| | | } catch {} |
| | | }; |
| | | |
| | | const handleFirstTimeInit = async (targetOrderId: string) => { |
| | | setAcSwitchOn(true); |
| | | setTempStatus(DEFAULT_FIRST_TEMP); |
| | | lastConfirmedTempRef.current = DEFAULT_FIRST_TEMP; |
| | | setAcStatusLoaded(true); |
| | | setIsTogglingAc(true); |
| | | |
| | | let acSuccess = false; |
| | | let tempSuccess = false; |
| | | |
| | | try { |
| | | await airConditionerUnlock({ orderId: targetOrderId, open: true }); |
| | | acSuccess = true; |
| | | } catch {} |
| | | |
| | | try { |
| | | const res = await setTemperature({ orderId: targetOrderId, targetTemperature: DEFAULT_FIRST_TEMP }); |
| | | if (res) { |
| | | tempSuccess = true; |
| | | } |
| | | const nextOpenState = parseAcSwitchState(res); |
| | | setAcSwitchOn(nextOpenState); |
| | | } catch {} |
| | | |
| | | setIsTogglingAc(false); |
| | | |
| | | if (acSuccess && tempSuccess) { |
| | | Taro.showToast({ title: '已开启空调', icon: 'none' }); |
| | | } else if (!acSuccess) { |
| | | Taro.showToast({ title: AC_TOGGLE_FAIL_TEXT, icon: 'none' }); |
| | | } else { |
| | | Taro.showToast({ title: AC_TEMP_FAIL_TEXT, icon: 'none' }); |
| | | } |
| | | |
| | | saveAcStorage(targetOrderId, { acOpen: true, temperature: DEFAULT_FIRST_TEMP }); |
| | | }; |
| | | |
| | | const handleCalibration = async (targetOrderId: string, storageAcOpen: boolean, storageTemp: number) => { |
| | | const snapshotSeq = userActionSeqRef.current; |
| | | calibrationSeqRef.current = snapshotSeq; |
| | | |
| | | const CALIBRATION_TIMEOUT_MS = 1500; |
| | | |
| | | type CalibrationResult = |
| | | | { type: 'success'; acOpen: boolean; temperature: number } |
| | | | { type: 'timeout' } |
| | | | { type: 'error' }; |
| | | |
| | | const queryPromise: Promise<CalibrationResult> = Promise.all([ |
| | | queryVehicleAirConditionerSwitch({ orderId: targetOrderId }), |
| | | queryTemperatureByOrderId({ orderId: targetOrderId }) |
| | | ]).then(([acRes, tempRes]): CalibrationResult => { |
| | | const acOpen = parseAcSwitchState(acRes); |
| | | const parsedTemp = Number(tempRes); |
| | | const temperature = Number.isNaN(parsedTemp) ? storageTemp : parsedTemp; |
| | | return { type: 'success', acOpen, temperature }; |
| | | }).catch((): CalibrationResult => ({ type: 'error' })); |
| | | |
| | | const timeoutPromise: Promise<CalibrationResult> = new Promise((resolve) => |
| | | setTimeout(() => resolve({ type: 'timeout' }), CALIBRATION_TIMEOUT_MS) |
| | | ); |
| | | |
| | | const result = await Promise.race([queryPromise, timeoutPromise]); |
| | | |
| | | if (userActionSeqRef.current > snapshotSeq) { |
| | | return; |
| | | } |
| | | |
| | | if (result.type === 'timeout' || result.type === 'error') { |
| | | // 超时或出错:展示 storageData,并同步远程 |
| | | setAcSwitchOn(storageAcOpen); |
| | | setTempStatus(storageTemp); |
| | | lastConfirmedTempRef.current = storageTemp; |
| | | setAcStatusLoaded(true); |
| | | } catch (error) { |
| | | if (nextSeq !== acQuerySeqRef.current) { |
| | | return; |
| | | |
| | | if (storageAcOpen) { |
| | | // 空调开启 → 正常下发开关 + 温度 |
| | | syncStorageToBackend(targetOrderId, storageAcOpen, storageTemp); |
| | | } else { |
| | | // 空调关闭 → 只下发关闭,不下发温度(下发温度会自动开空调) |
| | | try { await airConditionerUnlock({ orderId: targetOrderId, open: false }); } catch {} |
| | | } |
| | | setAcSwitchOn(false); |
| | | setAcStatusLoaded(false); |
| | | Taro.showToast({ title: AC_QUERY_FAIL_TEXT, icon: 'none' }); |
| | | } finally { |
| | | if (nextSeq === acQuerySeqRef.current) { |
| | | setIsQueryingAc(false); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | // 接口在限定时间内返回:以接口数据为准 |
| | | setAcSwitchOn(result.acOpen); |
| | | setTempStatus(result.temperature); |
| | | lastConfirmedTempRef.current = result.temperature; |
| | | setAcStatusLoaded(true); |
| | | saveAcStorage(targetOrderId, { acOpen: result.acOpen, temperature: result.temperature }); |
| | | }; |
| | | |
| | | // 查询车辆当前温度 |
| | | const queryTemperature = async (targetOrderId: string) => { |
| | | setIsQueryingTemp(true); |
| | | try { |
| | | const res: any = await queryTemperatureByOrderId({ orderId: targetOrderId }); |
| | | const currentTemperature = Number(res); |
| | | if (!Number.isNaN(currentTemperature)) { |
| | | const shouldResetTemperature = currentTemperature < TEMP_MIN || currentTemperature > TEMP_MAX; |
| | | if (!shouldResetTemperature) { |
| | | setTempStatus(currentTemperature); |
| | | lastConfirmedTempRef.current = currentTemperature; |
| | | return; |
| | | } |
| | | try { |
| | | await setTemperatureIgnoreBusinessError({ |
| | | orderId: targetOrderId, |
| | | targetTemperature: INITIAL_OUT_OF_RANGE_TARGET_TEMP |
| | | }); |
| | | setTempStatus(INITIAL_OUT_OF_RANGE_TARGET_TEMP); |
| | | lastConfirmedTempRef.current = INITIAL_OUT_OF_RANGE_TARGET_TEMP; |
| | | } catch (innerError) { |
| | | setTempStatus(currentTemperature); |
| | | lastConfirmedTempRef.current = currentTemperature; |
| | | } |
| | | } |
| | | } catch (error) { |
| | | // ignore |
| | | } finally { |
| | | setIsQueryingTemp(false); |
| | | } |
| | | }; |
| | | |
| | | // 车辆即将到达状态时拉取车辆当前温度 |
| | | useEffect(() => { |
| | | if (tripStatus !== TripStatus.VehicleApproaching) { |
| | | return; |
| | |
| | | if (!targetOrderId) { |
| | | return; |
| | | } |
| | | queryTemperature(targetOrderId); |
| | | |
| | | const storageData = getAcStorage(targetOrderId); |
| | | |
| | | if (!storageData) { |
| | | handleFirstTimeInit(targetOrderId); |
| | | } else { |
| | | // 先不展示 storageData,等待校准(限定时间内接口返回 → 展示接口数据,超时 → 展示 storageData 并同步远程) |
| | | handleCalibration(targetOrderId, storageData.acOpen, storageData.temperature); |
| | | } |
| | | }, [tripStatus, orderId, pageDidShowVersion]); |
| | | |
| | | // 车辆即将到达状态或页面重进时查询空调状态 |
| | | useEffect(() => { |
| | | if (tripStatus !== TripStatus.VehicleApproaching) { |
| | | return; |
| | | } |
| | | if (skipQueryOnThisCycleRef.current) { |
| | | return; |
| | | } |
| | | const targetOrderId = getOrderId(orderId); |
| | | if (!targetOrderId) { |
| | | return; |
| | | } |
| | | resetAcUiState(); |
| | | queryAcSwitchStatus(targetOrderId); |
| | | }, [tripStatus, orderId, pageDidShowVersion]); |
| | | |
| | | // 初始化确认温度并在卸载时清理定时器 |
| | | useEffect(() => { |
| | | lastConfirmedTempRef.current = tempStatus; |
| | | return () => { |
| | |
| | | }; |
| | | }, []); |
| | | |
| | | // 防抖提交目标温度到后端 |
| | | const scheduleTemperatureUpdate = (targetTemperature: number) => { |
| | | if (tempTimerRef.current) { |
| | | clearTimeout(tempTimerRef.current); |
| | |
| | | }).then((res: any) => { |
| | | if (res) { |
| | | lastConfirmedTempRef.current = targetTemperature; |
| | | Taro.showToast({ |
| | | title: '设置成功', |
| | | icon: 'none' |
| | | }); |
| | | userActionSeqRef.current++; |
| | | saveAcStorage(targetOrderId, { acOpen: acSwitchOn, temperature: targetTemperature }); |
| | | Taro.showToast({ title: '设置成功', icon: 'none' }); |
| | | } else { |
| | | setTempStatus(lastConfirmedTempRef.current ?? null); |
| | | Taro.showToast({ |
| | | title: res?.msg || '网络连接不稳定,设置失败,请重试', |
| | | icon: 'none' |
| | | }); |
| | | Taro.showToast({ title: AC_TEMP_FAIL_TEXT, icon: 'none' }); |
| | | } |
| | | }).catch(() => { |
| | | setTempStatus(lastConfirmedTempRef.current ?? null); |
| | | Taro.showToast({ |
| | | title: '网络连接不稳定,设置失败,请重试', |
| | | icon: 'none' |
| | | }); |
| | | Taro.showToast({ title: AC_TEMP_FAIL_TEXT, icon: 'none' }); |
| | | }); |
| | | }, TEMP_UPDATE_DEBOUNCE_MS); |
| | | }; |
| | | |
| | | // 处理温度增减并做边界校验 |
| | | const handleTempChange = (delta: number) => { |
| | | if (!canAdjustTemperature) { |
| | | return; |
| | |
| | | }); |
| | | }; |
| | | |
| | | // 处理空调开关点击:以操作接口返回结果作为当前空调状态 |
| | | const handleToggleAcSwitch = async () => { |
| | | if (!canToggleSwitch) { |
| | | return; |
| | |
| | | } |
| | | const targetOpenState = !acSwitchOn; |
| | | setIsTogglingAc(true); |
| | | // Taro.showLoading({ |
| | | // title: targetOpenState ? AC_OPENING_TEXT : AC_CLOSING_TEXT, |
| | | // mask: true |
| | | // }); |
| | | try { |
| | | const res = await airConditionerUnlock({ |
| | | // 200就认为是指令下发成功了 |
| | | await airConditionerUnlock({ |
| | | orderId: targetOrderId, |
| | | open: targetOpenState |
| | | }); |
| | | const nextOpenState = parseAcSwitchState(typeof res === 'undefined' ? targetOpenState : res); |
| | | setAcSwitchOn(nextOpenState); |
| | | // 开启空调时同时下发温度设置 |
| | | if (targetOpenState) { |
| | | const currentTemp = typeof tempStatus === 'number' ? tempStatus : DEFAULT_FIRST_TEMP; |
| | | try { |
| | | await setTemperature({ orderId: targetOrderId, targetTemperature: currentTemp }); |
| | | } catch { |
| | | console.error('设置空调温度失败'); |
| | | } |
| | | } |
| | | setAcSwitchOn(targetOpenState); |
| | | setAcStatusLoaded(true); |
| | | if (nextOpenState) { |
| | | queryTemperature(targetOrderId); |
| | | userActionSeqRef.current++; |
| | | const currentTemp = typeof tempStatus === 'number' ? tempStatus : DEFAULT_FIRST_TEMP; |
| | | saveAcStorage(targetOrderId, { acOpen: targetOpenState, temperature: currentTemp }); |
| | | Taro.showToast({ title: targetOpenState ? '已开启空调' : '已关闭空调', icon: 'none' }); |
| | | if (targetOpenState && typeof tempStatus !== 'number') { |
| | | setTempStatus(DEFAULT_FIRST_TEMP); |
| | | lastConfirmedTempRef.current = DEFAULT_FIRST_TEMP; |
| | | } |
| | | } catch (error) { |
| | | // Taro.hideLoading(); |
| | | Taro.showToast({ title: AC_TOGGLE_FAIL_TEXT, icon: 'none' }); |
| | | } finally { |
| | | // Taro.hideLoading(); |
| | | setIsTogglingAc(false); |
| | | } |
| | | }; |
| | | |
| | | if (tripStatus !== TripStatus.VehicleApproaching) { |
| | | // 车还在上一单时不展示空调卡片 |
| | | if (tripStatus !== TripStatus.VehicleApproaching || hasBeforeUserEndSite) { |
| | | return null; |
| | | } |
| | | |
| | |
| | | } |
| | | </View> |
| | | <View className="car-card-temp-num-text"> |
| | | <View className={`car-card-temp-num-text-temp ${tempTextDisabledClassName}`.trim()}>{typeof tempStatus === 'number' ? `${tempStatus.toFixed(1)}°C` : `${TEMP_MIN}°C`}</View> |
| | | <View className={`car-card-temp-num-text-temp ${tempTextDisabledClassName}`.trim()}>{typeof tempStatus === 'number' ? `${tempStatus.toFixed(1)}°C` : `--`}</View> |
| | | <View className={`car-card-temp-num-text-desc ${tempTextDisabledClassName}`.trim()}>设定温度</View> |
| | | </View> |
| | | <View className="car-card-temp-num-btn"> |
| | |
| | | </View> |
| | | <View className={`car-card-temp-switch ${acSwitchOn ? 'car-card-temp-switch-open' : ''}`.trim()} onClick={handleToggleAcSwitch}> |
| | | { |
| | | acSwitchOn ? |
| | | <Image className="car-card-temp-switch-img" src={AC_SWITCH_CLOSE_ICON} /> |
| | | isTogglingAc ? |
| | | <Image className="car-card-temp-switch-img" style={{ visibility: 'hidden' }} src={AC_SWITCH_OPEN_ICON} /> |
| | | : |
| | | <Image className="car-card-temp-switch-img" src={AC_SWITCH_OPEN_ICON} /> |
| | | acSwitchOn ? |
| | | <Image className="car-card-temp-switch-img" src={AC_SWITCH_CLOSE_ICON} /> |
| | | : |
| | | <Image className="car-card-temp-switch-img" src={AC_SWITCH_OPEN_ICON} /> |
| | | } |
| | | <View className="car-card-temp-switch-text">空调开关</View> |
| | | {!isTogglingAc && <View className="car-card-temp-switch-text">空调开关</View>} |
| | | {isTogglingAc && <View className="car-card-temp-switch-text" style={{ visibility: 'hidden' }}>空调开关</View>} |
| | | {isTogglingAc && <Image className="car-card-temp-switch-img car-card-temp-switch-img-spinning" style={{ position: 'absolute' }} src={AC_SWITCH_LOADING_ICON} />} |
| | | </View> |
| | | </View> |
| | | </View> |
| New file |
| | |
| | | /** |
| | | * 空调接口本地 Mock 层(仅开发期使用)。 |
| | | * |
| | | * 用途:在不依赖云端 tripStatus / 真实订单的情况下,本地跑通空调设置完整流程。 |
| | | * 用法: |
| | | * 1. 将下方 AC_MOCK_ENABLED 改为 true; |
| | | * 2. 在微信开发者工具 Storage 注入 tripStatus=4、orderId=任意字符串,进入 begin-route 页面; |
| | | * 3. 即可触发首次初始化 / 校准 / 防抖设置 / 开关切换 / 失败回滚等所有分支。 |
| | | * |
| | | * ⚠️ 上线前务必把 AC_MOCK_ENABLED 改回 false(默认 false,不影响线上)。 |
| | | * |
| | | * 设计:本模块对外导出与 api/begin-route 中同名的 4 个空调接口,组件按原名引用即可, |
| | | * 主体逻辑无需改动。开关关闭时直接透传真实接口;开关打开时走内存模拟后端。 |
| | | */ |
| | | import { |
| | | airConditionerUnlock as realAirConditionerUnlock, |
| | | queryTemperatureByOrderId as realQueryTemperatureByOrderId, |
| | | queryVehicleAirConditionerSwitch as realQueryVehicleAirConditionerSwitch, |
| | | setTemperature as realSetTemperature |
| | | } from '../../../../../api/begin-route'; |
| | | import type { |
| | | AirConditionerUnlockPayload, |
| | | OrderIdPayload, |
| | | QueryTemperatureByOrderIdResponse, |
| | | QueryVehicleAirConditionerSwitchResponse, |
| | | SetTemperaturePayload, |
| | | SetTemperatureResponse |
| | | } from '../../../../../api/types/begin-route'; |
| | | |
| | | // ============== 可调配置 ============== |
| | | |
| | | // TODO(仅用于测试): 上线前将 AC_MOCK_ENABLED 改回 false |
| | | /** 总开关:true 走本地 mock,false 走真实接口。⚠️ 上线务必为 false。 */ |
| | | export const AC_MOCK_ENABLED = false; |
| | | |
| | | /** 模拟接口耗时(毫秒),用于观察 loading 文案、防抖等中间态。 */ |
| | | const AC_MOCK_DELAY_MS = 600; |
| | | |
| | | /** |
| | | * 失败注入:将对应操作设为 true 可强制其失败,验证失败 toast 与回滚逻辑。 |
| | | */ |
| | | const AC_MOCK_FAIL = { |
| | | toggle: false, // airConditionerUnlock 开关失败 |
| | | setTemp: false, // setTemperature 设置温度失败 |
| | | queryAc: false, // queryVehicleAirConditionerSwitch 查询失败 |
| | | queryTemp: false // queryTemperatureByOrderId 查询失败 |
| | | }; |
| | | |
| | | /** |
| | | * 后端初始状态种子(按 orderId 缓存前的"出厂值")。 |
| | | * - 设为 null:表示该订单尚无空调记录,命中组件「首次初始化」分支; |
| | | * - 设为对象:模拟后端已有状态,可与本地 Storage 不一致以触发「校准同步」分支。 |
| | | */ |
| | | const AC_MOCK_BACKEND_SEED: { acOpen: boolean; temperature: number } | null = { acOpen: true, temperature: 25 }; |
| | | |
| | | // ============== 内存模拟后端 ============== |
| | | |
| | | interface MockAcState { |
| | | acOpen: boolean; |
| | | temperature: number; |
| | | } |
| | | |
| | | const mockBackendStore = new Map<string, MockAcState>(); |
| | | |
| | | function delay<T>(value: T): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(value), AC_MOCK_DELAY_MS)); |
| | | } |
| | | |
| | | function reject(message: string): Promise<never> { |
| | | return new Promise((_, rej) => setTimeout(() => rej(new Error(message)), AC_MOCK_DELAY_MS)); |
| | | } |
| | | |
| | | function getMockState(orderId: string): MockAcState | undefined { |
| | | if (!mockBackendStore.has(orderId) && AC_MOCK_BACKEND_SEED) { |
| | | mockBackendStore.set(orderId, { ...AC_MOCK_BACKEND_SEED }); |
| | | } |
| | | return mockBackendStore.get(orderId); |
| | | } |
| | | |
| | | // ============== 对外导出(与 api/begin-route 同名签名) ============== |
| | | |
| | | export const airConditionerUnlock = (payload: AirConditionerUnlockPayload): Promise<void> => { |
| | | if (!AC_MOCK_ENABLED) { |
| | | return realAirConditionerUnlock(payload); |
| | | } |
| | | if (AC_MOCK_FAIL.toggle) { |
| | | console.log('[AC_MOCK] airConditionerUnlock fail', { payload, fail: AC_MOCK_FAIL.toggle }); |
| | | return reject('[AC_MOCK] toggle failed'); |
| | | } |
| | | const orderId = String(payload.orderId); |
| | | const prev = getMockState(orderId); |
| | | mockBackendStore.set(orderId, { |
| | | acOpen: payload.open, |
| | | temperature: prev?.temperature ?? 25 |
| | | }); |
| | | console.log('[AC_MOCK] airConditionerUnlock success', { payload, state: getMockState(orderId) }); |
| | | return delay(undefined); |
| | | }; |
| | | |
| | | export const setTemperature = (payload: SetTemperaturePayload): Promise<SetTemperatureResponse> => { |
| | | if (!AC_MOCK_ENABLED) { |
| | | return realSetTemperature(payload); |
| | | } |
| | | if (AC_MOCK_FAIL.setTemp) { |
| | | console.log('[AC_MOCK] setTemperature fail', { payload, fail: AC_MOCK_FAIL.setTemp }); |
| | | return delay(false); |
| | | } |
| | | const orderId = String(payload.orderId); |
| | | const prev = getMockState(orderId); |
| | | mockBackendStore.set(orderId, { |
| | | acOpen: prev?.acOpen ?? true, |
| | | temperature: Number(payload.targetTemperature) |
| | | }); |
| | | console.log('[AC_MOCK] setTemperature success', { payload, state: getMockState(orderId) }); |
| | | return delay(true); |
| | | }; |
| | | |
| | | export const queryVehicleAirConditionerSwitch = ( |
| | | payload: OrderIdPayload |
| | | ): Promise<QueryVehicleAirConditionerSwitchResponse> => { |
| | | if (!AC_MOCK_ENABLED) { |
| | | return realQueryVehicleAirConditionerSwitch(payload); |
| | | } |
| | | if (AC_MOCK_FAIL.queryAc) { |
| | | console.log('[AC_MOCK] queryVehicleAirConditionerSwitch fail', { payload, fail: AC_MOCK_FAIL.queryAc }); |
| | | return reject('[AC_MOCK] query ac switch failed'); |
| | | } |
| | | const state = getMockState(String(payload.orderId)); |
| | | console.log('[AC_MOCK] queryVehicleAirConditionerSwitch success', { payload, result: { open: state?.acOpen ?? false } }); |
| | | return delay({ open: state?.acOpen ?? false }); |
| | | }; |
| | | |
| | | export const queryTemperatureByOrderId = ( |
| | | payload: OrderIdPayload |
| | | ): Promise<QueryTemperatureByOrderIdResponse> => { |
| | | if (!AC_MOCK_ENABLED) { |
| | | return realQueryTemperatureByOrderId(payload); |
| | | } |
| | | if (AC_MOCK_FAIL.queryTemp) { |
| | | console.log('[AC_MOCK] queryTemperatureByOrderId fail', { payload, fail: AC_MOCK_FAIL.queryTemp }); |
| | | return reject('[AC_MOCK] query temperature failed'); |
| | | } |
| | | const state = getMockState(String(payload.orderId)); |
| | | console.log('[AC_MOCK] queryTemperatureByOrderId success', { payload, result: state?.temperature ?? 25 }); |
| | | return delay(25); |
| | | return delay(state?.temperature ?? 25); |
| | | }; |
| | |
| | | handleShareTrip, |
| | | showModifyDestination = true |
| | | }: Props) { |
| | | const canShowTripAction = [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService].includes(tripStatus); |
| | | // 按行程状态控制按钮集合,避免在 JSX 中堆叠过多条件判断 |
| | | const actionList = [ |
| | | { |
| | |
| | | key: "destination", |
| | | label: "修改目的地", |
| | | icon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/change-destination.png', |
| | | visible: [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService].includes(tripStatus) && showModifyDestination, |
| | | visible: canShowTripAction, |
| | | disabled: !showModifyDestination, |
| | | onClick: handleModifyDestination |
| | | }, |
| | | { |
| | | key: "share", |
| | | label: "分享行程", |
| | | icon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/share-trip.png', |
| | | visible: [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService].includes(tripStatus), |
| | | visible: canShowTripAction, |
| | | onClick: handleShareTrip |
| | | } |
| | | ].filter(item => item.visible); |
| | |
| | | ) : ( |
| | | <View |
| | | key={item.key} |
| | | className="trip-action-bar-item" |
| | | hoverClass="trip-action-bar-item-hover" |
| | | onClick={() => item.onClick?.()} |
| | | className={`trip-action-bar-item${item.disabled ? ' trip-action-bar-item-disabled' : ''}`} |
| | | hoverClass={item.disabled ? 'none' : 'trip-action-bar-item-hover'} |
| | | onClick={() => { |
| | | if (item.disabled) return; |
| | | item.onClick?.(); |
| | | }} |
| | | > |
| | | <Image className="trip-action-bar-item-icon" src={item.icon} /> |
| | | <Text className="trip-action-bar-item-text">{item.label}</Text> |
| | |
| | | plateNumber?: string; |
| | | }; |
| | | orderId?: string; |
| | | /** WebSocket 推送的上一单下车点坐标(有值表示车还在执行上一单) */ |
| | | hasBeforeUserEndSite?: boolean; |
| | | orderTrunkStatus?: boolean; |
| | | /** 当前页面内是否已经解锁过车门 */ |
| | | doorUnlockedInCurrentPage?: boolean; |
| | |
| | | finishCountdownEnabled = false, |
| | | vehicleInfo, |
| | | orderId, |
| | | hasBeforeUserEndSite = false, |
| | | orderTrunkStatus, |
| | | doorUnlockedInCurrentPage = false, |
| | | bluetoothStatusReady = false, |
| | |
| | | <View className="car-card" style={{ marginBottom: tripStatus === TripStatus.TripFinished ? '70px' : '0px' }}> |
| | | <VehicleInfoCard vehicleInfo={vehicleInfo} /> |
| | | |
| | | <TemperatureControl tripStatus={tripStatus} orderId={orderId} pageDidShowVersion={pageDidShowVersion} /> |
| | | <TemperatureControl tripStatus={tripStatus} orderId={orderId} pageDidShowVersion={pageDidShowVersion} hasBeforeUserEndSite={hasBeforeUserEndSite} /> |
| | | |
| | | <DoorTrunkControl |
| | | tripStatus={tripStatus} |
| | |
| | | @keyframes ac-switch-spin { |
| | | from { transform: rotate(0deg); } |
| | | to { transform: rotate(360deg); } |
| | | } |
| | | |
| | | .calling-car { |
| | | position: relative; |
| | | padding: 40px 42px 88px 34px; |
| | |
| | | } |
| | | |
| | | &-switch { |
| | | position: relative; |
| | | padding: 24px 52px; |
| | | border-radius: 16px; |
| | | background: #F5F6FA; |
| | |
| | | &-img { |
| | | width: 68px; |
| | | height: 68px; |
| | | |
| | | &-spinning { |
| | | animation: ac-switch-spin 1s linear infinite; |
| | | transform-origin: center center; |
| | | } |
| | | } |
| | | |
| | | &-text { |
| | |
| | | justify-content: center; |
| | | box-sizing: border-box; |
| | | |
| | | &-disabled { |
| | | opacity: 0.4; |
| | | } |
| | | |
| | | &-icon { |
| | | flex-shrink: 0; |
| | | width: 48px; |
| | |
| | | background: #ffffff; |
| | | border-radius: 16px 16px 0 0; |
| | | padding: 48px 45px 88px 45px; |
| | | height: 75vh; |
| | | overflow: hidden; |
| | | .passenger-content-bg { |
| | | position: absolute; |
| | | top: 0; |
| | | right: 0; |
| | | left: 0; |
| | | width: 100%; |
| | | min-height: 744px; |
| | | z-index: -1; |
| | | border-radius: 16px 16px 0 0; |
| | | overflow: hidden; |
| | | .passenger-content-bg-img { |
| | | width: 100%; |
| | | } |
| | | } |
| | | |
| | | &-title { |
| | | font-size: 32px; |
| | |
| | | &-desc-wrap { |
| | | position: relative; |
| | | margin-top: 34px; |
| | | height: 342px; |
| | | height: calc(75vh - 400px); |
| | | padding-right: 1em; |
| | | |
| | | z-index: 1; |
| | | .passenger-content-desc { |
| | | height: 100%; |
| | | overflow-y: scroll; |
| | |
| | | color: #FFFFFF; |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | const ROUTE_PANEL_ESTIMATE_HEIGHT_PX = 230; // 首次展开未拿到真实高度时的路线面板预估高度 |
| | | const MODIFY_DESTINATION_UNLIMITED_COUNT = -1; // 后端返回 -1 代表修改次数不限 |
| | | const MODIFY_DESTINATION_DISABLED_COUNT = 0; // 后端返回 0 代表没有修改机会 |
| | | const CHINESE_NUMBER_DIGITS = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; // 中文数字映射 |
| | | |
| | | /** |
| | | * 将正整数转为中文数字展示。 |
| | | */ |
| | | const formatChineseCount = (count: number) => { |
| | | if (!Number.isInteger(count) || count < 0) { |
| | | return String(count); |
| | | } |
| | | if (count < 10) { |
| | | return CHINESE_NUMBER_DIGITS[count]; |
| | | } |
| | | if (count < 100) { |
| | | const tensDigit = Math.floor(count / 10); |
| | | const unitsDigit = count % 10; |
| | | const tensText = tensDigit === 1 ? '十' : `${CHINESE_NUMBER_DIGITS[tensDigit]}十`; |
| | | return unitsDigit === 0 ? tensText : `${tensText}${CHINESE_NUMBER_DIGITS[unitsDigit]}`; |
| | | } |
| | | return String(count); |
| | | }; |
| | | |
| | | /** |
| | | * 生成修改目的地次数提示文案。 |
| | |
| | | return ''; |
| | | } |
| | | if (remainingCount === MODIFY_DESTINATION_DISABLED_COUNT) { |
| | | return '暂无修改机会'; |
| | | return '剩余修改次数:0次'; |
| | | } |
| | | if (typeof remainingCount === 'number' && remainingCount > MODIFY_DESTINATION_DISABLED_COUNT) { |
| | | return `仅可修改${formatChineseCount(remainingCount)}次`; |
| | | return `剩余修改次数:${remainingCount}次`; |
| | | } |
| | | return '修改次数获取失败'; |
| | | }; |
| | |
| | | ? '次数加载中...' |
| | | : !isModifyCountReady |
| | | ? '次数获取失败' |
| | | : isRouteDataIncomplete |
| | | ? '路线加载中...' |
| | | : isModifyCountDisabled |
| | | ? '暂无修改机会' |
| | | : isRouteDataIncomplete |
| | | ? '路线加载中...' |
| | | : '确认修改'} |
| | | </View> |
| | | {isConfirmDisabled && <View className='modify-destination-review-confirm-mask' />} |
| | |
| | | <View className='passenger-popup' onClick={() => onCheckedChange?.(true)}> |
| | | <View className='passenger-mask' catchMove></View> |
| | | <View className='passenger-content' animation={animationData}> |
| | | <View className='passenger-content-bg'> |
| | | <Image className='passenger-content-bg-img' mode='widthFix' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/passenger-content-bg.png'></Image> |
| | | </View> |
| | | <View className='passenger-content-title'>乘车需知</View> |
| | | <View className='passenger-content-desc-wrap'> |
| | | <ScrollView |
| | |
| | | longitude: pickupSite.longitude, |
| | | iconPath: shouldUseChangePickupIcon |
| | | ? CHANGE_SITE_UP_ICON_URL |
| | | : 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/maker-up.png', |
| | | width: 22, |
| | | height: 40, |
| | | : 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/start-icon.png', |
| | | width: 21, |
| | | height: 28, |
| | | anchor: { x: 0.5, y: 1 }, |
| | | zIndex: 1, |
| | | name: pickupSite?.name || '上车点', |
| | |
| | | id: 2, |
| | | latitude: dropoffSite.latitude, |
| | | longitude: dropoffSite.longitude, |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/maker-down.png', |
| | | width: 22, |
| | | height: 40, |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/end-icon.png', |
| | | width: 21, |
| | | height: 28, |
| | | anchor: { x: 0.5, y: 1 }, |
| | | zIndex: 1, |
| | | name: dropoffName, |
| | |
| | | } |
| | | paySubmittingRef.current = true; |
| | | try { |
| | | await getOrderPayment({ orderId: targetOrderId }); |
| | | syncPaymentSuccessStatus(); |
| | | // await getOrderPayment({ orderId: targetOrderId }); |
| | | // syncPaymentSuccessStatus(); |
| | | |
| | | |
| | | // const paymentResult = await getOrderPayment({ orderId: targetOrderId }); |
| | | // const paymentStatus = Number(paymentResult?.status); |
| | | const paymentResult = await getOrderPayment({ orderId: targetOrderId }); |
| | | const paymentStatus = Number(paymentResult?.status); |
| | | |
| | | // if (paymentStatus === RobotaxiPaymentEntryStatus.Unsigned) { |
| | | // const cashierToken = paymentResult?.value?.token; |
| | | // if (!cashierToken) { |
| | | // Taro.showToast({ title: '支付凭证缺失,请稍后重试', icon: 'none' }); |
| | | // return; |
| | | // } |
| | | // await deps.handleUnsignedPayment(cashierToken); |
| | | // return; |
| | | // } |
| | | if (paymentStatus === RobotaxiPaymentEntryStatus.Unsigned) { |
| | | const cashierToken = paymentResult?.value?.token; |
| | | if (!cashierToken) { |
| | | Taro.showToast({ title: '支付凭证缺失,请稍后重试', icon: 'none' }); |
| | | return; |
| | | } |
| | | await deps.handleUnsignedPayment(cashierToken); |
| | | return; |
| | | } |
| | | |
| | | // if (paymentStatus === RobotaxiPaymentEntryStatus.Signed) { |
| | | // const signedPaymentValue = paymentResult?.value as SignedOrderPaymentValue; |
| | | // if (!signedPaymentValue?.packageValue) { |
| | | // Taro.showToast({ title: '支付参数缺失,请稍后重试', icon: 'none' }); |
| | | // return; |
| | | // } |
| | | // await deps.handleSignedCreditPayment(signedPaymentValue); |
| | | // return; |
| | | // } |
| | | if (paymentStatus === RobotaxiPaymentEntryStatus.Signed) { |
| | | const signedPaymentValue = paymentResult?.value as SignedOrderPaymentValue; |
| | | if (!signedPaymentValue?.packageValue) { |
| | | Taro.showToast({ title: '支付参数缺失,请稍后重试', icon: 'none' }); |
| | | return; |
| | | } |
| | | await deps.handleSignedCreditPayment(signedPaymentValue); |
| | | return; |
| | | } |
| | | |
| | | // Taro.showToast({ title: '暂不支持当前支付方式', icon: 'none' }); |
| | | Taro.showToast({ title: '暂不支持当前支付方式', icon: 'none' }); |
| | | |
| | | } catch (error) { |
| | | Taro.showToast({ title: '支付失败,请重试', icon: 'none' }); |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 解析下单时应透传的有效优惠券 ID。 |
| | | */ |
| | | const resolveSubmitCouponId = (selectedRoute: any) => { |
| | | const routeFareEstimate = selectedRoute?.fareEstimate; |
| | | return String( |
| | | routeFareEstimate?.couponId |
| | | || routeFareEstimate?.userCouponId |
| | | || deps.fareEstimate?.couponId |
| | | || deps.fareEstimate?.userCouponId |
| | | || Taro.getStorageSync('selectedCouponId') |
| | | || '' |
| | | ).trim(); |
| | | }; |
| | | |
| | | /** |
| | | * 提交订单。 |
| | | */ |
| | | const submitOrder = async (startSite: any, openCityId: number): Promise<SubmitOrderResult | null> => { |
| | | const waitingTimeMinutes = Number(deps.fareEstimate?.waitingTime || 0); |
| | | const estimatedTimestamp = Date.now() + waitingTimeMinutes * MILLISECONDS_PER_MINUTE; |
| | | const estimatedTime = formatDateTime(new Date(estimatedTimestamp)); |
| | | const cachedCouponId = Taro.getStorageSync('selectedCouponId') || ''; |
| | | const selectedRoute = Array.isArray(deps.routeOptions) && typeof deps.selectedRouteIndex === 'number' |
| | | ? deps.routeOptions[deps.selectedRouteIndex] |
| | | : null; |
| | | const resolvedCouponId = resolveSubmitCouponId(selectedRoute); |
| | | const payload: any = { |
| | | endPointId: deps.endSite.id, |
| | | openCityId, |
| | |
| | | estimatedTime, |
| | | }; |
| | | |
| | | if (deps.routeOptions && typeof deps.selectedRouteIndex === 'number') { |
| | | const selectedRoute = deps.routeOptions[deps.selectedRouteIndex]; |
| | | if (selectedRoute?.route?.routeID) { |
| | | payload.selectedRouteId = selectedRoute.route.routeID; |
| | | payload.navigationSnapshotId = selectedRoute.snapshotId; |
| | | } |
| | | if (selectedRoute?.route?.routeID) { |
| | | payload.selectedRouteId = selectedRoute.route.routeID; |
| | | payload.navigationSnapshotId = selectedRoute.snapshotId; |
| | | } |
| | | |
| | | if (cachedCouponId) { |
| | | payload.userCouponId = cachedCouponId; |
| | | if (resolvedCouponId) { |
| | | payload.userCouponId = resolvedCouponId; |
| | | } |
| | | |
| | | const response = await saveOrder(payload); |
| | |
| | | reportEvent(ORDER_SUBMIT_EVENT_ID, { |
| | | total_amount: deps.fareEstimate.originalFare, |
| | | payable_amount: deps.fareEstimate.finalFare, |
| | | coupon_id: cachedCouponId, |
| | | coupon_id: resolvedCouponId, |
| | | status_code: statusCode |
| | | }); |
| | | |
| | |
| | | * 处理 polyline 点击事件。 |
| | | */ |
| | | const handlePolylineTap = (event: any) => { |
| | | console.log('[点击折现事件:',event); |
| | | const targetIndex = resolveRouteIndexByTap(event); |
| | | if (targetIndex >= 0 && targetIndex < allRoutePoints.length) { |
| | | handleSelectRoute(targetIndex, { syncSites: false }); |
| | |
| | | * 处理地图点击事件。 |
| | | */ |
| | | const handleMapTap = (event: any) => { |
| | | console.log('[地图点击事件:',event); |
| | | const targetIndex = resolveRouteIndexByTap(event); |
| | | if (targetIndex >= 0 && targetIndex < allRoutePoints.length) { |
| | | handleSelectRoute(targetIndex, { syncSites: false }); |
| | |
| | | } | null>(null); |
| | | const vehicleMarkerReadyRef = useRef(false); |
| | | const vehicleMarkerReadyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| | | const vehicleMarkerIdRef = useRef<number>(vehicleMarkerId); |
| | | const translateSessionIdRef = useRef(0); |
| | | |
| | | /** |
| | | * 清理车辆 marker ready 延时器,避免旧定时器污染新一轮平移态。 |
| | |
| | | clearTimeout(vehicleMarkerReadyTimerRef.current); |
| | | vehicleMarkerReadyTimerRef.current = null; |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 重置当前车辆 marker 对应的平移上下文,避免沿用旧 marker 的角度与 ready 状态。 |
| | | */ |
| | | const resetTranslateRuntimeState = () => { |
| | | isTranslatingRef.current = false; |
| | | pendingTranslateRef.current = null; |
| | | lastAppliedTranslateTaskRef.current = null; |
| | | vehicleMarkerReadyRef.current = false; |
| | | clearVehicleMarkerReadyTimer(); |
| | | translateSessionIdRef.current += 1; |
| | | }; |
| | | |
| | | /** |
| | |
| | | if (isTranslatingRef.current) return; |
| | | if (!pendingTranslateRef.current) return; |
| | | const task = pendingTranslateRef.current; |
| | | const translateSessionId = translateSessionIdRef.current; |
| | | pendingTranslateRef.current = null; |
| | | isTranslatingRef.current = true; |
| | | let finished = false; |
| | | let fallbackTimer: any = null; |
| | | const finishTranslate = () => { |
| | | if (finished) return; |
| | | if (translateSessionId !== translateSessionIdRef.current) return; |
| | | finished = true; |
| | | if (fallbackTimer) clearTimeout(fallbackTimer); |
| | | isTranslatingRef.current = false; |
| | |
| | | rotate: task.rotate, |
| | | duration: task.duration, |
| | | animationEnd: () => { |
| | | if (translateSessionId !== translateSessionIdRef.current) { |
| | | return; |
| | | } |
| | | lastAppliedTranslateTaskRef.current = { |
| | | latitude: task.latitude, |
| | | longitude: task.longitude, |
| | |
| | | finishTranslate(); |
| | | }, |
| | | fail: () => { |
| | | if (translateSessionId !== translateSessionIdRef.current) { |
| | | return; |
| | | } |
| | | lastAppliedTranslateTaskRef.current = { |
| | | latitude: task.latitude, |
| | | longitude: task.longitude, |
| | |
| | | }, [statusRule.translateCar, vehicleLocation, vehicleDirection, vehicleMarkerId]); |
| | | |
| | | useEffect(() => { |
| | | if (!statusRule.translateCar) { |
| | | vehicleMarkerIdRef.current = vehicleMarkerId; |
| | | return; |
| | | } |
| | | if (vehicleMarkerIdRef.current === vehicleMarkerId) { |
| | | return; |
| | | } |
| | | vehicleMarkerIdRef.current = vehicleMarkerId; |
| | | resetTranslateRuntimeState(); |
| | | }, [statusRule.translateCar, vehicleMarkerId]); |
| | | |
| | | useEffect(() => { |
| | | if (statusRule.translateCar) return; |
| | | isTranslatingRef.current = false; |
| | | pendingTranslateRef.current = null; |
| | | lastAppliedTranslateTaskRef.current = null; |
| | | vehicleMarkerReadyRef.current = false; |
| | | clearVehicleMarkerReadyTimer(); |
| | | resetTranslateRuntimeState(); |
| | | }, [statusRule.translateCar]); |
| | | |
| | | useEffect(() => () => { |
| | |
| | | <CallingCar |
| | | key={deps.tripStatus} |
| | | orderId={deps.orderId} |
| | | hasBeforeUserEndSite={deps.hasBeforeUserEndSite} |
| | | tripStatus={deps.tripStatus} |
| | | finishCountdownEnabled={deps.finishCountdownEnabled} |
| | | vehicleInfo={deps.vehicleInfo} |
| | |
| | | return { |
| | | points: item.polylinePoints || [], |
| | | color: isSelected ? '#6D7FD9' : '#A1B1FF', |
| | | width: isSelected ? 4 : 2, |
| | | width: 2, |
| | | dottedLine: !isSelected, |
| | | arrowLine: false, |
| | | borderColor: isSelected ? '#6D7FD9' : '#A1B1FF', |
| | |
| | | id: 1, |
| | | longitude: startPoint.longitude, |
| | | latitude: startPoint.latitude, |
| | | width: 22, |
| | | height: 40, |
| | | width: 20, |
| | | height: 36, |
| | | anchor: { x: 0.5, y: 1 }, |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/maker-up.png', |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/start-text.png', |
| | | } : null, |
| | | endPoint ? { |
| | | id: 2, |
| | | longitude: endPoint.longitude, |
| | | latitude: endPoint.latitude, |
| | | width: 22, |
| | | height: 40, |
| | | width: 20, |
| | | height: 36, |
| | | anchor: { x: 0.5, y: 1 }, |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/maker-down.png', |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/end-text.png', |
| | | } : null |
| | | ].filter(Boolean) as any[]; |
| | | setMarkers(markerList); |
| | |
| | | if (status >= 1 && status <= 7) { |
| | | return 'order-status--processing' |
| | | } |
| | | if (status == 8) { |
| | | if (status == 8 || status == 9) { |
| | | return 'order-status--pending-pay' |
| | | } |
| | | if (status == 10 || status == 9 || status == 0) { |
| | | if (status == 10 || status == 0) { |
| | | return 'order-status--completed' |
| | | } |
| | | return '' |
| | |
| | | import CustomerServicePopup from '../../../components/customer-service'; |
| | | import TipPopup from '../../../components/tip-popup'; |
| | | import { formatTripDistance, formatTripDurationFromSeconds } from '../../../../utils/Robotaxi-public-method'; |
| | | import { SELECT_SITE_RANGE_STATUS, type SelectSiteRangeStatus } from '../hooks/use-select-map-site-state'; |
| | | |
| | | type Deps = { |
| | | longitude: number | null; |
| | |
| | | selectionMode: 'point' | 'station'; |
| | | selectedStation: any; |
| | | stationSiteList: any[]; |
| | | outOfServiceRange: boolean; |
| | | rangeStatus: SelectSiteRangeStatus; |
| | | tooltipBgSrc: string; |
| | | pageType: string; |
| | | bounce: boolean; |
| | |
| | | activeStar: string; |
| | | noStar: string; |
| | | confirmSite: () => void; |
| | | confirmButtonText: string; |
| | | confirmButtonDisabled: boolean; |
| | | crossOperatingAreaHintText: string; |
| | | showViewOperatingAreaButton: boolean; |
| | | handleViewOperatingArea: () => void; |
| | | showCustomerService: boolean; |
| | | handleCloseCustomerService: () => void; |
| | | showTipPopup: boolean; |
| | | handleCloseTipPopup: () => void; |
| | | tipType: string; |
| | | toPersonalCenter: () => void; |
| | | toSiteSearch: () => void; |
| | | }; |
| | | |
| | | /** |
| | | * 选点页视图组件:仅负责渲染地图与页面 UI,不包含业务状态编排。 |
| | | */ |
| | | export default function SelectMapSiteView(deps: Deps) { |
| | | const isOutOfOperatingArea = deps.rangeStatus === SELECT_SITE_RANGE_STATUS.outOfOperatingArea; |
| | | const isCrossOperatingArea = deps.rangeStatus === SELECT_SITE_RANGE_STATUS.crossOperatingArea; |
| | | const showRangeWarning = isOutOfOperatingArea || isCrossOperatingArea; |
| | | const shouldShowSelectedSiteEcho = !showRangeWarning && Boolean(deps.selectedSite.name); |
| | | const shouldShowStationSelection = deps.selectionMode === 'station' && !showRangeWarning; |
| | | |
| | | return ( |
| | | <View className="home-container"> |
| | | <Map |
| | |
| | | <View onClick={deps.toBack} className="select-city"> |
| | | <Image className="select-city-icon" src={deps.backIcon} /> |
| | | </View> |
| | | {/* 搜索框 */} |
| | | <View onClick={deps.toSiteSearch} className="search-card"> |
| | | <View className={`search-icon ${deps.pageType == 'end' ? 'end-icon' : ''}`}></View> |
| | | <View className="search-input-box"> |
| | | <View className="search-input-placeholder">{deps.pageType == 'start' ? '请输入上车点' : '请输入目的地'}</View> |
| | | </View> |
| | | </View> |
| | | {/* 地图浮层入口 */} |
| | | <View className="map-entry"> |
| | | <Image onClick={deps.toPersonalCenter} className="map-entry-icon" src={deps.personalCenter} /> |
| | |
| | | {deps.nearbyLoading && !deps.isAndroid && ( |
| | | <Image className="waiting-radar" src={deps.waitingRadarSrc} /> |
| | | )} |
| | | {((deps.showToolTip && deps.selectedSite.name) || deps.outOfServiceRange) && ( |
| | | {((deps.showToolTip && shouldShowSelectedSiteEcho) || showRangeWarning) && ( |
| | | <View className="toolTip"> |
| | | {(deps.outOfServiceRange || deps.selectedSite.time) && ( |
| | | {(showRangeWarning || deps.selectedSite.time) && ( |
| | | <View className="toolTip-bg"> |
| | | <Image className="toolTip-bg-img" src={deps.tooltipBgSrc} /> |
| | | <Text className="toolTip-bg-text"> |
| | | {deps.outOfServiceRange ? '不在运营区' : `限停${deps.selectedSite.time}分钟`} |
| | | {isOutOfOperatingArea ? '不在运营区' : isCrossOperatingArea ? '不在同一运营区' : `限停${deps.selectedSite.time}分钟`} |
| | | </Text> |
| | | </View> |
| | | )} |
| | | <View className="toolTip-txt"> |
| | | <View className="toolTip-txt-content"> |
| | | {deps.outOfServiceRange ? `请拖动至${deps.pageType == 'end' ? '目的地' : '上车点'}` : deps.selectedSite.name} |
| | | {isOutOfOperatingArea |
| | | ? `请拖动至${deps.pageType == 'end' ? '目的地' : '上车点'}` |
| | | : isCrossOperatingArea |
| | | ? '请在同一运营区内选择' |
| | | : deps.selectedSite.name} |
| | | </View> |
| | | </View> |
| | | </View> |
| | |
| | | <View className="origin-box"> |
| | | <Image onClick={deps.toOrigin} className="origin-icon" src={deps.originIcon} /> |
| | | </View> |
| | | <View catchMove onClick={(e) => e.stopPropagation()}> |
| | | <View className="content-box-wrap" catchMove onClick={(e) => e.stopPropagation()}> |
| | | {deps.showViewOperatingAreaButton && ( |
| | | <View onClick={deps.handleViewOperatingArea} className="select-operating-area-entry"> |
| | | 查看运营区域 |
| | | </View> |
| | | )} |
| | | {/* 确认站点 */} |
| | | <View className="content-box"> |
| | | <View className="content-box-item"> |
| | | <View className='content-box-item-title'> |
| | | <View className="content-title">{deps.selectionMode === 'station' ? deps.selectedStation?.name : (deps.pageType === 'start' ? '确认上车站点' : '确认目的地')}</View> |
| | | <View className="content-title">{shouldShowStationSelection ? deps.selectedStation?.name : (deps.pageType === 'start' ? '确认上车站点' : '确认目的地')}</View> |
| | | {deps.pageType === 'start' && deps.walkingDuration !== null && deps.walkingDistance !== null && ( |
| | | <View className="limit-tip"> |
| | | 从当前位置出发步行{formatTripDurationFromSeconds(deps.walkingDuration)}({formatTripDistance(deps.walkingDistance)})上车 |
| | |
| | | <View className="limit-tip">点击紫色原点或拖动大头针,可修改{deps.pageType === 'start' ? '上车点' : '目的地'}</View> |
| | | )} |
| | | </View> |
| | | {deps.selectionMode === 'station' && ( |
| | | {shouldShowStationSelection && ( |
| | | <Image |
| | | onClick={deps.handleCollect} |
| | | className='collect-icon' |
| | |
| | | showScrollbar={true} |
| | | > |
| | | <View className='site-list-inner'> |
| | | {deps.selectionMode === 'station' ? ( |
| | | {shouldShowStationSelection ? ( |
| | | deps.stationSiteList.length > 0 ? deps.stationSiteList.map((item) => ( |
| | | <View |
| | | key={item.id || item.pointId || `${item.latitude}_${item.longitude}`} |
| | |
| | | <View>{item.name}</View> |
| | | </View> |
| | | )) : <View className="no-service">当前场站暂无可选站点</View> |
| | | ) : deps.selectedSite.name ? ( |
| | | ) : shouldShowSelectedSiteEcho ? ( |
| | | <View className='site-item site-active'> |
| | | <View>{deps.selectedSite.name}</View> |
| | | {deps.selectionMode === 'point' && ( |
| | | <Image onClick={deps.handleCollect} className='collect-icon' src={deps.selectedSite.isCollect === 1 ? deps.activeStar : deps.noStar} /> |
| | | )} |
| | | </View> |
| | | ) : deps.outOfServiceRange ? ( |
| | | <View className="no-service">超出服务范围,请在运营区内选择</View> |
| | | ) : showRangeWarning ? ( |
| | | <View className="no-service">{isCrossOperatingArea ? deps.crossOperatingAreaHintText : '超出服务范围,请在运营区内选择'}</View> |
| | | ) : <View className="no-service">附近无可选站点</View> |
| | | } |
| | | </View> |
| | | </ScrollView> |
| | | </View> |
| | | <View onClick={deps.confirmSite} className={`site-confirm ${deps.selectedSite.name ? '' : 'no-confirm'}`}> |
| | | {deps.pageType === 'start' ? '确认上车站点' : '确认目的地站点'} |
| | | <View onClick={deps.confirmButtonDisabled ? undefined : deps.confirmSite} className={`site-confirm ${deps.confirmButtonDisabled ? 'no-confirm' : ''}`}> |
| | | {deps.confirmButtonText} |
| | | </View> |
| | | </View> |
| | | </View> |
| | |
| | | import Taro from "@tarojs/taro"; |
| | | import { useMemo, useRef, useState } from 'react'; |
| | | |
| | | export const SELECT_SITE_RANGE_STATUS = { |
| | | normal: 'normal', |
| | | outOfOperatingArea: 'out_of_operating_area', |
| | | crossOperatingArea: 'cross_operating_area' |
| | | } as const; |
| | | |
| | | export type SelectSiteRangeStatus = typeof SELECT_SITE_RANGE_STATUS[keyof typeof SELECT_SITE_RANGE_STATUS]; |
| | | |
| | | /** |
| | | * 选点页状态容器:仅维护页面状态、refs 与轻量派生数据,不承载业务副作用。 |
| | | */ |
| | |
| | | const [polyline, setPolyline] = useState<any[]>([]); // 步行路线 |
| | | const [userLongitude, setUserLongitude] = useState<number | null>(null); // 用户经度 |
| | | const [userLatitude, setUserLatitude] = useState<number | null>(null); // 用户纬度 |
| | | const [outOfServiceRange, setOutOfServiceRange] = useState(false); // 是否超出运营范围 |
| | | const [rangeStatus, setRangeStatus] = useState<SelectSiteRangeStatus>(SELECT_SITE_RANGE_STATUS.normal); // 当前选点范围状态 |
| | | const [walkingDistance, setWalkingDistance] = useState<number | null>(null); // 步行距离 |
| | | const [walkingDuration, setWalkingDuration] = useState<number | null>(null); // 步行时长 |
| | | const tooltipBgUrl = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/tooltip-bg.png'; // 提示背景图资源 |
| | |
| | | const pendingNearbyRequestCountRef = useRef(0); // 待完成附近站点请求数 |
| | | const autoScaleLockedRef = useRef(false); // 自动缩放锁:仅初始化计算一次 |
| | | const stationMetaMapRef = useRef<Record<string, any>>({}); // 场站元数据映射(来自围栏) |
| | | const geofenceListRef = useRef<any[]>([]); // 原始围栏数据缓存(用于跨运营区态重算展示围栏) |
| | | const pickupConfirmTypeRef = useRef<PickupConfirmType>('drag'); // 最近一次确认站点来源类型 |
| | | const pinLocationRef = useRef<{ lat: number; lng: number }>({ lat: 0, lng: 0 }); // 当前指针确认位置 |
| | | const router = Taro.useRouter(); // 路由实例 |
| | |
| | | setUserLongitude, |
| | | userLatitude, |
| | | setUserLatitude, |
| | | outOfServiceRange, |
| | | setOutOfServiceRange, |
| | | rangeStatus, |
| | | setRangeStatus, |
| | | walkingDistance, |
| | | setWalkingDistance, |
| | | walkingDuration, |
| | |
| | | pendingNearbyRequestCountRef, |
| | | autoScaleLockedRef, |
| | | stationMetaMapRef, |
| | | geofenceListRef, |
| | | pickupConfirmTypeRef, |
| | | pinLocationRef, |
| | | router, |
| | |
| | | import Taro from "@tarojs/taro"; |
| | | import { useEffect } from 'react'; |
| | | import Taro, { useDidShow } from "@tarojs/taro"; |
| | | import { useEffect, useMemo } from 'react'; |
| | | import { |
| | | buildStationMetaMap, |
| | | findNearestMarker, |
| | |
| | | getAutoScaleBySites, |
| | | getDistance, |
| | | fetchMapData, |
| | | filterRenderableGeofence, |
| | | extractPointsFromPolygons, |
| | | preloadImageToTemp, |
| | | reportEvent, |
| | |
| | | import { checkOtp } from '../../../../utils/tools'; |
| | | import { jumpTo } from '../../../../utils/jump'; |
| | | import useSelectMapSiteState from './use-select-map-site-state'; |
| | | import { SELECT_SITE_RANGE_STATUS } from './use-select-map-site-state'; |
| | | import useSelectMapSiteWalkingRoute from './use-select-map-site-walking-route'; |
| | | import { |
| | | MODIFY_DESTINATION_CANDIDATE_KEY, |
| | |
| | | completeConfirmRideSelectionSession, |
| | | writeConfirmRideSiteSourceType |
| | | } from '../../../constants/confirm-ride-report'; |
| | | import { GeofenceType } from '../../../constants/geofence'; |
| | | |
| | | const AUTO_SCALE_MAX = 17; // 自动缩放最大等级 |
| | | const AUTO_SCALE_MIN = 11; // 自动缩放最小等级 |
| | | const INCLUDE_POINTS_PADDING = [100, 100, 100, 100] as [number, number, number, number]; // 地图回视图内边距 |
| | | const CROSS_OPERATING_AREA_FILL_COLOR = 'rgba(0, 0, 0, 0.25)'; // 非可选运营区围栏填充色 |
| | | const CROSS_OPERATING_AREA_STROKE_COLOR = 'rgba(0, 0, 0, 0.4)'; // 非可选运营区围栏描边色 |
| | | const SELECT_MAP_POINT_ON_OFF = { |
| | | start: 1, |
| | | end: 2 |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 获取当前页面用于约束同运营区选择的对端站点电子围栏 ID。 |
| | | */ |
| | | const getReferenceElectronicAreaId = () => { |
| | | const cachedSite = state.pageType === 'start' |
| | | ? Taro.getStorageSync('endSite') |
| | | : Taro.getStorageSync('startSite'); |
| | | return String(cachedSite?.electronicAreaId || ''); |
| | | }; |
| | | |
| | | /** |
| | | * 判断目标电子围栏是否与对端已选站点处于同一运营区。 |
| | | */ |
| | | const isSameOperatingAreaById = (electronicAreaId?: string | number) => { |
| | | const referenceElectronicAreaId = getReferenceElectronicAreaId(); |
| | | const targetElectronicAreaId = String(electronicAreaId || ''); |
| | | if (!referenceElectronicAreaId) { |
| | | return true; |
| | | } |
| | | if (!targetElectronicAreaId) { |
| | | return false; |
| | | } |
| | | return referenceElectronicAreaId === targetElectronicAreaId; |
| | | }; |
| | | |
| | | /** |
| | | * 解析地图围栏所属运营区 ID,围栏数据优先使用 areaId,与缓存的 electronicAreaId 对齐比对。 |
| | | */ |
| | | const getMapGeofenceAreaId = (geofence: any) => { |
| | | return String(geofence?.areaId || geofence?.electronicAreaId || ''); |
| | | }; |
| | | |
| | | /** |
| | | * 判断目标站点是否属于当前允许的同运营区范围。 |
| | | */ |
| | | const isSelectableInSameOperatingArea = (site: any) => { |
| | | return isSameOperatingAreaById(site?.electronicAreaId); |
| | | }; |
| | | |
| | | /** |
| | | * 过滤当前允许参与吸附和选择的同运营区候选站点。 |
| | | */ |
| | | const filterSelectableSites = (sites: any[]) => { |
| | | if (!Array.isArray(sites)) { |
| | | return []; |
| | | } |
| | | return sites.filter((site) => isSelectableInSameOperatingArea(site)); |
| | | }; |
| | | |
| | | /** |
| | | * 从 marker 中解析对应的业务站点对象,优先使用完整的站点数据。 |
| | | */ |
| | | const findSiteByMarker = (marker: any, candidateSites?: any[]) => { |
| | | const sourceSites = Array.isArray(candidateSites) ? candidateSites : state.nearbySites; |
| | | return sourceSites.find((item: any) => { |
| | | const markerPointId = String(marker?.pointId || marker?.id || ''); |
| | | const sitePointId = String(item?.pointId || item?.id || ''); |
| | | if (markerPointId && sitePointId && markerPointId === sitePointId) { |
| | | return true; |
| | | } |
| | | return Number(item?.latitude) === Number(marker?.latitude) && Number(item?.longitude) === Number(marker?.longitude); |
| | | }); |
| | | }; |
| | | |
| | | /** |
| | | * 根据允许选择的站点列表,构造可参与吸附的 marker 集合。 |
| | | */ |
| | | const getSelectableMarkers = (markers: any[], candidateSites: any[]) => { |
| | | const selectableSites = filterSelectableSites(candidateSites); |
| | | if (selectableSites.length === 0) { |
| | | return []; |
| | | } |
| | | const selectablePointIds = new Set( |
| | | selectableSites.map((item: any) => String(item?.pointId || item?.id || '')).filter(Boolean) |
| | | ); |
| | | return markers.filter((marker: any) => { |
| | | const markerPointId = String(marker?.pointId || marker?.id || ''); |
| | | if (markerPointId && selectablePointIds.has(markerPointId)) { |
| | | return true; |
| | | } |
| | | return selectableSites.some((site: any) => ( |
| | | Number(site?.latitude) === Number(marker?.latitude) |
| | | && Number(site?.longitude) === Number(marker?.longitude) |
| | | )); |
| | | }); |
| | | }; |
| | | |
| | | /** |
| | | * 判断当前中心点附近是否存在跨运营区但距离满足吸附阈值的候选站点。 |
| | | */ |
| | | const hasCrossOperatingAreaCandidateNearby = (lat: number, lng: number, markers: any[], candidateSites: any[]) => { |
| | | if (!getReferenceElectronicAreaId()) { |
| | | return false; |
| | | } |
| | | return markers.some((marker: any) => { |
| | | const site = findSiteByMarker(marker, candidateSites) || marker; |
| | | if (isSelectableInSameOperatingArea(site)) { |
| | | return false; |
| | | } |
| | | return getDistance(lat, lng, marker.latitude, marker.longitude) < state.distanceConfig.adsorptionDistance; |
| | | }); |
| | | }; |
| | | |
| | | /** |
| | | * 计算是否超出可选范围(运营区外或起终点电子围栏不一致)。 |
| | | */ |
| | | const getOutOfServiceRange = (inside: boolean, site: any) => { |
| | | const cachedStartSite = Taro.getStorageSync('startSite'); |
| | | const cachedEndSite = Taro.getStorageSync('endSite'); |
| | | const isCrossArea = (state.pageType === 'start' |
| | | ? (cachedEndSite?.electronicAreaId && site?.electronicAreaId && cachedEndSite.electronicAreaId !== site.electronicAreaId) |
| | | : (cachedStartSite?.electronicAreaId && site?.electronicAreaId && cachedStartSite.electronicAreaId !== site.electronicAreaId)); |
| | | return !inside || isCrossArea; |
| | | const getRangeStatus = (inside: boolean, site: any, fallbackElectronicAreaId?: string | number) => { |
| | | const effectiveElectronicAreaId = String(site?.electronicAreaId || fallbackElectronicAreaId || ''); |
| | | const isCrossArea = !isSameOperatingAreaById(effectiveElectronicAreaId); |
| | | if (!inside) { |
| | | return SELECT_SITE_RANGE_STATUS.outOfOperatingArea; |
| | | } |
| | | if (isCrossArea) { |
| | | return SELECT_SITE_RANGE_STATUS.crossOperatingArea; |
| | | } |
| | | return SELECT_SITE_RANGE_STATUS.normal; |
| | | }; |
| | | |
| | | /** |
| | | * 当前是否存在对端已选站点,用于约束同运营区选择。 |
| | | */ |
| | | const referenceElectronicAreaId = useMemo(() => getReferenceElectronicAreaId(), [state.pageType, state.selectedSite?.electronicAreaId]); |
| | | |
| | | /** |
| | | * 跨运营区态下底部提示文案。 |
| | | */ |
| | | const crossOperatingAreaHintText = useMemo(() => ( |
| | | state.pageType === 'start' |
| | | ? '与目的地不在同一个运营区,请重新选择' |
| | | : '与上车点不在同一个运营区,请重新选择' |
| | | ), [state.pageType]); |
| | | |
| | | /** |
| | | * 跨运营区态下的确认按钮文案。 |
| | | */ |
| | | const crossOperatingAreaConfirmButtonText = useMemo(() => ( |
| | | state.pageType === 'start' ? '确认上车点' : '确认目的地站点' |
| | | ), [state.pageType]); |
| | | |
| | | /** |
| | | * 当前可参与展示计算的原始围栏数据。 |
| | | */ |
| | | const renderableGeofenceList = useMemo(() => { |
| | | return filterRenderableGeofence(state.geofenceListRef.current || []); |
| | | }, [state.fencePolylines, state.polygons]); |
| | | |
| | | /** |
| | | * 同运营区围栏集合,保持原始蓝色/场站色展示。 |
| | | */ |
| | | const selectableGeofenceList = useMemo(() => { |
| | | if (!referenceElectronicAreaId) { |
| | | return renderableGeofenceList; |
| | | } |
| | | return renderableGeofenceList.filter((item: any) => isSameOperatingAreaById(getMapGeofenceAreaId(item))); |
| | | }, [referenceElectronicAreaId, renderableGeofenceList]); |
| | | |
| | | /** |
| | | * 非同运营区围栏集合,统一灰显处理。 |
| | | */ |
| | | const disabledGeofenceList = useMemo(() => { |
| | | if (!referenceElectronicAreaId) { |
| | | return []; |
| | | } |
| | | return renderableGeofenceList.filter((item: any) => !isSameOperatingAreaById(getMapGeofenceAreaId(item))); |
| | | }, [referenceElectronicAreaId, renderableGeofenceList]); |
| | | |
| | | /** |
| | | * 同运营区围栏展示数据,保持原始配色。 |
| | | */ |
| | | const selectableGeofenceDisplay = useMemo(() => { |
| | | if (selectableGeofenceList.length === 0) { |
| | | return { polygons: [], fencePolylines: [] }; |
| | | } |
| | | return formatGeofenceByType(selectableGeofenceList); |
| | | }, [selectableGeofenceList]); |
| | | |
| | | /** |
| | | * 围栏灰显展示:仅保留同运营区围栏原色,其余围栏统一置灰。 |
| | | */ |
| | | const displayPolygons = useMemo(() => { |
| | | if (!referenceElectronicAreaId || renderableGeofenceList.length === 0) { |
| | | return state.polygons; |
| | | } |
| | | const disabledPolygons = formatGeofenceByType(disabledGeofenceList).polygons.map((polygon: any) => ({ |
| | | ...polygon, |
| | | fillColor: CROSS_OPERATING_AREA_FILL_COLOR, |
| | | strokeColor: 'rgba(0, 0, 0, 0)' |
| | | })); |
| | | return [...selectableGeofenceDisplay.polygons, ...disabledPolygons]; |
| | | }, [ |
| | | disabledGeofenceList, |
| | | referenceElectronicAreaId, |
| | | renderableGeofenceList.length, |
| | | selectableGeofenceDisplay.polygons, |
| | | state.polygons |
| | | ]); |
| | | |
| | | /** |
| | | * 围栏边框灰显展示:非可选运营区统一输出灰色虚线边框。 |
| | | */ |
| | | const displayFencePolylines = useMemo(() => { |
| | | if (!referenceElectronicAreaId || renderableGeofenceList.length === 0) { |
| | | return state.fencePolylines; |
| | | } |
| | | const disabledFencePolylines = disabledGeofenceList.reduce((acc: any[], item: any, index: number) => { |
| | | if (!Array.isArray(item?.points) || item.points.length < 2) { |
| | | return acc; |
| | | } |
| | | const linePoints = item.points.map((point: any) => ({ |
| | | latitude: point.lat, |
| | | longitude: point.lng |
| | | })); |
| | | acc.push({ |
| | | id: `cross_operating_area_${index}_${getMapGeofenceAreaId(item)}`, |
| | | points: [...linePoints, linePoints[0]], |
| | | color: CROSS_OPERATING_AREA_STROKE_COLOR, |
| | | width: 2, |
| | | dottedLine: true, |
| | | arrowLine: false |
| | | }); |
| | | return acc; |
| | | }, []); |
| | | return [...selectableGeofenceDisplay.fencePolylines, ...disabledFencePolylines]; |
| | | }, [ |
| | | disabledGeofenceList, |
| | | referenceElectronicAreaId, |
| | | renderableGeofenceList.length, |
| | | selectableGeofenceDisplay.fencePolylines, |
| | | state.fencePolylines |
| | | ]); |
| | | |
| | | /** |
| | | * 地图最终折线集合:跨运营区灰显后的围栏边框 + 步行路线。 |
| | | */ |
| | | const displayMapPolylines = useMemo(() => { |
| | | return [...displayFencePolylines, ...state.polyline]; |
| | | }, [displayFencePolylines, state.polyline]); |
| | | |
| | | /** |
| | | * 当前可选运营区围栏集合,用于“查看运营区域”回视图。 |
| | | */ |
| | | const selectablePolygons = useMemo(() => ( |
| | | referenceElectronicAreaId && renderableGeofenceList.length > 0 |
| | | ? selectableGeofenceDisplay.polygons |
| | | : state.polygons |
| | | ), [referenceElectronicAreaId, renderableGeofenceList.length, selectableGeofenceDisplay.polygons, state.polygons]); |
| | | |
| | | /** |
| | | * 是否展示“查看运营区域”按钮。 |
| | | */ |
| | | const showViewOperatingAreaButton = useMemo(() => ( |
| | | ( |
| | | state.rangeStatus === SELECT_SITE_RANGE_STATUS.crossOperatingArea |
| | | || state.rangeStatus === SELECT_SITE_RANGE_STATUS.outOfOperatingArea |
| | | ) |
| | | && !!referenceElectronicAreaId |
| | | && selectablePolygons.length > 0 |
| | | ), [referenceElectronicAreaId, selectablePolygons.length, state.rangeStatus]); |
| | | |
| | | /** |
| | | * 更新地图业务数据:围栏内判断、站点列表、自动缩放。 |
| | |
| | | state.pendingNearbyRequestCountRef.current += 1; |
| | | state.setNearbyLoading(true); |
| | | try { |
| | | const { isInside, newMarkers, sites } = await fetchMapData(lat, lng, selectedCity?.code, pointOnOff); |
| | | const { isInside, newMarkers, sites, electronicAreaId } = await fetchMapData( |
| | | lat, |
| | | lng, |
| | | selectedCity?.code, |
| | | pointOnOff, |
| | | { allowedElectronicAreaId: referenceElectronicAreaId } |
| | | ); |
| | | state.setIsOperatingArea(isInside); |
| | | state.setMarkers(newMarkers); |
| | | state.setNearbySites(sites); |
| | | if (state.selectionMode === 'station' && state.selectedStation?.stationId) { |
| | | const nextStationSites = sites.filter((item: any) => String(item?.stationId || '') === String(state.selectedStation.stationId)); |
| | | const nextStationSites = filterSelectableSites(sites).filter((item: any) => String(item?.stationId || '') === String(state.selectedStation.stationId)); |
| | | state.setStationSiteList(nextStationSites); |
| | | } |
| | | |
| | |
| | | } |
| | | state.autoScaleLockedRef.current = true; |
| | | } |
| | | return { isInside, newMarkers, sites }; |
| | | return { isInside, newMarkers, sites, electronicAreaId }; |
| | | } finally { |
| | | state.pendingNearbyRequestCountRef.current = Math.max(0, state.pendingNearbyRequestCountRef.current - 1); |
| | | if (state.pendingNearbyRequestCountRef.current === 0) { |
| | |
| | | state.setLatitude(res.latitude); |
| | | state.setLongitude(res.longitude); |
| | | syncPinLocation(res.latitude, res.longitude); |
| | | updateMapData(res.latitude, res.longitude).then(({ isInside }) => { |
| | | state.setOutOfServiceRange(getOutOfServiceRange(isInside, state.selectedSite)); |
| | | updateMapData(res.latitude, res.longitude).then(({ isInside, electronicAreaId }) => { |
| | | state.setRangeStatus(getRangeStatus(isInside, state.selectedSite, electronicAreaId)); |
| | | }); |
| | | }); |
| | | }; |
| | |
| | | success: () => { |
| | | handleRegionChange({ type: 'end', causedBy: 'gesture' }); |
| | | } |
| | | }); |
| | | }; |
| | | |
| | | /** |
| | | * 将当前可选运营区围栏移动到视图内。 |
| | | */ |
| | | const handleViewOperatingArea = () => { |
| | | const points = extractPointsFromPolygons(selectablePolygons); |
| | | if (points.length === 0) { |
| | | Taro.showToast({ title: '暂无可选运营区域', icon: 'none' }); |
| | | return; |
| | | } |
| | | const mapCtx = Taro.createMapContext('map'); |
| | | mapCtx.includePoints({ |
| | | points, |
| | | padding: INCLUDE_POINTS_PADDING |
| | | }); |
| | | }; |
| | | |
| | |
| | | mapCtx.getCenterLocation({ |
| | | success: async (res) => { |
| | | const { latitude: lat, longitude: lng } = res; |
| | | const { newMarkers, sites, isInside } = await updateMapData(lat, lng); |
| | | const { targetMarker, minDistance } = findNearestMarker(lat, lng, newMarkers); |
| | | const { newMarkers, sites, isInside, electronicAreaId } = await updateMapData(lat, lng); |
| | | const selectableMarkers = getSelectableMarkers(newMarkers, sites); |
| | | const { targetMarker, minDistance } = findNearestMarker(lat, lng, selectableMarkers); |
| | | if (targetMarker && minDistance < state.distanceConfig.adsorptionDistance) { |
| | | mapCtx.moveToLocation({ |
| | | latitude: targetMarker.latitude, |
| | | longitude: targetMarker.longitude, |
| | | }); |
| | | const activeSite = applySelectionBySite(targetMarker, sites); |
| | | state.setOutOfServiceRange(getOutOfServiceRange(isInside, activeSite)); |
| | | const activeSite = applySelectionBySite(findSiteByMarker(targetMarker, sites) || targetMarker, filterSelectableSites(sites)); |
| | | state.setRangeStatus(getRangeStatus(isInside, activeSite)); |
| | | state.setLatitude(activeSite?.latitude || targetMarker.latitude); |
| | | state.setLongitude(activeSite?.longitude || targetMarker.longitude); |
| | | syncPinLocation(activeSite?.latitude || targetMarker.latitude, activeSite?.longitude || targetMarker.longitude); |
| | |
| | | } else { |
| | | resetStationSelection(); |
| | | state.setSelectedSite({}); |
| | | state.setOutOfServiceRange(getOutOfServiceRange(isInside, {})); |
| | | state.setRangeStatus( |
| | | isInside && !isSameOperatingAreaById(electronicAreaId) |
| | | ? SELECT_SITE_RANGE_STATUS.crossOperatingArea |
| | | : isInside && hasCrossOperatingAreaCandidateNearby(lat, lng, newMarkers, sites) |
| | | ? SELECT_SITE_RANGE_STATUS.crossOperatingArea |
| | | : getRangeStatus(isInside, {}, electronicAreaId) |
| | | ); |
| | | // 只有当坐标发生实质性变化才去更新 React 状态中的经纬度,避免频繁反推给原生组件导致抖动 |
| | | if (Math.abs(state.latitude - lat) > 0.0001 || Math.abs(state.longitude - lng) > 0.0001) { |
| | | state.setLatitude(lat); |
| | |
| | | if (markerId === undefined) return; |
| | | const targetMarker = state.markers.find((m) => m.id === markerId); |
| | | if (!targetMarker) return; |
| | | const targetSite = findSiteByMarker(targetMarker, state.nearbySites) || targetMarker; |
| | | state.pickupConfirmTypeRef.current = 'click'; |
| | | state.setShowToolTip(false); |
| | | state.setBounce(true); |
| | |
| | | state.setLongitude(targetMarker.longitude); |
| | | setTimeout(() => { |
| | | state.setBounce(false); |
| | | const activeSite = applySelectionBySite(targetMarker, state.nearbySites); |
| | | state.setOutOfServiceRange(getOutOfServiceRange(state.isOperatingArea, activeSite)); |
| | | if (!isSelectableInSameOperatingArea(targetSite)) { |
| | | resetStationSelection(); |
| | | state.setSelectedSite({}); |
| | | state.setRangeStatus(SELECT_SITE_RANGE_STATUS.crossOperatingArea); |
| | | syncPinLocation(targetMarker.latitude, targetMarker.longitude); |
| | | state.setShowToolTip(true); |
| | | return; |
| | | } |
| | | const activeSite = applySelectionBySite(targetSite, filterSelectableSites(state.nearbySites)); |
| | | state.setRangeStatus(getRangeStatus(state.isOperatingArea, activeSite)); |
| | | state.setLatitude(activeSite?.latitude || targetMarker.latitude); |
| | | state.setLongitude(activeSite?.longitude || targetMarker.longitude); |
| | | syncPinLocation(activeSite?.latitude || targetMarker.latitude, activeSite?.longitude || targetMarker.longitude); |
| | |
| | | Taro.showToast({ title: '请选择站点', icon: 'none' }); |
| | | return; |
| | | } |
| | | if (state.rangeStatus === SELECT_SITE_RANGE_STATUS.crossOperatingArea) { |
| | | Taro.showToast({ title: '请在同一运营区内选择', icon: 'none' }); |
| | | return; |
| | | } |
| | | if (state.pageType === 'start') { |
| | | const endSiteCache = Taro.getStorageSync('endSite'); |
| | | if (endSiteCache?.electronicAreaId && state.selectedSite?.electronicAreaId && endSiteCache.electronicAreaId !== state.selectedSite.electronicAreaId) { |
| | | Taro.showToast({ title: '起终点需在同一运营区域', icon: 'none' }); |
| | | Taro.showToast({ title: '请在同一运营区内选择', icon: 'none' }); |
| | | return; |
| | | } |
| | | if (state.userLatitude && state.userLongitude) { |
| | |
| | | } |
| | | const startSiteCache = Taro.getStorageSync('startSite'); |
| | | if (startSiteCache?.electronicAreaId && state.selectedSite?.electronicAreaId && startSiteCache.electronicAreaId !== state.selectedSite.electronicAreaId) { |
| | | Taro.showToast({ title: '起终点需在同一运营区域', icon: 'none' }); |
| | | Taro.showToast({ title: '请在同一运营区内选择', icon: 'none' }); |
| | | return; |
| | | } |
| | | if (startSiteCache && startSiteCache.longitude && startSiteCache.latitude) { |
| | |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | state.setOutOfServiceRange(getOutOfServiceRange(state.isOperatingArea, state.selectedSite)); |
| | | }, [state.isOperatingArea, state.pageType, state.selectedSite?.electronicAreaId]); |
| | | state.setRangeStatus(getRangeStatus(state.isOperatingArea, state.selectedSite)); |
| | | }, [referenceElectronicAreaId, state.isOperatingArea, state.pageType, state.selectedSite?.electronicAreaId]); |
| | | |
| | | useEffect(() => { |
| | | const systemInfo = Taro.getSystemInfoSync(); |
| | |
| | | state.setLatitude(cachedSite.latitude); |
| | | state.setLongitude(cachedSite.longitude); |
| | | applySelectionBySite(cachedSite); |
| | | updateMapData(cachedSite.latitude, cachedSite.longitude).then(({ isInside }) => { |
| | | state.setOutOfServiceRange(getOutOfServiceRange(isInside, cachedSite)); |
| | | updateMapData(cachedSite.latitude, cachedSite.longitude).then(({ isInside, electronicAreaId }) => { |
| | | state.setRangeStatus(getRangeStatus(isInside, cachedSite, electronicAreaId)); |
| | | }); |
| | | } |
| | | fetchUserLocation(); |
| | |
| | | if (selectedCity && selectedCity.code) { |
| | | getGeofence({ cityCode: selectedCity.code }).then((res: any) => { |
| | | if (res && Array.isArray(res)) { |
| | | const { polygons: newPolygons, fencePolylines: nextFencePolylines } = formatGeofenceByType(res); |
| | | state.stationMetaMapRef.current = buildStationMetaMap(res); |
| | | state.setPolygons(newPolygons); |
| | | state.setFencePolylines(nextFencePolylines); |
| | | if (!hasCachedSite && res.length > 0 && res[0]?.points?.length > 0) { |
| | | const firstPoint = res[0].points[0]; |
| | | const renderableGeofenceList = filterRenderableGeofence(res); |
| | | const stationGeofenceList = renderableGeofenceList.filter((item) => Number(item?.type) === GeofenceType.STATION); |
| | | const { polygons: newPolygons, fencePolylines: nextFencePolylines } = formatGeofenceByType(renderableGeofenceList); |
| | | state.geofenceListRef.current = renderableGeofenceList; |
| | | state.stationMetaMapRef.current = buildStationMetaMap(renderableGeofenceList); |
| | | state.setPolygons(newPolygons.map((polygon: any, index: number) => ({ |
| | | ...polygon, |
| | | electronicAreaId: String(renderableGeofenceList[index]?.electronicAreaId || ''), |
| | | geofenceType: Number(renderableGeofenceList[index]?.type || 0) |
| | | }))); |
| | | state.setFencePolylines(nextFencePolylines.map((polyline: any, index: number) => ({ |
| | | ...polyline, |
| | | electronicAreaId: String(stationGeofenceList[index]?.electronicAreaId || ''), |
| | | geofenceType: Number(stationGeofenceList[index]?.type || 0) |
| | | }))); |
| | | if (!hasCachedSite && renderableGeofenceList.length > 0 && renderableGeofenceList[0]?.points?.length > 0) { |
| | | const firstPoint = renderableGeofenceList[0].points[0]; |
| | | const lat = Number(firstPoint.lat); |
| | | const lng = Number(firstPoint.lng); |
| | | state.setLatitude(lat); |
| | | state.setLongitude(lng); |
| | | updateMapData(lat, lng).then(({ isInside }) => { |
| | | state.setOutOfServiceRange(getOutOfServiceRange(isInside, {})); |
| | | updateMapData(lat, lng).then(({ isInside, electronicAreaId }) => { |
| | | state.setRangeStatus(getRangeStatus(isInside, {}, electronicAreaId)); |
| | | }); |
| | | const mapCtx = Taro.createMapContext('map'); |
| | | const points = extractPointsFromPolygons(newPolygons); |
| | | mapCtx.includePoints({ |
| | | points, |
| | | padding: [100, 100, 100, 100] |
| | | padding: INCLUDE_POINTS_PADDING |
| | | }); |
| | | } else if (!hasCachedSite) { |
| | | getLocationInfo(); |
| | | } |
| | | } else { |
| | | state.geofenceListRef.current = []; |
| | | state.stationMetaMapRef.current = {}; |
| | | state.setPolygons([]); |
| | | state.setFencePolylines([]); |
| | |
| | | } |
| | | }).catch((err) => { |
| | | console.error('获取电子围栏失败', err); |
| | | state.geofenceListRef.current = []; |
| | | state.stationMetaMapRef.current = {}; |
| | | state.setPolygons([]); |
| | | state.setFencePolylines([]); |
| | |
| | | return () => { cancelled = true; }; |
| | | }, []); |
| | | |
| | | // 从 site-search 返回时同步已选择的站点 |
| | | useDidShow(() => { |
| | | const pType = state.router.params.pageType || state.pageType || 'end'; |
| | | let cachedSite: any = null; |
| | | if (pType === 'start') { |
| | | cachedSite = Taro.getStorageSync('startSite'); |
| | | } else if (pType === 'end') { |
| | | cachedSite = Taro.getStorageSync('endSite'); |
| | | } |
| | | if (cachedSite && cachedSite.latitude && cachedSite.longitude) { |
| | | const currentId = String(state.selectedSite?.pointId || state.selectedSite?.id || ''); |
| | | const cachedId = String(cachedSite?.pointId || cachedSite?.id || ''); |
| | | if (currentId !== cachedId) { |
| | | state.setLatitude(cachedSite.latitude); |
| | | state.setLongitude(cachedSite.longitude); |
| | | applySelectionBySite(cachedSite); |
| | | updateMapData(cachedSite.latitude, cachedSite.longitude).then(({ isInside }) => { |
| | | state.setRangeStatus(getRangeStatus(isInside, cachedSite)); |
| | | }); |
| | | } |
| | | } |
| | | }); |
| | | |
| | | useSelectMapSiteWalkingRoute({ |
| | | pageType: state.pageType, |
| | | userLatitude: state.userLatitude, |
| | |
| | | if (state.selectionMode !== 'station') return; |
| | | if (!state.selectedStation?.stationId) return; |
| | | const stationId = String(state.selectedStation.stationId); |
| | | const nextStationSites = state.nearbySites.filter((item: any) => String(item?.stationId || '') === stationId); |
| | | const nextStationSites = filterSelectableSites(state.nearbySites).filter((item: any) => String(item?.stationId || '') === stationId); |
| | | state.setStationSiteList(nextStationSites); |
| | | if (!state.selectedSite?.stationId || String(state.selectedSite.stationId) !== stationId) { |
| | | if (nextStationSites.length > 0) { |
| | |
| | | latitude: state.latitude, |
| | | mapScale: state.mapScale, |
| | | markers: state.markers, |
| | | mapPolylines: state.mapPolylines, |
| | | polygons: state.polygons, |
| | | mapPolylines: displayMapPolylines, |
| | | polygons: displayPolygons, |
| | | handleRegionChange, |
| | | handleMarkerTap, |
| | | toBack, |
| | |
| | | selectionMode: state.selectionMode, |
| | | selectedStation: state.selectedStation, |
| | | stationSiteList: state.stationSiteList, |
| | | outOfServiceRange: state.outOfServiceRange, |
| | | rangeStatus: state.rangeStatus, |
| | | tooltipBgSrc: state.tooltipBgSrc, |
| | | pageType: state.pageType, |
| | | bounce: state.bounce, |
| | |
| | | handleCollect, |
| | | changeStationSite, |
| | | confirmSite, |
| | | confirmButtonText: state.rangeStatus === SELECT_SITE_RANGE_STATUS.crossOperatingArea |
| | | ? crossOperatingAreaConfirmButtonText |
| | | : (state.pageType === 'start' ? '确认上车点' : '确认目的地站点'), |
| | | confirmButtonDisabled: state.rangeStatus === SELECT_SITE_RANGE_STATUS.crossOperatingArea || !state.selectedSite.name, |
| | | crossOperatingAreaHintText, |
| | | showViewOperatingAreaButton, |
| | | handleViewOperatingArea, |
| | | showCustomerService: state.showCustomerService, |
| | | handleCloseCustomerService, |
| | | showTipPopup: state.showTipPopup, |
| | |
| | | } |
| | | } |
| | | |
| | | .search-card { |
| | | position: fixed; |
| | | top: 180px; |
| | | left: 31px; |
| | | right: 150px; |
| | | display: flex; |
| | | align-items: center; |
| | | border-radius: 24px; |
| | | background: rgba(255, 255, 255, 0.95); |
| | | backdrop-filter: blur(40px); |
| | | box-sizing: border-box; |
| | | border: 2px solid #F1F1F1; |
| | | box-shadow: 0px 16px 60px 0px rgba(0, 0, 0, 0.05); |
| | | padding: 22px 36px 24px 32px; |
| | | z-index: 11; |
| | | |
| | | .search-icon { |
| | | width: 10px; |
| | | height: 10px; |
| | | background: #768AFF; |
| | | border-radius: 50%; |
| | | margin-right: 16px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .end-icon { |
| | | background: #8BB654; |
| | | } |
| | | |
| | | .search-input-box { |
| | | flex: 1; |
| | | display: flex; |
| | | align-items: center; |
| | | |
| | | .search-input-placeholder { |
| | | flex: 1; |
| | | height: 40px; |
| | | font-size: 28px; |
| | | font-weight: normal; |
| | | line-height: 40px; |
| | | color: rgba(0, 0, 0, 0.3); |
| | | text-align: left; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .map-entry { |
| | | position: fixed; |
| | | top: 182px; |
| | |
| | | height: 1px; |
| | | background-color: rgba(0, 0, 0, 0.1); |
| | | } |
| | | } |
| | | |
| | | .select-operating-area-entry { |
| | | position: absolute; |
| | | bottom: calc(100% + 32rpx); |
| | | left: 50%; |
| | | transform: translateX(-50%); |
| | | z-index: 10; |
| | | width: fit-content; |
| | | line-height: 64px; |
| | | font-size: 24px; |
| | | font-weight: normal; |
| | | letter-spacing: normal; |
| | | color: #FFFFFF; |
| | | border-radius: 200px; |
| | | background: rgba(0, 0, 0, 0.4); |
| | | backdrop-filter: blur(40px); |
| | | padding: 0 44px; |
| | | } |
| | | |
| | | .pointer-box { |
| | |
| | | bottom: 0; |
| | | z-index: 10; |
| | | overflow: hidden; |
| | | |
| | | .content-box-wrap { |
| | | position: relative; |
| | | } |
| | | |
| | | .origin-box { |
| | | display: flex; |
| | |
| | | |
| | | .no-confirm { |
| | | background: rgba(0, 0, 0, 0.15); |
| | | pointer-events: none; |
| | | } |
| | | } |
| | | } |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useEffect, useRef } from 'react'; |
| | | import './index.less'; |
| | | import { reportEvent } from '../../../utils/Robotaxi-public-method'; |
| | |
| | | }, [handleScaleChange]); |
| | | |
| | | const hasReportedPickupPageViewRef = useRef(false); |
| | | const hasReportedDropoffPageViewRef = useRef(false); |
| | | const hasReportedDropoffPageViewRef = useRef(false); |
| | | |
| | | useEffect(() => { |
| | | if (selectMapSite.pageType === 'start' && !hasReportedPickupPageViewRef.current) { |
| | |
| | | selectionMode={selectMapSite.selectionMode} |
| | | selectedStation={selectMapSite.selectedStation} |
| | | stationSiteList={selectMapSite.stationSiteList} |
| | | outOfServiceRange={selectMapSite.outOfServiceRange} |
| | | rangeStatus={selectMapSite.rangeStatus} |
| | | tooltipBgSrc={selectMapSite.tooltipBgSrc} |
| | | pageType={selectMapSite.pageType} |
| | | bounce={selectMapSite.bounce} |
| | |
| | | activeStar='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/active-star.png' |
| | | noStar='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/no-star.png' |
| | | confirmSite={selectMapSite.confirmSite} |
| | | confirmButtonText={selectMapSite.confirmButtonText} |
| | | confirmButtonDisabled={selectMapSite.confirmButtonDisabled} |
| | | crossOperatingAreaHintText={selectMapSite.crossOperatingAreaHintText} |
| | | showViewOperatingAreaButton={selectMapSite.showViewOperatingAreaButton} |
| | | handleViewOperatingArea={selectMapSite.handleViewOperatingArea} |
| | | showCustomerService={selectMapSite.showCustomerService} |
| | | handleCloseCustomerService={selectMapSite.handleCloseCustomerService} |
| | | showTipPopup={selectMapSite.showTipPopup} |
| | | handleCloseTipPopup={selectMapSite.handleCloseTipPopup} |
| | | tipType={selectMapSite.tipType} |
| | | toSiteSearch={() => { |
| | | const lat = selectMapSite.latitude; |
| | | const lon = selectMapSite.longitude; |
| | | const electronicAreaId = selectMapSite.selectedSite?.electronicAreaId || ''; |
| | | let url = '/robotaxi/pages/site-search/index?pageType=' + selectMapSite.pageType; |
| | | if (lat != null && lon != null) { |
| | | url += '&latitude=' + lat + '&longitude=' + lon; |
| | | } |
| | | if (electronicAreaId) { |
| | | url += '&electronicAreaId=' + electronicAreaId; |
| | | } |
| | | Taro.navigateTo({ url }); |
| | | }} |
| | | /> |
| | | ); |
| | | } |
| | |
| | | id: 1, |
| | | latitude: pickupSite.latitude, |
| | | longitude: pickupSite.longitude, |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/maker-up.png', |
| | | width: 22, |
| | | height: 40, |
| | | iconPath: "https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/start-text.png", |
| | | width: 20, |
| | | height: 36, |
| | | anchor: { x: 0.5, y: 1 }, |
| | | zIndex: 1, |
| | | name: pickupSite?.name || '上车点', |
| | |
| | | id: 2, |
| | | latitude: dropoffSite.latitude, |
| | | longitude: dropoffSite.longitude, |
| | | iconPath: |
| | | 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/maker-down.png', |
| | | width: 22, |
| | | height: 40, |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/end-text.png', |
| | | width: 20, |
| | | height: 36, |
| | | anchor: { x: 0.5, y: 1 }, |
| | | zIndex: 1, |
| | | name: dropoffName, |
| | |
| | | line-height: normal; |
| | | letter-spacing: normal; |
| | | color: rgba(0, 0, 0, 0.4); |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | } |
| | | } |
| | |
| | | align-items: center; |
| | | justify-content: center; |
| | | cursor: pointer; |
| | | |
| | | height: 70px; |
| | | .more-text { |
| | | font-family: PingFang SC; |
| | | font-size: 22px; |
| | |
| | | sortSitesByCollectedFirst, |
| | | sortSitesByNameMatchAndDistance, |
| | | splitHighlightParts, |
| | | filterNearbyListByCurrentSite |
| | | filterNearbyListByCurrentSite, |
| | | filterRecommendedDropoffSitesByMinDistance |
| | | } from './utils/site-search'; |
| | | import { |
| | | MODIFY_DESTINATION_CANDIDATE_KEY, |
| | |
| | | |
| | | const MAX_SHOW_COUNT = 5 // 默认最多显示5条 |
| | | const MAX_EXPANDED_COUNT = 10 |
| | | const MAX_SHOW_HISTORY = 3 // 默认历史记录最多3条 |
| | | |
| | | const router = Taro.useRouter(); |
| | | const { |
| | |
| | | const filteredNearbyList = filterNearbyListByCurrentSite(nearbySiteList, currentSiteCache); |
| | | const sortedNearbyList = sortSitesByCollectedFirst(filteredNearbyList); |
| | | const fallbackNearbyList = sortedNearbyList.slice(0, MAX_EXPANDED_COUNT); // 兜底推荐列表(最多展示 MAX_EXPANDED_COUNT 条) |
| | | const recommendList = searchValue ? sortSitesByNameMatchAndDistance(siteList, searchValue) : sortedNearbyList; |
| | | const trimmedSearchValue = searchValue.trim(); |
| | | const startSiteCache = Taro.getStorageSync('startSite'); |
| | | const recommendSourceList = trimmedSearchValue |
| | | ? sortSitesByNameMatchAndDistance(siteList, searchValue) |
| | | : sortedNearbyList; |
| | | const recommendList = pageType === 'end' && !trimmedSearchValue |
| | | ? filterRecommendedDropoffSitesByMinDistance(recommendSourceList, startSiteCache, distanceConfig?.minDistance) |
| | | : recommendSourceList; |
| | | const sortedHistorySite = sortSitesByCollectedFirst(historySite); |
| | | const visibleHistory = showAllHistory ? sortedHistorySite.slice(0, MAX_EXPANDED_COUNT) : sortedHistorySite.slice(0, MAX_SHOW_COUNT); |
| | | const visibleHistory = showAllHistory ? sortedHistorySite.slice(0, MAX_EXPANDED_COUNT) : sortedHistorySite.slice(0, MAX_SHOW_HISTORY); |
| | | const visibleRecommend = showAllRecommend ? recommendList.slice(0, MAX_EXPANDED_COUNT) : recommendList.slice(0, MAX_SHOW_COUNT); |
| | | const canShowSearchFallback = !searchValue || (!isSearching && lastCompletedKeyword === searchValue.trim()); |
| | | |
| | |
| | | }, true, true) |
| | | } |
| | | </View> |
| | | {sortedHistorySite.length > MAX_SHOW_COUNT && ( |
| | | {sortedHistorySite.length > MAX_SHOW_HISTORY && ( |
| | | <View className='show-more' onClick={toggleHistoryShow}> |
| | | <Text className='more-text'>{showAllHistory ? '收起' : '展示更多'}</Text> |
| | | <Image className={`more-icon ${showAllHistory ? 'rotate-up' : ''}`} src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/arrow.png"></Image> |
| | |
| | | if (pageType === 'start' && searchValue.trim()) { |
| | | reportSearchResultEvent(item, index, recommendList.length); |
| | | } |
| | | if (pageType === 'end' && searchValue.trim()) { |
| | | if (pageType === 'end' && trimmedSearchValue) { |
| | | reportSearchResultEvent(item, index, recommendList.length); |
| | | } |
| | | if (pageType === 'end' && !searchValue.trim()) { |
| | | if (pageType === 'end' && !trimmedSearchValue) { |
| | | reportRecommendedDropoffStationEvent(item, index, recommendList.length); |
| | | } |
| | | onChangeSelectSite( |
| | | item, |
| | | true, |
| | | searchValue.trim() |
| | | trimmedSearchValue |
| | | ? SEARCH_CONFIRM_RIDE_SOURCE_TYPE |
| | | : RECOMMENDED_CONFIRM_RIDE_SOURCE_TYPE |
| | | ); |
| | |
| | | import { formatDistance } from '../../../../utils/Robotaxi-public-method'; |
| | | import { formatDistance, getDistance } from '../../../../utils/Robotaxi-public-method'; |
| | | |
| | | // 收藏状态值:接口字段 isCollect=1 表示已收藏 |
| | | const COLLECTED_FLAG_VALUE = 1; |
| | |
| | | return true; |
| | | }); |
| | | }; |
| | | |
| | | // 过滤与上车点距离过近的目的地推荐站点,避免展示会触发“建议步行前往”的点位 |
| | | export const filterRecommendedDropoffSitesByMinDistance = ( |
| | | recommendSiteList: any[], |
| | | startSiteCache: any, |
| | | minDistance?: number |
| | | ) => { |
| | | const startLongitude = Number(startSiteCache?.longitude); |
| | | const startLatitude = Number(startSiteCache?.latitude); |
| | | const minDistanceValue = Number(minDistance); |
| | | if ( |
| | | !Array.isArray(recommendSiteList) |
| | | || !Number.isFinite(startLongitude) |
| | | || !Number.isFinite(startLatitude) |
| | | || !Number.isFinite(minDistanceValue) |
| | | || minDistanceValue <= 0 |
| | | ) { |
| | | return recommendSiteList; |
| | | } |
| | | return recommendSiteList.filter((item: any) => { |
| | | const itemLongitude = Number(item?.longitude); |
| | | const itemLatitude = Number(item?.latitude); |
| | | if (!Number.isFinite(itemLongitude) || !Number.isFinite(itemLatitude)) { |
| | | return true; |
| | | } |
| | | const siteDistance = getDistance(startLatitude, startLongitude, itemLatitude, itemLongitude); |
| | | return !Number.isFinite(siteDistance) || siteDistance >= minDistanceValue; |
| | | }); |
| | | }; |
| | |
| | | } |
| | | |
| | | // 获取地图数据(围栏检查+附近站点) |
| | | export const fetchMapData = async (lat: number, lng: number, cityCode?: string, pointOnOff?: number) => { |
| | | export const fetchMapData = async ( |
| | | lat: number, |
| | | lng: number, |
| | | cityCode?: string, |
| | | pointOnOff?: number, |
| | | options?: { allowedElectronicAreaId?: string } |
| | | ) => { |
| | | try { |
| | | // 1. 先检查是否在运营区域内 |
| | | const fenceRes: any = await getFenceCheck({ latitude: lat, longitude: lng, cityCode }).catch(e => { |
| | |
| | | |
| | | // 2. 只有在运营区域内才获取附近站点 type 传1 地图查询附近站点 不传 查搜索页附近站点 |
| | | let sites: any = { points: [] }; |
| | | if (isInside) { |
| | | const allowedElectronicAreaId = String(options?.allowedElectronicAreaId || ''); |
| | | const shouldQueryNearbySites = isInside && (!allowedElectronicAreaId || allowedElectronicAreaId === String(electronicAreaId || '')); |
| | | if (shouldQueryNearbySites) { |
| | | sites = await getNearestSite({ latitude: lat, longitude: lng, electronicAreaId, pointOnOff, type:1 }).catch(e => { |
| | | return { points: [] }; |
| | | }); |