Merge branch 'feature_v1.0' into feature_v1.0_fix_zjk
| | |
| | | .DS_Store |
| | | .history |
| | | key/ |
| | | .swc/ |
| | | # project.private.config.json |
| | |
| | | appid: 'wx57bb0455a168278b', |
| | | privateKeyPath: 'key/private.wx57bb0455a168278b.key', |
| | | robot: Number(process.env.CI_ROBOT) || 1, |
| | | // 与本地 project.config.json 的 setting 保持一致,确保 CI 上传与开发者工具压缩档位相同 |
| | | setting: { |
| | | es6: false, // 对应 project.config.json es6: false(es6 转 es5) |
| | | es7: true, // 对应 enhance: true(增强编译) |
| | | minify: true, // 对应 minified: true(压缩代码) |
| | | minifyJS: true, // 压缩 JS(由 minified 覆盖,显式开启保持一致) |
| | | minifyWXML: true, // 对应 minifyWXML: true |
| | | minifyWXSS: true, // 对应 minifyWXSS: true |
| | | autoPrefixWXSS: true, // 对应 postcss: true(样式自动补全) |
| | | codeProtect: false, // 对应日志 codeprotect=0(代码保护关闭) |
| | | }, |
| | | } |
| | | |
| | | const CIPluginOpt = { |
| | |
| | | module.exports = function (merge) { |
| | | if (isCI) { |
| | | if (API_ENV === "development") { |
| | | return merge({}, config, require("./dev")); |
| | | // CI 上传/预览(体验版)即使指向预发环境,也必须使用 production 版 React 打包。 |
| | | // dev.js 里 env.NODE_ENV 固定为 "development",会让 webpack 打入 development 版 React, |
| | | // 而 development React 依赖仅存在于开发构建的内部变量(如 app.mount、 |
| | | // ReactCurrentActQueue.isBatchingLegacy),上传为体验版/预览版在真机或微信后台运行时会报 |
| | | // "null/undefined is not an object" 而崩溃(本地开发者工具用 production 构建所以正常)。 |
| | | // 这里保留 dev.js 的预发 API 域名等 defineConstants,仅把 NODE_ENV 覆盖回 production。 |
| | | return merge({}, config, require("./dev"), { env: { NODE_ENV: '"production"' } }); |
| | | |
| | | } |
| | | } |
| | | |
| | |
| | | "dependencies": { |
| | | "@babel/runtime": "^7.7.7", |
| | | "@rematch/core": "^1.1.304", |
| | | "@tarojs/components": "3.4.9", |
| | | "@tarojs/plugin-framework-react": "3.4.9", |
| | | "@tarojs/components": "3.6.40", |
| | | "@tarojs/plugin-framework-react": "3.6.40", |
| | | "@tarojs/plugin-platform-kwai": "^2.0.0", |
| | | "@tarojs/plugin-platform-xhs": "^1.1.0", |
| | | "@tarojs/react": "3.4.9", |
| | | "@tarojs/runtime": "3.4.9", |
| | | "@tarojs/taro": "3.4.9", |
| | | "@tarojs/react": "3.6.40", |
| | | "@tarojs/runtime": "3.6.40", |
| | | "@tarojs/taro": "3.6.40", |
| | | "ahooks": "^3.7.6", |
| | | "arrify": "^1.0.1", |
| | | "babel-plugin-setname": "^0.1.1", |
| | |
| | | }, |
| | | "devDependencies": { |
| | | "@babel/core": "^7.8.0", |
| | | "@tarojs/cli": "3.4.9", |
| | | "@tarojs/mini-runner": "3.4.9", |
| | | "@tarojs/cli": "3.6.40", |
| | | "@tarojs/mini-runner": "3.6.40", |
| | | "@tarojs/plugin-mini-ci": "^4.1.2", |
| | | "@tarojs/webpack-runner": "3.4.9", |
| | | "@tarojs/webpack-runner": "3.6.40", |
| | | "@types/react": "^17.0.2", |
| | | "@types/webpack-env": "^1.13.6", |
| | | "@typescript-eslint/eslint-plugin": "^4.15.1", |
| | | "@typescript-eslint/parser": "^4.15.1", |
| | | "babel-preset-taro": "3.4.9", |
| | | "babel-preset-taro": "3.6.40", |
| | | "eslint": "^6.8.0", |
| | | "eslint-config-taro": "3.4.9", |
| | | "eslint-config-taro": "3.6.40", |
| | | "eslint-plugin-import": "^2.12.0", |
| | | "eslint-plugin-react": "^7.8.2", |
| | | "eslint-plugin-react-hooks": "^4.2.0", |
| | |
| | | import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; |
| | | import { useMemo, useState, useRef, useCallback } from 'react'; |
| | | import { GEOFENCE_STATION_FILL_COLOR, GEOFENCE_STATION_STROKE_COLOR } from '../constants/geofence'; |
| | | |
| | | type Deps = { |
| | |
| | | * 场站站点地图围栏展示聚焦控制 Hook。 |
| | | * |
| | | * 职责: |
| | | * 1. 当选中“场站内”的站点,并且发生地图放大操作使得缩放达到 17~18 级(即 >= 16.9)时,去掉运营区围栏蒙层,仅展示场站围栏。 |
| | | * 2. 移出场站时、选择其他站点时,或缩放比例小于 17 时,恢复展示所有围栏。 |
| | | * 1. 当地图放大操作使得缩放达到 17~18 级(即 >= 16.9)时,去掉运营区围栏蒙层,仅展示场站围栏。 |
| | | * 2. 缩放比例小于 17 时,恢复展示所有围栏。 |
| | | */ |
| | | export default function useStationScaleFocus({ currentSite, originalPolygons, originalFencePolylines }: Deps) { |
| | | const isStationSite = useMemo(() => { |
| | |
| | | const [shouldFocusFence, setShouldFocusFence] = useState(false); |
| | | const lastScaleRef = useRef<number | undefined>(undefined); |
| | | |
| | | useEffect(() => { |
| | | if (!isStationSite) { |
| | | setShouldFocusFence(false); |
| | | } else { |
| | | // 刚选中场站时,重置一次参照物,避免拿上一次旧的值去对比 |
| | | lastScaleRef.current = undefined; |
| | | } |
| | | }, [isStationSite]); |
| | | |
| | | const handleScaleChange = useCallback((scale: number) => { |
| | | if (!isStationSite) { |
| | | lastScaleRef.current = scale; |
| | | return; |
| | | } |
| | | |
| | | if (lastScaleRef.current !== undefined) { |
| | | if (Math.abs(scale - lastScaleRef.current) > 0.001) { |
| | | // 如果是有效放大操作(规避细微浮点误差),并且达到了 17 级及以上 |
| | |
| | | return; |
| | | } |
| | | lastScaleRef.current = scale; |
| | | }, [isStationSite]); |
| | | }, []); |
| | | |
| | | // 根据当前是否选中场站站点且满足聚焦条件时,过滤围栏数据 |
| | | // 根据缩放级别过滤围栏数据 |
| | | const displayPolygons = useMemo(() => { |
| | | if (!shouldFocusFence) { |
| | | return originalPolygons; |
| | | } |
| | | // 选中场站且满足条件时,仅保留场站的围栏 |
| | | // 放大到 17+ 级时,仅保留场站的围栏 |
| | | return originalPolygons.filter(polygon => polygon.fillColor === GEOFENCE_STATION_FILL_COLOR); |
| | | }, [shouldFocusFence, originalPolygons]); |
| | | |
| | |
| | | if (!shouldFocusFence) { |
| | | return originalFencePolylines; |
| | | } |
| | | // 选中场站且满足条件时,仅保留场站的虚线边框 |
| | | // 放大到 17+ 级时,仅保留场站的虚线边框 |
| | | return originalFencePolylines.filter(polyline => polyline.color === GEOFENCE_STATION_STROKE_COLOR); |
| | | }, [shouldFocusFence, originalFencePolylines]); |
| | | |
| | |
| | | onCalloutTap={handleCalloutTap} |
| | | onRegionChange={handleRegionChange} |
| | | onPolylineTap={onPolylineTap} |
| | | onTap={onMapTap} |
| | | // onTap={onMapTap} |
| | | style={{ width: '100%', height: '87vh' }} |
| | | > |
| | | {renderMapOverlays()} |
| | |
| | | |
| | | const LOCK_CLOSE_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/lock-close.png'; |
| | | const LOCK_OPEN_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/lock-open.png'; |
| | | const TRUNK_CLOSE_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-close.png'; |
| | | const TRUNK_OPEN_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-open.png'; |
| | | const SWITCH_LOADING_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-loading-v2.png'; |
| | | |
| | | type Props = { |
| | | tripStatus: TripStatus; |
| | |
| | | }: Props) { |
| | | const [trunkStatus, setTrunkStatus] = useState(orderTrunkStatus); // 后备箱开关状态 |
| | | const [lockStatus, setLockStatus] = useState(false); // 车门开关状态 |
| | | const [loadingControlType, setLoadingControlType] = useState<'door' | 'trunk' | null>(null); // 当前处于指令执行中的控件 |
| | | const trunkPollTimerRef = useRef<any>(null); // 后备箱状态轮询定时器 |
| | | const overRoutePopupDebounceTimerRef = useRef<any>(null); // 结束行程弹窗防抖定时器 |
| | | const OVER_ROUTE_POPUP_DEBOUNCE_MS = 1000; // 结束行程弹窗点击防抖时长 |
| | |
| | | const handleUnlock = async (doorType: 'door' | 'trunk', onSuccess?: () => void) => { |
| | | const targetOrderId = getOrderId(orderId); |
| | | if (!targetOrderId) return; |
| | | if (loadingControlType) { |
| | | return; |
| | | } |
| | | if (doorType === 'door' && lockStatus) { |
| | | Taro.showToast({ title: '车门已解锁,请开门上车', icon: 'none' }); |
| | | return; |
| | |
| | | Taro.showToast({ title: '获取定位失败,请打开定位开关', icon: 'none' }); |
| | | return; |
| | | } |
| | | setLoadingControlType(doorType); |
| | | const isCloseAction = doorType === 'trunk' ? !!trunkStatus : false; |
| | | const actionLoadingText = doorType === 'trunk' |
| | | ? (isCloseAction ? '正在关闭后备箱' : '正在开启后备箱') |
| | |
| | | |
| | | if (res?.code !== 200 || !res?.data) { |
| | | // Taro.hideLoading(); |
| | | setLoadingControlType(null); |
| | | Taro.showToast({ title: res?.msg || '解锁失败,请重试或联系客服', icon: 'none' }); |
| | | return; |
| | | } |
| | |
| | | trunkPollTimerRef.current = setTimeout(doPoll, pollInterval); |
| | | } else { |
| | | stopPolling(); |
| | | setLoadingControlType(null); |
| | | // Taro.hideLoading(); |
| | | Taro.showToast({ |
| | | title: failToastText, |
| | |
| | | stopPolling(); |
| | | const isOpen = !isCloseAction; |
| | | setTrunkStatus(isOpen); |
| | | setLoadingControlType(null); |
| | | // Taro.hideLoading(); |
| | | Taro.showToast({ |
| | | title: successToastText, |
| | |
| | | |
| | | if (doorType === 'door') { |
| | | // Taro.hideLoading(); |
| | | setLoadingControlType(null); |
| | | Taro.showToast({ title: '右侧车门已解锁', icon: 'none' }); |
| | | setLockStatus(true); |
| | | onDoorUnlockSuccess?.(); |
| | |
| | | } |
| | | }).catch(() => { |
| | | // Taro.hideLoading(); |
| | | setLoadingControlType(null); |
| | | Taro.showToast({ title: '解锁失败,请重试或联系客服', icon: 'none' }); |
| | | }); |
| | | }; |
| | | |
| | | /** 渲染带 loading 效果的控制按钮图标。 */ |
| | | const renderControlIcon = (controlType: 'door' | 'trunk', defaultIconSrc: string, className: string) => { |
| | | const isLoading = loadingControlType === controlType; |
| | | return ( |
| | | <View style={{ position: 'relative' }}> |
| | | <Image className={className} style={isLoading ? { visibility: 'hidden' } : undefined} src={defaultIconSrc} /> |
| | | {isLoading && ( |
| | | <Image |
| | | className={`${className} car-card-temp-switch-img-spinning`} |
| | | style={{ position: 'absolute', top: 0, left: 0 }} |
| | | src={SWITCH_LOADING_ICON_URL} |
| | | /> |
| | | )} |
| | | </View> |
| | | ); |
| | | }; |
| | | |
| | | if (tripStatus === TripStatus.VehicleArrivedPickup) { |
| | | return ( |
| | | <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 ${trunkStatus && 'close-btn-item'} ${loadingControlType ? 'trip-action-bar-item-disabled' : ''}`.trim()} onClick={() => handleUnlock('trunk', () => setTrunkStatus(!trunkStatus))}> |
| | | {renderControlIcon('trunk', trunkStatus ? TRUNK_OPEN_ICON_URL : TRUNK_CLOSE_ICON_URL, 'car-card-arrive-item-img')} |
| | | <View className="car-card-arrive-item-text" style={loadingControlType === 'trunk' ? { visibility: 'hidden' } : undefined}>{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-arrive-item-text">车锁</View> |
| | | <View className={`car-card-arrive-item ${lockStatus && 'close-btn-item'} ${loadingControlType ? 'trip-action-bar-item-disabled' : ''}`.trim()} onClick={() => handleUnlock('door')}> |
| | | {renderControlIcon('door', lockStatus ? LOCK_OPEN_ICON_URL : LOCK_CLOSE_ICON_URL, 'car-card-arrive-item-img')} |
| | | <View className="car-card-arrive-item-text" style={loadingControlType === 'door' ? { visibility: 'hidden' } : undefined}>车锁</View> |
| | | </View> |
| | | </View> |
| | | ); |
| | |
| | | <View className="car-card-reach-text">点击按钮开车门</View> |
| | | <Image className='car-card-reach-img' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/door-anim.webp' /> |
| | | <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 ${trunkStatus && 'close-btn-item'} ${loadingControlType ? 'trip-action-bar-item-disabled' : ''}`.trim()} onClick={() => handleUnlock('trunk', () => setTrunkStatus(!trunkStatus))}> |
| | | {renderControlIcon('trunk', trunkStatus ? TRUNK_OPEN_ICON_URL : TRUNK_CLOSE_ICON_URL, 'car-card-reach-btn-item-img')} |
| | | <View className="car-card-reach-btn-item-text" style={loadingControlType === 'trunk' ? { visibility: 'hidden' } : undefined}>{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" /> |
| | |
| | | onOpenCouponSelect |
| | | }: Props) { |
| | | const [showDetail, setShowDetail] = useState(false); // 费用明细展开状态 |
| | | const canOpenCouponSelect = tripStatus === TripStatus.TripFinished && typeof onOpenCouponSelect === 'function'; // 仅待支付支持切换优惠券 |
| | | |
| | | // 外部重置版本变化时收起明细 |
| | | useEffect(() => { |
| | |
| | | : |
| | | <View className="car-card-end-particulars-money" onClick={handleToggleDetail}> |
| | | <View className="car-card-end-particulars-money-amount">{formatMoney( |
| | | (fareBreakdown?.orderMoney - fareBreakdown?.redPacketMoney - fareBreakdown?.couponMoney) <= 0 |
| | | ? 0 : (fareBreakdown?.orderMoney - fareBreakdown?.redPacketMoney - fareBreakdown?.couponMoney))}</View> |
| | | fareBreakdown?.payMoney <= 0 |
| | | ? 0 : fareBreakdown?.payMoney)}</View> |
| | | <View>元</View> |
| | | { |
| | | fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney > 0 && |
| | |
| | | (fareBreakdown?.operateFree != 1 && [TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus)) && Number(fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney) > 0 && |
| | | <View className="car-card-end-through"> |
| | | <View className="car-card-end-through-label">优惠活动</View> |
| | | <View className="car-card-end-through-content" onClick={onOpenCouponSelect}> |
| | | <View className="car-card-end-through-content" onClick={canOpenCouponSelect ? onOpenCouponSelect : undefined}> |
| | | <View className="car-card-end-through-content-title">最佳优惠</View> |
| | | <View className="car-card-end-through-content-money">-{formatMoney(fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney)}元</View> |
| | | <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/ccc-icon-right.png" /> |
| | | {canOpenCouponSelect && ( |
| | | <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/ccc-icon-right.png" /> |
| | | )} |
| | | </View> |
| | | </View> |
| | | } |
| | |
| | | { |
| | | fareBreakdown?.operateFree != 1 ? |
| | | <View className="pay-box-money"><View>{formatMoney( |
| | | (fareBreakdown?.orderMoney - fareBreakdown?.redPacketMoney - fareBreakdown?.couponMoney) <= 0 |
| | | ? 0 : (fareBreakdown?.orderMoney - fareBreakdown?.redPacketMoney - fareBreakdown?.couponMoney))}</View><View className="unit">元</View></View> |
| | | fareBreakdown?.payMoney <= 0 |
| | | ? 0 : fareBreakdown?.payMoney)}</View><View className="unit">元</View></View> |
| | | : |
| | | <View className="pay-box-free">载客测试免费</View> |
| | | } |
| | | }{/* */} |
| | | <View onClick={handlePay} className="pay-box-btn">立即支付</View> |
| | | </View> |
| | | } |
| New file |
| | |
| | | /** |
| | | * 速度自动缩放调试面板(已下线,文件保留供后续临时调参/回归测试时复用)。 |
| | | * |
| | | * ============================================================================ |
| | | * 何时再用? |
| | | * - 调整 LOW_SPEED_MAX_KMH / MID_SPEED_MAX_KMH 阈值后想快速验证分档 |
| | | * - 调整 LOW_SPEED_SCALE / MID_SPEED_SCALE / HIGH_SPEED_SCALE 缩放等级后看效果 |
| | | * - 排查"速度变了但地图没缩放""车辆离开视区"等问题,临时模拟车速/订单状态/车辆位置 |
| | | * |
| | | * 启用步骤: |
| | | * 1. 把 `speed-scale-config.ts` 的 `SPEED_SCALE_DEBUG` 改为 `true` |
| | | * (会自动切换到 debug 阈值:低速 <10、中速 10~20、高速 >20 km/h) |
| | | * 2. 在 `index.tsx` 的 `BeginRoute()` 返回 JSX 末尾(与 ModifyDestinationFailPopup 同级) |
| | | * 引入并挂载本组件,传入页面已有的状态: |
| | | * |
| | | * // 顶部 import: |
| | | * import DebugAutoscalePanel from './components/debug-autoscale-panel'; |
| | | * |
| | | * // 顶层 fragment 内: |
| | | * <DebugAutoscalePanel |
| | | * tripStatus={tripStatus} |
| | | * mapScale={mapScale} |
| | | * applyVehicleSpeedSample={applyVehicleSpeedSample} |
| | | * setTripStatus={setTripStatus} |
| | | * vehicleLocation={vehicleLocation} |
| | | * mapLatitude={mapLatitude} |
| | | * mapLongitude={mapLongitude} |
| | | * mockVehicleLocation={(lat, lng) => |
| | | * vehicleState.updateVehicleLocationThrottled(lat, lng, true) |
| | | * } |
| | | * /> |
| | | * |
| | | * 3. `applyVehicleSpeedSample` 由 `useSpeedBasedScale` 解构得到;其余 props |
| | | * (vehicleLocation/mapScale/...) 都是页面已有变量,按上方写法传即可。 |
| | | * 4. 测完把 SPEED_SCALE_DEBUG 改回 false 并删除上面 1 个 import + 1 段 JSX。 |
| | | * 面板文件本身保留,无需删除。 |
| | | * |
| | | * 面板能力: |
| | | * - 默认收起,左侧悬浮"调速"小按钮,点开展开完整面板 |
| | | * - Mock 行程状态(接驾中 / 行程中 / 还原) |
| | | * - Mock 车辆位置("把车放到地图中心" / "微移车辆") |
| | | * - 速度滑块 + 三档预设按钮(低速 / 中速 / 高速),喂 1 条或喂满窗口 |
| | | * - 实时显示当前 tripStatus、mapScale、vehicleLocation、当前挡位/目标 scale |
| | | * |
| | | * 配套 console 工具(同样受 SPEED_SCALE_DEBUG 控制): |
| | | * __mockAutoscaleSpeed(kmh) // 一次性喂 SPEED_SAMPLE_WINDOW 条样本,立即生效 |
| | | * __mockAutoscaleSpeed(kmh, 1) // 只喂 1 条,观察样本积累过程 |
| | | * |
| | | * 注意: |
| | | * - 仅在 SPEED_SCALE_DEBUG=true 时渲染,开关为 false 时本组件返回 null |
| | | * - 面板覆盖层基于普通 <View>(与页面 .map-entry/.origin-box 同方案), |
| | | * 无需 cover-view;如果被原生地图盖住,再考虑切到 cover-view |
| | | * ============================================================================ |
| | | */ |
| | | import { useMemo, useRef, useState } from 'react'; |
| | | import { View, Text, Slider } from '@tarojs/components'; |
| | | import { TripStatus } from '../../../constants/trip-status'; |
| | | import { |
| | | HIGH_SPEED_SCALE, |
| | | LOW_SPEED_MAX_KMH, |
| | | LOW_SPEED_SCALE, |
| | | MID_SPEED_MAX_KMH, |
| | | MID_SPEED_SCALE, |
| | | resolveSpeedTierByKmh, |
| | | SPEED_SAMPLE_WINDOW, |
| | | SPEED_SCALE_DEBUG |
| | | } from '../hooks/speed-scale-config'; |
| | | |
| | | type Props = { |
| | | /** 当前行程状态。 */ |
| | | tripStatus: TripStatus; |
| | | /** 当前地图缩放等级(用于展示实际生效值)。 */ |
| | | mapScale: number; |
| | | /** 喂入一条速度样本(km/h)。 */ |
| | | applyVehicleSpeedSample: (speedKmh: number) => void; |
| | | /** 设置行程状态(用于本地 mock 接驾中/行程中等态,方便调试)。 */ |
| | | setTripStatus: (status: TripStatus) => void; |
| | | /** 当前车辆位置(用于面板展示)。 */ |
| | | vehicleLocation: { latitude: number; longitude: number } | null; |
| | | /** 当前地图受控中心纬度(用于"放到地图中心"按钮)。 */ |
| | | mapLatitude: number | null; |
| | | /** 当前地图受控中心经度(用于"放到地图中心"按钮)。 */ |
| | | mapLongitude: number | null; |
| | | /** Mock 车辆位置(直接更新 vehicleLocation 状态)。 */ |
| | | mockVehicleLocation: (latitude: number, longitude: number) => void; |
| | | }; |
| | | |
| | | /** 启用速度缩放的行程状态。 */ |
| | | const ACTIVE_STATUSES = [TripStatus.VehicleApproaching, TripStatus.InService]; |
| | | /** 滑块速度上限(km/h),按中速上限的 2 倍取整,保证能覆盖高速挡。 */ |
| | | const SLIDER_MAX_KMH = Math.ceil(MID_SPEED_MAX_KMH * 2); |
| | | |
| | | /** 挡位标签配色。 */ |
| | | const TIER_COLOR: Record<string, string> = { |
| | | low: '#36b37e', |
| | | mid: '#ffab00', |
| | | high: '#ff5630' |
| | | }; |
| | | |
| | | /** 行程状态显示名(仅展示用,不影响业务)。 */ |
| | | const TRIP_STATUS_LABEL: Record<number, string> = { |
| | | [TripStatus.PublishRoute]: '发布行程', |
| | | [TripStatus.Publishing]: '发布中', |
| | | [TripStatus.CallingCar]: '呼叫车中', |
| | | [TripStatus.VehicleApproaching]: '接驾中', |
| | | [TripStatus.VehicleArrivedPickup]: '到达上车点', |
| | | [TripStatus.InService]: '行程中', |
| | | [TripStatus.ArrivedDestination]: '到达目的地', |
| | | [TripStatus.TripFinished]: '行程结束', |
| | | [TripStatus.PaymentSuccess]: '支付成功', |
| | | [TripStatus.CancelOnlyPickup]: '已取消' |
| | | }; |
| | | |
| | | /** |
| | | * 速度自动缩放调试面板:默认收起,展开后可拖动滑块或点击预设模拟车速,验证地图自动缩放。 |
| | | * 仅在 SPEED_SCALE_DEBUG=true 时渲染,关闭开关后自动消失。 |
| | | */ |
| | | export default function DebugAutoscalePanel(props: Props) { |
| | | const [expanded, setExpanded] = useState(false); |
| | | const [speedKmh, setSpeedKmh] = useState(Math.round((LOW_SPEED_MAX_KMH + MID_SPEED_MAX_KMH) / 2)); |
| | | const originalTripStatusRef = useRef<TripStatus | null>(null); // 首次 mock 前的原状态,用于「还原」 |
| | | |
| | | const isActive = ACTIVE_STATUSES.includes(props.tripStatus); |
| | | const tier = useMemo(() => resolveSpeedTierByKmh(speedKmh), [speedKmh]); |
| | | |
| | | if (!SPEED_SCALE_DEBUG) { |
| | | return null; |
| | | } |
| | | |
| | | /** 喂入 N 条当前速度样本(km/h)。 */ |
| | | const feedSamples = (kmh: number, repeat: number) => { |
| | | for (let index = 0; index < repeat; index += 1) { |
| | | props.applyVehicleSpeedSample(kmh); |
| | | } |
| | | }; |
| | | |
| | | /** 点击预设:设置速度并一次性喂满窗口立即生效。 */ |
| | | const applyPreset = (kmh: number) => { |
| | | setSpeedKmh(kmh); |
| | | feedSamples(kmh, SPEED_SAMPLE_WINDOW); |
| | | }; |
| | | |
| | | /** Mock 行程状态:首次 mock 前先记录原状态,以便「还原」恢复。 */ |
| | | const mockTripStatus = (next: TripStatus) => { |
| | | if (originalTripStatusRef.current === null) { |
| | | originalTripStatusRef.current = props.tripStatus; |
| | | } |
| | | console.log('[autoscale] 🧪 mock 行程状态', { from: props.tripStatus, to: next }); |
| | | props.setTripStatus(next); |
| | | }; |
| | | |
| | | /** 还原到首次 mock 前的状态。 */ |
| | | const restoreTripStatus = () => { |
| | | if (originalTripStatusRef.current === null) { |
| | | return; |
| | | } |
| | | const target = originalTripStatusRef.current; |
| | | originalTripStatusRef.current = null; |
| | | console.log('[autoscale] 🧪 还原行程状态', { to: target }); |
| | | props.setTripStatus(target); |
| | | }; |
| | | |
| | | const lowPreset = Math.max(0, LOW_SPEED_MAX_KMH - 2); |
| | | const midPreset = Math.round((LOW_SPEED_MAX_KMH + MID_SPEED_MAX_KMH) / 2); |
| | | const highPreset = MID_SPEED_MAX_KMH + Math.round(MID_SPEED_MAX_KMH * 0.5); |
| | | |
| | | // 收起态:仅显示一个浮动小按钮。 |
| | | if (!expanded) { |
| | | return ( |
| | | <View |
| | | onClick={() => setExpanded(true)} |
| | | style={{ |
| | | position: 'fixed', |
| | | left: '0', |
| | | top: '45%', |
| | | zIndex: 9999, |
| | | padding: '8px 10px', |
| | | background: 'rgba(0,0,0,0.7)', |
| | | color: '#fff', |
| | | fontSize: '12px', |
| | | borderTopRightRadius: '16px', |
| | | borderBottomRightRadius: '16px' |
| | | }} |
| | | > |
| | | 调速 |
| | | </View> |
| | | ); |
| | | } |
| | | |
| | | const buttonStyle = (background: string) => ({ |
| | | flex: 1, |
| | | textAlign: 'center' as const, |
| | | padding: '8px 0', |
| | | margin: '0 4px', |
| | | background, |
| | | color: '#fff', |
| | | fontSize: '13px', |
| | | borderRadius: '8px' |
| | | }); |
| | | |
| | | // 展开态:完整调试面板。 |
| | | return ( |
| | | <View |
| | | catchMove |
| | | style={{ |
| | | position: 'fixed', |
| | | left: '12px', |
| | | right: '12px', |
| | | bottom: '24px', |
| | | zIndex: 9999, |
| | | padding: '12px 14px', |
| | | background: 'rgba(20,20,20,0.92)', |
| | | color: '#fff', |
| | | borderRadius: '12px', |
| | | boxShadow: '0 4px 16px rgba(0,0,0,0.4)' |
| | | }} |
| | | > |
| | | <View style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}> |
| | | <Text style={{ fontSize: '14px', fontWeight: 'bold' }}>速度缩放调试</Text> |
| | | <View onClick={() => setExpanded(false)} style={{ padding: '2px 10px', fontSize: '13px', background: 'rgba(255,255,255,0.15)', borderRadius: '8px' }}> |
| | | 收起 |
| | | </View> |
| | | </View> |
| | | |
| | | <View style={{ fontSize: '12px', marginBottom: '6px', opacity: 0.85 }}> |
| | | <Text>{`当前状态:${TRIP_STATUS_LABEL[props.tripStatus] || props.tripStatus} (${props.tripStatus})`}</Text> |
| | | {originalTripStatusRef.current !== null && ( |
| | | <Text style={{ marginLeft: '8px', color: '#ffab00' }}>· 已 mock</Text> |
| | | )} |
| | | </View> |
| | | <View style={{ display: 'flex', marginBottom: '10px' }}> |
| | | <View |
| | | onClick={() => mockTripStatus(TripStatus.VehicleApproaching)} |
| | | style={buttonStyle(props.tripStatus === TripStatus.VehicleApproaching ? '#4c9aff' : 'rgba(255,255,255,0.2)')} |
| | | > |
| | | 接驾中 |
| | | </View> |
| | | <View |
| | | onClick={() => mockTripStatus(TripStatus.InService)} |
| | | style={buttonStyle(props.tripStatus === TripStatus.InService ? '#4c9aff' : 'rgba(255,255,255,0.2)')} |
| | | > |
| | | 行程中 |
| | | </View> |
| | | <View |
| | | onClick={restoreTripStatus} |
| | | style={buttonStyle(originalTripStatusRef.current === null ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.25)')} |
| | | > |
| | | 还原 |
| | | </View> |
| | | </View> |
| | | |
| | | <View style={{ fontSize: '12px', marginBottom: '6px', opacity: 0.85 }}> |
| | | <Text> |
| | | {props.vehicleLocation |
| | | ? `车辆位置:${props.vehicleLocation.latitude.toFixed(5)}, ${props.vehicleLocation.longitude.toFixed(5)}` |
| | | : '车辆位置:null(未设置,scale 切换时无法居中)'} |
| | | </Text> |
| | | </View> |
| | | <View style={{ display: 'flex', marginBottom: '10px' }}> |
| | | <View |
| | | onClick={() => { |
| | | const lat = Number(props.mapLatitude); |
| | | const lng = Number(props.mapLongitude); |
| | | if (!Number.isFinite(lat) || !Number.isFinite(lng)) { |
| | | console.log('[autoscale] 🧪 mock 车辆位置失败:地图中心无效', { lat, lng }); |
| | | return; |
| | | } |
| | | console.log('[autoscale] 🧪 mock 车辆位置 → 地图中心', { lat, lng }); |
| | | props.mockVehicleLocation(lat, lng); |
| | | }} |
| | | style={buttonStyle('rgba(76,154,255,0.8)')} |
| | | > |
| | | 把车放到地图中心 |
| | | </View> |
| | | <View |
| | | onClick={() => { |
| | | if (!props.vehicleLocation) { |
| | | return; |
| | | } |
| | | // 在当前位置随机偏移约 30 米,验证 scale 切换后仍能跟随车辆居中 |
| | | const lat = props.vehicleLocation.latitude + (Math.random() - 0.5) * 0.0006; |
| | | const lng = props.vehicleLocation.longitude + (Math.random() - 0.5) * 0.0006; |
| | | console.log('[autoscale] 🧪 mock 车辆位置 → 微移', { lat, lng }); |
| | | props.mockVehicleLocation(lat, lng); |
| | | }} |
| | | style={buttonStyle(props.vehicleLocation ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)')} |
| | | > |
| | | 微移车辆 |
| | | </View> |
| | | </View> |
| | | |
| | | <View style={{ fontSize: '12px', lineHeight: '1.7', marginBottom: '6px', opacity: 0.85 }}> |
| | | <Text>{`阈值:低速<${LOW_SPEED_MAX_KMH} / 中速${LOW_SPEED_MAX_KMH}~${MID_SPEED_MAX_KMH} / 高速>${MID_SPEED_MAX_KMH} km/h`}</Text> |
| | | <Text style={{ display: 'block' }}>{`scale:低速 ${LOW_SPEED_SCALE} / 中速 ${MID_SPEED_SCALE} / 高速 ${HIGH_SPEED_SCALE}`}</Text> |
| | | </View> |
| | | |
| | | <View style={{ display: 'flex', justifyContent: 'space-between', fontSize: '13px', marginBottom: '4px' }}> |
| | | <Text>{`当前选择:${speedKmh} km/h`}</Text> |
| | | <Text style={{ color: TIER_COLOR[tier.key] }}>{`${tier.label} → scale ${tier.scale}`}</Text> |
| | | </View> |
| | | |
| | | <Slider |
| | | min={0} |
| | | max={SLIDER_MAX_KMH} |
| | | step={1} |
| | | value={speedKmh} |
| | | activeColor='#4c9aff' |
| | | blockSize={20} |
| | | onChanging={(event) => setSpeedKmh(Number(event.detail.value))} |
| | | onChange={(event) => setSpeedKmh(Number(event.detail.value))} |
| | | /> |
| | | |
| | | <View style={{ display: 'flex', marginTop: '8px' }}> |
| | | <View onClick={() => applyPreset(lowPreset)} style={buttonStyle(TIER_COLOR.low)}>{`低速 ${lowPreset}`}</View> |
| | | <View onClick={() => applyPreset(midPreset)} style={buttonStyle(TIER_COLOR.mid)}>{`中速 ${midPreset}`}</View> |
| | | <View onClick={() => applyPreset(highPreset)} style={buttonStyle(TIER_COLOR.high)}>{`高速 ${highPreset}`}</View> |
| | | </View> |
| | | |
| | | <View style={{ display: 'flex', marginTop: '8px' }}> |
| | | <View onClick={() => feedSamples(speedKmh, 1)} style={buttonStyle('rgba(255,255,255,0.2)')}>喂 1 条</View> |
| | | <View onClick={() => feedSamples(speedKmh, SPEED_SAMPLE_WINDOW)} style={buttonStyle('#4c9aff')}>{`喂满 ${SPEED_SAMPLE_WINDOW} 条(立即生效)`}</View> |
| | | </View> |
| | | |
| | | <View style={{ fontSize: '12px', marginTop: '8px', lineHeight: '1.6' }}> |
| | | <Text style={{ opacity: 0.85 }}>{`实际地图 scale:${props.mapScale}`}</Text> |
| | | {!isActive && ( |
| | | <Text style={{ display: 'block', color: '#ff5630' }}> |
| | | 当前非接驾中/行程中,喂入不会生效,请先点上方按钮 mock 状态 |
| | | </Text> |
| | | )} |
| | | </View> |
| | | </View> |
| | | ); |
| | | } |
| | |
| | | <View className='evaluate-problem-pic-box'> |
| | | {pictures.map((p, idx) => ( |
| | | <View key={idx} className='evaluate-problem-pic-box-item'> |
| | | <Image className='evaluate-problem-pic-box-img' src={p.server?.url || p.localUrl} /> |
| | | <Image className='evaluate-problem-pic-box-img' src={p.server?.url || p.localUrl} mode='aspectFit'/> |
| | | { |
| | | p.status === 'uploading' && ( |
| | | <View className='evaluate-problem-pic-box-mask'> |
| | |
| | | selectedRouteIndex = 0, |
| | | handleSelectRoute |
| | | }: Props) { |
| | | const DEFAULT_PREFERENCE: RoutePreferenceOption = { label: '智能推荐', value: 'default', code: 'default' }; // 路线偏好默认值,避免初始渲染空指针 |
| | | const DEFAULT_PREFERENCE: RoutePreferenceOption = { label: '', value: 'default', code: 'default' }; // 路线偏好默认值,避免初始渲染空指针 |
| | | const [showPreferencePopup, setShowPreferencePopup] = useState(false); |
| | | const [preference, setPreference] = useState<RoutePreferenceOption>(DEFAULT_PREFERENCE); |
| | | const [preferenceOptions, setPreferenceOptions] = useState<RoutePreferenceOption[]>([]); |
| | |
| | | <View className='route-select-type'> |
| | | <View className='route-select-title'>路线选择:</View> |
| | | <View className='route-select-preference' onClick={() => setShowPreferencePopup(true)}> |
| | | <View className='route-select-preference-text'>{preference?.label || '智能推荐'}</View> |
| | | <View className='route-select-preference-text'>{preference?.label || ''}</View> |
| | | <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/select-line.png" /> |
| | | </View> |
| | | </View> |
| New file |
| | |
| | | /** |
| | | * 速度自动缩放配置(纯常量与映射,无副作用),供 hook 与调试面板共享,避免阈值散落多处。 |
| | | * |
| | | * 速度分级与缩放等级(按 km/h,非 debug 模式): |
| | | * - 低速 <20:scale 19 |
| | | * - 中速 20~80:scale 17 |
| | | * - 高速 >80:scale 15 |
| | | * |
| | | * 注意:后端 ORDER_ROUTE 消息 route.speed 字段单位为 km/h(不是 m/s),全链路按 km/h 处理。 |
| | | */ |
| | | |
| | | /** |
| | | * 调试开关:实车难以跑到高速,开启后用更低的速度阈值方便测试,并启用页面调速面板。 |
| | | * 测试完成后改回 false 即可恢复线上正常阈值,并自动隐藏调试面板。 |
| | | * - false(线上):低速 <20km/h,中速 20~80km/h,高速 >80km/h |
| | | * - true (测试):低速 <5km/h,中速 5~20km/h,高速 >20km/h |
| | | */ |
| | | export const SPEED_SCALE_DEBUG = false; |
| | | |
| | | /** 速度滑动平均窗口,取最近 N 条消息求均值,避免临界值来回闪烁。 */ |
| | | export const SPEED_SAMPLE_WINDOW = 1; |
| | | |
| | | /** 低速挡上限(km/h)。 */ |
| | | export const LOW_SPEED_MAX_KMH = SPEED_SCALE_DEBUG ? 10 : 20; |
| | | /** 中速挡上限(km/h)。 */ |
| | | export const MID_SPEED_MAX_KMH = SPEED_SCALE_DEBUG ? 20 : 60; |
| | | |
| | | /** 低速挡缩放等级。 */ |
| | | export const LOW_SPEED_SCALE = SPEED_SCALE_DEBUG ? 20 : 20; |
| | | |
| | | /** 中速挡缩放等级。 */ |
| | | export const MID_SPEED_SCALE = SPEED_SCALE_DEBUG ? 16 : 18; |
| | | |
| | | /** 高速挡缩放等级。 */ |
| | | export const HIGH_SPEED_SCALE = SPEED_SCALE_DEBUG ? 13 : 16; |
| | | |
| | | |
| | | /** 速度挡位描述。 */ |
| | | export type SpeedTier = { |
| | | key: 'low' | 'mid' | 'high'; |
| | | label: string; |
| | | scale: number; |
| | | }; |
| | | |
| | | /** |
| | | * 根据速度(km/h)解析挡位与对应缩放等级。 |
| | | */ |
| | | export const resolveSpeedTierByKmh = (kmh: number): SpeedTier => { |
| | | const safeKmh = Math.max(0, Number(kmh) || 0); |
| | | if (safeKmh < LOW_SPEED_MAX_KMH) { |
| | | return { key: 'low', label: '低速', scale: LOW_SPEED_SCALE }; |
| | | } |
| | | if (safeKmh < MID_SPEED_MAX_KMH) { |
| | | return { key: 'mid', label: '中速', scale: MID_SPEED_SCALE }; |
| | | } |
| | | return { key: 'high', label: '高速', scale: HIGH_SPEED_SCALE }; |
| | | }; |
| | | |
| | | /** |
| | | * 根据速度(km/h)解析目标缩放等级。 |
| | | */ |
| | | export const resolveScaleByKmh = (kmh: number): number => resolveSpeedTierByKmh(kmh).scale; |
| | |
| | | latitude: number | null; |
| | | }; |
| | | |
| | | const CAR_CALLOUT_EDGE_GAP = 12; |
| | | const CAR_CALLOUT_SIDE_GAP = 18; |
| | | const CAR_CALLOUT_TOP_GAP = 20; |
| | | const CAR_CALLOUT_BOTTOM_GAP = 34; |
| | | const CAR_CALLOUT_DEFAULT_SIZE = { width: 166, height: 56 }; |
| | | const CAR_CALLOUT_WITH_BEFORE_SIZE = { width: 188, height: 84 }; |
| | | |
| | | type CarCalloutPlacement = 'top' | 'bottom' | 'left' | 'right'; |
| | | |
| | | /** |
| | | * 计算车辆附近路线切线方向,供气泡选边使用。 |
| | | */ |
| | | const resolveRouteVector = ( |
| | | routePoints: Array<{ latitude: number; longitude: number }> = [], |
| | | vehicleLocation: { latitude: number; longitude: number } | null |
| | | ) => { |
| | | if (!vehicleLocation || routePoints.length < 2) { |
| | | return null; |
| | | } |
| | | let closestIndex = 0; |
| | | let closestDistance = Infinity; |
| | | routePoints.forEach((point, index) => { |
| | | const lat = Number(point?.latitude); |
| | | const lng = Number(point?.longitude); |
| | | if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; |
| | | const distance = ((lat - vehicleLocation.latitude) ** 2) + ((lng - vehicleLocation.longitude) ** 2); |
| | | if (distance < closestDistance) { |
| | | closestDistance = distance; |
| | | closestIndex = index; |
| | | } |
| | | }); |
| | | const prevIndex = closestIndex <= 0 ? 0 : closestIndex - 1; |
| | | const nextIndex = closestIndex >= routePoints.length - 1 ? routePoints.length - 1 : closestIndex + 1; |
| | | if (prevIndex === nextIndex) { |
| | | return null; |
| | | } |
| | | const prevPoint = routePoints[prevIndex]; |
| | | const nextPoint = routePoints[nextIndex]; |
| | | const deltaX = Number(nextPoint?.longitude) - Number(prevPoint?.longitude); |
| | | const deltaY = Number(nextPoint?.latitude) - Number(prevPoint?.latitude); |
| | | if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) { |
| | | return null; |
| | | } |
| | | return { deltaX, deltaY }; |
| | | }; |
| | | |
| | | /** |
| | | * 基于路线走向与当前屏幕空间,选择车辆气泡最稳妥的停靠方向。 |
| | | */ |
| | | const resolveCarCalloutPlacement = ( |
| | | pos: { x: number; y: number }, |
| | | rect: any, |
| | | routeVector: { deltaX: number; deltaY: number } | null, |
| | | calloutSize: { width: number; height: number }, |
| | | verticalExtraOffset: number |
| | | ) => { |
| | | const routeIsMostlyHorizontal = !!routeVector && Math.abs(routeVector.deltaX) >= Math.abs(routeVector.deltaY); |
| | | const topPreferred = rect ? (pos.y - rect.top) >= (rect.top + rect.height - pos.y) : false; |
| | | const leftPreferred = rect ? (pos.x - rect.left) >= (rect.left + rect.width - pos.x) : false; |
| | | const preferredOrder: CarCalloutPlacement[] = routeIsMostlyHorizontal |
| | | ? (topPreferred ? ['top', 'bottom', leftPreferred ? 'left' : 'right', leftPreferred ? 'right' : 'left'] : ['bottom', 'top', leftPreferred ? 'left' : 'right', leftPreferred ? 'right' : 'left']) |
| | | : (leftPreferred ? ['left', 'right', topPreferred ? 'top' : 'bottom', topPreferred ? 'bottom' : 'top'] : ['right', 'left', topPreferred ? 'top' : 'bottom', topPreferred ? 'bottom' : 'top']); |
| | | |
| | | const computeCandidate = (placement: CarCalloutPlacement) => { |
| | | if (placement === 'top') { |
| | | return { |
| | | placement, |
| | | left: pos.x - (calloutSize.width / 2), |
| | | top: pos.y - calloutSize.height - CAR_CALLOUT_TOP_GAP, |
| | | }; |
| | | } |
| | | if (placement === 'bottom') { |
| | | return { |
| | | placement, |
| | | left: pos.x - (calloutSize.width / 2), |
| | | top: pos.y + CAR_CALLOUT_BOTTOM_GAP + verticalExtraOffset, |
| | | }; |
| | | } |
| | | if (placement === 'left') { |
| | | return { |
| | | placement, |
| | | left: pos.x - calloutSize.width - CAR_CALLOUT_SIDE_GAP, |
| | | top: pos.y - (calloutSize.height / 2), |
| | | }; |
| | | } |
| | | return { |
| | | placement, |
| | | left: pos.x + CAR_CALLOUT_SIDE_GAP, |
| | | top: pos.y - (calloutSize.height / 2), |
| | | }; |
| | | }; |
| | | |
| | | const scoreCandidate = (candidate: { placement: CarCalloutPlacement; left: number; top: number }) => { |
| | | const right = candidate.left + calloutSize.width; |
| | | const bottom = candidate.top + calloutSize.height; |
| | | const minLeft = rect.left + CAR_CALLOUT_EDGE_GAP; |
| | | const minTop = rect.top + CAR_CALLOUT_EDGE_GAP; |
| | | const maxRight = rect.left + rect.width - CAR_CALLOUT_EDGE_GAP; |
| | | const maxBottom = rect.top + rect.height - CAR_CALLOUT_EDGE_GAP; |
| | | const overflowPenalty = ( |
| | | Math.max(0, minLeft - candidate.left) |
| | | + Math.max(0, minTop - candidate.top) |
| | | + Math.max(0, right - maxRight) |
| | | + Math.max(0, bottom - maxBottom) |
| | | ) * 100; |
| | | const routePenalty = routeIsMostlyHorizontal |
| | | ? (candidate.placement === 'left' || candidate.placement === 'right' ? 24 : 0) |
| | | : (candidate.placement === 'top' || candidate.placement === 'bottom' ? 24 : 0); |
| | | const freeSpaceScore = Math.min( |
| | | candidate.left - minLeft, |
| | | candidate.top - minTop, |
| | | maxRight - right, |
| | | maxBottom - bottom |
| | | ); |
| | | const orderPenalty = preferredOrder.indexOf(candidate.placement) * 4; |
| | | return freeSpaceScore - overflowPenalty - routePenalty - orderPenalty; |
| | | }; |
| | | |
| | | const bestCandidate = preferredOrder |
| | | .map(computeCandidate) |
| | | .sort((first, second) => scoreCandidate(second) - scoreCandidate(first))[0]; |
| | | |
| | | const minLeft = rect.left + CAR_CALLOUT_EDGE_GAP; |
| | | const minTop = rect.top + CAR_CALLOUT_EDGE_GAP; |
| | | const maxLeft = rect.left + rect.width - calloutSize.width - CAR_CALLOUT_EDGE_GAP; |
| | | const maxTop = rect.top + rect.height - calloutSize.height - CAR_CALLOUT_EDGE_GAP; |
| | | |
| | | return { |
| | | left: Math.min(Math.max(bestCandidate.left, minLeft), Math.max(minLeft, maxLeft)), |
| | | top: Math.min(Math.max(bestCandidate.top, minTop), Math.max(minTop, maxTop)) |
| | | }; |
| | | }; |
| | | |
| | | export default function useAndroidOverlays(deps: Deps) { |
| | | // 等待雷达样式与显示状态 |
| | |
| | | const pos = await computeScreenPosition(deps.vehicleLocation, rect, 0); |
| | | if (!pos) return; |
| | | const verticalExtraOffset = deps.hasBeforeUserEndSite ? 18 : 0; |
| | | if (deps.tripStatus === TripStatus.InService) { |
| | | const routeVector = resolveRouteVector(deps.routePoints || [], deps.vehicleLocation); |
| | | const calloutSize = deps.hasBeforeUserEndSite ? CAR_CALLOUT_WITH_BEFORE_SIZE : CAR_CALLOUT_DEFAULT_SIZE; |
| | | const placement = resolveCarCalloutPlacement(pos, rect, routeVector, calloutSize, verticalExtraOffset); |
| | | setCarCalloutStyle(buildTransformOverlayStyle({ |
| | | x: placement.left, |
| | | y: placement.top |
| | | }, '')); |
| | | setCarCalloutVisible(true); |
| | | return; |
| | | } |
| | | setCarCalloutStyle(buildTransformOverlayStyle({ |
| | | x: pos.x, |
| | | y: pos.y + 34 + verticalExtraOffset |
| | | y: pos.y + CAR_CALLOUT_BOTTOM_GAP + verticalExtraOffset |
| | | }, 'translate(-50%, 6px)')); |
| | | setCarCalloutVisible(true); |
| | | }; |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| | | import { TripStatus } from '../../../constants/trip-status'; |
| | | import { waitForOrderSuccessSubscribePopup } from '../../../../utils/subscribe'; |
| | | |
| | | type Deps = { |
| | | tripStatus: TripStatus; |
| | |
| | | const BLUETOOTH_ACCESS_LOG_PREFIX = '[蓝牙权限]'; |
| | | const BLUETOOTH_AUTH_SYNC_DELAY_MS = 300; // 授权完成后首轮刷新延迟,给小程序权限态一点同步时间 |
| | | const BLUETOOTH_AUTH_PROTECTION_WINDOW_MS = 5000; // 授权成功后的保护窗口,避免瞬时 permission_deny 覆盖 UI |
| | | const SUBSCRIBE_POPUP_WAIT_TIMEOUT_MS = 8000; // 进入接驾态时等待订阅弹窗结束的最长兜底时间,超时后仍放行蓝牙初始化 |
| | | // 蓝牙图标资源:点亮(已授权)与熄灭(未开/未授权)两态 |
| | | const BLUETOOTH_ICON_ON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/bluetooth-icon.png'; |
| | | const BLUETOOTH_ICON_OFF = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/close-bluetooth.png'; |
| | |
| | | tripStatus |
| | | }); |
| | | setStatusReady(false); |
| | | refreshBluetoothState(); |
| | | // 方案 B:先等待“下单成功”订阅弹窗结束,再初始化蓝牙 |
| | | // (refreshBluetoothState 内部 openBluetoothAdapter 会触发系统蓝牙授权弹窗), |
| | | // 保证订阅弹窗与蓝牙授权弹窗按先后顺序出现,避免叠加; |
| | | // Promise.race 设兜底超时,订阅异常未 complete 时不阻塞蓝牙初始化。 |
| | | let cancelled = false; |
| | | const subscribeTimeout = new Promise<void>((resolve) => { |
| | | setTimeout(resolve, SUBSCRIBE_POPUP_WAIT_TIMEOUT_MS); |
| | | }); |
| | | Promise.race([waitForOrderSuccessSubscribePopup(), subscribeTimeout]).then(() => { |
| | | if (cancelled) return; |
| | | logBluetoothAccess('订阅弹窗已结束,开始初始化蓝牙'); |
| | | refreshBluetoothState(); |
| | | }); |
| | | startBluetoothStateWatchRef.current(); |
| | | return () => { |
| | | cancelled = true; |
| | | stopBluetoothStateWatchRef.current(); |
| | | }; |
| | | } |
| | |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | zIndex: 2, |
| | | name: '车辆位置', |
| | | // ...(!shouldUsePureTranslateCarMarker ? { |
| | | // rotate: typeof deps.vehicleDirection === 'number' ? deps.vehicleDirection : 0, |
| | | // } : {}), |
| | | ...(deps.isAndroid ? { |
| | | rotate: typeof deps.vehicleDirection === 'number' ? deps.vehicleDirection : 0, |
| | | } : {}), |
| | | customCallout: { anchorY, anchorX, display: 'ALWAYS' } |
| | | }; |
| | | }; |
| | |
| | | |
| | | /** |
| | | * 行程动态缩放生效时,将地图中心跟随到车辆位置,确保车辆留在当前视野内。 |
| | | * @param options.force 跳过节流/冷却/syncing-in-progress/死区四道闸,强制按安全区目标重定位。 |
| | | * 仅供速度驱动缩放后强制居中使用,其他调用点不传该参数即沿用原有行为。 |
| | | */ |
| | | const syncActiveTripViewportToVehicle = useCallback(() => { |
| | | const syncActiveTripViewportToVehicle = useCallback((options?: { force?: boolean }) => { |
| | | const force = options?.force === true; |
| | | if (![TripStatus.VehicleApproaching, TripStatus.InService].includes(deps.tripStatus)) { |
| | | return; |
| | | } |
| | |
| | | return; |
| | | } |
| | | const now = Date.now(); |
| | | if (activeTripViewportSyncingRef.current) { |
| | | return; |
| | | } |
| | | if (now - lastActiveTripViewportSyncAtRef.current < ACTIVE_TRIP_VIEWPORT_SYNC_INTERVAL_MS) { |
| | | return; |
| | | } |
| | | if (getDynamicScaleHoldUntil() > now) { |
| | | return; |
| | | if (!force) { |
| | | if (activeTripViewportSyncingRef.current) { |
| | | return; |
| | | } |
| | | if (now - lastActiveTripViewportSyncAtRef.current < ACTIVE_TRIP_VIEWPORT_SYNC_INTERVAL_MS) { |
| | | return; |
| | | } |
| | | if (getDynamicScaleHoldUntil() > now) { |
| | | return; |
| | | } |
| | | } |
| | | activeTripViewportSyncingRef.current = true; |
| | | Promise.all([ensureMapRect(), getMapRegion(), getMapCenterLocation()]) |
| | |
| | | // 纵向死区检查 |
| | | const isYInDeadZone = vehicleScreenPosition.y >= safeAreaMetrics.deadZoneTopY |
| | | && vehicleScreenPosition.y <= safeAreaMetrics.deadZoneBottomY; |
| | | if (isYInDeadZone && !isXOutOfBounds) { |
| | | if (!force && isYInDeadZone && !isXOutOfBounds) { |
| | | return; |
| | | } |
| | | const nativeCenterLatitude = Number(mapCenterLocation?.latitude); |
| | |
| | | const currentLongitude = Number.isFinite(nativeCenterLongitude) |
| | | ? nativeCenterLongitude |
| | | : fallbackCenterLongitude; |
| | | const desiredCenterLatitude = isYInDeadZone |
| | | ? currentLatitude |
| | | : vehicleLatitude - (0.5 - safeAreaMetrics.targetMapRatio) * vehicleScreenPosition.latitudeSpan; |
| | | const desiredCenterLongitude = isXOutOfBounds ? vehicleLongitude : currentLongitude; |
| | | // force=true 时纵横向都按安全区目标重定位; |
| | | // 其余情况(force=false)保持原有死区半纠偏行为不变。 |
| | | const desiredCenterLatitude = (force || !isYInDeadZone) |
| | | ? vehicleLatitude - (0.5 - safeAreaMetrics.targetMapRatio) * vehicleScreenPosition.latitudeSpan |
| | | : currentLatitude; |
| | | const desiredCenterLongitude = (force || isXOutOfBounds) ? vehicleLongitude : currentLongitude; |
| | | const hasLatitudeDelta = !Number.isFinite(currentLatitude) || Math.abs(currentLatitude - desiredCenterLatitude) >= ACTIVE_TRIP_VIEWPORT_SYNC_MIN_DELTA; |
| | | const hasLongitudeDelta = !Number.isFinite(currentLongitude) || Math.abs(currentLongitude - desiredCenterLongitude) >= ACTIVE_TRIP_VIEWPORT_SYNC_MIN_DELTA; |
| | | if (!hasLatitudeDelta && !hasLongitudeDelta) { |
| | |
| | | * 处理 polyline 点击事件。 |
| | | */ |
| | | const handlePolylineTap = (event: any) => { |
| | | console.log('[点击折现事件:',event); |
| | | console.log('[点击折线事件:',event); |
| | | const targetIndex = resolveRouteIndexByTap(event); |
| | | if (targetIndex >= 0 && targetIndex < allRoutePoints.length) { |
| | | handleSelectRoute(targetIndex, { syncSites: false }); |
| New file |
| | |
| | | import { useCallback, useEffect, useRef } from 'react'; |
| | | import { TripStatus } from '../../../constants/trip-status'; |
| | | import { |
| | | resolveScaleByKmh, |
| | | SPEED_SAMPLE_WINDOW |
| | | } from './speed-scale-config'; |
| | | |
| | | type Deps = { |
| | | /** 当前行程状态。 */ |
| | | tripStatus: TripStatus; |
| | | /** 设置地图缩放等级。 */ |
| | | setMapScale: (scale: number) => void; |
| | | /** 获取速度缩放暂停截止时间,用于和一次性视口调整/用户手势冷却互斥。 */ |
| | | getDynamicScaleHoldUntil: () => number; |
| | | /** 车辆当前位置,缩放时以车辆为中心。 */ |
| | | vehicleLocation: { latitude: number; longitude: number } | null; |
| | | /** 当前地图实际缩放等级,用于判断是否需要重新应用。 */ |
| | | mapScale: number; |
| | | /** 设置受控地图中心纬度,缩放时同步到车辆位置避免 React 重渲染按陈旧中心重定位。 */ |
| | | setLatitude: (latitude: number) => void; |
| | | /** 设置受控地图中心经度,同上。 */ |
| | | setLongitude: (longitude: number) => void; |
| | | /** scale 应用后,调 use-map-viewport 的安全区同步逻辑(force 模式)将车辆精确居中。 */ |
| | | syncMapToVehicleSafeArea: () => void; |
| | | }; |
| | | |
| | | /** 启用速度缩放的行程状态:仅接驾中、行程中。 */ |
| | | const ACTIVE_STATUSES = [TripStatus.VehicleApproaching, TripStatus.InService]; |
| | | |
| | | /** |
| | | * 接驾中/行程中根据车辆行驶速度自动缩放地图。 |
| | | * |
| | | * - 速度来自后端 ORDER_ROUTE 消息的 route.speed(单位 km/h)。 |
| | | * - 维护最近 SPEED_SAMPLE_WINDOW 条速度的滑动平均,用均值判定速度挡位,避免临界值抖动。 |
| | | * - 仅在接驾中/行程中生效,且尊重 holdDynamicScale 冷却期(用户手势、一次性视口调整后短暂让位)。 |
| | | */ |
| | | export default function useSpeedBasedScale(deps: Deps) { |
| | | const speedSamplesRef = useRef<number[]>([]); // 最近的速度样本(km/h) |
| | | |
| | | const applyVehicleSpeedSample = useCallback((rawSpeedKmh: number) => { |
| | | console.log('[autoscale] 收到速度样本', { rawSpeedKmh, tripStatus: deps.tripStatus }); |
| | | // 仅接驾中/行程中启用速度缩放。 |
| | | if (!ACTIVE_STATUSES.includes(deps.tripStatus)) { |
| | | console.log('[autoscale] 跳过:当前非接驾中/行程中状态', { tripStatus: deps.tripStatus }); |
| | | return; |
| | | } |
| | | const speedKmh = Number(rawSpeedKmh); |
| | | // 非法或负速度直接忽略;speed 为 0(停车)仍是合法低速样本。 |
| | | if (!Number.isFinite(speedKmh) || speedKmh < 0) { |
| | | return; |
| | | } |
| | | const samples = speedSamplesRef.current; |
| | | samples.push(speedKmh); |
| | | if (samples.length > SPEED_SAMPLE_WINDOW) { |
| | | samples.shift(); |
| | | } |
| | | // 样本不足窗口大小时不调整缩放,避免行程初期数据不稳导致抖动。 |
| | | if (samples.length < SPEED_SAMPLE_WINDOW) { |
| | | return; |
| | | } |
| | | const averageSpeedKmh = samples.reduce((sum, value) => sum + value, 0) / samples.length; |
| | | const targetScale = resolveScaleByKmh(averageSpeedKmh); |
| | | // 目标缩放与当前地图实际缩放一致则无需重复设置(无论是速度驱动还是用户手动调到同值)。 |
| | | if (targetScale === deps.mapScale) { |
| | | return; |
| | | } |
| | | // 处于用户手势/一次性视口调整冷却期内时让位,不抢回缩放;不更新已应用挡位以便冷却结束后重试。 |
| | | const holdUntil = deps.getDynamicScaleHoldUntil(); |
| | | if (holdUntil > Date.now()) { |
| | | return; |
| | | } |
| | | console.log('[autoscale] ✅ 应用缩放', { |
| | | 目标缩放等级: targetScale, |
| | | 均值速度_kmh: Number(averageSpeedKmh.toFixed(2)) |
| | | }); |
| | | // 先把受控中心 lat/lng 更新到车辆位置,再切 scale; |
| | | // 否则 setMapScale 触发的 React 重渲染会把 <Map> 按陈旧的中心+新 scale 重新定位,导致车辆离开视区。 |
| | | const vehicleLat = deps.vehicleLocation?.latitude; |
| | | const vehicleLng = deps.vehicleLocation?.longitude; |
| | | const hasVehicle = Number.isFinite(vehicleLat) && Number.isFinite(vehicleLng); |
| | | if (hasVehicle) { |
| | | deps.setLatitude(vehicleLat as number); |
| | | deps.setLongitude(vehicleLng as number); |
| | | } |
| | | |
| | | deps.setMapScale(targetScale); |
| | | // scale 应用后,让 use-map-viewport 的安全区同步逻辑(force 模式)按 targetMapRatio 把车辆精确居中。 |
| | | // 必须等 React 重渲染 + 原生地图应用新 scale 后再触发,否则 syncActive 内部 getRegion() 拿到的 latSpan |
| | | // 还是旧 scale 下的值,算出的偏移会失真。 |
| | | setTimeout(() => { |
| | | deps.syncMapToVehicleSafeArea(); |
| | | }, 500); |
| | | }, [deps.tripStatus, deps.getDynamicScaleHoldUntil, deps.setMapScale, deps.vehicleLocation, deps.mapScale, deps.setLatitude, deps.setLongitude, deps.syncMapToVehicleSafeArea]); |
| | | |
| | | // 退出接驾/行程态时清空采样,避免跨行程污染。 |
| | | useEffect(() => { |
| | | if (!ACTIVE_STATUSES.includes(deps.tripStatus)) { |
| | | speedSamplesRef.current = []; |
| | | } |
| | | }, [deps.tripStatus]); |
| | | |
| | | return { |
| | | applyVehicleSpeedSample |
| | | }; |
| | | } |
| | |
| | | export default function useViewDelegates(deps: Deps) { |
| | | /** 时长文案 */ |
| | | const durationDisplay = useMemo(() => formatRemainingDurationDisplay(deps.routeDuration), [deps.routeDuration]); |
| | | /** 车辆公里/分钟气泡专用时长文案:不足 1 分钟时统一展示 1 分钟。 */ |
| | | const vehicleMetricDurationDisplay = useMemo(() => ( |
| | | durationDisplay.unit === '秒' |
| | | ? { value: 1, unit: '分钟' as const } |
| | | : durationDisplay |
| | | ), [durationDisplay]); |
| | | /** 修改目的地预览态下隐藏车辆公里/分钟气泡。 */ |
| | | const shouldShowVehicleMetricCallout = !deps.isModifyDestinationReviewVisible; |
| | | const routeSummaryText = useMemo(() => ( |
| | |
| | | <AndroidCarCalloutContent |
| | | hasBeforeUserEndSite={deps.hasBeforeUserEndSite} |
| | | routeDistance={deps.routeDistance} |
| | | durationDisplay={durationDisplay} |
| | | durationDisplay={vehicleMetricDurationDisplay} |
| | | /> |
| | | ), [deps.hasBeforeUserEndSite, deps.routeDistance, durationDisplay]); |
| | | ), [deps.hasBeforeUserEndSite, deps.routeDistance, vehicleMetricDurationDisplay]); |
| | | |
| | | /** |
| | | * 渲染等待态气泡。 |
| | |
| | | <CoverView className='car-time'> |
| | | <CoverView className='car-time-num'>{deps.routeDistance?.value ?? 0}</CoverView> |
| | | <CoverView>{deps.routeDistance?.unit === 'm' ? '米' : '公里'}</CoverView> |
| | | <CoverView style={{ marginLeft: '2.5px' }} className='car-time-num'>{durationDisplay.value}</CoverView> |
| | | <CoverView>{durationDisplay.unit}</CoverView> |
| | | <CoverView style={{ marginLeft: '2.5px' }} className='car-time-num'>{vehicleMetricDurationDisplay.value}</CoverView> |
| | | <CoverView>{vehicleMetricDurationDisplay.unit}</CoverView> |
| | | </CoverView> |
| | | </CoverView> |
| | | )} |
| | |
| | | deps.routeSummaryBgSrc, |
| | | deps.showTrip1StyleEndpoint, |
| | | deps.tripStatus, |
| | | durationDisplay, |
| | | routeSummaryText, |
| | | vehicleMetricDurationDisplay, |
| | | shouldShowVehicleMetricCallout |
| | | ]); |
| | | |
| | |
| | | onOrderUpdateLineRendered?: (points: any[]) => void; |
| | | applySmoothRouteSnapshot: (snapshot: any) => void; |
| | | resetSmoothRouteMotion: () => void; |
| | | /** 上报车辆实时速度样本(km/h),驱动接驾中/行程中按速度自动缩放地图。 */ |
| | | applyVehicleSpeedSample?: (speed: number) => void; |
| | | }; |
| | | |
| | | type BusinessSocketEventName = |
| | |
| | | distanceMeters: Number.isFinite(distanceMeters) ? distanceMeters : null, |
| | | durationSeconds: Number.isFinite(durationSecondsRaw) ? durationSecondsRaw : null |
| | | }); |
| | | // 车辆实时速度(km/h),用于接驾中/行程中根据速度自动缩放地图。 |
| | | const speedKmh = Number(route?.speed); |
| | | if (Number.isFinite(speedKmh)) { |
| | | deps.applyVehicleSpeedSample?.(speedKmh); |
| | | } |
| | | }; |
| | | |
| | | /** |
| | |
| | | // 地图视口状态管理 |
| | | import useMapViewport from './hooks/use-map-viewport'; |
| | | import useActiveTripScale from './hooks/use-active-trip-scale'; |
| | | // 速度驱动的地图自动缩放 |
| | | import useSpeedBasedScale from './hooks/use-speed-based-scale'; |
| | | // 页面恢复与初始化编排 |
| | | import usePageRestore from './hooks/use-page-restore'; |
| | | // 页面动作编排 |
| | |
| | | const { |
| | | mapBottomPadding, |
| | | mapScale, |
| | | setMapScale, |
| | | originBoxHidden, |
| | | originOpacity, |
| | | setOriginOpacity, |
| | |
| | | resolveSafeTopInset, |
| | | holdDynamicScale |
| | | }); // 行程中 ORDER_UPDATE_LINE 视口编排 |
| | | const { applyVehicleSpeedSample } = useSpeedBasedScale({ |
| | | tripStatus, |
| | | setMapScale, |
| | | getDynamicScaleHoldUntil, |
| | | vehicleLocation, |
| | | mapScale, |
| | | setLatitude, |
| | | setLongitude, |
| | | syncMapToVehicleSafeArea: () => syncActiveTripViewportToVehicle({ force: true }) |
| | | }); // 接驾中/行程中按车辆速度自动缩放 |
| | | const arrivedMarkerReady = tripStatus !== TripStatus.ArrivedDestination || typeof vehicleDirection === 'number'; |
| | | const arrivedTooltipReady = tripStatus !== TripStatus.ArrivedDestination || (arrivedMarkerReady && mapScale >= 20); |
| | | const markerState = useMapMarkers({ |
| | |
| | | onOrderUpdateLineRendered: activeTripScale.handleOrderUpdateLineRendered, |
| | | applySmoothRouteSnapshot, |
| | | resetSmoothRouteMotion, |
| | | applyVehicleSpeedSample, |
| | | modifyDestinationPickupStatusGuardMs: MODIFY_DESTINATION_PICKUP_STATUS_GUARD_MS |
| | | }); |
| | | |
| | |
| | | const handleRegionChange = (e) => { |
| | | activeTripScale.handleRegionChange(e); |
| | | handleAndroidOverlayRegionChange(e); |
| | | // 用户手势导致的缩放/拖拽结束后,把原生地图的实际 scale 同步回 React 状态, |
| | | // 避免受控 <Map scale={mapScale}> 与原生实际缩放漂移,导致后续速度驱动误判"挡位未变化"。 |
| | | const causedBy = String(e?.causedBy || e?.detail?.causedBy || '').toLowerCase(); |
| | | const eventType = String(e?.type || e?.detail?.type || '').toLowerCase(); |
| | | if (eventType === 'end' && (causedBy === 'scale' || causedBy === 'drag' || causedBy === 'gesture')) { |
| | | Taro.createMapContext('map').getScale({ |
| | | success: (res: any) => { |
| | | const actualScale = Number(res?.scale); |
| | | if (!Number.isFinite(actualScale)) { |
| | | return; |
| | | } |
| | | const rounded = Math.round(actualScale); |
| | | if (rounded !== mapScale) { |
| | | setMapScale(rounded); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | }; |
| | | |
| | | const handleToggleDetailFade = (expanded: boolean, hasFare: boolean) => { |
| | |
| | | } |
| | | updateMapViewportPadding(); |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | if (isRouteSelectExpanded) { |
| | | setOriginOpacity(0); |
| | | } else { |
| | | setOriginOpacity(1); |
| | | } |
| | | }, [isRouteSelectExpanded]); |
| | | const handleModifyDestination = () => { |
| | | isCheckedEnter.current = true; |
| | | handleStartModifyDestination(fareEstimate, routePoints); |
| | |
| | | }; |
| | | // 打开优惠券选择弹窗 |
| | | const handleOpenCouponSelect = () => { |
| | | if (![TripStatus.PublishRoute, TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus)) { |
| | | if (![TripStatus.PublishRoute, TripStatus.TripFinished].includes(tripStatus)) { |
| | | return; |
| | | } |
| | | setShowCouponSelectPopup(true); |
| | |
| | | const handleCloseCouponSelect = () => { |
| | | setShowCouponSelectPopup(false); |
| | | }; |
| | | // 支付成功后关闭优惠券选择弹窗 |
| | | useEffect(() => { |
| | | if (tripStatus === TripStatus.PaymentSuccess) { |
| | | setShowCouponSelectPopup(false); |
| | | } |
| | | }, [tripStatus]); |
| | | // 选择优惠券后仅刷新价格,不刷新地图路线 |
| | | const handleSelectCoupon = (item: any) => { |
| | | const selectedCouponId = String(item?.operationCouponId || item?.couponId || ''); |
| | |
| | | return []; |
| | | } |
| | | return geofenceList.map(item => { |
| | | const isSelected = item.mapPolygonId === selectedId; |
| | | return { |
| | | points: item.polygonPoints || [], |
| | | strokeWidth: 0, |
| | | strokeColor: 'rgba(0, 0, 0, 0)', |
| | | fillColor: '#6d7fd933', |
| | | fillColor: isSelected ? '#6d7fd933' : '#6d7fd91a', |
| | | zIndex: 1, |
| | | id: item.mapPolygonId |
| | | } |
| | |
| | | const isSelected = item.mapPolygonId === selectedId; |
| | | return { |
| | | points: item.polylinePoints || [], |
| | | color: isSelected ? '#6D7FD9' : '#A1B1FF', |
| | | color: '#A1B1FF', |
| | | width: 2, |
| | | dottedLine: !isSelected, |
| | | arrowLine: false, |
| | | borderColor: isSelected ? '#6D7FD9' : '#A1B1FF', |
| | | borderColor: '#A1B1FF', |
| | | borderWidth: 0, |
| | | zIndex: isSelected ? 3 : 2, |
| | | id: Number(item.mapPolygonId ?? index + 1) |
| | |
| | | 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 CROSS_OPERATING_AREA_FILL_COLOR = '#00000040'; // 非可选运营区围栏填充色 |
| | | const CROSS_OPERATING_AREA_STROKE_COLOR = '#00000066'; // 非可选运营区围栏描边色 |
| | | const SELECT_MAP_POINT_ON_OFF = { |
| | | start: 1, |
| | | end: 2 |
| | |
| | | ];
|
| | | const ORDER_SUCCESS_CACHE_EXPIRE_MS = 10 * 60 * 1000; // 下单成功订阅缓存有效期(毫秒)
|
| | | let isOrderSuccessSubscribeRequesting = false; // 下单成功订阅请求中标记,避免重复触发
|
| | | let orderSuccessSubscribePromise: Promise<void> | null = null; // 下单成功订阅弹窗进行中的 Promise,结束后 resolve
|
| | | let orderSuccessSubscribeResolve: (() => void) | null = null; // 上述 Promise 的 resolve 引用
|
| | | let pendingOrderSuccessResult: Record<string, string> | null = null; // 点击阶段缓存的订阅返回结果
|
| | | let pendingOrderSuccessAcceptedTemplateIds: string[] = []; // 点击阶段缓存的已同意模板
|
| | | let pendingOrderSuccessCapturedAt = 0; // 点击阶段缓存写入时间戳
|
| | |
| | | export function requestOrderSuccessSubscribeOnTap() {
|
| | | if (isOrderSuccessSubscribeRequesting) return;
|
| | | isOrderSuccessSubscribeRequesting = true;
|
| | | orderSuccessSubscribePromise = new Promise<void>((resolve) => {
|
| | | orderSuccessSubscribeResolve = resolve;
|
| | | });
|
| | | Taro.requestSubscribeMessage({
|
| | | tmplIds: ORDER_SUCCESS_SUBSCRIBE_CONFIG.tmplIds,
|
| | | success: (result) => {
|
| | |
| | | },
|
| | | complete: () => {
|
| | | isOrderSuccessSubscribeRequesting = false;
|
| | | // 订阅弹窗已结束(用户已处理或未弹出),放行等待中的后续原生弹窗
|
| | | orderSuccessSubscribeResolve?.();
|
| | | orderSuccessSubscribeResolve = null;
|
| | | orderSuccessSubscribePromise = null;
|
| | | }
|
| | | });
|
| | | }
|
| | |
|
| | | /**
|
| | | * 等待“下单成功”订阅弹窗结束。
|
| | | * 若当前没有进行中的订阅弹窗,立即 resolve;否则在订阅弹窗 complete 后 resolve。
|
| | | * 用于让蓝牙系统授权等其它原生弹窗排在订阅弹窗之后,避免多个原生弹窗叠加。
|
| | | */
|
| | | export function waitForOrderSuccessSubscribePopup(): Promise<void> {
|
| | | return orderSuccessSubscribePromise || Promise.resolve();
|
| | | }
|
| | |
|
| | | /**
|
| | | * 消费点击阶段缓存结果,并在拿到订单 ID 后补齐频控与后端上报。
|
| | | */
|
| | | async function consumeOrderSuccessSubscribeCache(orderId: string) {
|