From f525b6c3cc105bd1a997d893937689bebcc9570d Mon Sep 17 00:00:00 2001
From: 卢春龙 <lucl@xiaopeng.com>
Date: 星期一, 17 八月 2026 09:33:38 +0800
Subject: [PATCH] Merge branch 'feature_2.6.0' into 'develop'
---
src/robotaxi/pages/begin-route/hooks/use-android-overlays.ts | 25
src/robotaxi/pages/begin-route/hooks/use-order-payment.ts | 18
src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts | 19
src/robotaxi/pages/begin-route/hooks/use-route-manager.ts | 1
src/robotaxi/pages/coupon/index.tsx | 8
src/utils/preload-assets.ts | 54
src/robotaxi/pages/begin-route/components/calling-car-modules/trip-status-header.tsx | 43
src/robotaxi/api/feedback.ts | 14
src/robotaxi/components/demo/index.tsx | 5
src/robotaxi/constants/preload-assets.ts | 46
src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-status-socket-handler.ts | 12
src/pages/robotaxi/home/hooks/use-region-change.ts | 52
src/robotaxi/pages/begin-route/hooks/use-defer-until-dispatch-settled.ts | 51
src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-route-socket-handler.ts | 4
src/robotaxi/constants/trip-status.ts | 20
src/robotaxi/pages/begin-route/components/calling-car-modules/fragrance-control/index.tsx | 10
src/robotaxi/pages/begin-route/hooks/use-popup-manager.ts | 3
src/robotaxi/pages/begin-route/hooks/use-transition-asset-preload.ts | 54
src/robotaxi/pages/begin-route/components/begin-route-view.tsx | 175 +
src/robotaxi/pages/begin-route/components/calling-car-modules/seat-hv-control/index.tsx | 19
src/robotaxi/pages/begin-route/hooks/camera/policies/drive.policy.ts | 77
src/robotaxi/components/fireworks/index.less | 9
src/robotaxi/pages/begin-route/components/debug-transition-panel.tsx | 173 +
src/robotaxi/pages/begin-route/components/debug-order-simulator-panel.tsx | 10
src/robotaxi/pages/begin-route/components/modify-destination-review.tsx | 30
src/robotaxi/pages/payment-setting-detail/index.tsx | 4
src/robotaxi/pages/select-map-site/components/select-map-site-view.tsx | 10
src/pages/robotaxi/home/components/privacy-popup/index.tsx | 7
src/robotaxi/pages/begin-route/components/css-folder/fragrance-control.less | 4
src/robotaxi/pages/begin-route/hooks/camera/collect-viewport-points.ts | 73
src/robotaxi/pages/begin-route/components/dispatch-transition-layer.tsx | 148 +
src/robotaxi/pages/begin-route/components/css-folder/release-be-being.less | 2
src/robotaxi/pages/begin-route/components/css-folder/route-selection-panel.less | 25
src/robotaxi/pages/begin-route/components/route-preference-popup/index.less | 5
src/robotaxi/utils/platform-env.ts | 67
src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts | 1322 ---------
src/robotaxi/style/ui-variables.less | 1
src/robotaxi/pages/begin-route/components/cancel-popup.tsx | 76
src/robotaxi/pages/begin-route/hooks/camera/policies/calling.policy.ts | 32
src/robotaxi/pages/begin-route/hooks/use-route-interaction.ts | 54
src/robotaxi/pages/begin-route/components/css-folder/release-route.less | 26
src/robotaxi/pages/begin-route/hooks/use-trip-runtime.ts | 19
src/robotaxi/pages/begin-route/hooks/camera/map-perf-log.ts | 12
src/robotaxi/pages/begin-route/hooks/camera/policies/publish-route.policy.ts | 54
src/robotaxi/api/order.ts | 1
src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx | 25
src/robotaxi/pages/begin-route/components/evaluate-popup.tsx | 77
src/robotaxi/pages/begin-route/hooks/camera/camera-policy.types.ts | 61
src/robotaxi/pages/begin-route/hooks/camera/policies/order-detail.policy.ts | 35
src/robotaxi/components/site-route-card/index.less | 86
src/robotaxi/pages/begin-route/hooks/camera/map-camera.ts | 455 +++
src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx | 9
src/robotaxi/pages/begin-route/hooks/use-bluetooth-access.ts | 13
src/pages/robotaxi/home/hooks/use-navigation.ts | 19
src/robotaxi/pages/share-trip/hooks/use-map-markers.ts | 2
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx | 96
src/robotaxi/components/fireworks/index.tsx | 436 +++
src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx | 24
src/pages/robotaxi/home/index.tsx | 3
src/robotaxi/pages/begin-route/hooks/use-route-request.ts | 5
src/robotaxi/pages/begin-route/components/calling-car-modules/evaluation-card.tsx | 8
src/robotaxi/constants/assets.ts | 62
src/pages/robotaxi/home/constants.ts | 4
src/robotaxi/pages/begin-route/components/calling-car-modules/ac-switch/index.tsx | 18
src/robotaxi/pages/begin-route/components/css-folder/temperature-control.less | 3
src/pages/robotaxi/home/hooks/use-quick-capsule.ts | 9
src/robotaxi/pages/begin-route/components/calling-car-modules/music-control/index.tsx | 19
src/robotaxi/components/fireworks/error-boundary.tsx | 35
src/robotaxi/components/site-route-card/index.tsx | 71
src/robotaxi/pages/begin-route/hooks/use-dispatch-transition.ts | 170 +
src/robotaxi/pages/begin-route/hooks/use-credit-pay-return.ts | 10
src/robotaxi/pages/begin-route/components/trip-finished-fireworks/index.tsx | 60
src/robotaxi/pages/feedback/index.tsx | 8
src/robotaxi/pages/expense-description/index.less | 68
src/robotaxi/pages/begin-route/components/css-folder/evaluate-popup.less | 10
src/robotaxi/pages/begin-route/components/css-folder/seat-hv-control.less | 23
src/pages/robotaxi/home/hooks/use-origin-action.ts | 3
src/robotaxi/pages/begin-route/components/css-folder/modify-destination-review.less | 66
src/robotaxi/utils/video-preload.ts | 69
src/robotaxi/pages/begin-route/hooks/camera/use-map-camera-controller.ts | 258 ++
src/robotaxi/pages/begin-route/components/calling-car.tsx | 202 +
src/pages/robotaxi/home/hooks/use-site-state.ts | 7
src/pages/robotaxi/home/components/map-pointer/index.tsx | 5
src/pages/robotaxi/home/index.less | 80
src/robotaxi/pages/begin-route/hooks/use-modify-destination-flow.ts | 8
src/robotaxi/pages/begin-route/hooks/camera/policies/pickup.policy.ts | 78
src/robotaxi/pages/begin-route/hooks/camera/policies/index.ts | 23
src/robotaxi/pages/begin-route/hooks/use-order-status.ts | 3
.gitignore | 2
src/robotaxi/pages/begin-route/hooks/use-order-actions.shared.ts | 12
src/robotaxi/pages/begin-route/index.less | 13
src/robotaxi/pages/share-trip/components/share-trip-view.tsx | 45
src/robotaxi/pages/begin-route/components/calling-car-modules/ac-switch/index.less | 2
src/robotaxi/constants/music-control-feature.ts | 13
src/robotaxi/pages/feedback/hooks/use-media-upload.ts | 7
src/robotaxi/pages/begin-route/hooks/use-order-submit.ts | 28
src/robotaxi/pages/begin-route/components/begin-route-view-context.tsx | 18
src/robotaxi/pages/begin-route/components/calling-car-modules/vehicle-info-card.tsx | 40
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less | 304 +
src/robotaxi/pages/begin-route/hooks/use-order-payment-result-query.ts | 7
src/robotaxi/pages/personal-center/index.tsx | 2
src/robotaxi/pages/share-trip/index.less | 77
src/robotaxi/api/begin-route.ts | 15
src/pages/robotaxi/home/hooks/use-marketing.ts | 4
src/robotaxi/pages/share-trip/hooks/use-view-delegates.tsx | 10
src/robotaxi/pages/expense-description/index.tsx | 21
src/robotaxi/pages/begin-route/components/css-folder/cancel-popup.less | 52
src/robotaxi/pages/select-map-site/index.less | 70
src/robotaxi/pages/begin-route/index.tsx | 434 +-
src/robotaxi/pages/begin-route/hooks/use-order-cancel.ts | 67
/dev/null | 217 -
src/robotaxi/pages/order/detail/index.tsx | 12
src/robotaxi/pages/begin-route/hooks/use-page-restore.ts | 40
src/robotaxi/utils/user-location.ts | 79
src/robotaxi/pages/begin-route/components/css-folder/service-route-change.less | 94
src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-meta-socket-handlers.ts | 8
src/robotaxi/components/fireworks/礼花效果.md | 139 +
src/robotaxi/pages/begin-route/hooks/use-map-markers.ts | 11
src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts | 7
119 files changed, 4,762 insertions(+), 2,688 deletions(-)
diff --git a/.gitignore b/.gitignore
index 456b0cb..7937225 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,6 @@
.history
key/
.swc/
+.tmp_frames/
+.qoder/
# project.private.config.json
diff --git a/src/pages/robotaxi/home/components/map-pointer/index.tsx b/src/pages/robotaxi/home/components/map-pointer/index.tsx
index 8985d75..0b658d2 100644
--- a/src/pages/robotaxi/home/components/map-pointer/index.tsx
+++ b/src/pages/robotaxi/home/components/map-pointer/index.tsx
@@ -37,8 +37,9 @@
return (
<View className="pointer-box">
+ {/* 雷达只在真正命中吸附、指针跳动到具体站点时才展示(由 bounce 驱动),不随真实请求态 nearbyLoading 提前出现 */}
{
- props.nearbyLoading && props.isAndroid && (
+ props.bounce && props.isAndroid && (
<Image className="waiting-radar-img" src={props.waitingRadarSrc} webp />
)
}
@@ -64,7 +65,7 @@
)
}
{
- props.nearbyLoading && !props.isAndroid && (
+ props.bounce && !props.isAndroid && (
<Image className="waiting-radar" src={props.waitingRadarSrc} />
)
}
diff --git a/src/pages/robotaxi/home/components/privacy-popup/index.tsx b/src/pages/robotaxi/home/components/privacy-popup/index.tsx
index a093735..0f4b792 100644
--- a/src/pages/robotaxi/home/components/privacy-popup/index.tsx
+++ b/src/pages/robotaxi/home/components/privacy-popup/index.tsx
@@ -4,6 +4,7 @@
import './index.less';
import { privicyShowManager } from './utils'
import { submitAgreement } from '../../../../../robotaxi/api/home'
+import { showToast } from '../../../../../robotaxi/components/toast'
import { getNormalizedLoginAgreementList, type LoginAgreementItem } from '../../../../../robotaxi/utils/login-agreement'
import { reportEvent } from '../../../../../utils/Robotaxi-public-method'
import { PROTOCOL_CLICK_EVENT_ID } from '../../../../../robotaxi/constants/report-events'
@@ -32,7 +33,7 @@
}, {});
setIsAgree(initState);
}).catch(() => {
- Taro.showToast({ title: '协议获取失败,请重试', icon: 'none' });
+ showToast({ title: '协议获取失败,请重试', icon: 'none' });
});
};
@@ -97,7 +98,7 @@
const handleAgree = () => {
if (!isAllAgree) {
- Taro.showToast({ title: '请先同意全部协议', icon: 'none' });
+ showToast({ title: '请先同意全部协议', icon: 'none' });
return
};
const selectedType = Object.keys(isAgree)
@@ -113,7 +114,7 @@
privicyShowManager.setHasShow(true)
onAgree?.();
}).catch(() => {
- Taro.showToast({ title: '提交失败,请重试', icon: 'none' });
+ showToast({ title: '提交失败,请重试', icon: 'none' });
});
};
diff --git a/src/pages/robotaxi/home/constants.ts b/src/pages/robotaxi/home/constants.ts
index 530c279..0f7e474 100644
--- a/src/pages/robotaxi/home/constants.ts
+++ b/src/pages/robotaxi/home/constants.ts
@@ -33,8 +33,8 @@
/** 首页订单状态文案映射。 */
export const HOME_ORDER_STATUS: Partial<Record<TripStatus, string>> = {
[TripStatus.VehicleApproaching]: '车辆正在赶来',
- [TripStatus.VehicleArrivedPickup]: '车辆已到达上车点,请尽快前往',
+ [TripStatus.VehicleArrivedPickup]: '车辆已到达上车点',
[TripStatus.InService]: '正在前往目的地',
- [TripStatus.ArrivedDestination]: '车辆已到达目的地,请尽快结束行程',
+ [TripStatus.ArrivedDestination]: '车辆已到达目的地',
[TripStatus.TripFinished]: '行程已结束,请尽快支付'
};
diff --git a/src/pages/robotaxi/home/hooks/use-marketing.ts b/src/pages/robotaxi/home/hooks/use-marketing.ts
index c5d4859..83eef91 100644
--- a/src/pages/robotaxi/home/hooks/use-marketing.ts
+++ b/src/pages/robotaxi/home/hooks/use-marketing.ts
@@ -1,5 +1,5 @@
-import Taro from '@tarojs/taro';
import { useState } from 'react';
+import { showToast } from '../../../../robotaxi/components/toast';
import { FLOAT_ACTIVITY_DEFAULT_IMAGE } from '../constants';
type MarketingData = Record<string, any>;
@@ -163,7 +163,7 @@
*/
const handleFloatActivityClick = () => {
if (!floatActivityUrl) {
- Taro.showToast({ title: '活动页面建设中', icon: 'none' });
+ showToast({ title: '活动页面建设中', icon: 'none' });
return;
}
deps.toH5(floatActivityUrl, true);
diff --git a/src/pages/robotaxi/home/hooks/use-navigation.ts b/src/pages/robotaxi/home/hooks/use-navigation.ts
index 8248310..16913e4 100644
--- a/src/pages/robotaxi/home/hooks/use-navigation.ts
+++ b/src/pages/robotaxi/home/hooks/use-navigation.ts
@@ -24,6 +24,7 @@
writeConfirmRideSiteSourceType
} from '../../../../robotaxi/constants/confirm-ride-report';
import { resolveHomeStartSiteDisplaySite } from '../../../../robotaxi/hooks/use-home-start-site-display';
+import { showToast } from '../../../../robotaxi/components/toast';
type Deps = {
/** 是否未登录 */
@@ -124,12 +125,12 @@
try {
const res = await Taro.getNetworkType();
if (res?.networkType === 'none') {
- Taro.showToast({ title: '网络未连接', icon: 'none' });
+ showToast({ title: '网络未连接', icon: 'none' });
return false;
}
return true;
} catch (e) {
- Taro.showToast({ title: '网络未连接', icon: 'none' });
+ showToast({ title: '网络未连接', icon: 'none' });
return false;
}
};
@@ -176,11 +177,11 @@
const ok = await ensureNetwork();
if (!ok) return;
if (deps.isLocationAuthDenied) {
- Taro.showToast({ title: '请先开启定位权限', icon: 'none' });
+ showToast({ title: '请先开启定位权限', icon: 'none' });
return;
}
if (!deps.electronicAreaId) {
- Taro.showToast({ title: '请先移动到运营区内', icon: 'none' });
+ showToast({ title: '请先移动到运营区内', icon: 'none' });
return;
}
if (isToMapStart) {
@@ -197,7 +198,7 @@
const preferredStartSite = resolveHomeStartSiteDisplaySite();
const effectiveStartSite = preferredStartSite || deps.currentSite;
if (!effectiveStartSite || !effectiveStartSite.id) {
- Taro.showToast({ title: '请先选择起点站点', icon: 'none' });
+ showToast({ title: '请先选择起点站点', icon: 'none' });
return;
}
Taro.setStorageSync('startSite', effectiveStartSite);
@@ -223,7 +224,7 @@
const startLongitude = hasValidSiteCoord ? siteLongitude : (hasValidMapCoord ? mapLongitude : null);
const startLatitude = hasValidSiteCoord ? siteLatitude : (hasValidMapCoord ? mapLatitude : null);
if (startLongitude === null || startLatitude === null) {
- Taro.showToast({ title: '请先移动到运营区内', icon: 'none' });
+ showToast({ title: '请先移动到运营区内', icon: 'none' });
return;
}
Taro.navigateTo({
@@ -321,7 +322,7 @@
if (!ok) return;
if (deps.isLocationAuthDenied) return;
if (!deps.selectedCity?.code) {
- Taro.showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
+ showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
return;
}
let nextPolygons = deps.polygons;
@@ -341,7 +342,7 @@
deps.setPolygons([]);
}
if (!nextPolygons || nextPolygons.length === 0) {
- Taro.showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
+ showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
return;
}
const points = extractPointsFromPolygons(nextPolygons);
@@ -358,7 +359,7 @@
});
}, 600);
} else {
- Taro.showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
+ showToast({ title: '此城市暂未开通运营区域', icon: 'none' });
}
};
diff --git a/src/pages/robotaxi/home/hooks/use-origin-action.ts b/src/pages/robotaxi/home/hooks/use-origin-action.ts
index b80efb2..51b0c0b 100644
--- a/src/pages/robotaxi/home/hooks/use-origin-action.ts
+++ b/src/pages/robotaxi/home/hooks/use-origin-action.ts
@@ -1,4 +1,5 @@
import Taro from '@tarojs/taro';
+import { showToast } from '../../../../robotaxi/components/toast';
type Deps = {
/** 是否拒绝定位 */
@@ -37,7 +38,7 @@
const toOrigin = async () => {
const systemSetting = await Promise.resolve((Taro as any).getSystemSetting?.());
if (systemSetting?.locationEnabled === false) {
- Taro.showToast({ title: '需开启设备定位服务开关', icon: 'none' });
+ showToast({ title: '需开启设备定位服务开关', icon: 'none' });
return;
}
if (deps.isLocationAuthDenied) return;
diff --git a/src/pages/robotaxi/home/hooks/use-quick-capsule.ts b/src/pages/robotaxi/home/hooks/use-quick-capsule.ts
index bdffa11..2340f13 100644
--- a/src/pages/robotaxi/home/hooks/use-quick-capsule.ts
+++ b/src/pages/robotaxi/home/hooks/use-quick-capsule.ts
@@ -14,6 +14,7 @@
OrderDistanceConfig
} from '../../../../robotaxi/utils/order-distance';
import { hasActiveTripOrderInStorage } from '../../../../robotaxi/utils/order';
+import { showToast } from '../../../../robotaxi/components/toast';
type CapsuleSite = {
id: string | number;
@@ -142,16 +143,16 @@
*/
const handleQuickCapsuleClick = async () => {
if (hasActiveTripOrderInStorage()) {
- Taro.showToast({ title: '您已有进行中的订单', icon: 'none' });
+ showToast({ title: '您已有进行中的订单', icon: 'none' });
return;
}
const targetStartSite = resolveValidStartSite();
if (!targetStartSite) {
- Taro.showToast({ title: '请先选择起点站点', icon: 'none' });
+ showToast({ title: '请先选择起点站点', icon: 'none' });
return;
}
if (!capsuleSite) {
- Taro.showToast({ title: '暂无快捷目的地', icon: 'none' });
+ showToast({ title: '暂无快捷目的地', icon: 'none' });
return;
}
const distanceConfig = await ensureDistanceConfigReady();
@@ -160,7 +161,7 @@
endSite: capsuleSite,
minDistance: distanceConfig?.minDistance
})) {
- Taro.showToast({ title: ORDER_DISTANCE_MIN_DISTANCE_TOAST, icon: 'none' });
+ showToast({ title: ORDER_DISTANCE_MIN_DISTANCE_TOAST, icon: 'none' });
return;
}
Taro.setStorageSync('endSite', capsuleSite);
diff --git a/src/pages/robotaxi/home/hooks/use-region-change.ts b/src/pages/robotaxi/home/hooks/use-region-change.ts
index 4948306..6ca7136 100644
--- a/src/pages/robotaxi/home/hooks/use-region-change.ts
+++ b/src/pages/robotaxi/home/hooks/use-region-change.ts
@@ -5,6 +5,9 @@
writeHomeStartSiteDisplayState
} from '../../../../robotaxi/hooks/use-home-start-site-display';
+/** 指针跳动到附近站点的动效总时长:指针跳动(600ms)+ 雷达扫描确认(320ms),需与 index.less 中对应动画时长保持一致。 */
+const POINTER_BOUNCE_SEQUENCE_DURATION_MS = 920;
+
type Deps = {
mapState: any;
siteState: any;
@@ -56,7 +59,6 @@
*/
const queryPointerByCenter = async (lat: number, lng: number, options?: { allowAdsorb?: boolean }) => {
const allowAdsorb = options?.allowAdsorb !== false;
- siteState.setBounce(true);
siteState.setShowToolTip(false);
try {
const { isInside, newMarkers, sites } = await mapState.updateMapData(lat, lng, undefined, { commitMarkers: false });
@@ -68,38 +70,43 @@
if (allowAdsorb) {
siteState.setShowToolTip(true); // 运营区外恢复气泡提示
}
- siteState.setBounce(false);
return;
}
const nearestCandidates = sites.length > 0 ? sites : newMarkers;
const { adsorbed, marker } = allowAdsorb
- ? siteState.tryAdsorb(lat, lng, nearestCandidates)
+ ? siteState.tryAdsorb(lat, lng, nearestCandidates, { deferShowToolTip: true })
: { adsorbed: false, marker: null }; // 仅浏览运营区域时不吸附最近点位
commitQueriedMarkers(newMarkers, true, adsorbed);
const finalLat = adsorbed && marker ? marker.latitude : lat; // 最终纬度
const finalLng = adsorbed && marker ? marker.longitude : lng; // 最终经度
+ if (allowAdsorb && adsorbed) {
+ // 地图中心点(硬切换)与指针跳动动画在同一时刻触发,避免二者时序脱钩导致的突兀感
+ siteState.setBounce(true);
+ }
if (allowAdsorb) {
// 仅浏览运营区域时不回写地图中心,避免 Map 组件按旧 scale 重绘导致刚展开的运营区视野被收回
mapState.setLatitude(finalLat);
mapState.setLongitude(finalLng);
}
settleRegionResult({ lat: finalLat, lon: finalLng, isInside: true });
- setTimeout(async () => {
- siteState.setBounce(false);
- if (!adsorbed) {
- await siteState.resolveAddress(lat, lng);
- }
+ if (adsorbed) {
+ setTimeout(() => {
+ siteState.setBounce(false);
+ // 跳动 + 雷达动效播放完毕后才展示 tooltip,避免与前两段动效叠加显得突兀
+ siteState.setShowToolTip(true);
+ }, POINTER_BOUNCE_SEQUENCE_DURATION_MS);
+ } else {
+ await siteState.resolveAddress(lat, lng);
if (!allowAdsorb) {
siteState.setShowToolTip(false); // 查看运营区域期间隐藏气泡,避免遮挡围栏,拖动地图后自动恢复
}
- }, 600);
+ }
} catch (error) {
siteState.setBounce(false);
}
};
const handleRegionEnd = (causedBy: string, wasReturningToOrigin: boolean) => {
- siteState.setBounce(true);
const mapCtx = Taro.createMapContext('map');
mapCtx.getScale({
success: (res) => {
@@ -148,7 +155,7 @@
return;
}
const nearestCandidates = sites.length > 0 ? sites : newMarkers;
- const { adsorbed, marker } = siteState.tryAdsorb(lat, lng, nearestCandidates);
+ const { adsorbed, marker } = siteState.tryAdsorb(lat, lng, nearestCandidates, { deferShowToolTip: true });
commitQueriedMarkers(newMarkers, true, adsorbed);
const finalLat = adsorbed && marker ? marker.latitude : lat; // 最终纬度
const finalLng = adsorbed && marker ? marker.longitude : lng; // 最终经度
@@ -156,20 +163,29 @@
syncManualSelectedStartSite(marker);
}
settleRegionResult({ lat: finalLat, lon: finalLng, isInside: true });
- if ((causedBy === 'gesture' || causedBy === 'drag' || wasReturningToOrigin) && marker) {
+ const isGestureSettle = causedBy === 'gesture' || causedBy === 'drag' || wasReturningToOrigin;
+ const willPlayBounceSequence = isGestureSettle && adsorbed && !!marker;
+ if (willPlayBounceSequence) {
+ // 命中吸附且为手势/拖拽结算:地图中心点(硬切换)与指针跳动动画在同一时刻触发,避免二者时序脱钩导致的突兀感
+ siteState.setBounce(true);
mapCtx.moveToLocation({
latitude: marker.latitude,
longitude: marker.longitude
});
mapState.setLatitude(marker.latitude);
mapState.setLongitude(marker.longitude);
+ setTimeout(() => {
+ siteState.setBounce(false);
+ // 跳动 + 雷达动效播放完毕后才展示 tooltip,避免与前两段动效叠加显得突兀
+ siteState.setShowToolTip(true);
+ }, POINTER_BOUNCE_SEQUENCE_DURATION_MS);
+ } else if (adsorbed) {
+ // 未触发跳动动效(如吸附命中但非手势导致的静默刷新)时,立即展示 tooltip
+ siteState.setShowToolTip(true);
+ } else {
+ // 未命中吸附:不播放跳动/雷达动效,立即回填地址,避免无意义的等待
+ await siteState.resolveAddress(lat, lng);
}
- setTimeout(async () => {
- siteState.setBounce(false);
- if (!adsorbed) {
- await siteState.resolveAddress(lat, lng);
- }
- }, 600);
} catch (error) {
siteState.setBounce(false);
}
diff --git a/src/pages/robotaxi/home/hooks/use-site-state.ts b/src/pages/robotaxi/home/hooks/use-site-state.ts
index 32a1acb..ba19358 100644
--- a/src/pages/robotaxi/home/hooks/use-site-state.ts
+++ b/src/pages/robotaxi/home/hooks/use-site-state.ts
@@ -28,14 +28,17 @@
/**
* 统一吸附判断,命中阈值则更新吸附站点并显示提示。
+ * @param options.deferShowToolTip 为 true 时不立即展示 tooltip,由调用方在合适的时机(如指针跳动动效结束后)自行调用 setShowToolTip(true)。
*/
- const tryAdsorb = (lat: number, lng: number, candidates: any[]) => {
+ const tryAdsorb = (lat: number, lng: number, candidates: any[], options?: { deferShowToolTip?: boolean }) => {
const threshold = adsorptionDistance || 1000;
const { targetMarker, minDistance } = findNearestMarker(lat, lng, candidates);
if (minDistance < threshold && targetMarker) {
setIsAdsorbed(true);
setCurrentSite(targetMarker);
- setShowToolTip(true);
+ if (!options?.deferShowToolTip) {
+ setShowToolTip(true);
+ }
Taro.setStorageSync('startSite', targetMarker);
writeConfirmRideSiteSourceType('start', RECOMMENDED_CONFIRM_RIDE_SOURCE_TYPE);
return { adsorbed: true, marker: targetMarker };
diff --git a/src/pages/robotaxi/home/index.less b/src/pages/robotaxi/home/index.less
index 0fda132..7f6ce3e 100644
--- a/src/pages/robotaxi/home/index.less
+++ b/src/pages/robotaxi/home/index.less
@@ -123,7 +123,7 @@
.order-status {
position: relative;
border-radius: 24px;
- background-color: #141E27;
+ background-color: @black-text-main;
background-image: url("https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/order-card-homepage-20260724.png");
background-size: cover;
box-sizing: border-box;
@@ -214,9 +214,11 @@
font-size: 24px;
font-weight: 500;
letter-spacing: normal;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
border-radius: 200px;
background: #ECF0F5;
+ flex-shrink: 0;
+ flex-grow: 0;
}
}
}
@@ -348,8 +350,8 @@
.coupon-message-toast {
position: fixed;
- top: 370px;
- right: 144px;
+ top: 393px;
+ right: 124px;
z-index: 1000012;
max-width: 560px;
min-height: 64px;
@@ -390,6 +392,8 @@
.toolTip {
position: relative;
z-index: 10;
+ animation: tooltip-fade-in 240ms ease-out;
+ will-change: transform, opacity;
.toolTip-bg {
background-image: url("https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/tooltip-bg-20260715.png");
@@ -471,6 +475,8 @@
width: 64px;
height: 86px;
margin-top: -54px;
+ opacity: 1;
+ transition: opacity 240ms ease-out;
&.bounce {
animation: pointer-bounce 600ms ease-in-out;
@@ -480,28 +486,32 @@
opacity: 0;
}
}
-}
-.waiting-radar {
- position: fixed;
- width: 700px;
- height: 350px;
- top: 48%;
- left: 50%;
- transform: translate(-50%, -48%);
- z-index: 1;
- pointer-events: none;
-}
+ // 雷达以站点位置(.pointer-box 底边,即 translate(-50%,-100%) 后与地图中心点对齐的边)为圆心展开,
+ // 用绝对定位直接锚定该边,不受内部 flex 子元素排列影响
+ .waiting-radar {
+ position: absolute;
+ width: 700px;
+ height: 350px;
+ bottom: 0;
+ left: 50%;
+ transform: translate(-50%, 50%);
+ z-index: 1;
+ pointer-events: none;
+ animation: radar-fade-in 200ms ease-out;
+ }
-.waiting-radar-img {
- position: fixed;
- width: 700px;
- height: 350px;
- top: 48%;
- left: 50%;
- transform: translate(-50%, -48%);
- z-index: 1;
- pointer-events: none;
+ .waiting-radar-img {
+ position: absolute;
+ width: 700px;
+ height: 350px;
+ bottom: 0;
+ left: 50%;
+ transform: translate(-50%, 50%);
+ z-index: 1;
+ pointer-events: none;
+ animation: radar-fade-in 200ms ease-out;
+ }
}
@keyframes pointer-bounce {
@@ -522,6 +532,28 @@
}
}
+@keyframes tooltip-fade-in {
+ 0% {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes radar-fade-in {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
.home-container {
display: flex;
flex-direction: column;
diff --git a/src/pages/robotaxi/home/index.tsx b/src/pages/robotaxi/home/index.tsx
index 71fc5ab..2826e9b 100644
--- a/src/pages/robotaxi/home/index.tsx
+++ b/src/pages/robotaxi/home/index.tsx
@@ -41,6 +41,7 @@
import { checkOtp, toH5 } from '../../../utils/tools';
import { getCityLocation, reportEvent } from '../../../utils/Robotaxi-public-method';
import { withRobotaxiWhitelist } from '../../../utils/whitelist';
+import { withToast } from '../../../robotaxi/components/toast/with-toast';
import {
HOME_MAP_ID,
HOME_ORDER_STATUS,
@@ -636,4 +637,4 @@
);
}
-export default withRobotaxiWhitelist(Home);
+export default withRobotaxiWhitelist(withToast(Home));
diff --git a/src/robotaxi/api/begin-route.ts b/src/robotaxi/api/begin-route.ts
index ae54dbb..c8539ec 100644
--- a/src/robotaxi/api/begin-route.ts
+++ b/src/robotaxi/api/begin-route.ts
@@ -43,12 +43,20 @@
QueryLedgerCreditOrderResultResponse
} from './types/begin-route';
+type QueryRouteAndFareRequestOptions = {
+ showToast?: boolean;
+};
+
// 导航-获取路线和费用
-export const queryRouteAndFare = async (payload: QueryRouteAndFarePayload): Promise<QueryRouteAndFareResponse> => {
+export const queryRouteAndFare = async (
+ payload: QueryRouteAndFarePayload,
+ options: QueryRouteAndFareRequestOptions = {}
+): Promise<QueryRouteAndFareResponse> => {
return request({
url: _ROBOTAXI_HOST + '/itinerary/base/v1/mini-program/navigation/multi-route-and-fare',
method: 'POST',
- payload
+ payload,
+ showToast: options.showToast
}, true);
}
@@ -278,7 +286,8 @@
return request({
url: _ROBOTAXI_HOST + `/order/api/v1/mini-program/order/comment`,
method: 'POST',
- payload
+ payload,
+ showToast: false
}, true);
}
diff --git a/src/robotaxi/api/feedback.ts b/src/robotaxi/api/feedback.ts
index 8ff3d3e..c1017c4 100644
--- a/src/robotaxi/api/feedback.ts
+++ b/src/robotaxi/api/feedback.ts
@@ -1,9 +1,21 @@
import request from '../../utils/request';
import { _ROBOTAXI_HOST } from '../../constants/api';
-import type { SubmitComplaintPayload, SubmitFeedbackResponse } from './types/begin-route';
+import type { SubmitComplaintPayload, SubmitFeedbackResponse, SubmitOrderCommentPayload } from './types/begin-route';
import type { FeedbackTypeItem } from './types/feedback';
// 提交订单投诉
+export const submitOrderComplaint = async (
+ payload: SubmitOrderCommentPayload
+): Promise<SubmitFeedbackResponse> => {
+ return request({
+ url: _ROBOTAXI_HOST + `/operation/base/v1/mini-program/orderComplaint/submit`,
+ method: 'POST',
+ payload,
+ showToast: false
+ }, true);
+}
+
+// 提交独立反馈投诉
export const submitComplaint = async (
payload: SubmitComplaintPayload
): Promise<SubmitFeedbackResponse> => {
diff --git a/src/robotaxi/api/order.ts b/src/robotaxi/api/order.ts
index 402c6ff..a1ef4a1 100644
--- a/src/robotaxi/api/order.ts
+++ b/src/robotaxi/api/order.ts
@@ -53,6 +53,7 @@
start: FareItem[];
time: number;
payMoney?: number; // 实际支付金额,虽然detail.txt没写,但页面展示需要,可能包含在返回中或需计算
+ hasComplaint: boolean; // 是否已反馈
}
export type FareBreakdownPayload = {
diff --git a/src/robotaxi/components/demo/index.tsx b/src/robotaxi/components/demo/index.tsx
index 60e52a2..2dec586 100644
--- a/src/robotaxi/components/demo/index.tsx
+++ b/src/robotaxi/components/demo/index.tsx
@@ -1,4 +1,3 @@
-import Taro from '@tarojs/taro'
import { PureComponent } from 'react'
import { View, Text } from '@tarojs/components'
import './index.less'
@@ -164,8 +163,8 @@
fareEstimate={DEMO_MODIFY_FARE_ESTIMATE}
routeOptions={DEMO_MODIFY_ROUTE_OPTIONS}
selectedRouteIndex={0}
- onEditDestination={() => Taro.showToast({ title: '编辑目的地(Demo)', icon: 'none' })}
- onConfirm={() => Taro.showToast({ title: '确认修改(Demo)', icon: 'none' })}
+ onEditDestination={() => showToast({ title: '编辑目的地(Demo)', icon: 'none' })}
+ onConfirm={() => showToast({ title: '确认修改(Demo)', icon: 'none' })}
/>
</View>
</View>
diff --git a/src/robotaxi/components/fireworks/error-boundary.tsx b/src/robotaxi/components/fireworks/error-boundary.tsx
new file mode 100644
index 0000000..b0ff54d
--- /dev/null
+++ b/src/robotaxi/components/fireworks/error-boundary.tsx
@@ -0,0 +1,35 @@
+import { Component, ReactNode } from 'react';
+
+// 通用错误边界:捕获子树在「渲染 / 生命周期」阶段抛出的错误,出错后静默渲染为空,
+// 避免动效等非核心 UI 的异常向上冒泡拖垮整个页面。
+// 注意:错误边界无法捕获异步回调(requestAnimationFrame、Promise、事件处理器)中的错误,
+// 那部分需在对应异步逻辑内自行 try/catch 兜底。
+type Props = {
+ children: ReactNode;
+ /** 出错时的兜底 UI,默认不显示任何内容 */
+ fallback?: ReactNode;
+};
+
+type State = {
+ hasError: boolean;
+};
+
+export default class ErrorBoundary extends Component<Props, State> {
+ state: State = { hasError: false };
+
+ static getDerivedStateFromError(): State {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: any) {
+ // eslint-disable-next-line no-console
+ console.warn('[Fireworks] 已忽略渲染错误:', error);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return this.props.fallback ?? null;
+ }
+ return this.props.children;
+ }
+}
diff --git a/src/robotaxi/components/fireworks/index.less b/src/robotaxi/components/fireworks/index.less
new file mode 100644
index 0000000..dc00905
--- /dev/null
+++ b/src/robotaxi/components/fireworks/index.less
@@ -0,0 +1,9 @@
+.xp-fireworks-canvas {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 999;
+ pointer-events: none;
+}
diff --git a/src/robotaxi/components/fireworks/index.tsx b/src/robotaxi/components/fireworks/index.tsx
new file mode 100644
index 0000000..3627a9e
--- /dev/null
+++ b/src/robotaxi/components/fireworks/index.tsx
@@ -0,0 +1,436 @@
+import Taro from '@tarojs/taro';
+import { Canvas } from '@tarojs/components';
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+} from 'react';
+import ErrorBoundary from './error-boundary';
+import './index.less';
+
+// 粒子礼花动效:小程序原生 Canvas 2D 自绘,复刻 react-canvas-confetti 的 fireworks 预设。
+// 参数命名与 canvas-confetti 对齐,方便与 H5 侧对照调参。
+
+export type FireworkShape = 'square' | 'circle';
+
+export type FireworksOptions = {
+ /** 单簇粒子数量(连发时随剩余时间递减) */
+ particleCount?: number;
+ /** 散布角度(度),360 即向四周炸开 */
+ spread?: number;
+ /** 发射方向角度(度),90 表示向上(spread=360 时无影响) */
+ angle?: number;
+ /** 初始速度 */
+ startVelocity?: number;
+ /** 重力(每帧对 vy 的增量) */
+ gravity?: number;
+ /** 横向漂移 */
+ drift?: number;
+ /** 每帧速度衰减(空气阻力) */
+ decay?: number;
+ /** 粒子大小缩放(方片边长 = 6 * scalar) */
+ scalar?: number;
+ /** 粒子存活帧数 */
+ ticks?: number;
+ /** 颜色数组 */
+ colors?: string[];
+ /** 形状(单个),与 shapes 二选一 */
+ shape?: FireworkShape;
+ /** 形状数组,随机取一,优先级高于 shape */
+ shapes?: FireworkShape[];
+ /** 发射中心点,取值 0~1 的相对坐标(x 向右、y 向下) */
+ origin?: { x?: number; y?: number };
+ /** 连发总时长(ms),仅 play 连发时生效 */
+ duration?: number;
+ /** 两次发射间隔(ms),仅 play 连发时生效 */
+ interval?: number;
+};
+
+export type FireworksRef = {
+ /** 连发礼花(在 duration 内每隔 interval 在随机位置发射一簇) */
+ play: (options?: FireworksOptions) => void;
+ /** 单次发射一簇礼花(等价 canvas-confetti 的 confetti(options)) */
+ shoot: (options?: FireworksOptions) => void;
+ /** 立即停止并清空 */
+ stop: () => void;
+};
+
+type Props = {
+ /** canvas 层级 */
+ zIndex?: number;
+};
+
+const DEFAULTS = {
+ particleCount: 80,
+ spread: 360,
+ angle: 90,
+ startVelocity: 28,
+ gravity: 0.85,
+ drift: 0,
+ decay: 0.9,
+ scalar: 1,
+ ticks: 150,
+ colors: ['#7FE6F2', '#F9A76A', '#95CF29'],
+ shapes: ['square'] as FireworkShape[],
+ origin: { x: 0.5, y: 0.5 },
+ duration: 2500,
+ interval: 250,
+};
+
+type NormalizedOptions = {
+ particleCount: number;
+ spread: number;
+ angle: number;
+ startVelocity: number;
+ gravity: number;
+ drift: number;
+ decay: number;
+ scalar: number;
+ ticks: number;
+ colors: string[];
+ shapes: FireworkShape[];
+ origin: { x: number; y: number };
+ duration: number;
+ interval: number;
+};
+
+// 合并默认值 + 兼容 shape 单值写法,产出归一化配置
+function normalize(options: FireworksOptions = {}): NormalizedOptions {
+ const shapes = options.shapes && options.shapes.length
+ ? options.shapes
+ : options.shape
+ ? [options.shape]
+ : DEFAULTS.shapes;
+ return {
+ particleCount: options.particleCount ?? DEFAULTS.particleCount,
+ spread: options.spread ?? DEFAULTS.spread,
+ angle: options.angle ?? DEFAULTS.angle,
+ startVelocity: options.startVelocity ?? DEFAULTS.startVelocity,
+ gravity: options.gravity ?? DEFAULTS.gravity,
+ drift: options.drift ?? DEFAULTS.drift,
+ decay: options.decay ?? DEFAULTS.decay,
+ scalar: options.scalar ?? DEFAULTS.scalar,
+ ticks: options.ticks ?? DEFAULTS.ticks,
+ colors: options.colors ?? DEFAULTS.colors,
+ shapes,
+ origin: {
+ x: options.origin?.x ?? DEFAULTS.origin.x,
+ y: options.origin?.y ?? DEFAULTS.origin.y,
+ },
+ duration: options.duration ?? DEFAULTS.duration,
+ interval: options.interval ?? DEFAULTS.interval,
+ };
+}
+
+function rand(min: number, max: number) {
+ return Math.random() * (max - min) + min;
+}
+
+// 粒子模型对齐 canvas-confetti:每片纸屑带 wobble(飘动)+ tiltAngle(翻转),
+// 因此绘制成会抖动、翻面的四边形,而非静止方块,视觉上更接近真实纸屑。
+type Particle = {
+ x: number;
+ y: number;
+ velocity: number; // 爆发初速(逐帧按 decay 衰减)
+ angle2D: number; // 发射方向(弧度)
+ wobble: number; // 飘动相位
+ wobbleSpeed: number;
+ wobbleX: number;
+ wobbleY: number;
+ tiltAngle: number; // 翻转角
+ gravity: number; // 每帧恒定下落量(= opt.gravity * 3)
+ decay: number;
+ drift: number;
+ scalar: number;
+ color: string;
+ shape: FireworkShape;
+ tick: number;
+ totalTicks: number;
+};
+
+// 生成一簇粒子。angle 为发射主方向(度,90 向上),spread 为散布角度。
+// angle2D = -angle ± spread/2,屏幕 y 向下,故 angle=90 时 sin(angle2D)=-1 表现为向上。
+// 初速取 sqrt(random)×startVelocity:用平方根让粒子按「面积」均匀分布,
+// 避免大量粒子挤在中心(若用线性 random,低速粒子过多会在中心结团);
+// 同时最小值仍接近 0,保留少量粒子填充中心,整体从中心向四周均匀铺满屏幕。
+function createBurst(cx: number, cy: number, particleCount: number, opt: NormalizedOptions) {
+ const list: Particle[] = [];
+ const radAngle = (opt.angle * Math.PI) / 180;
+ const radSpread = (opt.spread * Math.PI) / 180;
+ for (let i = 0; i < particleCount; i++) {
+ list.push({
+ x: cx,
+ y: cy,
+ velocity: Math.sqrt(Math.random()) * opt.startVelocity,
+ angle2D: -radAngle + (0.5 * radSpread - Math.random() * radSpread),
+ wobble: Math.random() * 10,
+ wobbleSpeed: Math.min(0.11, Math.random() * 0.1 + 0.05),
+ wobbleX: 0,
+ wobbleY: 0,
+ tiltAngle: (Math.random() * 0.5 + 0.25) * Math.PI,
+ gravity: opt.gravity * 3,
+ decay: opt.decay,
+ drift: opt.drift,
+ // 每片纸屑大小在 scalar 基础上随机(0.5×~1.8×),大的一端更大,呈现有大有小的层次感
+ scalar: opt.scalar * rand(0.5, 1.8),
+ color: opt.colors[(Math.random() * opt.colors.length) | 0],
+ shape: opt.shapes[(Math.random() * opt.shapes.length) | 0],
+ tick: 0,
+ totalTicks: opt.ticks,
+ });
+ }
+ return list;
+}
+
+// canvas 节点 id 前缀。真正的 id 会在每个实例内拼接自增序号 + 随机串,保证同页多个
+// <Fireworks> 实例不会因固定 id 冲突而 createSelectorQuery 选错节点(class 仍固定用于样式)。
+const CANVAS_ID_PREFIX = 'xp-fireworks-canvas';
+// 实例自增序号:模块级,配合随机串生成唯一 canvas id
+let fireworksInstanceSeq = 0;
+
+const FireworksInner = forwardRef<FireworksRef, Props>((props, ref) => {
+ const canvasRef = useRef<any>(null);
+ const ctxRef = useRef<any>(null);
+ const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
+ const particlesRef = useRef<Particle[]>([]);
+ const runningRef = useRef(false);
+ // 世代号:stop() 递增,loop 每帧比对——若世代变化则自终止,解决 stop() 停不掉已排队的 rAF
+ const generationRef = useRef(0);
+ // 当前发射器:play 设 fireRef,shoot 设 null;运行中更换 fireRef 即可热替换发射逻辑,解决吞新 play()
+ const fireRef = useRef<((now: number) => boolean) | null>(null);
+ // 本实例唯一的 canvas id(惰性生成一次,跨渲染稳定)
+ const canvasIdRef = useRef<string>('');
+ if (!canvasIdRef.current) {
+ fireworksInstanceSeq += 1;
+ canvasIdRef.current = `${CANVAS_ID_PREFIX}-${fireworksInstanceSeq}-${Math.random().toString(36).slice(2, 8)}`;
+ }
+
+ // 统一的错误吞噬:礼花只是锦上添花的动效,任何异常都不应外溢影响页面,出错即静默不显示
+ const swallow = (err: unknown) => {
+ runningRef.current = false;
+ // eslint-disable-next-line no-console
+ console.warn('[Fireworks] 已忽略动效内部错误:', err);
+ };
+
+ // 包裹异步入口方法:错误边界捕获不到 rAF/Promise 等异步栈里的错误,这里统一兜底吞掉
+ const safe = <A extends any[]>(fn: (...args: A) => void | Promise<void>) => (...args: A) => {
+ try {
+ const ret = fn(...args);
+ if (ret && typeof (ret as Promise<void>).catch === 'function') {
+ (ret as Promise<void>).catch(swallow);
+ }
+ } catch (err) {
+ swallow(err);
+ }
+ };
+
+ // 初始化 canvas 节点(Canvas 2D)。同步异常会 reject 由 safe 兜底;
+ // exec 回调是异步栈,safe 捕获不到,故回调内单独 try/catch。
+ const initCanvas = () =>
+ new Promise<void>((resolve) => {
+ if (canvasRef.current) {
+ resolve();
+ return;
+ }
+ const query = Taro.createSelectorQuery();
+ query
+ .select(`#${canvasIdRef.current}`)
+ .fields({ node: true, size: true })
+ .exec((res) => {
+ try {
+ if (!res || !res[0] || !res[0].node) {
+ resolve();
+ return;
+ }
+ const canvas = res[0].node;
+ const ctx = canvas.getContext('2d');
+ const dpr = (Taro.getSystemInfoSync() || {}).pixelRatio || 1;
+ canvas.width = res[0].width * dpr;
+ canvas.height = res[0].height * dpr;
+ ctx.scale(dpr, dpr);
+ canvasRef.current = canvas;
+ ctxRef.current = ctx;
+ sizeRef.current = { w: res[0].width, h: res[0].height };
+ resolve();
+ } catch (err) {
+ swallow(err);
+ resolve();
+ }
+ });
+ });
+
+ // 逐帧更新并绘制单个粒子,返回 false 表示已消亡。逻辑对齐 canvas-confetti 的 updateFizz。
+ const updateAndDraw = (ctx: any, p: Particle) => {
+ // 位置:沿 angle2D 前进(速度按 decay 衰减)+ 恒定重力下落 + 横向漂移
+ p.x += Math.cos(p.angle2D) * p.velocity + p.drift;
+ p.y += Math.sin(p.angle2D) * p.velocity + p.gravity;
+ p.velocity *= p.decay;
+
+ // wobble:让粒子飘动,绘制成随时间抖动的四边形
+ p.wobble += p.wobbleSpeed;
+ p.wobbleX = p.x + 10 * p.scalar * Math.cos(p.wobble);
+ p.wobbleY = p.y + 10 * p.scalar * Math.sin(p.wobble);
+
+ // tiltAngle:让纸屑翻面(宽度周期性收缩,模拟 3D 翻转)
+ p.tiltAngle += 0.1;
+ const tiltSin = Math.sin(p.tiltAngle);
+ const tiltCos = Math.cos(p.tiltAngle);
+ const random = Math.random() + 2; // 对齐 canvas-confetti,决定纸片另一维的尺寸
+ const x1 = p.x + random * tiltCos;
+ const y1 = p.y + random * tiltSin;
+ const x2 = p.wobbleX + random * tiltCos;
+ const y2 = p.wobbleY + random * tiltSin;
+
+ const progress = p.tick / p.totalTicks;
+ p.tick++;
+ if (p.tick >= p.totalTicks) return false;
+
+ ctx.globalAlpha = 1 - progress;
+ ctx.fillStyle = p.color;
+ ctx.beginPath();
+ if (p.shape === 'circle') {
+ // 椭圆:随 wobble/tilt 改变长短轴,呈现翻转的圆片(ovalScalar=0.6)
+ ctx.ellipse(
+ p.x,
+ p.y,
+ Math.abs(x2 - x1) * 0.6,
+ Math.abs(y2 - y1) * 0.6,
+ (Math.PI / 10) * p.wobble,
+ 0,
+ 2 * Math.PI,
+ );
+ } else {
+ // 方片:由 4 个随 wobble/tilt 抖动的顶点构成的四边形,会飘动翻面
+ ctx.moveTo(Math.floor(p.x), Math.floor(p.y));
+ ctx.lineTo(Math.floor(p.wobbleX), Math.floor(y1));
+ ctx.lineTo(Math.floor(x2), Math.floor(y2));
+ ctx.lineTo(Math.floor(x1), Math.floor(p.wobbleY));
+ }
+ ctx.closePath();
+ ctx.fill();
+ return true;
+ };
+
+ // 逐帧渲染循环:读取 fireRef 驱动发射、比对 generationRef 自终止,无粒子且无发射时自动停止 rAF
+ const startLoop = () => {
+ const canvas = canvasRef.current;
+ const ctx = ctxRef.current;
+ if (!canvas || !ctx) return;
+ if (runningRef.current) return; // 循环唯一,发射器靠 fireRef 热替换,新 play 无需重启循环
+ runningRef.current = true;
+ const { w, h } = sizeRef.current;
+ const myGen = generationRef.current;
+
+ const loop = () => {
+ // 单帧出错时吞掉并终止循环,清空粒子,避免错误反复外溢
+ try {
+ // 世代号比对:stop() 递增后,已排队的下一帧检测到变化即自终止
+ if (myGen !== generationRef.current) {
+ runningRef.current = false;
+ return;
+ }
+ const now = Date.now();
+ const keepFiring = fireRef.current ? fireRef.current(now) : false;
+
+ ctx.clearRect(0, 0, w, h);
+ particlesRef.current = particlesRef.current.filter((p) => updateAndDraw(ctx, p));
+ ctx.globalAlpha = 1;
+
+ if (particlesRef.current.length || keepFiring) {
+ canvas.requestAnimationFrame(loop);
+ } else {
+ runningRef.current = false;
+ fireRef.current = null;
+ }
+ } catch (err) {
+ particlesRef.current = [];
+ try {
+ ctx.clearRect(0, 0, w, h);
+ } catch (_) {
+ // 清屏也失败则忽略
+ }
+ swallow(err);
+ }
+ };
+
+ canvas.requestAnimationFrame(loop);
+ };
+
+ // 单次发射一簇礼花(safe 包裹,异步异常统一吞掉)
+ const shoot = safe(async (options: FireworksOptions = {}) => {
+ await initCanvas();
+ if (!canvasRef.current || !ctxRef.current) return;
+ const opt = normalize(options);
+ const { w, h } = sizeRef.current;
+ const cx = opt.origin.x * w;
+ const cy = opt.origin.y * h;
+ fireRef.current = null;
+ particlesRef.current.push(...createBurst(cx, cy, opt.particleCount, opt));
+ startLoop();
+ });
+
+ // 连发礼花:在 duration 内每隔 interval 在随机位置发射一簇。
+ // fireRef 先设后调 startLoop——即使循环已在运行也能热替换发射逻辑,不再吞新 play()。
+ const play = safe(async (options: FireworksOptions = {}) => {
+ await initCanvas();
+ if (!canvasRef.current || !ctxRef.current) return;
+ const opt = normalize(options);
+ const { w, h } = sizeRef.current;
+ const end = Date.now() + opt.duration;
+ let lastFire = 0;
+
+ fireRef.current = (now) => {
+ if (now <= end && now - lastFire >= opt.interval) {
+ lastFire = now;
+ const remain = (end - now) / opt.duration;
+ const count = Math.max(1, Math.ceil(opt.particleCount * remain));
+ const cx = rand(0.2, 0.8) * w;
+ const cy = rand(0.35, 0.5) * h;
+ particlesRef.current.push(...createBurst(cx, cy, count, opt));
+ }
+ return now <= end;
+ };
+ startLoop();
+ });
+
+ const stop = safe(() => {
+ generationRef.current += 1;
+ runningRef.current = false;
+ fireRef.current = null;
+ particlesRef.current = [];
+ const ctx = ctxRef.current;
+ const { w, h } = sizeRef.current;
+ if (ctx) ctx.clearRect(0, 0, w, h);
+ });
+
+ useImperativeHandle(ref, () => ({ play, shoot, stop }));
+
+ useEffect(() => {
+ return () => {
+ generationRef.current += 1;
+ runningRef.current = false;
+ fireRef.current = null;
+ particlesRef.current = [];
+ };
+ }, []);
+
+ return (
+ <Canvas
+ type='2d'
+ id={canvasIdRef.current}
+ className='xp-fireworks-canvas'
+ style={props.zIndex != null ? { zIndex: props.zIndex } : undefined}
+ />
+ );
+});
+
+// 用错误边界包裹:捕获渲染/生命周期阶段的错误(异步错误由内部 safe 兜底),出错静默渲染为空。
+// ref 透传给内部组件,外部照常拿到 play / shoot / stop。
+const Fireworks = forwardRef<FireworksRef, Props>((props, ref) => (
+ <ErrorBoundary>
+ <FireworksInner {...props} ref={ref} />
+ </ErrorBoundary>
+));
+
+export default Fireworks;
diff --git "a/src/robotaxi/components/fireworks/\347\244\274\350\212\261\346\225\210\346\236\234.md" "b/src/robotaxi/components/fireworks/\347\244\274\350\212\261\346\225\210\346\236\234.md"
new file mode 100644
index 0000000..cb62906
--- /dev/null
+++ "b/src/robotaxi/components/fireworks/\347\244\274\350\212\261\346\225\210\346\236\234.md"
@@ -0,0 +1,139 @@
+# 礼花(Fireworks)动效组件
+
+> 小程序原生 **Canvas 2D** 自绘的粒子礼花组件,复刻 [`canvas-confetti`](https://github.com/catdad/canvas-confetti) / [`react-canvas-confetti`](https://github.com/ulitcos/react-canvas-confetti) 的 `confetti`/`fireworks` 观感,**不依赖任何第三方库**。
+> 参数命名与 `canvas-confetti` 对齐,方便与 H5 侧对照调参。
+
+组件位置:`src/robotaxi/components/fireworks/`
+
+```
+fireworks/
+├── index.tsx # 组件主体(forwardRef,暴露 play/shoot/stop)
+├── error-boundary.tsx # 通用错误边界(吞掉渲染/生命周期错误)
+├── index.less # 全屏、置顶、pointer-events:none 的 canvas 样式
+└── 礼花效果.md # 本文档
+```
+
+---
+
+## 1. 背景
+
+现有 H5 工程的撒花动效基于 `react-canvas-confetti`(底层 `canvas-confetti`),强依赖浏览器 DOM/BOM(`document`、`window`、DOM canvas)。**小程序没有 DOM,canvas 是专有组件**,无法直接复用,因此在小程序侧用**原生 Canvas 2D 自绘粒子系统**复刻同样的效果。
+
+## 2. 对外 API
+
+组件通过 `forwardRef` + `useImperativeHandle` 暴露 3 个命令式方法(`FireworksRef`):
+
+| 方法 | 说明 |
+|------|------|
+| `shoot(options?)` | **单次发射一簇**礼花,等价 `canvas-confetti` 的 `confetti(options)`。到达结束态即用此方法。 |
+| `play(options?)` | **连发**:在 `duration` 内每隔 `interval` 在随机位置发射一簇,`particleCount` 随剩余时间递减。 |
+| `stop()` | 立即停止并清空画布。 |
+
+Props:
+
+| Prop | 类型 | 说明 |
+|------|------|------|
+| `zIndex` | `number?` | canvas 层级;不传则用样式默认 `999`。 |
+
+### 用法
+
+```tsx
+import Fireworks, { FireworksRef } from '@/robotaxi/components/fireworks';
+
+const ref = useRef<FireworksRef | null>(null);
+
+// 挂载
+<Fireworks ref={(node) => { ref.current = node; }} zIndex={99999} />
+
+// 单簇(到达庆祝)
+ref.current?.shoot({
+ origin: { x: 0.5, y: 0.4 },
+ angle: 90,
+ particleCount: 260,
+ spread: 360,
+ startVelocity: 42,
+ gravity: 0.9,
+ scalar: 0.9,
+ ticks: 180,
+ colors: ['#FFD666', '#F9A76A', '#FFF1B8', '#7FE6F2', '#95CF29'],
+});
+
+// 连发
+ref.current?.play({ duration: 2500, interval: 250 });
+```
+
+### 当前落地场景
+
+- **到达结束态庆祝**:`src/robotaxi/pages/begin-route/index.tsx` 中,检测 `tripStatus` 由非结束态切到 `TripFinished(8)` 的上升沿,延迟约 180ms 后调用 `shoot` 发射一簇(与背景卡片升起同步呈现,只发一次避免重复观感)。
+- **调参 Demo**:`src/robotaxi/pages/fireworks-demo/index.tsx`(仅调试用),点击按钮调用 `shoot` 绽放一簇。
+
+## 3. 参数(`FireworksOptions`)与默认值
+
+| 参数 | 含义 | 默认值 |
+|------|------|--------|
+| `particleCount` | 单簇粒子数量(连发时随剩余时间递减) | `80` |
+| `spread` | 散布角度(度),`360` 即向四周炸开 | `360` |
+| `angle` | 发射主方向(度),`90` 向上(`spread=360` 时无影响) | `90` |
+| `startVelocity` | 初始速度(实际按 `sqrt(random)` 分布,见下) | `28` |
+| `gravity` | 重力(每帧恒定下落量 = `gravity * 3`) | `0.85` |
+| `drift` | 横向漂移 | `0` |
+| `decay` | 每帧速度衰减(空气阻力) | `0.9` |
+| `scalar` | 粒子大小缩放(每片再随机 `0.5×~1.8×`) | `1` |
+| `ticks` | 粒子存活帧数 | `150` |
+| `colors` | 颜色数组 | `['#7FE6F2', '#F9A76A', '#95CF29']` |
+| `shape` / `shapes` | 形状:`'square'`(方片)/ `'circle'`(椭圆片),`shapes` 优先 | `['square']` |
+| `origin` | 发射中心,相对坐标 `0~1`(x 向右、y 向下) | `{ x: 0.5, y: 0.5 }` |
+| `duration` | 连发总时长(ms),仅 `play` 生效 | `2500` |
+| `interval` | 两次发射间隔(ms),仅 `play` 生效 | `250` |
+
+## 4. 粒子模型与算法
+
+粒子模型对齐 `canvas-confetti`:每片纸屑带 `wobble`(飘动)与 `tiltAngle`(翻转),绘制成**会抖动、翻面的四边形/椭圆**,而非静止方块,视觉上更接近真实纸屑。
+
+**发射(`createBurst`)**
+
+- 主方向 `angle2D = -radAngle + (0.5*radSpread - random*radSpread)`;屏幕 y 向下,故 `angle=90` 时向上。
+- 初速 `velocity = sqrt(random) * startVelocity`:用平方根让粒子按「面积」均匀分布,避免大量粒子挤在中心结团;同时保留少量低速粒子填充中心,整体从中心向四周均匀铺满。
+- 每片大小 `scalar * rand(0.5, 1.8)`:有大有小的层次感。
+
+**每帧更新(`updateAndDraw`)**
+
+```
+x += cos(angle2D) * velocity + drift
+y += sin(angle2D) * velocity + gravity // gravity 为每帧恒定下落量
+velocity *= decay // 速度衰减
+wobble += wobbleSpeed // 飘动相位,决定四边形顶点抖动
+tiltAngle += 0.1 // 翻面(宽度周期性收缩,模拟 3D 翻转)
+alpha = 1 - tick / totalTicks // 线性淡出
+tick++ ; if (tick >= totalTicks) → 移除
+```
+
+- 方片:由 4 个随 wobble/tilt 抖动的顶点构成的四边形。
+- 椭圆:随 wobble/tilt 改变长短轴(ovalScalar=0.6),呈现翻转的圆片。
+
+**渲染循环(`startLoop`)**:单实例只跑一个 `requestAnimationFrame` 循环,新发射的粒子追加进同一循环;无存活粒子且无待发射时自动停止 rAF,避免空转耗电。
+
+## 5. 健壮性设计(三层容错)
+
+礼花只是锦上添花的动效,任何异常都不应拖垮页面,出错即静默不显示:
+
+1. **`ErrorBoundary`**:捕获子树在渲染/生命周期阶段抛出的错误,出错渲染为空。
+2. **`safe()` 包裹**:包裹 `shoot`/`play`/`stop` 等异步入口,兜底 rAF/Promise 等异步栈里错误边界捕获不到的错误。
+3. **逐帧 `try/catch`**:单帧出错即清空粒子并终止循环,避免错误反复外溢。
+
+## 6. 关键实现细节
+
+- **Canvas 2D(`type="2d"`)**:新版 canvas,性能优于旧版 `createCanvasContext`。
+- **DPR 适配**:`canvas.width = 逻辑宽 * pixelRatio` 并 `ctx.scale(dpr, dpr)`,否则高清屏发虚。
+- **唯一 canvas id**:每个组件实例惰性生成 `xp-fireworks-canvas-{seq}-{random}` 作为 canvas id(`className` 仍固定 `xp-fireworks-canvas` 用于样式),避免同页多个 `<Fireworks>` 实例因固定 id 导致 `createSelectorQuery` 选错节点。
+- **点击穿透**:canvas `position:fixed` 全屏置顶 + `pointer-events:none`,不阻挡按钮/支付等交互。
+- **卸载清理**:`useEffect` 卸载时停循环、清粒子;到达页对 `<Fireworks>` 做条件渲染,离开结束态即卸载。
+
+## 7. 验收标准
+
+- [ ] 到达结束态(6→8)时,随背景卡片升起绽放一簇礼花,粒子四散、受重力下落并淡出,仅播一次。
+- [ ] 配色/形状/密度可通过 `FireworksOptions` 配置。
+- [ ] 动画期间不阻塞页面交互(点击穿透正常)。
+- [ ] 动画结束后 rAF 停止、canvas 无残留、CPU 回落。
+- [ ] 组件内部任何异常都被静默吞掉,不影响页面主流程。
+- [ ] 真机(iOS + Android)低端机型帧率可接受,无明显卡顿。
diff --git a/src/robotaxi/components/site-route-card/index.less b/src/robotaxi/components/site-route-card/index.less
new file mode 100644
index 0000000..302a2d1
--- /dev/null
+++ b/src/robotaxi/components/site-route-card/index.less
@@ -0,0 +1,86 @@
+// SiteRouteCard 公共样式:抽离自 cancel-site-box / calling-car-site-box / share-site-box /
+// modify-destination-review-select-site,与 release-be-being 的 route-card 结构对齐。
+// 各业务方通过外层 className(如 cancel-site-box)叠加背景/内边距等容器级样式,
+// 组件自身只负责 item/marker/dot/check/line/divider/text 的结构样式。
+@import '../../style/ui-variables.less';
+
+.site-route-card {
+ .site-route-card-item {
+ position: relative;
+
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+
+ &.site-route-card-item-first {
+ padding-bottom: 80px;
+ }
+
+ // 虚线连接线:以 item 盒子为基准,用 top/bottom 锚定首尾 marker 中心,
+ // 文字换行导致 item 变高时自动拉伸,无需固定高度。
+ .site-route-card-item-line {
+ position: absolute;
+ left: 14px;
+ top: 40px;
+ bottom: -8px;
+ transform: translateX(-50%);
+ width: 0;
+ border-left: 2px dashed #ccd0d7;
+ }
+
+ // 两项之间的水平分割线:与文字列左对齐,居中于行间距内。
+ .site-route-card-item-divider {
+ position: absolute;
+ left: 48px;
+ right: 0;
+ top: 88px;
+ height: 0;
+ border-top: 1px solid rgba(204, 208, 215, 0.25);
+ }
+
+ .site-route-card-item-marker {
+ position: relative;
+ flex-shrink: 0;
+ width: 28px;
+ height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .site-route-card-item-dot {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: @black-text-main;
+
+ &.site-route-card-item-dot-first {
+ background: @gold-light;
+ }
+ }
+
+ .site-route-card-item-check {
+ width: 28px;
+ height: 28px;
+ }
+ }
+
+ .site-route-card-item-row {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .site-route-card-item-text {
+ flex: 1;
+ min-width: 0;
+ font-size: 32px;
+ font-weight: 500;
+ line-height: 48px;
+ letter-spacing: normal;
+ color: @black-text-main;
+ word-break: break-all;
+ }
+ }
+}
diff --git a/src/robotaxi/components/site-route-card/index.tsx b/src/robotaxi/components/site-route-card/index.tsx
new file mode 100644
index 0000000..0b538c2
--- /dev/null
+++ b/src/robotaxi/components/site-route-card/index.tsx
@@ -0,0 +1,71 @@
+import { View, Image } from '@tarojs/components';
+import './index.less';
+
+export type SiteRouteCardItem = {
+ /** 站点名称 */
+ name: React.ReactNode;
+ /** 名称文案的额外 className,用于置灰等场景 */
+ nameClassName?: string;
+ /** 右侧扩展内容(如时间文案),仅在需要时传入 */
+ extra?: React.ReactNode;
+ /** 该项点击事件(如终点可点击编辑) */
+ onClick?: () => void;
+};
+
+type Props = {
+ /** 站点列表,通常为 [起点, 终点] 两项 */
+ sites: SiteRouteCardItem[];
+ /** marker 类型:dot 为纯圆点,check 为图标(需配合 checkIconUrls 传入图标地址) */
+ markerType?: 'dot' | 'check';
+ /** markerType 为 check 时,按 sites 顺序传入对应图标地址 */
+ checkIconUrls?: string[];
+ /** 最外层 className,供各业务方沿用自身既有命名做样式覆盖 */
+ className?: string;
+};
+
+/**
+ * 站点路线卡片:统一的起终点两项展示结构(marker + 虚线连接线 + 分割线 + 文本)。
+ * 抽离自 cancel-site-box / calling-car-site-box / share-site-box / modify-destination-review-select-site
+ * 与 release-be-being 的 route-card 结构对齐。
+ */
+export default function SiteRouteCard({
+ sites,
+ markerType = 'dot',
+ checkIconUrls = [],
+ className = ''
+}: Props) {
+ return (
+ <View className={`site-route-card ${className}`.trim()}>
+ {sites.map((site, index) => {
+ const isFirst = index === 0;
+ return (
+ <View
+ key={index}
+ className={`site-route-card-item${isFirst ? ' site-route-card-item-first' : ''}`}
+ onClick={site.onClick}
+ >
+ {isFirst && <View className='site-route-card-item-line'></View>}
+ {isFirst && <View className='site-route-card-item-divider'></View>}
+ <View className='site-route-card-item-marker'>
+ {markerType === 'check' ? (
+ <Image className='site-route-card-item-check' src={checkIconUrls[index]} />
+ ) : (
+ <View
+ className={`site-route-card-item-dot${
+ isFirst ? ' site-route-card-item-dot-first' : ''
+ }`}
+ ></View>
+ )}
+ </View>
+ <View className='site-route-card-item-row'>
+ <View className={`site-route-card-item-text ${site.nameClassName || ''}`.trim()}>
+ {site.name}
+ </View>
+ {site.extra}
+ </View>
+ </View>
+ );
+ })}
+ </View>
+ );
+}
diff --git a/src/robotaxi/constants/assets.ts b/src/robotaxi/constants/assets.ts
new file mode 100644
index 0000000..cfbe1b3
--- /dev/null
+++ b/src/robotaxi/constants/assets.ts
@@ -0,0 +1,62 @@
+/**
+ * 素材链接单一数据源(Single Source of Truth)。
+ *
+ * 所有跨组件复用、且被预加载逻辑(constants/preload-assets.ts)引用的素材 URL 统一在此声明,
+ * 组件与预加载清单都从这里 import,杜绝「组件改了链接、预加载还指向旧链接」导致的静默失效。
+ *
+ * 依赖方向:组件 → assets.ts ← 预加载清单(assets.ts 处于最底层,不反向依赖任何页面模块)。
+ */
+
+/** 派单成功转场主视频(mp4)。 */
+export const VEHICLE_APPROACHING_VIDEO = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_v1_file_w_1128@h_1268.mp4';
+
+/** 派单成功转场视频封面(jpg)。 */
+export const VEHICLE_APPROACHING_POSTER = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_poster_v1_file_w_1128@h_1268.jpg';
+
+/** 派单成功转场视频尾帧(jpeg):结尾遮挡层用,挡住原生 <video> 卸载黑帧。 */
+export const VEHICLE_APPROACHING_END_POSTER = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/video/vehicle_approaching_end_poster_v1_file_w_1128@h_1268.jpeg';
+
+/** 常态行程卡片背景图(派单成功落定 / 接驾 / 行程中)。 */
+export const CARD_BG_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/card-bg-20260714.png';
+
+/** 默认车辆图(无实拍车图时的兜底)。 */
+export const DEFAULT_VEHICLE_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/default-vehicle-img-20260724-v2.png';
+
+/**
+ * 是否使用接口(queryVehicleByOrderId)动态返回的车图。
+ *
+ * 背景:当前线上只有一种车型,产品要求统一用默认车图 DEFAULT_VEHICLE_IMG,
+ * 暂不使用接口返回的动态车图(vehicleInfo.vehiclePictures.url)。
+ *
+ * 后续有新车型、需要按车型展示实拍车图时,把此开关改回 true 即可完全恢复动态车图逻辑,
+ * 无需改动其余代码(车图渲染处 calling-car.tsx、dispatch-transition-layer.tsx 均已按此开关分流;
+ * 默认车图 DEFAULT_VEHICLE_IMG 已在 constants/preload-assets.ts 静态预加载,展示无闪烁)。
+ */
+export const USE_DYNAMIC_VEHICLE_IMG = false;
+
+/** 到达终点 / 行程结束卡片背景图。 */
+export const TRIP_FINISHED_BG_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/trip-finished-20260703.png';
+
+/** 发布中/呼叫车中顶部卡片背景图(release-be-being、calling-car 呼叫态共用)。 */
+export const TOP_CARD_BG_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/top-card-bg-20260722.png';
+
+/** 站点完成态图标(金色:起点,黑色:其余站点)。 */
+export const ROUTE_STATE_ICON_GOLDEN = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/route-state-icon-golden-20260702.png';
+export const ROUTE_STATE_ICON_BLACK = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/route-state-icon-black-20260702.png';
+
+/** 站点连接虚线。 */
+export const DOTTED_LINE_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/dotted-line.png';
+
+/** 接驾中/行程中状态栏「车正在赶来」图标。 */
+export const COME_ICON_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/come-icon-20260728.webp';
+
+/** 发布中进度图标。 */
+export const PUBLISH_PROGRESS_IMG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/publish-progress-20260715.webp';
+
+/**
+ * 全局字体链接。
+ * ⚠️ 必须与 robotaxi/style/ui-variables.less 中的 @font-face src 保持一致;
+ * less 无法 import JS 常量,两处为约定同步(改一处需同步另一处)。
+ */
+export const FONT_XPSANS_BOLD = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/font/XPSANS-Bold.otf';
+export const FONT_XPSANS_REGULAR = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/font/XPSANS-Regular.otf';
diff --git a/src/robotaxi/constants/music-control-feature.ts b/src/robotaxi/constants/music-control-feature.ts
new file mode 100644
index 0000000..6afb07b
--- /dev/null
+++ b/src/robotaxi/constants/music-control-feature.ts
@@ -0,0 +1,13 @@
+/**
+ * 曲风(车载音乐)卡片展示开关。
+ *
+ * true → 正常展示曲风卡片与控制面板,并按原逻辑发起查询/下发请求;
+ * false → 曲风卡片与面板完全不展示,且不发起任何曲风相关接口请求
+ * (非接驾态、连环单等原有守卫逻辑不变)。
+ *
+ * 当前:暂时关闭曲风卡片。
+ */
+export const MUSIC_CONTROL_ENABLED = false;
+
+/** 曲风卡片是否展示 */
+export const isMusicControlEnabled = () => MUSIC_CONTROL_ENABLED;
diff --git a/src/robotaxi/constants/preload-assets.ts b/src/robotaxi/constants/preload-assets.ts
new file mode 100644
index 0000000..489400d
--- /dev/null
+++ b/src/robotaxi/constants/preload-assets.ts
@@ -0,0 +1,46 @@
+import { PreloadAsset } from '../../utils/preload-assets';
+import {
+ VEHICLE_APPROACHING_POSTER,
+ VEHICLE_APPROACHING_END_POSTER,
+ CARD_BG_IMG,
+ DEFAULT_VEHICLE_IMG,
+ TRIP_FINISHED_BG_IMG,
+ TOP_CARD_BG_IMG,
+ ROUTE_STATE_ICON_GOLDEN,
+ ROUTE_STATE_ICON_BLACK,
+ DOTTED_LINE_IMG,
+ COME_ICON_IMG,
+ PUBLISH_PROGRESS_IMG,
+} from './assets';
+
+/**
+ * 转场动效预加载素材清单,按行程状态分组。
+ *
+ * 所有 URL 均来自 constants/assets.ts 单一数据源,组件与本清单引用同一常量,
+ * 组件改链接时预加载自动跟随,不会失效。
+ *
+ * ⚠️ wx.preloadAssets 仅支持 image / font,**不支持视频**:
+ * 派单转场视频(mp4)无法预热,此处仅预加载其封面图(poster)。
+ */
+
+/**
+ * 派单成功转场素材(发布行程 → 发布中 → 呼叫中 → 派单成功/接驾)。
+ * 覆盖从发布行程到接驾整段流程中会用到的图片素材,尽量提前预热,减少首次打车动效卡顿。
+ */
+export const DISPATCH_SUCCESS_ASSETS: PreloadAsset[] = [
+ { type: 'image', src: VEHICLE_APPROACHING_POSTER }, // 转场视频封面(视频本身不支持预热)
+ { type: 'image', src: VEHICLE_APPROACHING_END_POSTER }, // 转场视频尾帧(结尾遮挡层,挡视频卸载黑帧)
+ { type: 'image', src: CARD_BG_IMG }, // 转场落定后的常态卡片背景
+ { type: 'image', src: DEFAULT_VEHICLE_IMG }, // 车模兜底图
+ { type: 'image', src: TOP_CARD_BG_IMG }, // 发布中/呼叫中顶部卡片背景
+ { type: 'image', src: ROUTE_STATE_ICON_GOLDEN }, // 起点完成态图标
+ { type: 'image', src: ROUTE_STATE_ICON_BLACK }, // 其余站点完成态图标
+ { type: 'image', src: DOTTED_LINE_IMG }, // 站点连接虚线
+ { type: 'image', src: COME_ICON_IMG }, // 接驾中/行程中「车正在赶来」图标
+ { type: 'image', src: PUBLISH_PROGRESS_IMG }, // 发布中进度图标
+];
+
+/** 行程结束转场素材(送驾中 → 行程结束)。礼花为纯 canvas 绘制,无素材。 */
+export const TRIP_FINISHED_ASSETS: PreloadAsset[] = [
+ { type: 'image', src: TRIP_FINISHED_BG_IMG }, // 到达终点卡片背景
+];
diff --git a/src/robotaxi/constants/trip-status.ts b/src/robotaxi/constants/trip-status.ts
index 1766b9e..4d6c0d6 100644
--- a/src/robotaxi/constants/trip-status.ts
+++ b/src/robotaxi/constants/trip-status.ts
@@ -52,6 +52,26 @@
Completed = 10,
}
+/**
+ * 取消订单弹窗类型枚举:与历史 number 值保持 1:1 对齐,避免行为变化。
+ */
+export enum CancelType {
+ /** 呼叫车中/发布中取消:预计 N 分钟内有车应答 */
+ CallingCar = 1,
+ /** 接驾中取消:车辆还有 N 分钟/时长到达上车点 */
+ VehicleApproaching = 2,
+ /** 车辆已到达上车点后取消 */
+ VehicleArrivedPickup = 3,
+ /** 订单已取消(终态提示,可再来一单) */
+ OrderCanceled = 4,
+ /** 附近没有空余车辆,已自动取消订单 */
+ NoAvailableVehicle = 5,
+ /** 车辆无法继续服务,正在为您重新派车中 */
+ Redispatching = 6,
+ /** 行程中取消:预计 N 分钟到达目的地 */
+ InService = 7,
+}
+
const TRIP_STATUS_SET = new Set<number>(Object.values(TripStatus).filter((value) => typeof value === 'number') as number[]); // 行程状态值集合
const SOCKET_STATUS_SET = new Set<number>(Object.values(SocketOrderStatus).filter((value) => typeof value === 'number') as number[]); // socket 状态值集合
diff --git a/src/robotaxi/pages/begin-route/components/begin-route-view-context.tsx b/src/robotaxi/pages/begin-route/components/begin-route-view-context.tsx
index ca80d44..3926ce7 100644
--- a/src/robotaxi/pages/begin-route/components/begin-route-view-context.tsx
+++ b/src/robotaxi/pages/begin-route/components/begin-route-view-context.tsx
@@ -1,5 +1,5 @@
import { createContext, useContext } from 'react';
-import { TripStatus } from '../../../constants/trip-status';
+import { TripStatus, CancelType } from '../../../constants/trip-status';
/** BeginRoute 视图上下文。 */
export type BeginRouteViewContextValue = {
@@ -7,14 +7,14 @@
mapLongitude: number;
/** 地图纬度。 */
mapLatitude: number;
- /** 地图缩放等级。 */
- mapScale: number;
/** 地图标记点。 */
markers: any[];
/** 地图折线。 */
polyline: any[];
/** 是否展示定位按钮。 */
showLocation: boolean;
+ /** 进入页面地图 loading 占位(网格图)是否显示。 */
+ mapLoading: boolean;
/** marker 气泡点击。 */
handleCalloutTap: (e: any) => void;
/** 地图区域变化。 */
@@ -71,6 +71,16 @@
walkIcon: string;
/** 渲染底部卡片。 */
renderTripCard: () => JSX.Element | null;
+ /** 是否展示优惠券选择弹窗。 */
+ showCouponSelectPopup: boolean;
+ /** 优惠券可用性计算订单金额。 */
+ couponOrderMoney: number;
+ /** 当前生效优惠券 ID。 */
+ activeCouponId: string;
+ /** 关闭优惠券选择弹窗。 */
+ handleCloseCouponSelect: () => void;
+ /** 选择优惠券。 */
+ handleSelectCoupon: (item: any) => void;
/** 是否展示客服弹窗。 */
showCustomerService: boolean;
/** 关闭客服弹窗。 */
@@ -78,7 +88,7 @@
/** 是否展示取消弹窗。 */
showCancelPopup: boolean;
/** 取消类型。 */
- cancelType: number;
+ cancelType: CancelType;
/** 取消弹窗等待分钟数。 */
cancelPopupWaitMinutes: number | null;
/** 起点。 */
diff --git a/src/robotaxi/pages/begin-route/components/begin-route-view.tsx b/src/robotaxi/pages/begin-route/components/begin-route-view.tsx
index 95c7897..cf872a0 100644
--- a/src/robotaxi/pages/begin-route/components/begin-route-view.tsx
+++ b/src/robotaxi/pages/begin-route/components/begin-route-view.tsx
@@ -1,5 +1,8 @@
import { View, Map, Image } from '@tarojs/components';
+import { memo, useCallback, useRef } from 'react';
import CustomerServicePopup from '../../../components/customer-service';
+import CouponSelectPopup from '../../../components/coupon-select-popup';
+import Toast from '../../../components/toast';
import CancelPopup from './cancel-popup';
import AnnouncementsPopup from './announcements-popup';
import OverRoute from './over-route';
@@ -10,46 +13,139 @@
import PrivacyPopup from '../../../../pages/robotaxi/home/components/privacy-popup';
import NoRoutePopup from './no-route-popup';
import { useBeginRouteViewContext } from './begin-route-view-context';
+import { mapPerfLog } from '../hooks/camera/map-perf-log';
+
+// <Map> 的 style 必须是模块级常量:内联对象字面量每次渲染都是新引用,
+// 会让 React 对 map 节点 diff 出变化 → Taro setData → 微信 map 全量重应用
+// latitude/longitude/scale(scale 未显式传则被打回默认值),产生 causedBy:'update' 的视口跳变。
+const MAP_STYLE = { width: '100%', height: '87vh' } as const;
+
+type StableNativeMapProps = {
+ mapLongitude: number;
+ mapLatitude: number;
+ markers: any[];
+ polyline: any[];
+ showLocation: boolean;
+ mapLoading: boolean;
+ renderMapOverlays: () => JSX.Element;
+ onCalloutTap: (e: any) => void;
+ onRegionChange: (e: any) => void;
+ onPolylineTap: (e: any) => void;
+};
+
+const STABLE_NATIVE_MAP_PROP_KEYS: Array<keyof StableNativeMapProps> = [
+ 'mapLongitude',
+ 'mapLatitude',
+ 'markers',
+ 'polyline',
+ 'showLocation',
+ 'mapLoading',
+ 'renderMapOverlays',
+ 'onCalloutTap',
+ 'onRegionChange',
+ 'onPolylineTap'
+];
+
+/**
+ * 显式记录 memo 比较结果:关闭业务弹窗时应为 changedProps: [];
+ * 若地图确有业务数据变化,则保留正常重渲染能力并直接打印变化字段。
+ */
+const areStableNativeMapPropsEqual = (
+ previous: StableNativeMapProps,
+ next: StableNativeMapProps
+) => {
+ const changedProps = STABLE_NATIVE_MAP_PROP_KEYS.filter(
+ (key) => previous[key] !== next[key]
+ );
+ // mapPerfLog('native-map:compare', { changedProps });
+ return changedProps.length === 0;
+};
+
+/**
+ * 原生地图渲染边界。
+ *
+ * React.memo 负责跳过业务 props 未变化的 React 渲染;主 Map 保持在页面组件作用域,
+ * 确保页面级 Taro.createMapContext('map') 可以继续执行回中、fit 和轨迹操作。
+ */
+const StableNativeMap = memo(({
+ mapLongitude,
+ mapLatitude,
+ markers,
+ polyline,
+ showLocation,
+ mapLoading,
+ renderMapOverlays,
+ onCalloutTap,
+ onRegionChange,
+ onPolylineTap,
+}: StableNativeMapProps) => {
+ // mapPerfLog('native-map:render', {
+ // markersCount: markers.length,
+ // polylineCount: polyline.length,
+ // mapLoading
+ // });
+ return (
+ <Map
+ id='map'
+ longitude={mapLongitude}
+ latitude={mapLatitude}
+ markers={markers}
+ polyline={polyline}
+ showLocation={showLocation}
+ onCalloutTap={onCalloutTap}
+ onRegionChange={onRegionChange}
+ onPolylineTap={onPolylineTap}
+ style={MAP_STYLE}
+ >
+ {renderMapOverlays()}
+ {mapLoading && <View className='map-placeholder-grid' />}
+ </Map>
+ );
+}, areStableNativeMapPropsEqual);
/**
* 地图区块。
*/
function BeginRouteMapArea() {
+ const context = useBeginRouteViewContext();
const {
mapLongitude,
mapLatitude,
- mapScale,
markers,
polyline,
showLocation,
- handleCalloutTap,
- handleRegionChange,
- onPolylineTap,
- onMapTap,
+ mapLoading,
renderMapOverlays,
renderCarCalloutOverlay,
- } = useBeginRouteViewContext();
+ } = context;
+
+ // 事件回调稳定化:context 里的回调每次渲染都是新引用,直接透传会让 map 节点
+ // 每次重渲染(弹窗开关等)都 diff 出 updatePayload → setData → 原生全量属性重应用。
+ // 用 ref 持有最新回调、向 <Map> 传恒定引用的包装函数,保证无关重渲染时 diff 为空。
+ const latestHandlersRef = useRef(context);
+ latestHandlersRef.current = context;
+ const stableCalloutTap = useCallback((e: any) => latestHandlersRef.current.handleCalloutTap(e), []);
+ const stableRegionChange = useCallback((e: any) => latestHandlersRef.current.handleRegionChange(e), []);
+ const stablePolylineTap = useCallback((e: any) => latestHandlersRef.current.onPolylineTap?.(e), []);
return (
<>
- <Map
- id='map'
- longitude={mapLongitude}
- latitude={mapLatitude}
- scale={mapScale}
+ <StableNativeMap
+ mapLongitude={mapLongitude}
+ mapLatitude={mapLatitude}
markers={markers}
polyline={polyline}
showLocation={showLocation}
- onCalloutTap={handleCalloutTap}
- onRegionChange={handleRegionChange}
- onPolylineTap={onPolylineTap}
- // onTap={onMapTap}
- style={{ width: '100%', height: '87vh' }}
- >
- {renderMapOverlays()}
- </Map>
- {/* iOS 里程气泡:作为 Map 的兄弟级普通 View 浮层,靠同层渲染浮在地图上,z-index 低于底部面板。 */}
- {renderCarCalloutOverlay()}
+ mapLoading={mapLoading}
+ renderMapOverlays={renderMapOverlays}
+ onCalloutTap={stableCalloutTap}
+ onRegionChange={stableRegionChange}
+ onPolylineTap={stablePolylineTap}
+ />
+ {/* iOS 里程气泡:作为 Map 的兄弟级普通 View 浮层,靠同层渲染浮在地图上,z-index 低于底部面板。
+ 包一层常驻 View:气泡内容 null↔有的切换只重写该容器的 children,
+ 避免增删 home-container 下与 <Map> 同级的节点导致 map 属性被全量重应用(scale 重置)。 */}
+ <View>{renderCarCalloutOverlay()}</View>
</>
);
}
@@ -166,6 +262,11 @@
*/
function BeginRoutePopupArea() {
const {
+ showCouponSelectPopup,
+ couponOrderMoney,
+ activeCouponId,
+ handleCloseCouponSelect,
+ handleSelectCoupon,
showCustomerService,
handleCloseCustomerService,
showCancelPopup,
@@ -206,7 +307,11 @@
} = useBeginRouteViewContext();
return (
- <>
+ <View>
+ {/* 弹窗区常驻 View 容器:弹窗组件(如 CancelPopup)隐藏时 return null,节点会整个挂载/卸载。
+ 若直接作为 home-container 的 children,增删节点会让 Taro 整组重发兄弟数组(含 <Map> 节点),
+ 微信 map 全量重应用 latitude/longitude/scale(scale 被打回默认)。
+ 包住后增删只发生在本容器内部,不再连坐 map。 */}
<CustomerServicePopup
show={showCustomerService}
onClose={handleCloseCustomerService}
@@ -243,13 +348,15 @@
onClose={handleClosePrivacyPopup}
onAgree={handleClosePrivacyPopup}
/>
- {!showCancelPopup && (
- <PassengerNotice
- show={showPassengerNotice}
- onClose={handleClosePassengerNotice}
- onCheckedChange={changeCheckedEnter}
- />
- )}
+ {/* 不能写成 {!showCancelPopup && <PassengerNotice/>}:条件挂载会增删兄弟节点,
+ Taro 对长度变化的 children 数组整组 setData,同级的 <map> 节点数据被捎带重发,
+ 原生 map 全量重应用 latitude/longitude/scale(scale 被打回默认)。
+ 节点常驻、用 show 控制显隐,保证取消弹窗开关不改变节点结构。 */}
+ <PassengerNotice
+ show={showPassengerNotice && !showCancelPopup}
+ onClose={handleClosePassengerNotice}
+ onCheckedChange={changeCheckedEnter}
+ />
<EvaluatePopup
show={showEvaluatePopup}
orderId={orderId}
@@ -267,7 +374,15 @@
onClose={handleCloseNoRoutePopup!}
onConfirm={handleConfirmNoRoutePopup!}
/>
- </>
+ <CouponSelectPopup
+ show={showCouponSelectPopup}
+ orderMoney={couponOrderMoney}
+ initialSelectedCouponId={activeCouponId}
+ onClose={handleCloseCouponSelect}
+ onSelect={handleSelectCoupon}
+ />
+ <Toast />
+ </View>
);
}
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/ac-switch/index.less b/src/robotaxi/pages/begin-route/components/calling-car-modules/ac-switch/index.less
index 29854ff..bd12086 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/ac-switch/index.less
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/ac-switch/index.less
@@ -12,6 +12,8 @@
flex-shrink: 0;
width: 104px;
height: 64px;
+ /* 图片右侧自带 14px 透明留白,负 margin 抵消,使开关视觉右边界与容器对齐 */
+ margin-right: -14px;
}
.ac-switch-loading {
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/ac-switch/index.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/ac-switch/index.tsx
index 242e92f..8638302 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/ac-switch/index.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/ac-switch/index.tsx
@@ -2,8 +2,11 @@
import './index.less';
// 图片开关三态图标(复用入口卡片同款)
-const SWITCH_OPEN_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/T-switch-open-20260709.png';
-const SWITCH_CLOSE_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/T-switch-close-20260709.png';
+const SWITCH_CLOSE_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/T-switch-close-20260812.png';
+// 开启态:金色轨道 + 白色滑块(浅色背景)
+const SWITCH_OPEN_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/T-switch-open-20260812.png';
+// 开启态:白色轨道 + 金色滑块(彩色/深色卡片背景)
+const SWITCH_OPEN_WHITE_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/T-switch-open-white-20260812.png';
const SWITCH_LOADING_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/T-switch-loading-20260715.png';
type Props = {
@@ -15,16 +18,23 @@
onChange?: () => void;
/** 自定义 class */
className?: string;
+ /**
+ * 开启态样式(关闭态两者一致):
+ * - gold(默认):金色轨道 + 白色滑块,用于浅色背景(香氛卡片、弹窗内开关);
+ * - white:白色轨道 + 金色滑块,用于彩色/深色卡片背景(空调入口卡片)。
+ */
+ theme?: 'gold' | 'white';
};
/**
* 空调风格图片开关(开/关/loading 三态),供卡片与面板统一复用。
*/
-export default function AcSwitch({ checked, loading = false, onChange, className = '' }: Props) {
+export default function AcSwitch({ checked, loading = false, onChange, className = '', theme = 'gold' }: Props) {
+ const openIcon = theme === 'white' ? SWITCH_OPEN_WHITE_ICON : SWITCH_OPEN_ICON;
return (
<Image
className={`ac-switch${loading ? ' ac-switch-loading' : ''} ${className}`.trim()}
- src={loading ? SWITCH_LOADING_ICON : (checked ? SWITCH_OPEN_ICON : SWITCH_CLOSE_ICON)}
+ src={loading ? SWITCH_LOADING_ICON : (checked ? openIcon : SWITCH_CLOSE_ICON)}
onClick={(e: any) => { e?.stopPropagation?.(); onChange && onChange(); }}
/>
);
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx
index 7359c5a..b56260a 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx
@@ -10,9 +10,11 @@
descriptionColor?: string;
/** 是否允许点击(默认 true)。false 时不绑定 onClick */
clickable?: boolean;
+ /** 蓝牙状态首轮判定是否完成(默认 true)。false 时占位保留高度、内容淡出且不可点,避免异步就绪撑高卡片产生跳变 */
+ ready?: boolean;
};
-export default function BluetoothOperateCard({ description, actionText = '', onClick, iconSrc, descriptionColor = '', clickable = true }: Props) {
+export default function BluetoothOperateCard({ description, actionText = '', onClick, iconSrc, descriptionColor = '', clickable = true, ready = true }: Props) {
const descriptionStyle = descriptionColor ? { color: descriptionColor } : undefined;
// 右侧区域渲染规则:
// - 不可点击(如车端钥匙下发失败)→ 不渲染
@@ -20,7 +22,10 @@
// - 其他可点击场景("去授权" / 已授权)→ 渲染容器;文字仅在有 actionText 时显示,箭头始终显示
const shouldRenderRight = clickable && actionText !== '去开启';
return (
- <View className="bluetooth-operate-card" onClick={clickable ? onClick : undefined}>
+ <View
+ className={`bluetooth-operate-card${ready ? '' : ' bluetooth-operate-card--pending'}`}
+ onClick={ready && clickable ? onClick : undefined}
+ >
<View className="bluetooth-operate-card-left">
<Image src={iconSrc} />
<View style={descriptionStyle}>{description}</View>
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx
index 6ddad51..3ad150a 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx
@@ -4,7 +4,7 @@
import { unlockVehicle, queryVehicleTrunkOpenStatus } from '../../../../api/begin-route';
import { getOrderId } from '../../../../utils/order';
import { TripStatus } from '../../../../constants/trip-status';
-import { showToast } from '../../../../components/toast';
+import { showToast, hideToast } from '../../../../components/toast';
import useMinimumSpinLoading from './use-minimum-spin-loading';
import TrunkConfirmPopup from './trunk-confirm-popup';
@@ -93,13 +93,13 @@
// 获取解锁请求所需定位参数,优先高精定位,失败后降级普通定位
const getUnlockLocationPayload = async () => {
- Taro.showLoading({ title: '正在获取位置...', mask: true });
+ showToast({ title: '正在获取位置...', icon: 'loading', mask: true });
try {
const location = await Taro.getLocation({ type: 'gcj02', isHighAccuracy: true });
const longitude = Number(location?.longitude);
const latitude = Number(location?.latitude);
if (!Number.isNaN(longitude) && !Number.isNaN(latitude)) {
- Taro.hideLoading();
+ hideToast();
return { longitude, latitude };
}
} catch (error) {
@@ -111,14 +111,14 @@
const longitude = Number(location?.longitude);
const latitude = Number(location?.latitude);
if (!Number.isNaN(longitude) && !Number.isNaN(latitude)) {
- Taro.hideLoading();
+ hideToast();
return { longitude, latitude };
}
} catch (error) {
console.error('普通定位获取失败:', error);
}
- Taro.hideLoading();
+ hideToast();
return null;
};
@@ -144,7 +144,7 @@
const actionLoadingText = doorType === 'trunk'
? (isCloseAction ? '正在关闭后备箱' : '正在开启后备箱')
: '正在解锁车门';
- // Taro.showLoading({ title: actionLoadingText, mask: true });
+ // showToast({ title: actionLoadingText, icon: 'loading', mask: true });
unlockVehicle({
orderId: targetOrderId,
doorType,
@@ -157,7 +157,7 @@
? Number(res?.data) == 1
: !!res?.data;
if (!unlockSucceeded) {
- // Taro.hideLoading();
+ // hideToast();
const failMsg = res?.msg || '解锁失败,请重试或联系客服';
stopLoading(doorType, () => {
showToast({ title: failMsg, icon: 'fail' });
@@ -187,7 +187,7 @@
trunkPollTimerRef.current = setTimeout(doPoll, pollInterval);
} else {
stopPolling();
- // Taro.hideLoading();
+ // hideToast();
stopLoading(doorType, () => {
showToast({
title: failToastText,
@@ -222,7 +222,7 @@
const isOpen = !isCloseAction;
setTrunkStatus(isOpen);
onSuccess?.();
- // Taro.hideLoading();
+ // hideToast();
stopLoading(doorType, () => {
showToast({
title: successToastText,
@@ -240,7 +240,7 @@
}
if (doorType === 'door') {
- // Taro.hideLoading();
+ // hideToast();
syncDoorUnlockButtonUI();
onSuccess?.();
stopLoading(doorType, () => {
@@ -248,7 +248,7 @@
});
}
}).catch(() => {
- // Taro.hideLoading();
+ // hideToast();
stopLoading(doorType, () => {
showToast({ title: '解锁失败,请重试或联系客服', icon: 'fail' });
});
@@ -306,7 +306,7 @@
</View>
<View className={`car-card-arrive-item ${lockStatus && 'close-btn-item'} ${loadingControlType ? 'trip-action-bar-item-disabled' : ''}`.trim()} onClick={() => handleUnlock('door', onDoorUnlockSuccess)}>
<View className="car-card-arrive-item-text">车锁</View>
- <View className="car-card-arrive-item-status">{lockStatus ? '车门已解锁' : '点击打开'}</View>
+ <View className="car-card-arrive-item-status">{lockStatus ? '车门已解锁' : '点击解锁'}</View>
{renderControlIcon('door', lockStatus ? LOCK_OPEN_ICON_URL : LOCK_CLOSE_ICON_URL, 'car-card-arrive-item-img')}
</View>
</View>
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/evaluation-card.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/evaluation-card.tsx
index 824e7ac..6c7c32b 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/evaluation-card.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/evaluation-card.tsx
@@ -33,6 +33,14 @@
const [isEvaluated, setIsEvaluated] = useState(false); // 是否已完成评价
const skipEvaluateResetRef = useRef(false); // 跳过关闭重置的一次性标记
+ // 订单切换时重置评价态:去掉 CallingCar 的 key 后本组件不再随状态重挂载,
+ // 需显式按 orderId 复位,避免上一单「已评价」状态串到下一单。
+ useEffect(() => {
+ setActiveIndex(0);
+ setIsEvaluated(false);
+ skipEvaluateResetRef.current = false;
+ }, [orderId]);
+
// 外部提交成功后同步评价状态
useEffect(() => {
if (typeof evaluateSubmitVersion === 'number' && evaluateSubmitVersion > 0) {
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/fragrance-control/index.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/fragrance-control/index.tsx
index 32423e4..a2f3892 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/fragrance-control/index.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/fragrance-control/index.tsx
@@ -47,6 +47,8 @@
pageDidShowVersion?: number;
/** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */
hasPreviousOrder?: boolean;
+ /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */
+ active?: boolean;
};
/**
@@ -85,7 +87,7 @@
* 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。
* 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。
*/
-export function useFragranceControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes {
+export function useFragranceControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false, active = true }: Props): ControlNodes {
// 车端真实状态
const [switchOn, setSwitchOn] = useState(false);
const [scents, setScents] = useState<FragranceScent[]>([]);
@@ -111,6 +113,10 @@
// 进入接驾态查询香氛状态;连环单不查询、不展示
useEffect(() => {
+ // 门槛未就绪(如派单成功转场动效未完成):暂不查询,active 就绪后重跑再查询一次。
+ if (!active) {
+ return;
+ }
if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
// 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。
queriedOrderIdRef.current = '';
@@ -145,7 +151,7 @@
setScents([]);
setSelectedChannelId('');
});
- }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]);
+ }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active]);
// 点击卡片:切换面板开合
const togglePanel = () => {
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/music-control/index.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/music-control/index.tsx
index b5670bb..144436d 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/music-control/index.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/music-control/index.tsx
@@ -4,6 +4,7 @@
import type { GenreItem } from '../../../../../api/types/genre';
import { getOrderId } from '../../../../../utils/order';
import { TripStatus } from '../../../../../constants/trip-status';
+import { isMusicControlEnabled } from '../../../../../constants/music-control-feature';
import useBottomPopupAnimation from '../../hooks/use-bottom-popup-animation';
import type { ControlNodes } from '../types';
import AcSwitch from '../ac-switch';
@@ -40,6 +41,8 @@
pageDidShowVersion?: number;
/** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */
hasPreviousOrder?: boolean;
+ /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */
+ active?: boolean;
};
/**
@@ -65,7 +68,7 @@
* 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。
* 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。
*/
-export function useMusicControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes {
+export function useMusicControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false, active = true }: Props): ControlNodes {
// 车端真实开关状态
const [switchOn, setSwitchOn] = useState(false);
// 后台配置曲风列表
@@ -90,9 +93,13 @@
// 开关 loading 仅在由开关(而非磁贴)触发下发时展示
const switchLoading = applying && applyingGenreId === '';
- // 进入接驾态查询曲风状态;连环单不查询、不展示
+ // 进入接驾态查询曲风状态;功能开关关闭、连环单不查询、不展示
useEffect(() => {
- if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
+ // 门槛未就绪(如派单成功转场动效未完成):暂不查询,active 就绪后重跑再查询一次。
+ if (!active) {
+ return;
+ }
+ if (!isMusicControlEnabled() || tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
// 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。
queriedOrderIdRef.current = '';
return;
@@ -126,7 +133,7 @@
setGenres([]);
setSelectedGenreId('');
});
- }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]);
+ }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active]);
// 点击卡片:切换面板开合
const togglePanel = () => {
@@ -202,8 +209,8 @@
applyGenre(nextSwitchOn, targetGenreId, '');
};
- // 连环单或非接驾态不展示音乐卡片
- if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
+ // 功能开关关闭、连环单或非接驾态不展示音乐卡片
+ if (!isMusicControlEnabled() || tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
return { card: null, popup: null };
}
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/seat-hv-control/index.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/seat-hv-control/index.tsx
index 71c8ec2..46f0289 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/seat-hv-control/index.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/seat-hv-control/index.tsx
@@ -50,6 +50,10 @@
*/
export default function SeatHvArea({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false, active = true }: Props) {
const [seats, setSeats] = useState<SeatHvItem[]>([]);
+ // 座椅状态查询是否已出结果(成功或失败)。未出结果前整块透明,避免先闪「不可用」再切成可用
+ const [queried, setQueried] = useState(false);
+ // 座舱背景图是否加载完成(用于背景渐现;一旦加载过就不再重置,重开面板直接显示)
+ const [bgLoaded, setBgLoaded] = useState(false);
// 正在下发的按钮标识 `${position}:${kind}`(该按钮显示 loading,其余按钮置灰防重复),空串表示无
const [applyingKey, setApplyingKey] = useState('');
const querySeqRef = useRef(0);
@@ -82,11 +86,13 @@
}
const list: SeatHvItem[] = Array.isArray(res?.seats) ? res.seats : [];
setSeats(list);
+ setQueried(true);
}).catch(() => {
if (querySeqRef.current !== snapshotSeq) {
return;
}
setSeats([]);
+ setQueried(true);
});
}, [active, tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]);
@@ -141,13 +147,24 @@
}
};
+ // 查询未出结果:整块透明占位(只占据区域高度、不画任何内容),避免先闪一下「不可用」
+ if (!queried) {
+ return <View className='seat-hv-area'></View>;
+ }
+
if (!hasSeats) {
return <View className='seat-hv-empty'>{NO_SEAT_TIP}</View>;
}
return (
<View className='seat-hv-area'>
- <Image className='seat-hv-bg' src={SEAT_BG_URL} mode='widthFix' />
+ <Image
+ className={`seat-hv-bg${bgLoaded ? ' is-loaded' : ''}`}
+ src={SEAT_BG_URL}
+ mode='widthFix'
+ onLoad={() => setBgLoaded(true)}
+ onError={() => setBgLoaded(true)}
+ />
<View className='seat-hv-list'>
{seats.map((seat) => {
const showVentilation = seat.ventilationLevels > 0;
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx
index c12ed3e..566c04e 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx
@@ -24,7 +24,10 @@
// 入口卡片图标(复用 temperature-fragrance-control 的 v2 资源)
const AC_HEADER_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/air-conditioner-20260716.png';
+// 开启态入口卡片图标与箭头:白色版本(配合白色文字与开启态背景)
+const AC_HEADER_ICON_WHITE = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/air-conditioner-white-20260812.png';
const AC_ARROW_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/temperature-fragrance-arrow-20260706.png';
+const AC_ARROW_ICON_WHITE = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/arrow-white-20260812.png';
const PANEL_CLOSE_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/close-icon.png';
const AC_TOGGLE_FAIL_TEXT = '空调开关操作失败,请重试';
const AC_TEMP_FAIL_TEXT = '温度设置失败,请重试';
@@ -32,7 +35,7 @@
const AC_TEMP_MAX_TIP_TEXT = '已到空调最高设定温度';
const TEMP_ITEM_WIDTH = 130; // 温度滑条单项宽度(所有项等宽)
const TEMP_SCROLL_SETTLE_MS = 120; // 滑动停止判定延时
-const TEMP_LEFT = 10; // 滚动位移误差(适配中间放大效果)
+const TEMP_LEFT = 20; // 滚动位移误差(适配中间放大效果)
type Props = {
tripStatus: TripStatus;
@@ -40,6 +43,8 @@
pageDidShowVersion?: number;
/** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */
hasPreviousOrder?: boolean;
+ /** 门槛:false 时暂不执行查询/下发逻辑(如派单成功转场动效未完成),卡片仍渲染占位 */
+ active?: boolean;
};
/**
@@ -48,7 +53,7 @@
* 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。
* 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。
*/
-export function useAcControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes {
+export function useAcControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false, active = true }: Props): ControlNodes {
const [tempStatus, setTempStatus] = useState<number | null>(null);
const [acSwitchOn, setAcSwitchOn] = useState(false);
const [acStatusLoaded, setAcStatusLoaded] = useState(false);
@@ -290,6 +295,11 @@
};
useEffect(() => {
+ // 门槛未就绪(如派单成功转场动效未完成):暂不查询/下发,避免转场中提前开空调、下发指令。
+ // 不重置去重标记,active 就绪后本 effect 重跑再正常查询一次。
+ if (!active) {
+ return;
+ }
if (tripStatus !== TripStatus.VehicleApproaching) {
// 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。
queriedOrderIdRef.current = '';
@@ -323,7 +333,7 @@
}
handleFirstTimeInit(targetOrderId);
- }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]);
+ }, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active]);
useEffect(() => {
lastConfirmedTempRef.current = tempStatus;
@@ -470,10 +480,11 @@
>
<View className='tf-card-header'>
<View className='tf-card-header-left'>
- <Image className='tf-card-header-icon' src={AC_HEADER_ICON} />
+ <Image className='tf-card-header-icon' src={acSwitchOn ? AC_HEADER_ICON_WHITE : AC_HEADER_ICON} />
<Text className='tf-card-header-title'>空调</Text>
</View>
- <AcSwitch checked={acSwitchOn} loading={isTogglingAc} onChange={handleToggleAcSwitch} />
+ {/* 空调卡片为彩色背景,开启态用白色轨道 + 金色滑块 */}
+ <AcSwitch checked={acSwitchOn} loading={isTogglingAc} onChange={handleToggleAcSwitch} theme='white' />
</View>
<View className='tf-card-bottom'>
{acStatusLoaded && (
@@ -484,7 +495,7 @@
)}
<View className='tf-card-footer'>
<Text className='tf-card-footer-text'>调节温度|通风加热</Text>
- <Image className='tf-card-footer-arrow' src={AC_ARROW_ICON} />
+ <Image className='tf-card-footer-arrow' src={acSwitchOn ? AC_ARROW_ICON_WHITE : AC_ARROW_ICON} />
</View>
</View>
</View>
@@ -532,7 +543,7 @@
onClick={() => handleTempItemClick(index, value)}
>
<Text className='popup-temp-item-value'>{value.toFixed(1)}</Text>
- {selected && <Text className='popup-temp-item-unit'>℃</Text>}
+ {selected && <Text className='popup-temp-item-unit'>°C</Text>}
</View>
);
})}
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/trip-status-header.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/trip-status-header.tsx
index d4c5c37..ef1da4d 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/trip-status-header.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/trip-status-header.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { queryOrderQueueStatus } from '../../../../api/begin-route';
import { TripStatus } from '../../../../constants/trip-status';
+import SiteRouteCard from '../../../../components/site-route-card';
type QueueStatus = {
currentPosition: number;
@@ -141,9 +142,9 @@
const endSiteName = cachedEndSite?.name || cachedEndSite?.address || '-';
// 状态栏右侧的距离/时间文案
- const distanceText = routeDistance ? `${routeDistance.value}${routeDistance.unit === 'm' ? 'M' : 'KM'}` : '';
- const approachingTimeText = `${normalizedDurationDisplay.value}MIN`;
- const inServiceTimeText = `${normalizedDurationDisplay.value}MIN`;
+ const distanceText = routeDistance ? `${routeDistance.value}${routeDistance.unit === 'm' ? '米' : '公里'}` : '';
+ const approachingTimeText = `${normalizedDurationDisplay.value}分钟`;
+ const inServiceTimeText = `${normalizedDurationDisplay.value}分钟`;
return (
<>
@@ -153,8 +154,8 @@
<View className='calling-car-content'>
<View className='calling-car-content-main'>
<View className='calling-car-content-title calling-car-content-title--strong'>
- {/* 预计{displayedWaitMinutes ?? '-'}分钟内有车应答 */}
- 预计{waitMinutes}分钟内有车应答
+ 预计{displayedWaitMinutes ?? '-'}分钟内有车应答
+ {/* 预计{waitMinutes}分钟内有车应答 */}
</View>
<View className='calling-car-content-desc'>
正全力为您叫车中...
@@ -168,24 +169,24 @@
</View>
<View
className='calling-car-cancel-btn'
- // onClick={() => handleOpenCancelPopup?.({ durationDisplay, waitMinutes: displayedWaitMinutes })}
- onClick={() => handleOpenCancelPopup?.({ durationDisplay, waitMinutes })}
+ onClick={() => handleOpenCancelPopup?.({ durationDisplay, waitMinutes: displayedWaitMinutes })}
+ // onClick={() => handleOpenCancelPopup?.({ durationDisplay, waitMinutes })}
>
取消行程
</View>
</View>
- <View className='calling-car-site-box'>
- <View className='calling-car-site-box-icon'>
- <Image className='calling-car-site-check'src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/route-state-icon-golden-20260702.png' ></Image>
- <Image className='dotted-line' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/dotted-line.png' />
- <Image className='calling-car-site-check' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/route-state-icon-black-20260702.png'></Image>
- </View>
- <View className='calling-car-site-box-content'>
- <View className='calling-car-site-box-content-name'>{startSiteName}</View>
- <View className='calling-car-site-box-divider'></View>
- <View className='calling-car-site-box-content-name'>{endSiteName}</View>
- </View>
- </View>
+ <SiteRouteCard
+ className='calling-car-site-box'
+ markerType='check'
+ checkIconUrls={[
+ 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/route-state-icon-golden-20260702.png',
+ 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/route-state-icon-black-20260702.png'
+ ]}
+ sites={[
+ { name: startSiteName },
+ { name: endSiteName }
+ ]}
+ />
</>
)}
@@ -204,7 +205,9 @@
{/* 车辆到达上车点:直接展示图片 */}
{tripStatus === TripStatus.VehicleArrivedPickup && (
<View className='calling-car-status-box calling-car-arrived-img'>
- <View className='calling-car-status-box-left'>车辆已到达上车点,倒计时结束将自动取消订单</View>
+ <View className='calling-car-status-box-left'>
+ {doorUnlockedInCurrentPage ? '车门已开启,请尽快上车开启行程' : '车辆已到达上车点,倒计时结束将自动取消订单'}
+ </View>
</View>
)}
diff --git a/src/robotaxi/pages/begin-route/components/calling-car-modules/vehicle-info-card.tsx b/src/robotaxi/pages/begin-route/components/calling-car-modules/vehicle-info-card.tsx
index 65615e1..437cc67 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car-modules/vehicle-info-card.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car-modules/vehicle-info-card.tsx
@@ -1,6 +1,9 @@
-import { View } from "@tarojs/components";
+import Taro from "@tarojs/taro";
+import { View, Image } from "@tarojs/components";
+import { TripStatus } from "../../../../constants/trip-status";
type Props = {
+ tripStatus?: TripStatus;
vehicleInfo?: {
vehiclePictures?: string;
vehicleCode?: string;
@@ -9,20 +12,33 @@
};
};
-export default function VehicleInfoCard({ vehicleInfo }: Props) {
+export default function VehicleInfoCard({ tripStatus, vehicleInfo }: Props) {
+ // 行程中(前往目的地):车牌信息下方展示目的地图标 + 站点名称
+ const showDestination = tripStatus === TripStatus.InService;
+ const cachedEndSite = showDestination ? (Taro.getStorageSync('endSite') || {}) : {};
+ const endSiteName = cachedEndSite?.name || cachedEndSite?.address || '-';
+
// 展示车辆基础信息:左侧车牌·车身颜色,右侧车型
return (
- <View className="car-card-detail">
- <View className="car-card-detail-plate">
- <View className="car-card-detail-num">{vehicleInfo?.plateNumber}</View>
- {vehicleInfo?.vehicleColor && (
- <>
- <View className="car-card-detail-dot"></View>
- <View className="car-card-detail-color-text">{vehicleInfo?.vehicleColor}</View>
- </>
- )}
+ <View className="car-card-detail-wrap">
+ <View className="car-card-detail">
+ <View className="car-card-detail-plate">
+ <View className="car-card-detail-num">{vehicleInfo?.plateNumber}</View>
+ {vehicleInfo?.vehicleColor && (
+ <>
+ <View className="car-card-detail-dot"></View>
+ <View className="car-card-detail-color-text">{vehicleInfo?.vehicleColor}</View>
+ </>
+ )}
+ </View>
+ <View className="car-card-detail-model">小鹏GX</View>
</View>
- <View className="car-card-detail-model">小鹏GX</View>
+ {showDestination && (
+ <View className='car-card-detail-destination'>
+ <Image className='car-card-detail-destination-icon' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/destination-icon-20260812.png' />
+ <View className='car-card-detail-destination-name'>{endSiteName}</View>
+ </View>
+ )}
</View>
);
}
diff --git a/src/robotaxi/pages/begin-route/components/calling-car.tsx b/src/robotaxi/pages/begin-route/components/calling-car.tsx
index 8de85be..eb8d4bc 100644
--- a/src/robotaxi/pages/begin-route/components/calling-car.tsx
+++ b/src/robotaxi/pages/begin-route/components/calling-car.tsx
@@ -1,9 +1,12 @@
-import { View, Image, ScrollView } from "@tarojs/components";
+import { View, Image } from "@tarojs/components";
import { useState, useEffect, useRef } from 'react';
import './css-folder/calling-car.less'
import { formatRemainingDurationDisplay } from '../../../utils/format';
import { safeParse, formatTripDistance } from '../../../../utils/Robotaxi-public-method';
+import { useDispatchTransition } from '../hooks/use-dispatch-transition';
+import DispatchTransitionLayer from './dispatch-transition-layer';
import { TripStatus } from '../../../constants/trip-status';
+import { CARD_BG_IMG, DEFAULT_VEHICLE_IMG, TRIP_FINISHED_BG_IMG, USE_DYNAMIC_VEHICLE_IMG } from '../../../constants/assets';
import { BLUETOOTH_ICON_OFF } from '../hooks/use-bluetooth-access'; // 蓝牙未开/未授权图标
import TripStatusHeader from './calling-car-modules/trip-status-header'; // 行程状态头
import VehicleInfoCard from './calling-car-modules/vehicle-info-card'; // 车辆信息卡片
@@ -21,6 +24,14 @@
type Props = {
/** 行程状态:3呼叫中、4即将到达、5已到达上车点、6行程中、7到达终点、8待支付、9已支付 */
tripStatus?: TripStatus;
+ /** 是否在本次挂载时播放"派单成功"转场(由父层捕捉首次进入派单成功(4)边沿传入,因本组件被 key 重挂载无法自判) */
+ playDispatchTransition?: boolean;
+ /** 是否在本次挂载时播放"直达待评价"入场(由父层捕捉 送驾中(6)→待评价(9) 边沿传入,内部免费订单跳过待支付时使用) */
+ playEvaluateEnter?: boolean;
+ /** 是否在本次挂载时播放"行程结束"入场(背景卡升起 + 里程渐显);由父层捕捉进入行程结束(8)边沿传入,恢复态不播放 */
+ playFinishedEnter?: boolean;
+ /** 派单成功转场动效完成(或本次不播放转场)时回调,用于放行页面级蓝牙初始化 */
+ onDispatchTransitionSettled?: () => void;
/** 打开取消弹窗,入参为预计到达展示文案 */
handleOpenCancelPopup?: (durationDisplay: any) => void;
/** 上车点超时后的回调 */
@@ -88,6 +99,8 @@
orderTrunkStatus?: boolean;
/** 当前页面内是否已经解锁过车门 */
doorUnlockedInCurrentPage?: boolean;
+ /** 后端推送 ORDER_ROUTE.content.oddOut === 1 时,强制展示取消行程按钮 */
+ forceShowCancelTrip?: boolean;
/** 手动开门成功后同步页面级开门状态 */
onDoorUnlockSuccess?: () => void;
bluetoothStatusReady?: boolean;
@@ -111,6 +124,10 @@
export default function CallingCar({
tripStatus = TripStatus.CallingCar,
+ playDispatchTransition = false,
+ playEvaluateEnter = false,
+ playFinishedEnter = false,
+ onDispatchTransitionSettled,
handleOpenCancelPopup,
handleCancelTimeout,
handleFinishTimeout,
@@ -143,6 +160,7 @@
hasPreviousOrder = false,
orderTrunkStatus,
doorUnlockedInCurrentPage = false,
+ forceShowCancelTrip = false,
onDoorUnlockSuccess,
bluetoothStatusReady = false,
bluetoothCardDescription = '授权并打开蓝牙链接,享受无感出行',
@@ -168,31 +186,83 @@
const isArriveSoon = tripStatus === TripStatus.VehicleApproaching && (typeof routeDurationSeconds === 'number' && routeDurationSeconds <= 60);
/** 上车倒计时即将结束(<60秒) */
const isPickupCountdownSoon = tripStatus === TripStatus.VehicleArrivedPickup && (typeof pickupCountdownSeconds === 'number' && pickupCountdownSeconds < 60);
- /** 开门后,接驾态不再展示取消行程。 */
- const showCancelTrip = [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus) && !doorUnlockedInCurrentPage;
+ /** 开门后,接驾态不再展示取消行程;但 ORDER_ROUTE 推送 oddOut === 1 时强制展示。 */
+ const showCancelTrip = forceShowCancelTrip
+ || ([TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus) && !doorUnlockedInCurrentPage);
+
+ // 派单成功转场状态机:phase 状态、定时器、视频事件均由 useDispatchTransition 管理
+ const {
+ dispatchPhase,
+ transitionDone,
+ shouldPlayEnter,
+ endCoverVisible,
+ handleDispatchVideoTimeUpdate,
+ enterSettle,
+ } = useDispatchTransition(playDispatchTransition);
+
+ // 派单成功转场门槛:未播转场时立即就绪;播转场时等动效完成(transitionDone)。
+ // 用于延迟车控/蓝牙卡/操作栏的「展示」与「逻辑执行」,避免转场中触发弹窗与提前下发指令。
+ const contentReady = !shouldPlayEnter || transitionDone;
/** 是否为“到达终点/待支付”状态(图一、图二) */
const isTripFinished = tripStatus === TripStatus.TripFinished;
const isCallingCar = tripStatus === TripStatus.CallingCar;
/**
+ * 「直达待评价」入场:与 useDispatchTransition 里的 shouldPlayEnter 同理,
+ * 父层的 playEvaluateEnter 在下一次渲染即翻 false,直接绑定会把动画类中途卸掉、动效被扼杀。
+ * 过去依赖 key={tripStatus} 重挂载做挂载快照;去 key 后组件不再重挂载,
+ * 改为在渲染期同步捕捉 playEvaluateEnter 上升沿置类(避免先渲染一帧静止态再跳到位移起点的闪跳),
+ * 播放一次后按动画时长(0.7s)清除类名——最终态与静止态一致无跳变,且使下一次 6→9 能重新触发。
+ */
+ const [shouldPlayEvaluateEnter, setShouldPlayEvaluateEnter] = useState(false);
+ const prevPlayEvaluateRef = useRef(false);
+ const evaluateEnterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ if (playEvaluateEnter && !prevPlayEvaluateRef.current) {
+ prevPlayEvaluateRef.current = true;
+ if (!shouldPlayEvaluateEnter) setShouldPlayEvaluateEnter(true);
+ } else if (!playEvaluateEnter && prevPlayEvaluateRef.current) {
+ prevPlayEvaluateRef.current = false;
+ }
+ useEffect(() => {
+ if (!shouldPlayEvaluateEnter) return;
+ if (evaluateEnterTimerRef.current) clearTimeout(evaluateEnterTimerRef.current);
+ evaluateEnterTimerRef.current = setTimeout(() => setShouldPlayEvaluateEnter(false), 750);
+ return () => {
+ if (evaluateEnterTimerRef.current) clearTimeout(evaluateEnterTimerRef.current);
+ };
+ }, [shouldPlayEvaluateEnter]);
+ /**
+ * 「行程结束」入场:与上面的 shouldPlayEvaluateEnter 完全同理(渲染期捕捉上升沿 → 置类 → 按动画时长清类)。
+ * 父层 playFinishedEnter 仅在真实 live 跃迁进入行程结束(8)时为真;恢复态(从首页进入即已是结束态)恒为 false,
+ * 此时不加 calling-car--finished-enter 类,结束态背景卡与里程直接显示最终态、不再弹起。
+ * 背景卡与里程升起:延迟 700ms + 时长 1s = 1700ms 才结束,故清类须 ≥1700ms,否则会中途移除类
+ * 导致动画被截断、瞬间 snap 到最终态(观感为「快结束时卡顿」)。取 1850ms 留缓冲。
+ */
+ const [shouldPlayFinishedEnter, setShouldPlayFinishedEnter] = useState(false);
+ const prevPlayFinishedRef = useRef(false);
+ const finishedEnterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ if (playFinishedEnter && !prevPlayFinishedRef.current) {
+ prevPlayFinishedRef.current = true;
+ if (!shouldPlayFinishedEnter) setShouldPlayFinishedEnter(true);
+ } else if (!playFinishedEnter && prevPlayFinishedRef.current) {
+ prevPlayFinishedRef.current = false;
+ }
+ useEffect(() => {
+ if (!shouldPlayFinishedEnter) return;
+ if (finishedEnterTimerRef.current) clearTimeout(finishedEnterTimerRef.current);
+ finishedEnterTimerRef.current = setTimeout(() => setShouldPlayFinishedEnter(false), 1850);
+ return () => {
+ if (finishedEnterTimerRef.current) clearTimeout(finishedEnterTimerRef.current);
+ };
+ }, [shouldPlayFinishedEnter]);
+ /**
* 车控(空调 / 音乐 / 香氛):卡片与弹窗分开挂载。
- * 卡片放进横向滑动的 ScrollView;弹窗是 fixed 全屏浮层,必须挂在 ScrollView 之外,
- * 否则 iOS(WKWebView)下滚动容器会成为 fixed 的包含块,弹窗被限制在卡片滑动区内。
+ * 卡片放进 2 列网格容器;弹窗是 fixed 全屏浮层,单独挂在网格容器之外,避免受父容器定位影响。
* 三个 hook 内部均按 tripStatus / hasPreviousOrder 守卫,非接驾态不发请求、返回空节点。
*/
- const acControl = useAcControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder });
- const musicControl = useMusicControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder });
- const fragranceControl = useFragranceControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder });
- /**
- * 车控卡片当前横向滚动位置。
- * 弹窗开合与其弹出动画会驱动本组件重渲染,weapp 的 scroll-view 在重渲染 / 重建后会回到
- * 初始位置(最左),表现为「弹窗一出现卡片就滑回去了」。这里用 ref 记录实时位置并回传
- * scroll-left,使重渲染后停留在原处;用 ref 而非 state 是为了滚动过程中不触发额外渲染。
- */
- const cardScrollLeftRef = useRef(0);
- const handleCardScroll = (e: any) => {
- cardScrollLeftRef.current = e?.detail?.scrollLeft ?? 0;
- };
+ const acControl = useAcControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active: contentReady });
+ const musicControl = useMusicControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active: contentReady });
+ const fragranceControl = useFragranceControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder, active: contentReady });
/** 费用明细是否展开(拦截 FareDetailCard 的回调本地记录,用于切换背景与卡片布局) */
const [detailExpanded, setDetailExpanded] = useState(false);
/** 大号里程文案:取全程里程,单位参考 formatTripDistance(米→M,公里→KM) */
@@ -202,6 +272,13 @@
useEffect(() => {
setDetailExpanded(false);
}, [resetDetailVersion, tripStatus]);
+
+ // 转场动效完成(或本次未播放转场)后通知父层:与车控卡片同步放行页面级蓝牙初始化。
+ useEffect(() => {
+ if (contentReady) {
+ onDispatchTransitionSettled?.();
+ }
+ }, [contentReady, onDispatchTransitionSettled]);
// 拦截费用明细展开/收起回调:本地记录展开态后再透传给外部
const handleFareToggle = (expanded: boolean, hasFare: boolean) => {
@@ -216,22 +293,40 @@
return <Image className='calling-car-bg carlling-bg' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/top-card-bg-20260722.png' />
}
if(isTripFinished) {
- return <Image className='calling-car-bg finished-bg' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/trip-finished-20260703.png' />
+ return <Image className='calling-car-bg finished-bg' src={TRIP_FINISHED_BG_IMG} />
}
- return <Image className='calling-car-bg' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/card-bg-20260714.png' />
+ // 派单转场中(挂载首帧 ~ 视频播放完成前):不返回任何背景图,杜绝转场期间出现静态背景图。
+ // 常态背景图改在 DispatchTransitionLayer 中不可见预载。settle 起在此返回背景图(视频层仍盖在其上,
+ // 600ms 后随 transitionDone 卸载、直切露出背景图,此时图片已预载、无加载间隙)。
+ if (shouldPlayEnter && !transitionDone && dispatchPhase !== 'settle') {
+ return null;
+ }
+ return <Image className='calling-car-bg' src={CARD_BG_IMG} />
}
- // 根节点状态类:待支付态 + 明细展开态
- const rootClassName = `calling-car${isTripFinished ? ' calling-car--finished' : ''}${isTripFinished && detailExpanded ? ' is-detail-expanded' : ''} ${isCallingCar ? 'calling-status-car' : ''}`;
+ // 根节点状态类:呼叫中覆盖 + 待支付态 + 明细展开态 + 派单转场阶段 + 整卡升起入场 + 直达待评价入场
+ const rootClassName = `calling-car${isCallingCar ? ' calling-status-car' : ''}${isTripFinished ? ' calling-car--finished' : ''}${isTripFinished && detailExpanded ? ' is-detail-expanded' : ''}${dispatchPhase !== 'idle' ? ` dispatch-${dispatchPhase}` : ''}${shouldPlayEnter ? ' calling-car--enter' : ''}${shouldPlayEvaluateEnter ? ' calling-car--evaluate-enter' : ''}${shouldPlayFinishedEnter ? ' calling-car--finished-enter' : ''}${!contentReady ? ' dispatch-defer-content' : ''}`;
// 按状态组合渲染行程卡片模块
return (
<View className={rootClassName}>
{getCardBgImg()}
+ <DispatchTransitionLayer
+ shouldPlayEnter={shouldPlayEnter}
+ transitionDone={transitionDone}
+ dispatchPhase={dispatchPhase}
+ endCoverVisible={endCoverVisible}
+ vehicleInfo={vehicleInfo}
+ onTimeUpdate={handleDispatchVideoTimeUpdate}
+ onEnded={enterSettle}
+ onError={enterSettle}
+ />
{
- [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.PaymentSuccess].includes(tripStatus) && vehicleInfo?.plateNumber &&
- <Image
+ // 常驻车图:接驾/行程中/支付成功且有车牌时展示。转场期间车模兜底由 DispatchTransitionLayer 渲染。
+ ([TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.PaymentSuccess].includes(tripStatus) && vehicleInfo?.plateNumber) && <Image
className='calling-car-vehicle-pic'
- src={safeParse(vehicleInfo?.vehiclePictures, {})?.url || 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/default-vehicle-img-20260724-v2.png'}
+ // 当前只有一种车型,产品要求统一用默认车图(USE_DYNAMIC_VEHICLE_IMG=false);
+ // 后续有新车型时把该开关改回 true 即恢复接口动态车图。详见 constants/assets.ts。
+ src={USE_DYNAMIC_VEHICLE_IMG ? (safeParse(vehicleInfo?.vehiclePictures, {})?.url || DEFAULT_VEHICLE_IMG) : DEFAULT_VEHICLE_IMG}
/>
}
<View className='calling-car-content-box'>
@@ -244,7 +339,7 @@
}
{
[TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.PaymentSuccess].includes(tripStatus) &&
- <VehicleInfoCard vehicleInfo={vehicleInfo} />
+ <VehicleInfoCard tripStatus={tripStatus} vehicleInfo={vehicleInfo} />
}
<TripStatusHeader
orderId={orderId}
@@ -264,8 +359,9 @@
/>
{
- [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus) && bluetoothStatusReady &&
+ [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus) &&
<BluetoothOperateCard
+ ready={bluetoothStatusReady}
description={bluetoothCardDescription}
actionText={bluetoothCardActionText}
onClick={handleBluetoothCardClick || (() => { })}
@@ -295,23 +391,15 @@
{
[TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService].includes(tripStatus) &&
<View className='car-card'>
- {/* 车控卡片横向滑动(仅接驾态展示):weapp 中普通 View 的 overflow 滚动无法响应拖动,必须用 ScrollView。
- 注意:这里只放卡片,三个控制弹窗在下方 ScrollView 之外单独挂载(iOS fixed 会被滚动容器裁切) */}
+ {/* 车控卡片(仅接驾态展示):2 列网格布局,列间隔 32px,超过 2 个自动换行。
+ 注意:这里只放卡片,三个控制弹窗在下方单独挂载(弹窗为 fixed 全屏浮层) */}
{
TripStatus.VehicleApproaching === tripStatus &&
- <ScrollView
- className='temp-fragrance-scroll'
- scrollX
- showScrollbar={false}
- scrollLeft={cardScrollLeftRef.current}
- onScroll={handleCardScroll}
- >
- <View className='temp-fragrance-control'>
- {acControl.card}
- {musicControl.card}
- {fragranceControl.card}
- </View>
- </ScrollView>
+ <View className='temp-fragrance-control'>
+ {acControl.card}
+ {musicControl.card}
+ {fragranceControl.card}
+ </View>
}
<DoorTrunkControl
@@ -337,6 +425,22 @@
</View>
}
+ {
+ /* 行程操作栏:位于车控卡片之后、门箱控制之前(对齐设计稿)。
+ 到达终点/已支付状态下 TripActionBar 内部按钮集合为空、自动返回 null,不占位。 */
+ [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus) &&
+ <TripActionBar
+ tripStatus={tripStatus}
+ handleCancelTrip={handleCancelTrip}
+ showCancelTrip={showCancelTrip}
+ showShareTrip={showShareTrip}
+ handleModifyDestination={handleModifyDestination}
+ handleModifyDestinationDisabledClick={handleModifyDestinationDisabledClick}
+ showModifyDestination={showModifyDestination}
+ handleShareTrip={handleShareTrip}
+ />
+ }
+
{/* 车控弹窗(空调 / 音乐 / 香氛):必须挂在横向滑动 ScrollView 之外。
iOS(WKWebView)下 scroll-view 的滚动容器会成为 fixed 子元素的包含块,
弹窗若留在 ScrollView 内会被限制、裁切在卡片滑动区里(Android 无此问题)。
@@ -359,20 +463,6 @@
onOpenCouponSelect={onOpenCouponSelect}
/>
</View>
- }
- {
- /* 行程操作栏:位于费用明细卡之后、门箱控制之前(对齐设计稿) */
- [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus) &&
- <TripActionBar
- tripStatus={tripStatus}
- handleCancelTrip={handleCancelTrip}
- showCancelTrip={showCancelTrip}
- showShareTrip={showShareTrip}
- handleModifyDestination={handleModifyDestination}
- handleModifyDestinationDisabledClick={handleModifyDestinationDisabledClick}
- showModifyDestination={showModifyDestination}
- handleShareTrip={handleShareTrip}
- />
}
<DoorTrunkControl
tripStatus={tripStatus === TripStatus.ArrivedDestination ? TripStatus.ArrivedDestination : -1}
diff --git a/src/robotaxi/pages/begin-route/components/cancel-popup.tsx b/src/robotaxi/pages/begin-route/components/cancel-popup.tsx
index 4452754..6559690 100644
--- a/src/robotaxi/pages/begin-route/components/cancel-popup.tsx
+++ b/src/robotaxi/pages/begin-route/components/cancel-popup.tsx
@@ -2,17 +2,18 @@
import { View, Text, Image } from '@tarojs/components';
import { useEffect, useState } from 'react';
import './css-folder/cancel-popup.less'
+import SiteRouteCard from '../../../components/site-route-card';
import { formatRemainingDurationDisplay } from '../../../utils/format';
import {
clearOrderTripStorage
} from '../../../../utils/Robotaxi-public-method'
-import { TripStatus } from '../../../constants/trip-status';
+import { TripStatus, CancelType } from '../../../constants/trip-status';
import useBottomPopupAnimation from './hooks/use-bottom-popup-animation';
type Props = {
show?: boolean;
- cancelType?: number;
+ cancelType?: CancelType;
startSite?: any;
endSite?: any;
waitMinutes?: number;
@@ -27,7 +28,7 @@
const CANCEL_ANIMATION_WEBP = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/cancel-img20260731.png?x-oss-process=image/format,webp';
const CANCEL_ANIMATION_PNG = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/cancel-img20260731.png';
-export default function CancelPopup({ show = false, cancelType = 1, startSite, endSite, waitMinutes, tripStatus, routeDurationSeconds, discountAmount, onClose, onConfirm }: Props) {
+export default function CancelPopup({ show = false, cancelType = CancelType.CallingCar, startSite, endSite, waitMinutes, tripStatus, routeDurationSeconds, discountAmount, onClose, onConfirm }: Props) {
const { visible, animationData } = useBottomPopupAnimation(show);
// 占位动画当前使用的图片地址,默认 webp,加载失败时回退到 png
const [animationSrc, setAnimationSrc] = useState(CANCEL_ANIMATION_WEBP);
@@ -51,42 +52,46 @@
// 不同取消阶段对应的文案配置
const STATUS_MAP = {
- 1: {
+ [CancelType.CallingCar]: {
title: `${waitMinutesValue}分钟内预计有车,确定取消吗?`,
tip: discountAmountValue > 0 ? `本单限时优惠${discountAmountValue}元,重新叫车可能等待更久` : '',
},
- 2: {
+ [CancelType.VehicleApproaching]: {
title: tripStatus === TripStatus.VehicleApproaching
? `车辆还有${approachingDurationDisplay.value}${approachingDurationDisplay.unit}到达,确定取消吗`
: `车辆还有${waitMinutesValue}分钟到达,确定取消吗`,
tip: discountAmountValue > 0 ? `本单限时优惠${discountAmountValue}元,重新叫车可能等待更久` : '',
},
- 3: {
+ [CancelType.VehicleArrivedPickup]: {
title: '车辆已到达,确定取消吗?',
tip: '车辆到达情况下取消可能会被限制发单',
},
- 4: {
+ [CancelType.OrderCanceled]: {
title: '订单已取消',
tip: '已为您取消订单,期待您下次体验',
},
- 5: {
+ [CancelType.NoAvailableVehicle]: {
title: '附近没有空余车辆,已为您取消订单',
tip: '当前运力紧张,建议您稍后重新发起订单',
},
- 6: {
+ [CancelType.Redispatching]: {
title: '正在为您重新派车中...',
tip: '当前车辆无法继续服务',
+ },
+ [CancelType.InService]: {
+ title: `预计${approachingDurationDisplay.value}${approachingDurationDisplay.unit}到达目的地,确认取消吗?`,
+ tip: '',
},
}
// 关闭弹窗时按类型执行对应流程
const closePopup = () => {
- if (cancelType == 5) {
+ if (cancelType == CancelType.NoAvailableVehicle) {
onConfirm?.();
onClose?.();
return
}
- if (cancelType == 4) {
+ if (cancelType == CancelType.OrderCanceled) {
Taro.reLaunch({
url: '/pages/robotaxi/home/index',
})
@@ -109,37 +114,32 @@
<View className='cancel-tip-box'>
<View className='cancel-tip-box-title'>{STATUS_MAP[cancelType]?.title}</View>
{
- STATUS_MAP[cancelType]?.tip && <View className={`cancel-tip-box-desc ${cancelType === 4 && 'color1'}`}>{STATUS_MAP[cancelType].tip}</View>
+ STATUS_MAP[cancelType]?.tip && <View className={`cancel-tip-box-desc ${cancelType === CancelType.OrderCanceled && 'color1'}`}>{STATUS_MAP[cancelType].tip}</View>
}
</View>
{
- [4, 5].includes(cancelType) ?
- <View>
- <View className='cancel-site-box'>
- <View className='cancel-site-box-icon'>
- <View className='cancel-site-box-icon-start'></View>
- <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/dotted-line.png" />
- <View className='cancel-site-box-icon-end'></View>
- </View>
- <View className='cancel-site-box-content'>
- <View className='cancel-site-box-content-name'>{startSiteName}</View>
- <View className='cancel-site-box-divider'></View>
- <View className='cancel-site-box-content-name'>{endSiteName}</View>
- </View>
- </View>
- <View className='cancel-btn-box'>
- <View className='cancel-btn-again' onClick={onConfirm}>再来一单</View>
- </View>
+ [CancelType.OrderCanceled, CancelType.NoAvailableVehicle].includes(cancelType) ?
+ <View>
+ <SiteRouteCard
+ className='cancel-site-box'
+ sites={[
+ { name: startSiteName },
+ { name: endSiteName }
+ ]}
+ />
+ <View className='cancel-btn-box'>
+ <View className='cancel-btn-again' onClick={onConfirm}>再来一单</View>
+ </View>
+ </View>
+ :
+ <>
+ {/* 占位动画:优先 webp,加载失败回退 png */}
+ <Image className='cancel-animation' src={animationSrc} onError={handleAnimationError} />
+ <View className='cancel-btn-box'>
+ <View className=' cancel-btn-cancel' onClick={onConfirm}>确认取消</View>
+ <View className='cancel-btn-again wait-btn' onClick={onClose}>再等等</View>
</View>
- :
- <>
- {/* 占位动画:优先 webp,加载失败回退 png */}
- <Image className='cancel-animation' src={animationSrc} onError={handleAnimationError} />
- <View className='cancel-btn-box'>
- <View className=' cancel-btn-cancel' onClick={onConfirm}>确认取消</View>
- <View className='cancel-btn-again wait-btn' onClick={onClose}>再等等</View>
- </View>
- </>
+ </>
}
</View>
</View>
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/calling-car.less b/src/robotaxi/pages/begin-route/components/css-folder/calling-car.less
index a71b574..ba3538e 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/calling-car.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/calling-car.less
@@ -10,6 +10,19 @@
}
}
+// 结束态背景图(calling-car-bg)「缓缓升起」进场:
+// height 恒为 844px(不改变高度、不拉伸图片),仅用 translateY 从「相当于 282px 露出」的位置升到最终位置(translateY 0)。
+// 起始位移 = 844 - 282 = 562px,随组件挂载触发,结束停在 translateY(0) 的正确位置。
+@keyframes finished-bg-rise {
+ from {
+ transform: translateY(562px);
+ }
+
+ to {
+ transform: translateY(0);
+ }
+}
+
.calling-car {
position: relative;
padding: 148px 0px 0px 0px;
@@ -21,7 +34,7 @@
}
.calling-car-content-box {
position: relative;
- padding: 48px 48px 82px 48px;
+ padding: 48px 48px 66px 48px;
background: @gary-theme;
border-radius: 60px 60px 0 0;
}
@@ -31,25 +44,179 @@
left: 0;
width: 100%;
height: 282px;
+ // 转场期间的常态背景图预载副本:仅提前触发加载、全程不可见。
+ // 必须用类隐藏——内联 style 的 opacity:0 会被 Taro 当 falsy 值丢弃,导致副本可见盖住封面图。
+ &.bg-preload { opacity: 0; }
&.carlling-bg {
height: 276px;
}
&.finished-bg {
height: 844px;
+ // 背景卡升起动画仅在真实 live 跃迁进入结束态时播放(见 .calling-car--finished-enter);
+ // 恢复态(从首页进入即已是结束态)不加该类,此处默认静态停在最终位(translateY(0)),不再弹起。
}
}
.calling-car-vehicle-pic {
position: absolute;
top: -4px;
- right: 116px;
+ right: 80px;
width: 342px;
height: 294px;
z-index: 1;
+ }
+ // 派单成功转场:视频层(覆盖背景卡位置)
+ .calling-car-video {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: auto;
+ aspect-ratio: 1128 / 1268;
+ // 修复:原生 video 方角会露出黑色,给视频自身加与卡片一致的顶部圆角并裁剪
+ border-radius: 60px 60px 0 0;
+ overflow: hidden;
+ z-index: 0;
+ // 注:不做淡入淡出——模拟器上原生 video 不受 CSS opacity 控制,挂了也无效;挂载即显示 poster、自然过渡到播放。
+ }
+ // 派单成功转场:poster 覆盖层(起始)+ 结尾遮挡层(结束)。均盖在视频之上用于消除黑帧:
+ // - poster:视频真正播放前盖住,消除「加载完成→首帧绘制」间的起始黑帧;
+ // - end-cover:settle 阶段盖住,先于视频上屏、视频卸载后仍在,挡住原生 <video> 卸载
+ // (原生合成层销毁 → webview 重绘间隙)产生的结尾黑帧。
+ // 尺寸/位置/圆角与 .calling-car-video 完全一致;z-index 与视频同为 0、低于车模(z-index:1),
+ // 保证 settle 期间车模归位动画照常可见。
+ .calling-car-poster,
+ .calling-car-end-cover {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: auto;
+ aspect-ratio: 1128 / 1268;
+ border-radius: 60px 60px 0 0;
+ overflow: hidden;
+ z-index: 0;
+ }
+ // 阶段一/二:视频展示 + 双卡反向位移。视频原地展示、不淡入(模拟器上原生 video 不受 opacity 控制)。
+ &.dispatch-video {
+ // 功能面板缓动下移,直到蓝牙/空调/香氛卡片移出可视区
+ .calling-car-content-box { transform: translateY(360px); transition: transform 0.8s ease; }
+ // 车模在视频区域内放大展示并居中。全程仅 transform 参与过渡,不切换 left/right 避免瞬跳。
+ .calling-car-vehicle-pic {
+ transform: translateY(70%) translateX(-88px) scale(1.5);
+ opacity: 1;
+ transition: transform 0.8s ease, opacity 0.5s ease;
+ z-index: 0;
+ }
+ }
+ // 阶段三:视频结束 → 功能卡回升归位、车模缩小回落到最终停靠位、视频切回图片背景
+ // --dispatch-settle-duration 与 use-dispatch-transition 中 BG_SHRINK_LEAD_SEC 保持一致
+ &.dispatch-settle {
+ --dispatch-settle-duration: 1.055s;
+ .calling-car-content-box { transform: translateY(0); transition: transform var(--dispatch-settle-duration) ease; }
+ // 车模从居中态回落到常驻停靠位(translateX 回 0 即基础态 right:116px 位置),并缩小归位。
+ // transform 函数列表与 video 阶段对齐(translateY/translateX/scale),保证平滑插值、无瞬跳。
+ .calling-car-vehicle-pic {
+ transform: translateY(0) translateX(0) scale(1);
+ opacity: 1;
+ transition: transform var(--dispatch-settle-duration) cubic-bezier(0.22, 1, 0.36, 1), opacity var(--dispatch-settle-duration) ease;
+ }
+ }
+}
+// 派单成功整卡入场:整卡从「呼叫中」卡片位置缓缓升起到位。
+// 用 keyframes 挂载自动播放(weapp 实测可靠);弃用 JS 两帧 + transition:起始态类依赖首帧提交才可能触发过渡,
+// 且会被父层重渲染中途卸掉导致整卡直接切换、无升起。
+.calling-car--enter {
+ // 缓动必须慢起步:入场升起与面板下移是同时进行的父子叠加运动,
+ // 慢起步让面板下移速度全程大于升起速度,面板绝对位置单调下降、无方向反转。
+ animation: calling-car-rise 0.8s cubic-bezier(0.45, 0, 0.2, 1);
+ will-change: transform;
+}
+// 起升距离 = 呼叫中与派单成功两态卡片顶部高度差。不能用 translateY(100%)(卡高 1500+px 会从屏外开始升)。
+@keyframes calling-car-rise {
+ from {
+ transform: translateY(170px);
+ }
+
+ to {
+ transform: translateY(0);
+ }
+}
+// 「送驾中(6) 直达待评价(9)」整卡入场:内部免费订单跳过待支付(8),原本是无动效硬切。
+// 卡片容器为底部锚定(.container position:fixed bottom:0),待评价态内容更高会瞬间向上顶,
+// 故从「相当于送驾态卡顶」的位置升起,用运动吃掉高度差;同时做轻微透明度渐变,柔化内容替换。
+//
+// 起升距离 = 待评价态与送驾态的卡片高度差。
+// 实测依据(同机型两态截图,designWidth 750 下 px 即设计稿单位):以两态共有的首个内容块
+// VehicleInfoCard(“小鹏GX”行)到屏幕底部的距离比对,送驾态约 288px、待评价态约 659px,
+// 即待评价态多出约 370px(新增费用明细两行 + 评价卡,减去状态条 + 操作栏)。
+// 用 LESS 变量而非 CSS 自定义属性:keyframes 内的 var() 在旧版 WKWebView 上存在不生效的风险,
+// LESS 变量在编译期就展开为字面量,按真机观感调这一处即可。
+@evaluate-enter-rise: 380px;
+
+@keyframes calling-car-evaluate-rise {
+ from {
+ transform: translateY(@evaluate-enter-rise);
+ opacity: 0.4;
+ }
+
+ // 透明度单独提前收口:0.4 → 1 用 0.2s 走完(0.7s 总时长的 28.57% 处),
+ // 之后 transform 继续独立完成剩余位移,不再受 opacity 影响。
+ 28.57% {
+ opacity: 1;
+ }
+
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+.calling-car--evaluate-enter {
+ // 位移行程约 380px(大于派单入场的 170px),时长相应放到 0.7s,保持速度感与派单入场一致。
+ animation: calling-car-evaluate-rise 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both;
+ will-change: transform;
+}
+// 「行程结束」入场:仅在真实 live 跃迁进入结束态时(父层 playFinishedEnter 上升沿)加此类,
+// 承载背景卡与里程「一起升起」动画;恢复态不加此类,直接显示最终态、不弹起。
+//
+// 入场时序:礼花先播放(见 trip-finished-fireworks),延迟 @finished-enter-delay 后卡片与里程
+// 才开始升起——让礼花播到中段时卡片才登场,避免二者同时抢镜(延迟解耦,不与礼花播放硬关联)。
+@finished-enter-delay: 700ms;
+.calling-car--finished-enter {
+ .calling-car-bg.finished-bg {
+ // 背景卡(背景图)缓缓升起:延迟至礼花中段后触发,时长 1s
+ animation: finished-bg-rise 1s cubic-bezier(0.22, 0.61, 0.36, 1) @finished-enter-delay both;
+ will-change: transform;
+ }
+ // 里程简化:与背景卡「同延迟、同时长、同曲线」纯位移升起(复用 finished-bg-rise),
+ // 与背景卡锁步移动,视觉上如同印在卡面上一起升起;不再做淡入淡出。
+ // 整块头部(到达目的地 + 里程)一起动,避免只动里程导致标题脱节。
+ .trip-finished-header {
+ animation: finished-bg-rise 1s cubic-bezier(0.22, 0.61, 0.36, 1) @finished-enter-delay both;
+ will-change: transform;
+ }
+}
+// 派单成功转场期间:蓝牙卡 / 车控卡 / 底部操作栏隐藏但保留占位(opacity),逻辑另在各组件延迟执行;
+// 动效完成后移除 .dispatch-defer-content,随 opacity 过渡淡入。用 opacity 而非 visibility,保证淡入过渡生效。
+.calling-car {
+ .bluetooth-operate-card,
+ .temp-fragrance-control,
+ .trip-action-bar {
+ transition: opacity 0.3s ease;
+ }
+ &.dispatch-defer-content {
+ .bluetooth-operate-card,
+ .temp-fragrance-control,
+ .trip-action-bar {
+ opacity: 0;
+ pointer-events: none;
+ }
}
}
// 到达终点/待支付态(图一、图二):背景透明化 + 到达目的地头部 + 白色费用卡
.calling-car--finished {
padding-top: 0;
+ // 结束态整卡背景透明:避免背景图升起时顶部露出 .calling-car 的 @gary-theme 底色
+ background: transparent;
.calling-car-content-box {
background: transparent;
padding: 0;
@@ -74,6 +241,8 @@
font-family: @font-xpsans-bold;
letter-spacing: normal;
color: #FFFFFF;
+ // 里程渐显动画仅在真实 live 跃迁进入结束态时播放(见 .calling-car--finished-enter);
+ // 恢复态默认静态显示最终态(opacity:1、translateY(0))。
}
// 白色费用卡(横向内边距由卡片自身承担,因 content-box 已透明化)
// 提升特异性:与 .calling-car .fare-box-card { padding-bottom: 0 } 同特异性且在其之前,
@@ -154,11 +323,30 @@
.fare-box-card {
padding-bottom: 0;
}
+ .car-card-detail-wrap {
+ margin-bottom: 32px;
+ }
.car-card-detail {
display: flex;
align-items: center;
justify-content: space-between;
- margin-bottom: 32px;
+ }
+ .car-card-detail-destination {
+ display: flex;
+ align-items: center;
+ margin-top: 12px;
+ .car-card-detail-destination-icon {
+ width: 28px;
+ height: 28px;
+ flex-shrink: 0;
+ }
+ .car-card-detail-destination-name {
+ margin-left: 8px;
+ font-size: 24px;
+ color: @black-text-main;
+ max-width: 620px;
+ word-break: break-all;
+ }
}
.car-card-detail-plate {
display: flex;
@@ -583,8 +771,7 @@
display: flex;
align-items: center;
justify-content: space-between;
- margin-top: 8px;
- padding: 32px 8px;
+ padding: 22px 8px 0 8px;
}
.trip-action-bar-item {
display: flex;
@@ -600,6 +787,7 @@
width: 28px;
height: 28px;
margin-right: 8px;
+ margin-top: 2px;
}
.trip-action-bar-item-destination {
.trip-action-bar-item-icon {
@@ -610,7 +798,7 @@
.trip-action-bar-item-text {
flex-shrink: 0;
font-size: 24px;
- line-height: 24px;
+ line-height: 56px;
color: @black-text-main;
}
.trip-action-bar-share-btn {
@@ -834,9 +1022,17 @@
padding: 24px 0px;
border-radius: 16px;
background: @gary-theme;
+ // 就绪后内容淡入;未就绪时占位保留高度、内容淡出,避免蓝牙状态异步就绪撑高卡片产生跳变
+ transition: opacity 0.3s ease;
+ &--pending {
+ opacity: 0;
+ pointer-events: none;
+ }
.bluetooth-operate-card-left {
display: flex;
align-items: center;
+ // 与右侧箭头容器 line-height:40px 对齐,保证「未就绪(无箭头)」与「就绪(有箭头)」两态行高一致、零跳变
+ min-height: 40px;
gap: 4px;
font-size: 24px;
font-weight: normal;
@@ -887,9 +1083,6 @@
margin: 0 12px;
color: rgba(20, 30, 39, 0.2);
}
- .calling-car-site-box-content-name {
- font-weight: 500;
- }
}
.calling-car-cancel-btn {
flex-shrink: 0;
@@ -909,36 +1102,6 @@
border-radius: 40px;
background: rgba(204, 208, 215, 0.25);
padding: 32px;
- display: flex;
- gap: 16px;
- height: fit-content;
- .calling-car-site-box-icon {
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- .calling-car-site-check {
- width: 32px;
- height: 32px;
- }
- .dotted-line {
- flex: 1;
- width: 2px;
- margin: 6px 0;
- height: fit-content;
- }
- }
- .calling-car-site-box-content {
- flex: 1;
- font-size: 28px;
- line-height: 44px;
- letter-spacing: normal;
- color: @black-text-main;
- .calling-car-site-box-divider {
- margin: 40px 24px 40px 0px;
- border-top: 1px solid rgba(20, 30, 39, 0.08);
- }
- }
}
.calling-car-status-box {
position: relative;
@@ -996,45 +1159,29 @@
}
}
-/* 车控入口卡片横向滑动容器(空调 / 音乐 / 香氛,卡片固定宽度,超出宽度可横向滑动)
- 用 ScrollView 而非 View + overflow:weapp WebView 渲染器会拦截 View 上的触摸手势,
- CSS overflow 滚动在真机与模拟器均无法拖动。 */
-.temp-fragrance-scroll {
+/* 车控入口卡片 2 列网格容器(空调 / 音乐 / 香氛):不再横向滑动,
+ 每行 2 个卡片、列间隔 32px,卡片宽度按内容区两等分,超过 2 个自动换行(行间隔同为 32px)。 */
+.temp-fragrance-control {
position: relative;
z-index: 2;
- width: 100vw; /* 滑动区铺满整屏宽 */
- height: 240px; /* 与卡片等高,避免 scroll-view 高度塌陷 */
margin-top: 8px;
- /* 左移 48px 抵消 .calling-car-content-box 的左内边距,使滑动区左边界对齐屏幕左边缘,
- 卡片可一直滑到屏幕两侧边缘(首/末卡片的 48px 视觉留白由内部轨道的 padding 承担)。
- 已显式指定 width: 100vw,故无需再写 margin-right(会因过约束被忽略)。 */
- margin-left: -48px;
- white-space: nowrap;
-
- /* 隐藏横向滚动条(show-scrollbar 仅 enhanced 模式生效,这里用 CSS 兜底) */
- &::-webkit-scrollbar,
- ::-webkit-scrollbar {
- display: none;
- width: 0;
- height: 0;
- }
-}
-
-/* 滑动轨道:宽度由卡片内容决定 */
-.temp-fragrance-control {
- display: inline-flex;
- flex-wrap: nowrap;
+ display: flex;
+ flex-wrap: wrap;
align-items: flex-start;
- gap: 24px;
- /* 静止时首卡对齐 48px 内容左边界;滑到底时末卡右侧同样保留 48px 留白 */
- padding-left: 48px;
- padding-right: 48px;
+ gap: 32px;
- /* 卡片不参与压缩,保证各卡片保持设计稿宽度并整体溢出滚动 */
+ /* 卡片外层容器:两等分内容宽度(减去一个 32px 列间隔) */
> .ac-control,
> .music-control,
> .fragrance-control {
- flex-shrink: 0;
+ flex: 0 0 calc((100% - 32px) / 2);
+ width: calc((100% - 32px) / 2);
+ min-width: 0;
+ }
+
+ /* 卡片撑满所在列(全局 .tf-card 为横滑布局下的固定 304px 宽) */
+ .tf-card {
+ width: 100%;
}
}
@@ -1128,6 +1275,7 @@
.tf-card-ac-temp-unit {
margin-top: 8px;
margin-left: 6px;
+ font-family: @font-xpsans-bold;
font-size: 28px;
font-weight: 500;
line-height: 32px;
@@ -1139,6 +1287,7 @@
display: flex;
align-items: center;
justify-content: space-between;
+ margin-top: 32px;
}
.tf-card-footer-text {
@@ -1160,14 +1309,21 @@
padding-top: 8px;
&.is-open {
- background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/aircondition-open-20260703.png');
+ background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/aircondition-open-20260812.png');
+
+ // 开启态卡片文字统一为白色
+ .tf-card-header-title,
+ .tf-card-ac-temp-value,
+ .tf-card-ac-temp-unit,
+ .tf-card-footer-text {
+ color: #FFFFFF;
+ }
}
&.is-close {
- background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/aircondition-close-20260703.png');
+ background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/aircondition-close-20260812.png');
- // 关闭态卡片文字统一弱化
- .tf-card-header-title,
+ // 关闭态卡片文字统一弱化(标题保持主题色,与香氛卡片一致)
.tf-card-ac-temp-value,
.tf-card-ac-temp-unit,
.tf-card-footer-text {
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/cancel-popup.less b/src/robotaxi/pages/begin-route/components/css-folder/cancel-popup.less
index 4bb2550..1963052 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/cancel-popup.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/cancel-popup.less
@@ -63,57 +63,9 @@
.cancel-site-box {
margin-top: 48px;
- border-radius: 40px;
+ padding: 48px 40px;
background: rgba(204, 208, 215, 0.25);
- padding: 48px 32px 42px 40px;
- display: flex;
- gap: 16px;
- height: fit-content;
-
- .cancel-site-box-icon {
- padding: 16px 0;
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
-
- .cancel-site-box-icon-start {
- width: 16px;
- height: 16px;
- background: #DFB369;
- border-radius: 50%;
- }
-
- .cancel-site-box-icon-end {
- width: 16px;
- height: 16px;
- background: #141E27;
- border-radius: 50%;
- }
-
- image {
- flex: 1;
- width: 2px;
- margin: 7px 0;
- height: fit-content;
- }
- }
-
- .cancel-site-box-content {
- flex: 1;
- font-size: 32px;
- line-height: 48px;
- letter-spacing: normal;
- color: #141E27;
- font-weight: 500;
- .cancel-site-box-divider {
- margin: 34px 32px 40px 0px;
- border-top: 1px solid rgba(204, 208, 215, 0.25);
- }
- .cancel-site-box-content-name {
- font-weight: 500;
- }
- }
+ border-radius: 40px;
}
.cancel-animation {
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/evaluate-popup.less b/src/robotaxi/pages/begin-route/components/css-folder/evaluate-popup.less
index 7e80193..fb0131b 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/evaluate-popup.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/evaluate-popup.less
@@ -25,6 +25,11 @@
border-radius: 60px 60px 0 0;
padding: 64px 0 0 0;
+ &.is-fare-feedback {
+ height: 812px;
+ padding-top: 48px;
+ }
+
.evaluate-rating-switcher,
.content-box {
transition: opacity 180ms ease, transform 180ms ease;
@@ -84,6 +89,11 @@
align-self: stretch;
padding-left: 30px;
box-sizing: border-box;
+
+ &.is-centered {
+ text-align: center;
+ padding-left: 0;
+ }
}
&-content {
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/fragrance-control.less b/src/robotaxi/pages/begin-route/components/css-folder/fragrance-control.less
index 7e37fc8..f68b3d6 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/fragrance-control.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/fragrance-control.less
@@ -28,11 +28,11 @@
/* 香氛入口卡片:背景随开关状态切换 */
.tf-card-fragrance {
&.is-open {
- background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/fragrance-open-20260703.png');
+ background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/fragrance-open-20260812.png');
}
&.is-close {
- background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/fragrance-close-20260703.png');
+ background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/fragrance-close-20260812.png');
// 关闭态卡片文字统一弱化
.tf-card-fragrance-name,
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/modify-destination-review.less b/src/robotaxi/pages/begin-route/components/css-folder/modify-destination-review.less
index bd072bd..ba328cc 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/modify-destination-review.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/modify-destination-review.less
@@ -59,17 +59,22 @@
margin-bottom: 32px;
}
- // 站点样式参考 cancel-popup 的 cancel-site-box:灰底盒子 + 左侧起终点圆点虚线列
+ // 站点样式参考 cancel-popup 的 cancel-site-box(route-card 风格),已抽离为 SiteRouteCard 公共组件,
+ // 此处只保留容器级样式(背景/圆角/内边距)
.modify-destination-review-select-site {
position: relative;
z-index: 1;
- display: flex;
- gap: 16px;
padding: 40px 32px 40px 48px;
border-radius: 40px;
background: rgba(204, 208, 215, 0.25);
box-sizing: border-box;
- height: fit-content;
+ .site-route-card-item-first {
+ .site-route-card-item-row {
+ .site-route-card-item-text {
+ color: @black-text-04;
+ }
+ }
+ }
}
&-trip-card {
@@ -80,59 +85,6 @@
box-sizing: border-box;
border: 2px solid #ffffff;
box-shadow: 0 20px 40px 0 rgba(87, 99, 147, 0.08);
- }
-
- .modify-destination-review-select-site-start {
- color: @black-text-04;
- }
-
- .modify-destination-review-select-site-icon {
- padding: 16px 0;
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- }
-
- .modify-destination-review-select-site-icon-start {
- width: 16px;
- height: 16px;
- background: #DFB369;
- border-radius: 50%;
- }
-
- .modify-destination-review-select-site-icon-end {
- width: 16px;
- height: 16px;
- background: @black-text-main;
- border-radius: 50%;
- }
-
- .modify-destination-review-select-site-icon image {
- flex: 1;
- width: 2px;
- margin: 7px 0;
- height: fit-content;
- }
-
- .modify-destination-review-select-site-content {
- flex: 1;
- min-width: 0;
- font-size: 32px;
- line-height: 48px;
- letter-spacing: normal;
- color: @black-text-main;
- }
-
- .modify-destination-review-select-site-start,
- .modify-destination-review-select-site-end {
- font-weight: 500;
- word-break: break-all;
- }
-
- .modify-destination-review-select-site-divider {
- margin: 34px 32px 40px 0px;
- border-top: 1px solid rgba(204, 208, 215, 0.25);
}
&-footer {
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/release-be-being.less b/src/robotaxi/pages/begin-route/components/css-folder/release-be-being.less
index 1307acf..ef0295f 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/release-be-being.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/release-be-being.less
@@ -5,7 +5,7 @@
background: @gary-theme;
border-radius: 60px 60px 0px 0px;
overflow: hidden;
- padding-top: 84px;
+ padding-top: 94px;
.release-be-being-content {
position: relative;
z-index: 1;
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/release-route.less b/src/robotaxi/pages/begin-route/components/css-folder/release-route.less
index 2593e95..9c67f08 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/release-route.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/release-route.less
@@ -70,9 +70,9 @@
.route-select-content-item {
flex: 1;
padding: 16px 0 24px 24px;
- border-radius: 16px;
- background: #F5F6FA;
- border: 1px solid #F2F3F7;
+ border-radius: 20px;
+ background: @gary-theme;
+
.route-select-content-item-title {
font-size: 20px;
@@ -115,28 +115,11 @@
}
.active-item {
- background: linear-gradient(97deg, rgba(255, 209, 129, 0.2) 0%, rgba(223, 179, 105, 0.2) 103%) !important;
- box-sizing: border-box;
+ background: rgba(223, 179, 105, 0.2);
position: relative;
.route-select-content-item-title {
color: @gold-text;
- }
-
- &::after {
- content: "";
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 16px;
- padding: 1px;
- background: linear-gradient(180deg, @gold-light -10%, rgba(223, 179, 105, 0.0469) 108%, rgba(0, 0, 0, 0) 108%);
- -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
- -webkit-mask-composite: xor;
- mask-composite: exclude;
- pointer-events: none;
}
}
}
@@ -362,6 +345,7 @@
.discount-amount-desc {
margin-left: 8px;
font-size: 24px;
+ font-weight: 500;
line-height: 40px;
letter-spacing: normal;
color: @gold-text;
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/route-selection-panel.less b/src/robotaxi/pages/begin-route/components/css-folder/route-selection-panel.less
index 41992c8..3fdd5b5 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/route-selection-panel.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/route-selection-panel.less
@@ -48,14 +48,13 @@
.route-select-content-item {
flex: 1;
padding: 16px 0 24px 24px;
- border-radius: 16px;
+ border-radius: 20px;
background: #F5F6FA;
- border: 1px solid #F2F3F7;
.route-select-content-item-title {
font-size: 20px;
line-height: 20px;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
}
.route-select-content-item-time {
@@ -63,7 +62,7 @@
font-size: 28px;
font-weight: 500;
line-height: 40px;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
}
.route-select-content-item-distance {
@@ -90,27 +89,11 @@
}
.active-item {
- background: linear-gradient(97deg, rgba(255, 209, 129, 0.2) 0%, rgba(223, 179, 105, 0.2) 103%) !important;
+ background: rgba(223, 179, 105, 0.2);
box-sizing: border-box;
position: relative;
.route-select-content-item-title {
color: @gold-text;
- }
-
- &::after {
- content: "";
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 16px;
- padding: 1px;
- background: linear-gradient(180deg, @gold-light -10%, rgba(223, 179, 105, 0.0469) 108%, rgba(0, 0, 0, 0) 108%);
- -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
- -webkit-mask-composite: xor;
- mask-composite: exclude;
- pointer-events: none;
}
}
}
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/seat-hv-control.less b/src/robotaxi/pages/begin-route/components/css-folder/seat-hv-control.less
index 10de5ee..1bb09dc 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/seat-hv-control.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/seat-hv-control.less
@@ -9,16 +9,32 @@
}
}
+// 座椅区背景图为 750×1232,容器按同比例固定高度:
+// 按钮以百分比定位,容器高度提前确定后,按钮首帧即在正确位置(不再等背景图 widthFix 撑开高度)
+@seat-hv-area-height: 1232px;
+// 可见座椅区高度:弹窗顶部到空调白卡顶部,对应 temperature-control.less 中 .popup-ac 的 margin-top
+@seat-hv-visible-height: 772px;
+
// 座椅通风加热区(内嵌于「温度调节」面板)
.seat-hv-area {
position: relative;
width: 100%;
+ height: @seat-hv-area-height;
overflow: hidden;
- // 座舱俯视图背景(widthFix,高度按图片比例自适应,撑起区域高度)
+ // 座舱俯视图背景:绝对定位不参与高度计算,加载完成后渐现
.seat-hv-bg {
+ position: absolute;
+ top: 0;
+ left: 0;
width: 100%;
display: block;
+ opacity: 0;
+ transition: opacity 240ms ease;
+
+ &.is-loaded {
+ opacity: 1;
+ }
}
// 座椅节点叠放层
@@ -139,13 +155,12 @@
}
}
-// 无座椅占位
+// 无座椅占位(不可用态):无背景色,铺满整个可见座椅区
.seat-hv-empty {
- height: 320px;
+ height: @seat-hv-visible-height;
display: flex;
align-items: center;
justify-content: center;
- background-color: #E9EBF0;
font-size: 28px;
color: rgba(0, 0, 0, 0.4);
}
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/service-route-change.less b/src/robotaxi/pages/begin-route/components/css-folder/service-route-change.less
index 6a08133..8280cfa 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/service-route-change.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/service-route-change.less
@@ -1,8 +1,8 @@
@import '../../../../style/ui-variables.less';
.service-route-change-container {
- background: #FFFFFF;
- border-radius: 40px 40px 0 0;
- padding: 40px 49px 68px 49px;
+ background: @gary-theme;
+ border-radius: 60px 60px 0 0;
+ padding: 40px 40px 80px 40px;
.service-route-select-container {
display: flex;
@@ -11,8 +11,8 @@
.service-route-select-title {
font-size: 28px;
font-weight: 500;
- line-height: 40px;
- color: rgba(0, 0, 0, 0.9);
+ line-height: 52px;
+ color: @black-text-main;
}
.service-route-select-preference {
@@ -20,11 +20,10 @@
align-items: center;
.service-route-select-preference-text {
- font-size: 24px;
+ font-size: 28px;
font-weight: 500;
- line-height: 40px;
- color: @gold-text;
- font-weight: 500;
+ line-height: 52px;
+ color: @dark-gold-text;
}
image {
@@ -41,28 +40,27 @@
.service-route-select-content {
display: flex;
justify-content: space-between;
- gap: 10px;
- margin-top: 16px;
+ gap: 14px;
+ margin-top: 34px;
.service-route-select-content-item {
flex: 1;
padding: 16px 24px 30px 24px;
- border-radius: 16px;
+ border-radius: 20px;
background: #F5F6FA;
- border: 1px solid #F2F3F7;
.service-route-select-content-item-title {
font-size: 20px;
line-height: 20px;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-04;
}
.service-route-select-content-item-time {
- margin-top: 10px;
+ margin-top: 18px;
font-size: 28px;
font-weight: 500;
line-height: 40px;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
}
.service-route-select-content-item-distance {
@@ -72,7 +70,7 @@
margin-top: 12px;
font-size: 20px;
line-height: 20px;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
.service-traffic-signal-item {
display: flex;
@@ -88,36 +86,20 @@
}
.service-route-select-content-item-estimate {
- margin-top: 34px;
+ margin-top: 38px;
font-size: 20px;
line-height: 20px;
- color: #F58900;
+ color: @gold-text;
}
}
.active {
- background: linear-gradient(97deg, rgba(255, 209, 129, 0.2) 0%, rgba(223, 179, 105, 0.2) 103%) !important;
+ background: rgba(223, 179, 105, 0.2);
box-sizing: border-box;
position: relative;
.service-route-select-content-item-title {
color: @gold-text;
- }
-
- &::after {
- content: "";
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 16px;
- padding: 1px;
- background: linear-gradient(180deg, @gold-light -10%, rgba(223, 179, 105, 0.0469) 108%, rgba(0, 0, 0, 0) 108%);
- -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
- -webkit-mask-composite: xor;
- mask-composite: exclude;
- pointer-events: none;
}
}
}
@@ -126,30 +108,36 @@
display: flex;
justify-content: space-between;
gap: 22px;
- margin-top: 48px;
+ margin-top: 58px;
font-size: 32px;
font-weight: 500;
text-align: center;
.service-route-select-footer-item-cancel {
- flex: 1;
- color: #141E27;
- border-radius: 1000px;
- box-sizing: border-box;
- border: 4px solid #141E27;
- line-height: 104px;
- height: 104px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ color: @black-text-main;
+ border-radius: 1000px;
+ box-sizing: border-box;
+ border: 4px solid @black-text-main;
+ line-height: 104px;
+ height: 102px;
}
.service-route-select-footer-item-confirm {
- flex: 1;
- color: #FFFFFF;
- border-radius: 1000px;
- line-height: 104px;
- border: 4px solid #141E27;
- box-sizing: border-box;
- height: 104px;
- background: #141E27;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ color: #FFFFFF;
+ border-radius: 1000px;
+ line-height: 104px;
+ border: 4px solid @black-text-main;
+ box-sizing: border-box;
+ height: 102px;
+ background: @black-text-main;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/robotaxi/pages/begin-route/components/css-folder/temperature-control.less b/src/robotaxi/pages/begin-route/components/css-folder/temperature-control.less
index 8692c79..bb983d9 100644
--- a/src/robotaxi/pages/begin-route/components/css-folder/temperature-control.less
+++ b/src/robotaxi/pages/begin-route/components/css-folder/temperature-control.less
@@ -128,7 +128,8 @@
// 居中选中项:放大加粗(空调开启时高亮)
&.active {
- width: 220px;
+ // 220px(原值)+ 左右各 20px:加大选中温度与相邻温度的间距
+ width: 260px;
.popup-temp-item-value {
font-size: 64px;
line-height: 72px;
diff --git a/src/robotaxi/pages/begin-route/components/debug-autoscale-panel.tsx b/src/robotaxi/pages/begin-route/components/debug-autoscale-panel.tsx
deleted file mode 100644
index df4a289..0000000
--- a/src/robotaxi/pages/begin-route/components/debug-autoscale-panel.tsx
+++ /dev/null
@@ -1,329 +0,0 @@
-/**
- * 速度自动缩放调试面板(已下线,文件保留供后续临时调参/回归测试时复用)。
- *
- * ============================================================================
- * 何时再用?
- * - 调整 LOW_SPEED_MAX_KMH 阈值后想快速验证分档
- * - 调整 LOW_SPEED_SCALE / HIGH_SPEED_SCALE 缩放等级后看效果
- * - 排查"速度变了但地图没缩放""车辆离开视区"等问题,临时模拟车速/订单状态/车辆位置
- *
- * 启用步骤:
- * 1. 把 `speed-scale-config.ts` 的 `SPEED_SCALE_DEBUG` 改为 `true`
- * (会自动切换到 debug 阈值:低速 0~10、中高速 >10 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,
- 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),按低速上限的 4 倍取整,保证能覆盖中高速挡。 */
-const SLIDER_MAX_KMH = Math.ceil(LOW_SPEED_MAX_KMH * 4);
-
-/** 挡位标签配色。 */
-const TIER_COLOR: Record<string, string> = {
- low: '#36b37e',
- 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 / 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 highPreset = LOW_SPEED_MAX_KMH + Math.round(LOW_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>{`阈值:低速 0~${LOW_SPEED_MAX_KMH} / 中高速 >${LOW_SPEED_MAX_KMH} km/h`}</Text>
- <Text style={{ display: 'block' }}>{`scale:低速 ${LOW_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(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>
- );
-}
diff --git a/src/robotaxi/pages/begin-route/components/debug-order-simulator-panel.tsx b/src/robotaxi/pages/begin-route/components/debug-order-simulator-panel.tsx
index d1c6941..5f6f69a 100644
--- a/src/robotaxi/pages/begin-route/components/debug-order-simulator-panel.tsx
+++ b/src/robotaxi/pages/begin-route/components/debug-order-simulator-panel.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { View, Text } from '@tarojs/components';
-import { TripStatus } from '../../../constants/trip-status';
+import { TripStatus, CancelType } from '../../../constants/trip-status';
import OrderFlowSimulator, {
buildDefaultRoutes,
ensureSimulationOrderId,
@@ -29,7 +29,7 @@
/** 下车点坐标。 */
endSite: SitePoint;
/** 设置取消弹窗类型(用于本地手动触发各类取消弹窗文案)。 */
- setCancelType?: (type: number) => void;
+ setCancelType?: (type: CancelType) => void;
/** 设置取消弹窗展示状态。 */
setShowCancelPopup?: (show: boolean) => void;
/** 设置修改目的地失败弹窗展示状态(用于本地手动触发)。 */
@@ -303,9 +303,9 @@
setReplaying(false);
};
- /** 手动触发「附近没有空余车辆,已为您取消订单」弹窗(cancelType 5)。 */
+ /** 手动触发「附近没有空余车辆,已为您取消订单」弹窗(CancelType.NoAvailableVehicle)。 */
const handleTriggerNoVehicleCancelPopup = () => {
- props.setCancelType?.(5);
+ props.setCancelType?.(CancelType.NoAvailableVehicle);
props.setShowCancelPopup?.(true);
};
@@ -538,4 +538,4 @@
</View>
</View>
);
-}
+}
\ No newline at end of file
diff --git a/src/robotaxi/pages/begin-route/components/debug-safearea-panel.tsx b/src/robotaxi/pages/begin-route/components/debug-safearea-panel.tsx
deleted file mode 100644
index fdab9d1..0000000
--- a/src/robotaxi/pages/begin-route/components/debug-safearea-panel.tsx
+++ /dev/null
@@ -1,274 +0,0 @@
-/**
- * 安全区调试面板(调试用,可常驻保留供后续排查跟车视口问题时复用)。
- *
- * ============================================================================
- * 用途:
- * 把 `syncActiveTripViewportToVehicle` 里用于判断「车辆是否需要回中」的安全区,
- * 用红色虚线直接描在地图上,方便肉眼校对死区/边距是否符合预期。
- *
- * 安全区定义(与 use-map-viewport.ts / active-trip-camera-policy.ts 完全一致):
- * - 纵向:车辆落在 [deadZoneTopY, deadZoneBottomY] 之间不触发回中(纵向死区)
- * - 横向:车辆落在 [safeXMargin, mapWidth - safeXMargin] 之间不触发回中
- * - 因此「安全区范围」= 上述纵向死区 ∩ 横向边距 构成的矩形
- * - 另外叠加展示:可用视区上下边界(safeTopY/safeBottomY) 与 车辆回中目标线(targetScreenY)
- *
- * 挂载方式(在 index.tsx 的 BeginRoute() 顶层 fragment 内,与 ModifyDestinationFailPopup 同级):
- * // 顶部 import:
- * import DebugSafeAreaPanel from './components/debug-safearea-panel';
- * // 顶层 fragment 内:
- * <DebugSafeAreaPanel
- * tripStatus={tripStatus}
- * mapBottomPadding={mapBottomPadding}
- * resolveSafeTopInset={resolveSafeTopInset}
- * />
- *
- * 注意:
- * - 覆盖层基于普通 <View> + position:fixed(与 debug-autoscale-panel 同方案),
- * 坐标取自 `#map` 的 boundingClientRect,故与地图屏幕像素对齐;
- * 如果在真机被原生地图盖住,再考虑切到 cover-view。
- * - 仅接驾中/行程中(VehicleApproaching/InService)才有安全区;其余状态点击
- * 描边时会按「行程中」做预览,并在面板上标注「预览」。
- * ============================================================================
- */
-import { useState } from 'react';
-import Taro from '@tarojs/taro';
-import { View, Text } from '@tarojs/components';
-import { TripStatus } from '../../../constants/trip-status';
-import {
- buildActiveTripSafeAreaMetrics,
- ActiveTripSafeAreaMetrics
-} from '../hooks/active-trip-camera-policy';
-import { ACTIVE_TRIP_VIEWPORT_SAFE_X_MARGIN_RATIO } from '../hooks/use-map-viewport';
-
-type Props = {
- /** 当前行程状态。 */
- tripStatus: TripStatus;
- /** 底部卡片动态留白(来自 useMapViewport)。 */
- mapBottomPadding: number;
- /** 顶部安全区高度计算函数(来自 useMapViewport)。 */
- resolveSafeTopInset: () => number;
-};
-
-/** 启用安全区的行程状态。 */
-const ACTIVE_STATUSES = [TripStatus.VehicleApproaching, TripStatus.InService];
-
-/** 描边结果快照。 */
-type SafeAreaSnapshot = {
- /** 地图容器矩形(boundingClientRect)。 */
- rect: { left: number; top: number; width: number; height: number };
- /** 安全区度量。 */
- metrics: ActiveTripSafeAreaMetrics;
- /** 横向单侧边距(px)。 */
- safeXMargin: number;
- /** 计算所用的行程状态(可能为预览态)。 */
- usedStatus: TripStatus;
- /** 是否为预览(当前非接驾中/行程中)。 */
- isPreview: boolean;
-};
-
-/**
- * 安全区调试面板:默认仅显示右侧悬浮「安全区」小按钮,点击后用红色虚线把
- * `syncActiveTripViewportToVehicle` 的安全区范围描在地图上,再点一次清除。
- */
-export default function DebugSafeAreaPanel(props: Props) {
- const [snapshot, setSnapshot] = useState<SafeAreaSnapshot | null>(null);
-
- /** 查询 #map 容器矩形。 */
- const queryMapRect = () =>
- new Promise<{ left: number; top: number; width: number; height: number } | null>((resolve) => {
- Taro.createSelectorQuery()
- .select('#map')
- .boundingClientRect((rect: any) => {
- if (rect && Number.isFinite(rect.width) && Number.isFinite(rect.height)) {
- resolve({ left: rect.left, top: rect.top, width: rect.width, height: rect.height });
- return;
- }
- resolve(null);
- })
- .exec();
- });
-
- /** 计算并描出安全区。 */
- const drawSafeArea = async () => {
- const rect = await queryMapRect();
- if (!rect) {
- console.log('[safearea] 🧪 获取地图容器失败,无法描边');
- return;
- }
- const isPreview = !ACTIVE_STATUSES.includes(props.tripStatus);
- const usedStatus = isPreview ? TripStatus.InService : props.tripStatus;
- const metrics = buildActiveTripSafeAreaMetrics({
- tripStatus: usedStatus,
- mapHeight: rect.height,
- bottomPadding: props.mapBottomPadding,
- safeTopInset: props.resolveSafeTopInset()
- });
- if (!metrics) {
- console.log('[safearea] 🧪 当前状态无安全区度量');
- return;
- }
- const safeXMargin = rect.width * ACTIVE_TRIP_VIEWPORT_SAFE_X_MARGIN_RATIO;
- console.log('[safearea] 🧪 安全区度量', { rect, metrics, safeXMargin, usedStatus, isPreview });
- setSnapshot({ rect, metrics, safeXMargin, usedStatus, isPreview });
- };
-
- /** 切换:有快照则清除,无则重新描边。 */
- const toggle = () => {
- if (snapshot) {
- setSnapshot(null);
- return;
- }
- drawSafeArea();
- };
-
- const buttonLabel = snapshot ? '清除安全区' : '描安全区';
-
- return (
- <>
- {/* 悬浮触发按钮 */}
- <View
- onClick={toggle}
- style={{
- position: 'fixed',
- right: '0',
- top: '38%',
- zIndex: 9999,
- padding: '8px 10px',
- background: snapshot ? 'rgba(255,60,60,0.85)' : 'rgba(0,0,0,0.7)',
- color: '#fff',
- fontSize: '12px',
- borderTopLeftRadius: '16px',
- borderBottomLeftRadius: '16px'
- }}
- >
- {buttonLabel}
- </View>
-
- {snapshot && (
- <>
- {/* 安全区范围:纵向死区 ∩ 横向边距,红色虚线主框 */}
- <View
- style={{
- position: 'fixed',
- left: `${snapshot.rect.left + snapshot.safeXMargin}px`,
- top: `${snapshot.rect.top + snapshot.metrics.deadZoneTopY}px`,
- width: `${snapshot.rect.width - snapshot.safeXMargin * 2}px`,
- height: `${snapshot.metrics.deadZoneBottomY - snapshot.metrics.deadZoneTopY}px`,
- border: '2px dashed #ff3c3c',
- boxSizing: 'border-box',
- zIndex: 9998,
- pointerEvents: 'none'
- }}
- >
- <Text
- style={{
- position: 'absolute',
- left: '4px',
- top: '2px',
- fontSize: '11px',
- color: '#ff3c3c',
- background: 'rgba(255,255,255,0.65)',
- padding: '0 4px',
- borderRadius: '4px'
- }}
- >
- 安全区
- </Text>
- </View>
-
- {/* 可用视区上下边界:淡红虚线,辅助参考 */}
- <View
- style={{
- position: 'fixed',
- left: `${snapshot.rect.left}px`,
- top: `${snapshot.rect.top + snapshot.metrics.safeTopY}px`,
- width: `${snapshot.rect.width}px`,
- height: '0',
- borderTop: '1px dashed rgba(255,60,60,0.5)',
- zIndex: 9998,
- pointerEvents: 'none'
- }}
- />
- <View
- style={{
- position: 'fixed',
- left: `${snapshot.rect.left}px`,
- top: `${snapshot.rect.top + snapshot.metrics.safeBottomY}px`,
- width: `${snapshot.rect.width}px`,
- height: '0',
- borderTop: '1px dashed rgba(255,60,60,0.5)',
- zIndex: 9998,
- pointerEvents: 'none'
- }}
- />
-
- {/* 车辆回中目标线:红色虚线 */}
- <View
- style={{
- position: 'fixed',
- left: `${snapshot.rect.left}px`,
- top: `${snapshot.rect.top + snapshot.metrics.targetScreenY}px`,
- width: `${snapshot.rect.width}px`,
- height: '0',
- borderTop: '2px dashed rgba(255,60,60,0.85)',
- zIndex: 9998,
- pointerEvents: 'none'
- }}
- >
- <Text
- style={{
- position: 'absolute',
- right: '4px',
- top: '-16px',
- fontSize: '11px',
- color: '#ff3c3c',
- background: 'rgba(255,255,255,0.65)',
- padding: '0 4px',
- borderRadius: '4px'
- }}
- >
- 回中目标
- </Text>
- </View>
-
- {/* 数值面板 */}
- <View
- catchMove
- style={{
- position: 'fixed',
- left: '12px',
- bottom: '24px',
- zIndex: 9999,
- padding: '8px 12px',
- background: 'rgba(20,20,20,0.9)',
- color: '#fff',
- borderRadius: '10px',
- fontSize: '12px',
- lineHeight: '1.7',
- maxWidth: '70%'
- }}
- >
- <Text style={{ display: 'block', fontWeight: 'bold' }}>
- {`安全区调试${snapshot.isPreview ? '(预览:行程中)' : ''}`}
- </Text>
- <Text style={{ display: 'block' }}>
- {`地图:${Math.round(snapshot.rect.width)}×${Math.round(snapshot.rect.height)} 底部留白 ${Math.round(props.mapBottomPadding)}`}
- </Text>
- <Text style={{ display: 'block' }}>
- {`纵向死区 Y:${Math.round(snapshot.metrics.deadZoneTopY)} ~ ${Math.round(snapshot.metrics.deadZoneBottomY)}`}
- </Text>
- <Text style={{ display: 'block' }}>
- {`横向边距:左右各 ${Math.round(snapshot.safeXMargin)}px`}
- </Text>
- <Text style={{ display: 'block' }}>
- {`可用视区 Y:${Math.round(snapshot.metrics.safeTopY)} ~ ${Math.round(snapshot.metrics.safeBottomY)}`}
- </Text>
- <Text style={{ display: 'block' }}>
- {`回中目标 Y:${Math.round(snapshot.metrics.targetScreenY)}(占比 ${(snapshot.metrics.targetMapRatio * 100).toFixed(1)}%)`}
- </Text>
- </View>
- </>
- )}
- </>
- );
-}
diff --git a/src/robotaxi/pages/begin-route/components/debug-transition-panel.tsx b/src/robotaxi/pages/begin-route/components/debug-transition-panel.tsx
new file mode 100644
index 0000000..758529c
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/components/debug-transition-panel.tsx
@@ -0,0 +1,173 @@
+import { View, Text } from '@tarojs/components';
+import { TripStatus } from '../../../constants/trip-status';
+import { ensureSimulationOrderId } from '../dev/order-flow-simulator';
+
+type Props = {
+ /** 当前页面订单 ID(为空时自动生成)。 */
+ orderId: string;
+ /** 设置页面订单 ID。 */
+ setOrderId: (orderId: string) => void;
+ /** 设置行程状态。 */
+ setTripStatus: (status: TripStatus) => void;
+ /** 绑定 socket 业务监听。 */
+ bindSocketListeners: () => void;
+};
+
+/**
+ * 转场动效调试面板(仅 dev 构建可见)。
+ *
+ * ## 用途
+ * 提供「呼叫中(3)→派单成功(4)」和「送驾中(6)→行程结束(8)/待评价(9)」两个关键转场的独立调试入口,
+ * 固定在地图上方,始终可见,方便反复验证转场动效。
+ *
+ * ## 使用方法
+ * 1. 点击「呼叫中(3)」/「派单成功(4)」/「送驾中(6)」/「行程结束(8)」直接跳到指定态。
+ * 2. 点击「重放转场」自动执行序列:先设前一态 → 400ms 后设后一态,稳定触发转场动效。
+ * 3. 点击「去评价」执行 送驾中(6) → 400ms → 待评价(9),用于验证内部免费订单
+ * 行程结束后跳过待支付、直接进入评价态的转场。
+ * 4. 打开页面前需先清缓存,避免被 redirectTo 首页。
+ */
+export default function DebugTransitionPanel(props: Props) {
+ if (process.env.NODE_ENV === 'production') {
+ return null;
+ }
+
+ /** 生成或复用订单 ID。 */
+ const resolveOrderId = (): string => {
+ const existing = String(props.orderId || '').trim();
+ if (existing) {
+ return existing;
+ }
+ return `SIM-${Date.now()}`;
+ };
+
+ const prepOrder = () => {
+ props.bindSocketListeners();
+ const orderId = resolveOrderId();
+ ensureSimulationOrderId(orderId);
+ props.setOrderId(orderId);
+ };
+
+ /** 呼叫中(3) */
+ const handleSetCallingCar = () => {
+ prepOrder();
+ props.setTripStatus(TripStatus.CallingCar);
+ };
+
+ /** 派单成功(4) */
+ const handleSetVehicleApproaching = () => {
+ prepOrder();
+ props.setTripStatus(TripStatus.VehicleApproaching);
+ };
+
+ /** 重放 呼叫中→派单成功:3 → 400ms → 4 */
+ const handleReplayCallingToDispatch = () => {
+ prepOrder();
+ props.setTripStatus(TripStatus.CallingCar);
+ setTimeout(() => {
+ props.setTripStatus(TripStatus.VehicleApproaching);
+ }, 400);
+ };
+
+ /** 送驾中(6) */
+ const handleSetInService = () => {
+ prepOrder();
+ props.setTripStatus(TripStatus.InService);
+ };
+
+ /** 行程结束(8) */
+ const handleSetTripFinished = () => {
+ prepOrder();
+ props.setTripStatus(TripStatus.TripFinished);
+ };
+
+ /**
+ * 重放 送驾中→待评价:6 → 400ms → 9。
+ *
+ * 用于测试内部免费订单(无需支付)行程结束后直接进入待评价态的转场,
+ * 跳过 行程结束(8) 待支付中间态。
+ */
+ const handleReplayInServiceToEvaluate = () => {
+ prepOrder();
+ props.setTripStatus(TripStatus.InService);
+ setTimeout(() => {
+ props.setTripStatus(TripStatus.PaymentSuccess);
+ }, 400);
+ };
+
+ const btn = (bg: string) =>
+ ({
+ flex: 1,
+ textAlign: 'center' as const,
+ padding: '7px 0',
+ margin: '0 3px',
+ background: bg,
+ color: '#fff',
+ fontSize: '12px',
+ borderRadius: '6px',
+ } as const);
+
+ return (
+ <View
+ catchMove
+ style={{
+ position: 'fixed',
+ left: '8px',
+ right: '8px',
+ top: '8px',
+ zIndex: 9998,
+ padding: '8px 10px',
+ background: 'rgba(20,20,20,0.88)',
+ color: '#fff',
+ borderRadius: '10px',
+ boxShadow: '0 2px 12px rgba(0,0,0,0.35)',
+ }}
+ >
+ {/* 呼叫派单转场 */}
+ <View
+ style={{
+ fontSize: '11px',
+ lineHeight: '1.5',
+ marginBottom: '4px',
+ opacity: 0.85,
+ }}
+ >
+ <Text style={{ fontWeight: 'bold' }}>呼叫派单转场(呼叫中 → 派单成功)</Text>
+ </View>
+ <View style={{ display: 'flex', marginBottom: '6px' }}>
+ <View onClick={handleSetCallingCar} style={btn('#4b9fd6')}>
+ 呼叫中(3)
+ </View>
+ <View onClick={handleSetVehicleApproaching} style={btn('#36b37e')}>
+ 派单成功(4)
+ </View>
+ <View onClick={handleReplayCallingToDispatch} style={btn('#ff7a45')}>
+ 重放转场
+ </View>
+ </View>
+
+ {/* 送驾到达转场 */}
+ <View
+ style={{
+ fontSize: '11px',
+ lineHeight: '1.5',
+ marginBottom: '4px',
+ opacity: 0.85,
+ }}
+ >
+ <Text style={{ fontWeight: 'bold' }}>送驾到达转场(送驾中 → 行程结束 / 待评价)</Text>
+ </View>
+ <View style={{ display: 'flex' }}>
+ <View onClick={handleSetInService} style={btn('#4b9fd6')}>
+ 送驾中(6)
+ </View>
+ <View onClick={handleSetTripFinished} style={btn('#36b37e')}>
+ 行程结束(8)
+ </View>
+ <View onClick={handleReplayInServiceToEvaluate} style={btn('#ff7a45')}>
+ 去评价
+ </View>
+ </View>
+ </View>
+ );
+}
diff --git a/src/robotaxi/pages/begin-route/components/dispatch-transition-layer.tsx b/src/robotaxi/pages/begin-route/components/dispatch-transition-layer.tsx
new file mode 100644
index 0000000..5522d22
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/components/dispatch-transition-layer.tsx
@@ -0,0 +1,148 @@
+import { useState, useRef, useEffect } from 'react';
+import { Image, Video } from "@tarojs/components";
+import { safeParse } from '../../../../utils/Robotaxi-public-method';
+import { DispatchPhase, DISPATCH_VIDEO_SRC, DISPATCH_VIDEO_POSTER, DISPATCH_VIDEO_END_POSTER } from '../hooks/use-dispatch-transition';
+import { CARD_BG_IMG, DEFAULT_VEHICLE_IMG, USE_DYNAMIC_VEHICLE_IMG } from '../../../constants/assets';
+import { getPreloadedVideoSrc } from '../../../utils/video-preload';
+
+type Props = {
+ shouldPlayEnter: boolean;
+ transitionDone: boolean;
+ dispatchPhase: DispatchPhase;
+ /** 结尾遮挡层是否可见:由 hook 在视频「真正快播放完」时置真(非 settle 起点),避免过早盖住仍在播放的视频。 */
+ endCoverVisible: boolean;
+ vehicleInfo?: {
+ vehiclePictures?: string;
+ vehicleCode?: string;
+ vehicleColor?: string;
+ plateNumber?: string;
+ };
+ onTimeUpdate: (e: any) => void;
+ onEnded: () => void;
+ onError: () => void;
+};
+
+/**
+ * 派单成功转场视觉层:视频 + poster 覆盖层 + 背景预载 + 车模兜底。
+ * 转场期间 getCardBgImg 不返回背景图,此处通过 bg-preload 不可见预载常态背景图,
+ * 保证 settle 切回背景图时无加载间隙。
+ *
+ * ## 黑帧消除(核心)
+ * 微信原生 <video> 的 poster 属性无法可靠衔接「加载完成 → 首帧绘制」之间的空档:
+ * poster 撤下后、首帧绘制前会露出约一帧黑屏。且视频 mp4 无法通过 wx.preloadAssets 预热。
+ * 双重方案:
+ * 1)视频预下载:进入下单流程即用 wx.downloadFile 把 mp4 下到本地临时文件(见 utils/video-preload),
+ * 此处经 getPreloadedVideoSrc 优先用本地路径作 src——本地文件无网络缓冲,首帧秒出,从根源缩短黑帧空档。
+ * 2)poster 覆盖层兜底:在视频之上叠一张真实 <Image>(同一 poster,已在 preload-assets 中预载,命中缓存秒开),
+ * 视频「真正开始播放」(首个 currentTime>0 的 timeupdate)前始终盖住视频区域,首帧到达后再撤下。
+ * 两者叠加,整个加载→首帧空档全程无黑屏。
+ * 注:本页 video 已启用同层渲染(车模 Image 能盖在 video 上即为佐证),故普通 Image 可覆盖 video。
+ */
+export default function DispatchTransitionLayer({
+ shouldPlayEnter,
+ transitionDone,
+ dispatchPhase,
+ endCoverVisible,
+ vehicleInfo,
+ onTimeUpdate,
+ onEnded,
+ onError,
+}: Props) {
+ // 视频是否已真正开始播放(首帧已绘制)。false 时用 poster 覆盖层遮挡视频,杜绝黑帧。
+ const [videoStarted, setVideoStarted] = useState(false);
+ // 视频是否已卸载。settle 阶段结尾遮挡层挂上后延一帧置真,先撤视频——保证遮挡先上屏、视频后拆,杜绝卸载黑帧。
+ const [videoRemoved, setVideoRemoved] = useState(false);
+ // 每轮转场开始(shouldPlayEnter 上升沿)在渲染期同步复位,使二次派单重新用 poster 遮挡、不透出黑帧。
+ // 采用渲染期 latch 而非 useEffect:effect 在绘制后才执行,会漏掉新一轮首帧导致闪黑。
+ const prevShouldPlayRef = useRef(false);
+ if (shouldPlayEnter && !prevShouldPlayRef.current) {
+ prevShouldPlayRef.current = true;
+ if (videoStarted) setVideoStarted(false);
+ if (videoRemoved) setVideoRemoved(false);
+ } else if (!shouldPlayEnter && prevShouldPlayRef.current) {
+ prevShouldPlayRef.current = false;
+ }
+
+ // 结尾遮挡层可见(视频真正快播放完)后:本次渲染已挂上遮挡层(盖在视频之上),
+ // 延一帧再卸原生视频——遮挡先上屏、视频后拆,原生层销毁的黑帧被遮挡层挡住,杜绝结尾黑帧。
+ // 改由 endCoverVisible 驱动(而非 settle):settle 起即收缩但视频仍在播,此时不能卸载视频。
+ useEffect(() => {
+ if (endCoverVisible && !videoRemoved) {
+ const t = setTimeout(() => setVideoRemoved(true), 50);
+ return () => clearTimeout(t);
+ }
+ }, [endCoverVisible, videoRemoved]);
+
+ if (!shouldPlayEnter || transitionDone) return null;
+
+ // 当前只有一种车型,产品要求统一用默认车图(USE_DYNAMIC_VEHICLE_IMG=false);
+ // 后续有新车型时把该开关改回 true 即恢复接口动态车图。详见 constants/assets.ts。
+ const vehiclePicSrc = USE_DYNAMIC_VEHICLE_IMG
+ ? (safeParse(vehicleInfo?.vehiclePictures, {})?.url || DEFAULT_VEHICLE_IMG)
+ : DEFAULT_VEHICLE_IMG;
+
+ // 优先用预下载好的本地视频路径(downloadFile 落地,无网络缓冲);未就绪回退 CDN URL。
+ const videoSrc = getPreloadedVideoSrc(DISPATCH_VIDEO_SRC);
+
+ // 视频真正开始播放(首个 currentTime>0 的 timeupdate):撤下 poster 覆盖层,交由视频接管。
+ const handleTimeUpdate = (e: any) => {
+ if (!videoStarted && (e?.detail?.currentTime ?? 0) > 0) setVideoStarted(true);
+ onTimeUpdate(e);
+ };
+
+ return (
+ <>
+ {/* 预载常态背景图:转场期间不可见(bg-preload 类),保证 settle 切回背景图时无加载间隙。
+ 勿用内联 opacity:0——Taro 序列化会丢弃数字 0 导致其可见。 */}
+ <Image
+ className='calling-car-bg bg-preload'
+ src={CARD_BG_IMG}
+ />
+ {!videoRemoved && (
+ <Video
+ className='calling-car-video'
+ src={videoSrc}
+ poster={DISPATCH_VIDEO_POSTER}
+ autoplay
+ muted
+ loop={false}
+ controls={false}
+ showCenterPlayBtn={false}
+ showPlayBtn={false}
+ showFullscreenBtn={false}
+ showProgress={false}
+ objectFit='cover'
+ onTimeUpdate={handleTimeUpdate}
+ onEnded={onEnded}
+ onError={onError}
+ />
+ )}
+ {/* Poster 覆盖层:视频真正开始播放前,用预载封面图盖住视频区域,消除「加载完成→首帧绘制」间的黑帧。
+ 首帧到达(首个 currentTime>0 的 timeupdate)后撤下,交由视频接管。 */}
+ {!videoStarted && (
+ <Image
+ className='calling-car-poster'
+ src={DISPATCH_VIDEO_POSTER}
+ />
+ )}
+ {/* 结尾遮挡层:视频「真正快播放完」时(endCoverVisible,非 settle 起点)用预载尾帧图盖在视频之上
+ (z-index 与 poster 同为 0),先于视频上屏、视频卸载后仍在,挡住原生 <video> 卸载黑帧;
+ transitionDone 时随本层卸载,露出已预载的常态背景图。
+ 改用 endCoverVisible 而非 dispatchPhase==='settle':settle 起即开始背景收缩但视频仍在播放,
+ 此时盖上遮挡层会抹掉视频尾段效果;改由 hook 在距结束 END_COVER_LEAD_SEC 时才置真。 */}
+ {endCoverVisible && (
+ <Image
+ className='calling-car-end-cover'
+ src={DISPATCH_VIDEO_END_POSTER}
+ />
+ )}
+ {/* 转场期间车模兜底渲染,承接视频卸载后与静态背景图的衔接 */}
+ {dispatchPhase !== 'idle' && (
+ <Image
+ className='calling-car-vehicle-pic'
+ src={vehiclePicSrc}
+ />
+ )}
+ </>
+ );
+}
diff --git a/src/robotaxi/pages/begin-route/components/evaluate-popup.tsx b/src/robotaxi/pages/begin-route/components/evaluate-popup.tsx
index 7005b97..9296f89 100644
--- a/src/robotaxi/pages/begin-route/components/evaluate-popup.tsx
+++ b/src/robotaxi/pages/begin-route/components/evaluate-popup.tsx
@@ -4,7 +4,7 @@
import { useEffect, useRef, useState } from 'react';
import './css-folder/evaluate-popup.less'
import { getOrderCommentType, submitOrderComment } from '../../../api/begin-route';
-import { submitComplaint } from '../../../api/feedback';
+import { submitOrderComplaint } from '../../../api/feedback';
import { _ROBOTAXI_HOST } from '../../../../constants/api';
import useImageUpload from '../../../hooks/use-image-upload';
import { getOrderId } from '../../../utils/order';
@@ -16,7 +16,7 @@
onConfirm?: () => void;
orderId?: string;
orderRating?: number;
- submitMode?: 'comment' | 'complaint';
+ submitMode?: 'comment' | 'orderComplaint';
/**
* 是否隐藏问题类型选择
*/
@@ -25,6 +25,11 @@
* 是否禁止切换满意度评分(点击图标/左右滑动均禁用),固定为传入的 orderRating
*/
disableRatingSwitch?: boolean;
+ /**
+ * 是否为「订单车费反馈」入口(车费说明页 complaint-btn 触发):
+ * 标题居中展示为「订单车费反馈」,隐藏满意度切换区(图标+文案)
+ */
+ isFareFeedback?: boolean;
}
export default function EvaluatePopup({
@@ -35,7 +40,8 @@
orderRating,
submitMode = 'comment',
hideFeedbackType = false,
- disableRatingSwitch = false
+ disableRatingSwitch = false,
+ isFareFeedback = false
}: Props) {
const bad = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/bad.png';
const normal = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/normal.png';
@@ -67,6 +73,8 @@
uploadUrl: _ROBOTAXI_HOST + '/operation/base/v1/mini-program/oss/upload'
}); // 图片上传状态与操作
+ const toastCommomText = submitMode === 'orderComplaint' ? '反馈' : '评价';
+
const currentOrderRating = Number(selectedRating) || 1;
// 根据当前选择的评分,动态展示评价弹窗的文案
const popupDisplayConfig = {
@@ -97,7 +105,7 @@
};
const evaluateDimension = evaluateDimensionMap[currentOrderRating] || 3; // 接口维度:1 满意,2 一般,3 不满意
const shouldRequireFeedbackType = !hideFeedbackType;
- const shouldHideFeedbackTextarea = submitMode === 'comment' && [2, 3].includes(currentOrderRating);
+ const shouldHideFeedbackTextarea = [2, 3].includes(currentOrderRating);
const hasFeedbackContent = !!feedbackContent.trim();
const canSubmit = shouldRequireFeedbackType
? selectedTypeIds.length > 0 && (shouldHideFeedbackTextarea || hasFeedbackContent)
@@ -210,7 +218,7 @@
const targetOrderId = getOrderId(orderId);
if (!targetOrderId) {
showToast({
- title: '订单信息缺失,无法提交评价',
+ title: `订单信息缺失,无法提交${toastCommomText}`,
icon: 'none'
});
return;
@@ -230,7 +238,7 @@
return;
}
isSubmittingRef.current = true;
- const submitter = submitMode === 'complaint' ? submitComplaint : submitOrderComment;
+ const submitter = submitMode === 'orderComplaint' ? submitOrderComplaint : submitOrderComment;
const picturePayload = JSON.stringify(
pictures
.filter(p => p.status === 'success' && p.server && p.server.id && p.server.url)
@@ -249,20 +257,19 @@
const isFailure = res?.success === false || (typeof res?.code === 'number' && res.code !== 0);
if (isFailure) {
showToast({
- title: '评价提交失败,请稍后重试',
+ title: `${toastCommomText}提交失败,请稍后重试`,
icon: 'none'
});
return;
}
- const successTitle = submitMode === 'complaint' ? '投诉已提交' : '评价已提交';
showToast({
- title: successTitle,
+ title: `${toastCommomText}已提交`,
icon: 'none'
});
onConfirm?.();
}).catch(() => {
showToast({
- title: '评价提交失败,请稍后重试',
+ title: `${toastCommomText}提交失败,请稍后重试`,
icon: 'none'
});
}).finally(() => {
@@ -330,7 +337,7 @@
<View className='evaluate-popup' catchMove onClick={(e) => e.stopPropagation()}>
<View className='evaluate-mask' onClick={onClose}></View>
<View
- className={`evaluate-content ${isRatingSwitching ? 'is-rating-switching' : ''}`}
+ className={`evaluate-content ${isRatingSwitching ? 'is-rating-switching' : ''} ${isFareFeedback ? 'is-fare-feedback' : ''}`}
animation={animationData}
onTouchStart={handleRatingTouchStart}
onTouchMove={handleRatingTouchMove}
@@ -340,33 +347,37 @@
<Image onClick={onClose} src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/close-fill-20260726.svg" />
</View>
<View className='evaluate-rating-switcher'>
- <View className='evaluate-rating-switcher-title'>您对本次行程满意吗?</View>
- <View className={`evaluate-rating-switcher-content ${disableRatingSwitch ? 'is-single' : ''}`}>
- {(!disableRatingSwitch || selectedRating === 1) && (
- <View className={`evaluate-rating-switcher-content-item ${disableRatingSwitch ? 'is-disabled' : ''}`} onClick={() => handleSelectRating(1)}>
- <Image src={selectedRating === 1 ? activeBad : bad} className='evaluate-rating-switcher-content-item-img' />
- <Text className={`evaluate-rating-switcher-content-item-text ${selectedRating === 1 ? 'active-text' : ''}`}>不满意</Text>
+ <View className={`evaluate-rating-switcher-title ${isFareFeedback ? 'is-centered' : ''}`}>{isFareFeedback ? '订单车费反馈' : '您对本次行程满意吗?'}</View>
+ {!isFareFeedback && (
+ <>
+ <View className={`evaluate-rating-switcher-content ${disableRatingSwitch ? 'is-single' : ''}`}>
+ {(!disableRatingSwitch || selectedRating === 1) && (
+ <View className={`evaluate-rating-switcher-content-item ${disableRatingSwitch ? 'is-disabled' : ''}`} onClick={() => handleSelectRating(1)}>
+ <Image src={selectedRating === 1 ? activeBad : bad} className='evaluate-rating-switcher-content-item-img' />
+ <Text className={`evaluate-rating-switcher-content-item-text ${selectedRating === 1 ? 'active-text' : ''}`}>不满意</Text>
+ </View>
+ )}
+ {(!disableRatingSwitch || selectedRating === 2) && (
+ <View className={`evaluate-rating-switcher-content-item ${disableRatingSwitch ? 'is-disabled' : ''}`} onClick={() => handleSelectRating(2)}>
+ <Image src={selectedRating === 2 ? activeNormal : normal} className='evaluate-rating-switcher-content-item-img' />
+ <Text className={`evaluate-rating-switcher-content-item-text ${selectedRating === 2 ? 'active-text' : ''}`}>一般般</Text>
+ </View>
+ )}
+ {(!disableRatingSwitch || selectedRating === 3) && (
+ <View className={`evaluate-rating-switcher-content-item ${disableRatingSwitch ? 'is-disabled' : ''}`} onClick={() => handleSelectRating(3)}>
+ <Image src={selectedRating === 3 ? activeGood : good} className='evaluate-rating-switcher-content-item-img' />
+ <Text className={`evaluate-rating-switcher-content-item-text ${selectedRating === 3 ? 'active-text' : ''}`}>很满意</Text>
+ </View>
+ )}
</View>
- )}
- {(!disableRatingSwitch || selectedRating === 2) && (
- <View className={`evaluate-rating-switcher-content-item ${disableRatingSwitch ? 'is-disabled' : ''}`} onClick={() => handleSelectRating(2)}>
- <Image src={selectedRating === 2 ? activeNormal : normal} className='evaluate-rating-switcher-content-item-img' />
- <Text className={`evaluate-rating-switcher-content-item-text ${selectedRating === 2 ? 'active-text' : ''}`}>一般般</Text>
- </View>
- )}
- {(!disableRatingSwitch || selectedRating === 3) && (
- <View className={`evaluate-rating-switcher-content-item ${disableRatingSwitch ? 'is-disabled' : ''}`} onClick={() => handleSelectRating(3)}>
- <Image src={selectedRating === 3 ? activeGood : good} className='evaluate-rating-switcher-content-item-img' />
- <Text className={`evaluate-rating-switcher-content-item-text ${selectedRating === 3 ? 'active-text' : ''}`}>很满意</Text>
- </View>
- )}
- </View>
- <View className='evaluate-rating-switcher-summary'>{popupDisplayConfig.summary}</View>
+ <View className='evaluate-rating-switcher-summary'>{popupDisplayConfig.summary}</View>
+ </>
+ )}
</View>
<View className='content-box'>
{!hideFeedbackType && (
<View className='evaluate-problem-type'>
-
+
<View className='evaluate-problem-type-content'>
{typeOptions.map((item) => (
<View key={item?.id} className={`evaluate-problem-type-content-item ${selectedTypeIds.includes(normalizeFeedbackTypeId(item?.id)) && 'active-tag'}`} onClick={() => toggleFeedbackType(item?.id)}>
diff --git a/src/robotaxi/pages/begin-route/components/modify-destination-review.tsx b/src/robotaxi/pages/begin-route/components/modify-destination-review.tsx
index b0e218e..5e89257 100644
--- a/src/robotaxi/pages/begin-route/components/modify-destination-review.tsx
+++ b/src/robotaxi/pages/begin-route/components/modify-destination-review.tsx
@@ -5,6 +5,7 @@
import { getUpdateAddressNum } from "../../../api/begin-route";
import "./css-folder/modify-destination-review.less";
import RouteSelectionPanel from "./route-selection-panel";
+import SiteRouteCard from "../../../components/site-route-card";
type Props = {
orderId?: string;
@@ -361,25 +362,16 @@
{modifyDestinationTipText}
</View>
) : null}
- <View className="modify-destination-review-select-site">
- <View className="modify-destination-review-select-site-icon">
- <View className="modify-destination-review-select-site-icon-start"></View>
- <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/dotted-line.png" />
- <View className="modify-destination-review-select-site-icon-end"></View>
- </View>
- <View className="modify-destination-review-select-site-content">
- <View className="modify-destination-review-select-site-start">
- {startSite?.name || "-"}
- </View>
- <View className="modify-destination-review-select-site-divider"></View>
- <View
- className="modify-destination-review-select-site-end"
- onClick={() => onEditDestination?.()}
- >
- {nextEndSite?.name || "-"}
- </View>
- </View>
- </View>
+ <SiteRouteCard
+ className="modify-destination-review-select-site"
+ sites={[
+ { name: startSite?.name || "-" },
+ {
+ name: nextEndSite?.name || "-",
+ onClick: () => onEditDestination?.()
+ }
+ ]}
+ />
<View className="modify-destination-review-footer">
<View className="modify-destination-review-fare">
{isOperateFree ? (
diff --git a/src/robotaxi/pages/begin-route/components/route-preference-popup/index.less b/src/robotaxi/pages/begin-route/components/route-preference-popup/index.less
index aa8e2f8..77ba0c8 100644
--- a/src/robotaxi/pages/begin-route/components/route-preference-popup/index.less
+++ b/src/robotaxi/pages/begin-route/components/route-preference-popup/index.less
@@ -28,12 +28,13 @@
.popup-content {
position: relative;
z-index: 103;
- background: #FFFFFF;
+ background: @gary-theme;
border-radius: 40px 40px 0 0;
width: 100%;
display: flex;
flex-direction: column;
transform: translateY(100%);
+ min-height: 672px;
}
.popup-header {
@@ -48,7 +49,7 @@
font-size: 32px;
font-weight: 500;
line-height: 32px;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
}
.close-icon {
diff --git a/src/robotaxi/pages/begin-route/components/trip-finished-fireworks/index.tsx b/src/robotaxi/pages/begin-route/components/trip-finished-fireworks/index.tsx
new file mode 100644
index 0000000..17cbb26
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/components/trip-finished-fireworks/index.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useRef } from 'react';
+import Fireworks, { FireworksRef } from '../../../../components/fireworks';
+import { TripStatus } from '../../../../constants/trip-status';
+
+type Props = {
+ /** 当前行程状态,由父页面传入 */
+ tripStatus: TripStatus;
+};
+
+/**
+ * 到达结束态全屏礼花组件(begin-route 页面专有)。
+ *
+ * ## 职责
+ * - 监听 tripStatus 变化,检测「送驾中(6) → 行程结束(8)」上升沿
+ * - 进入结束态后尽快发射一簇礼花(礼花先行播放),卡片/里程延迟约 700ms 才升起
+ * - 仅发射一次,prevStatus 去重防重复播放
+ * - tripStatus 非 TripFinished 时卸载 canvas(条件渲染)
+ */
+export default function TripFinishedFireworks({ tripStatus }: Props) {
+ const fireworksRef = useRef<FireworksRef | null>(null);
+ const prevStatusRef = useRef<TripStatus>(tripStatus);
+
+ // 送驾(6) → 结束(8) 转场:进入到达结束态时播放一次全屏礼花(顶层、pointer-events:none 不阻挡操作)
+ useEffect(() => {
+ const prevStatus = prevStatusRef.current;
+ prevStatusRef.current = tripStatus;
+ if (tripStatus !== TripStatus.TripFinished || prevStatus === TripStatus.TripFinished) {
+ return;
+ }
+ // 礼花先行:进入结束态后尽快发射(仅留极短延迟等 canvas 挂载),卡片随后延迟升起
+ const timer = setTimeout(() => {
+ // 仅发射一簇(single burst),避免连发造成「播放多次」的观感
+ fireworksRef.current?.shoot({
+ origin: { x: 0.5, y: 0.4 },
+ angle: 90,
+ particleCount: 180,
+ spread: 360,
+ startVelocity: 42,
+ gravity: 1.1,
+ scalar: 0.9,
+ // ticks 控制粒子存活帧数(≈ticks/60 秒)。卡片动效约 1.7s 收尾,
+ // 礼花缩短到约 1.5s(ticks 90)先行结束,避免动效结束后礼花还在飘。
+ ticks: 90,
+ colors: ['#FFD666', '#F9A76A', '#FFF1B8', '#7FE6F2', '#95CF29'],
+ });
+ }, 60);
+ return () => clearTimeout(timer);
+ }, [tripStatus]);
+
+ if (tripStatus !== TripStatus.TripFinished) {
+ return null;
+ }
+
+ return (
+ <Fireworks
+ ref={(node) => { fireworksRef.current = node; }}
+ zIndex={99999}
+ />
+ );
+}
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/camera-policy.types.ts b/src/robotaxi/pages/begin-route/hooks/camera/camera-policy.types.ts
new file mode 100644
index 0000000..634dd23
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/camera-policy.types.ts
@@ -0,0 +1,61 @@
+import { TripStatus } from '../../../../constants/trip-status';
+import { LatLng } from './map-camera';
+
+/**
+ * 相机上下文:策略据此计算「该看哪些点/哪个中心」。
+ * 由编排层(useMapCameraController)在每次触发时实时构造,保证数据最新。
+ */
+export interface CameraContext {
+ /** 当前行程状态。 */
+ tripStatus: TripStatus;
+ /** 上车点(站点对象,可能含 latitude/longitude)。 */
+ startSite: any;
+ /** 下车点。 */
+ endSite: any;
+ /** 车行路线点。 */
+ routePoints: LatLng[];
+ /** 步行路线点。 */
+ walkingPoints: LatLng[];
+ /** 最近一次用户定位(GPS/IP/兜底),首帧 focus 用。 */
+ userLocation: LatLng | null;
+ /** 车辆当前位置(接驾/行程态跟车与 fit 用)。 */
+ vehicleLocation: LatLng | null;
+}
+
+/**
+ * 相机意图:策略返回「想做什么」,由编排层按触发来源决定 isAuto 后交给相机执行。
+ * 这样策略保持纯函数(只描述期望视口),触发语义(自动/用户)与执行细节留给编排层。
+ */
+export type CameraIntent =
+ | {
+ type: 'focus';
+ point: LatLng;
+ /** 去重签名(自动 focus 用,同 fit)。 */
+ signature?: string;
+ /** 是否把点居中到「可视区域」(扣除底部卡片/顶部安全区)而非整张地图几何中心。 */
+ centerInVisibleArea?: boolean;
+ }
+ | { type: 'fit'; points: LatLng[]; signature?: string }
+ | { type: 'none' };
+
+/** 空意图常量。 */
+export const NO_CAMERA_INTENT: CameraIntent = { type: 'none' };
+
+/**
+ * 相机策略:每个 TripStatus 一份,声明各触发点下期望的相机意图。
+ * 编排层只认策略、不写死状态分支;新增状态(接驾页等)新增一份策略即可。
+ */
+export interface CameraPolicy {
+ /** 首帧(定位就绪后)。 */
+ onInitial?(ctx: CameraContext): CameraIntent;
+ /** 关键元素(上下车点/路线)到齐或变化后(自动,带签名去重)。 */
+ onElementsReady?(ctx: CameraContext): CameraIntent;
+ /** 用户点回中按钮(显式,必然生效)。 */
+ onRecenter?(ctx: CameraContext): CameraIntent;
+ /** 手势后空闲回中冷却(毫秒);<=0 或未配置则不做空闲回中。 */
+ idleRecenterMs?: number;
+ /** 空闲回中意图(默认复用 onRecenter)。 */
+ onIdleRecenter?(ctx: CameraContext): CameraIntent;
+ /** 页面恢复(待定,预留钩子)。 */
+ onRestore?(ctx: CameraContext): CameraIntent;
+}
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/collect-viewport-points.ts b/src/robotaxi/pages/begin-route/hooks/camera/collect-viewport-points.ts
new file mode 100644
index 0000000..ec4812e
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/collect-viewport-points.ts
@@ -0,0 +1,73 @@
+import { CameraContext } from './camera-policy.types';
+import { LatLng } from './map-camera';
+
+/** 站点 → 坐标点(非法返回 null)。 */
+export const siteToPoint = (site: any): LatLng | null => {
+ const latitude = Number(site?.latitude);
+ const longitude = Number(site?.longitude);
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
+ return null;
+ }
+ return { latitude, longitude };
+};
+
+/** 过滤合法坐标点。 */
+const filterValidPoints = (points: LatLng[] | undefined): LatLng[] =>
+ (points || []).filter(
+ (p) => p && Number.isFinite(p.latitude) && Number.isFinite(p.longitude)
+ );
+
+/**
+ * 收集「起点 + 终点 + 车行路线 + 步行路线」四要素点集(发单页视口)。
+ * 供 fit 使用;不同状态可另写收集函数。
+ */
+export const collectRouteViewportPoints = (ctx: CameraContext): LatLng[] => {
+ const points: LatLng[] = [];
+ points.push(...filterValidPoints(ctx.routePoints));
+ points.push(...filterValidPoints(ctx.walkingPoints));
+ const start = siteToPoint(ctx.startSite);
+ if (start) points.push(start);
+ const end = siteToPoint(ctx.endSite);
+ if (end) points.push(end);
+ return points;
+};
+
+/**
+ * 收集「接驾中」视口点集:接驾路线点(ws order_route 推送的 polyline,车→上车点) + 上车点。
+ * 不含车辆实时位置(polyline 首点即车辆所在,实时车点会抖动/偏离路线,把 fit 外接矩形撑歪);
+ * 不含下车点(接驾只看车到上车点);不含步行线(仅展示,用户定位异常时会撑歪视口)。
+ */
+export const collectPickupViewportPoints = (ctx: CameraContext): LatLng[] => {
+ const points: LatLng[] = [];
+ points.push(...filterValidPoints(ctx.routePoints));
+ const pickup = siteToPoint(ctx.startSite);
+ if (pickup) points.push(pickup);
+ return points;
+};
+
+/**
+ * 收集「送驾中(InService)」视口点集:行驶路线点(ws order_route 推送的 polyline,车→下车点) + 下车点。
+ * 不含车辆实时位置(polyline 首点即车辆所在,实时车点会抖动/偏离路线,把 fit 外接矩形撑歪);
+ * 不含上车点(已过)、不含步行线。
+ */
+export const collectInServiceViewportPoints = (ctx: CameraContext): LatLng[] => {
+ const points: LatLng[] = [];
+ points.push(...filterValidPoints(ctx.routePoints));
+ const dropoff = siteToPoint(ctx.endSite);
+ if (dropoff) points.push(dropoff);
+ return points;
+};
+
+/**
+ * 收集「完成/支付态(order_detail)」视口点集:全程轨迹 + 上车点 + 下车点。
+ * 无车(showCar:false)、无步行线;用于展示整趟行程概览。
+ */
+export const collectOrderDetailViewportPoints = (ctx: CameraContext): LatLng[] => {
+ const points: LatLng[] = [];
+ points.push(...filterValidPoints(ctx.routePoints));
+ const pickup = siteToPoint(ctx.startSite);
+ if (pickup) points.push(pickup);
+ const dropoff = siteToPoint(ctx.endSite);
+ if (dropoff) points.push(dropoff);
+ return points;
+};
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/map-camera.ts b/src/robotaxi/pages/begin-route/hooks/camera/map-camera.ts
new file mode 100644
index 0000000..b393ce9
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/map-camera.ts
@@ -0,0 +1,455 @@
+import Taro from '@tarojs/taro';
+import { mapPerfLog } from './map-perf-log';
+
+/**
+ * MapCamera —— 地图「相机」,统一封装并串行化所有原生视口 API。
+ *
+ * 设计要点:
+ * 1. 框架无关的类;上层用 useRef 持有单例,绑定到某个 <Map id>。
+ * 2. 只统筹「视口类」原生 API(includePoints / moveToLocation / getScale /
+ * getCenterLocation / getRegion)。车辆 translateMarker / eraseLines 不在此列,
+ * 但可通过 isBusy() 在相机动画窗口内避让。
+ * 3. 串行执行 + 最新意图优先 + 签名去重 + holdAuto 避让 + 安全超时。
+ * 4. 绝不回写 React 受控 props:相机只命令原生地图;需要当前视口值时用 getViewport() 现取。
+ *
+ * 已知限制:WeChat/Taro 无命令式 setScale。focus() 仅移动中心,scale 维持 <Map> 挂载值;
+ * fit() 通过 includePoints 间接决定 scale。
+ */
+
+/** 经纬度点。 */
+export type LatLng = { latitude: number; longitude: number };
+
+/** includePoints 留白:[上, 右, 下, 左](像素)。 */
+export type CameraPadding = [number, number, number, number];
+
+/**
+ * fit 留白输入:支持直接传值 / Promise / 惰性函数。
+ * 传函数时在命令真正执行(includePoints 前)才解析,保证拿到的是执行那一刻的最新留白
+ * (如底部卡片高度的即时测量),避免入队与执行之间的时间差导致 padding 过期。
+ */
+export type CameraPaddingInput =
+ | CameraPadding
+ | Promise<CameraPadding>
+ | (() => CameraPadding | Promise<CameraPadding> | undefined);
+
+/** fit 参数。 */
+export interface FitOptions {
+ /** includePoints 留白(由上层按底部卡片/安全区算好传入,支持惰性/异步解析)。 */
+ padding?: CameraPaddingInput;
+ /** 是否为「自动」意图:可被 holdAuto 避让、且参与签名去重。用户显式操作传 false。 */
+ isAuto?: boolean;
+ /** 去重签名:相同签名的自动 fit 不重复执行。 */
+ signature?: string;
+}
+
+/** focus 参数。 */
+export interface FocusOptions {
+ /** 是否为「自动」意图(可被 holdAuto 避让)。 */
+ isAuto?: boolean;
+ /** 期望缩放(信息性:当前无命令式 setScale,focus 仅移动中心)。 */
+ scale?: number;
+ /** 去重签名:相同签名的自动 focus 不重复执行(用户显式意图不去重)。 */
+ signature?: string;
+ /**
+ * 传入后按 padding 上下不对称量纵向偏移中心,使目标点居中在「可视区域」
+ * (扣除底部卡片/顶部安全区)而非整张地图的几何中心。
+ * 背景:moveToLocation / 单点 includePoints 都只会把点放到整张地图中心,
+ * 底部卡片较高时点会视觉上贴近卡片。
+ */
+ padding?: CameraPaddingInput;
+}
+
+/** 当前真实视口读数。 */
+export interface CameraViewport {
+ scale: number | null;
+ center: LatLng | null;
+ region: any | null;
+}
+
+/** 相机构造参数。 */
+export interface MapCameraOptions {
+ /** 默认 includePoints 留白。 */
+ defaultPadding?: CameraPadding;
+ /** 单条命令原生回调的安全超时(毫秒),防止队列卡死。 */
+ commandTimeoutMs?: number;
+}
+
+type CommandKind = 'fit' | 'focus';
+
+interface InternalCommand {
+ kind: CommandKind;
+ isAuto: boolean;
+ signature: string;
+ /** 本次意图涉及的点集(用于调试日志打印具体 points)。 */
+ points: LatLng[];
+ run: () => Promise<void>;
+}
+
+const DEFAULT_PADDING: CameraPadding = [60, 50, 60, 50];
+const DEFAULT_COMMAND_TIMEOUT_MS = 2000;
+
+export class MapCamera {
+ private readonly mapId: string;
+ private readonly defaultPadding: CameraPadding;
+ private readonly commandTimeoutMs: number;
+
+ /** 待执行意图队列(最多保留 1 个 pending:最新意图覆盖旧意图)。 */
+ private queue: InternalCommand[] = [];
+ /** 是否有命令正在执行。 */
+ private running = false;
+ /** 避让截止时间戳:此前忽略所有 isAuto 意图。 */
+ private holdUntilTs = 0;
+ /** 最近一次成功应用的 fit 签名(用于自动 fit 去重)。 */
+ private lastAppliedSignature = '';
+ /** 正在执行中的命令签名。 */
+ private currentSignature = '';
+ /** 地图像素高缓存(focus 可视区域居中换算用)。 */
+ private mapHeightPx = 0;
+
+ constructor(mapId: string, options?: MapCameraOptions) {
+ this.mapId = mapId;
+ this.defaultPadding = options?.defaultPadding ?? DEFAULT_PADDING;
+ this.commandTimeoutMs = options?.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
+ }
+
+ private getCtx() {
+ return Taro.createMapContext(this.mapId);
+ }
+
+ /** 把一组点纳入视野(回中/路线到齐/切路线统一入口)。 */
+ fit(points: LatLng[], options?: FitOptions): void {
+ const validPoints = (points || []).filter(
+ (p) => p && Number.isFinite(p.latitude) && Number.isFinite(p.longitude)
+ );
+ if (validPoints.length === 0) {
+ return;
+ }
+ const isAuto = options?.isAuto ?? true;
+ const signature = options?.signature ?? '';
+ const paddingInput = options?.padding;
+
+ // mapPerfLog('fit:request', { count: validPoints.length, points: validPoints, isAuto, signature });
+ this.enqueue({
+ kind: 'fit',
+ isAuto,
+ signature,
+ points: validPoints,
+ // 执行时才解析 padding(可能触发底部卡片的即时测量),保证 includePoints 用最新留白
+ run: () =>
+ this.resolvePadding(paddingInput).then((padding) => {
+ mapPerfLog('fit:exec', {
+ signature,
+ pointsCount: validPoints.length,
+ padding,
+ });
+ return this.runNative((done) => {
+ this.getCtx().includePoints({
+ points: validPoints,
+ padding,
+ complete: (res: any) => {
+ // mapPerfLog('fit:complete', {
+ // signature,
+ // errMsg: res?.errMsg || 'ok',
+ // });
+ done();
+ },
+ } as any);
+ });
+ }),
+ });
+ }
+
+ /** 解析 padding 输入(值 / Promise / 惰性函数),异常或空值时回退默认留白。 */
+ private resolvePadding(input?: CameraPaddingInput): Promise<CameraPadding> {
+ try {
+ const value = typeof input === 'function' ? input() : input;
+ return Promise.resolve(value)
+ .then((padding) => padding ?? this.defaultPadding)
+ .catch(() => this.defaultPadding);
+ } catch (error) {
+ return Promise.resolve(this.defaultPadding);
+ }
+ }
+
+ /** 居中到单点(首帧定位、单点场景);传入 padding 时居中到可视区域而非整张地图。 */
+ focus(point: LatLng, options?: FocusOptions): void {
+ if (!point || !Number.isFinite(point.latitude) || !Number.isFinite(point.longitude)) {
+ return;
+ }
+ const isAuto = options?.isAuto ?? true;
+ const signature = options?.signature ?? '';
+ const paddingInput = options?.padding;
+ // mapPerfLog('focus:request', { point, isAuto, signature });
+ this.enqueue({
+ kind: 'focus',
+ isAuto,
+ signature,
+ points: [point],
+ // 执行时才解析 padding 并换算纵向偏移,保证用的是执行那一刻的卡片高度/缩放
+ run: () =>
+ this.resolveFocusCenter(point, paddingInput).then((center) => {
+ // mapPerfLog('focus:exec', { point, center });
+ return this.runNative((done) => {
+ this.getCtx().moveToLocation({
+ latitude: center.latitude,
+ longitude: center.longitude,
+ complete: done,
+ } as any);
+ });
+ }),
+ });
+ }
+
+ /**
+ * 计算 focus 的实际中心:按 padding 上下不对称量把地图中心向南偏移,
+ * 使目标点落在可视区域(扣除底部卡片)的垂直中心。
+ * 换算:用 getRegion 的纬度跨度 / 地图像素高 得到当前缩放下每像素对应的纬度,
+ * 再把 (bottom - top) / 2 像素换算成纬度偏移。任一环节失败则回退几何居中。
+ */
+ private resolveFocusCenter(point: LatLng, paddingInput?: CameraPaddingInput): Promise<LatLng> {
+ if (paddingInput === undefined) {
+ return Promise.resolve(point);
+ }
+ return this.resolvePadding(paddingInput)
+ .then((padding) => {
+ const [top, , bottom] = padding;
+ const offsetPx = (Number(bottom) - Number(top)) / 2;
+ if (!Number.isFinite(offsetPx) || offsetPx === 0) {
+ // mapPerfLog('focus:fallback', { reason: 'zero-offset', offsetPx, padding });
+ return point;
+ }
+ return Promise.all([this.getRegionSafe(), this.getMapHeightPx()]).then(
+ ([region, heightPx]) => {
+ const latSpan =
+ (region?.northeast?.latitude ?? NaN) - (region?.southwest?.latitude ?? NaN);
+ if (!Number.isFinite(latSpan) || latSpan <= 0 || !heightPx) {
+ // mapPerfLog('focus:fallback', { reason: 'region-or-height-invalid', latSpan, heightPx });
+ return point;
+ }
+ const degPerPx = latSpan / heightPx;
+ const center = {
+ latitude: point.latitude - offsetPx * degPerPx,
+ longitude: point.longitude,
+ };
+ // mapPerfLog('focus:visible-center', { point, center, offsetPx, latSpan, heightPx, degPerPx });
+ return center;
+ }
+ );
+ })
+ .catch(() => point);
+ }
+
+ /** 读当前 region(失败返 null,不抛异常)。 */
+ private getRegionSafe(): Promise<any> {
+ return new Promise((resolve) => {
+ try {
+ this.getCtx().getRegion({
+ success: (res: any) => resolve(res || null),
+ fail: () => resolve(null),
+ } as any);
+ } catch (error) {
+ resolve(null);
+ }
+ });
+ }
+
+ /** 地图像素高:优先实测 #mapId(结果缓存),失败回退 windowHeight。 */
+ private getMapHeightPx(): Promise<number> {
+ if (this.mapHeightPx) {
+ return Promise.resolve(this.mapHeightPx);
+ }
+ return new Promise((resolve) => {
+ const fallback = () => {
+ try {
+ resolve(Taro.getSystemInfoSync()?.windowHeight || 0);
+ } catch (error) {
+ resolve(0);
+ }
+ };
+ try {
+ Taro.createSelectorQuery()
+ .select(`#${this.mapId}`)
+ .boundingClientRect()
+ .exec((res: any[]) => {
+ const height = res?.[0]?.height;
+ if (Number.isFinite(height) && height > 0) {
+ this.mapHeightPx = height;
+ resolve(height);
+ } else {
+ fallback();
+ }
+ });
+ } catch (error) {
+ fallback();
+ }
+ });
+ }
+
+ /** 读取当前真实视口(不入队,立即读取)。 */
+ getViewport(): Promise<CameraViewport> {
+ const ctx = this.getCtx();
+ const readScale = new Promise<number | null>((resolve) => {
+ ctx.getScale({
+ success: (res: any) => resolve(Number.isFinite(res?.scale) ? res.scale : null),
+ fail: () => resolve(null),
+ } as any);
+ });
+ const readCenter = new Promise<LatLng | null>((resolve) => {
+ ctx.getCenterLocation({
+ success: (res: any) =>
+ resolve(
+ Number.isFinite(res?.latitude) && Number.isFinite(res?.longitude)
+ ? { latitude: res.latitude, longitude: res.longitude }
+ : null
+ ),
+ fail: () => resolve(null),
+ } as any);
+ });
+ const readRegion = new Promise<any>((resolve) => {
+ ctx.getRegion({
+ success: (res: any) => resolve(res || null),
+ fail: () => resolve(null),
+ } as any);
+ });
+ return Promise.all([readScale, readCenter, readRegion]).then(
+ ([scale, center, region]) => ({ scale, center, region })
+ );
+ }
+
+ /** 进入避让窗口:期间忽略所有 isAuto 意图,并清掉队列中已排队的 auto 意图。 */
+ holdAuto(ms: number): void {
+ const safeMs = Math.max(0, Number(ms) || 0);
+ this.holdUntilTs = Date.now() + safeMs;
+ this.queue = this.queue.filter((cmd) => !cmd.isAuto);
+ }
+
+ /** 取消所有未执行意图(正在执行的无法取消)。 */
+ cancelPending(): void {
+ this.queue = [];
+ }
+
+ /** 重置 fit 去重签名:状态切换时调用,保证同一元素集合能重新 fit(如「再来一单」复用同路线)。 */
+ resetFitDedup(): void {
+ this.lastAppliedSignature = '';
+ }
+
+ /** 是否有命令正在执行(供车辆动画等在相机忙时避让)。 */
+ isBusy(): boolean {
+ return this.running;
+ }
+
+ private isHolding(): boolean {
+ return Date.now() < this.holdUntilTs;
+ }
+
+ private enqueue(cmd: InternalCommand): void {
+ // 避让窗口内忽略自动意图
+ if (cmd.isAuto && this.isHolding()) {
+ // mapPerfLog('skip:hold', { kind: cmd.kind, signature: cmd.signature, points: cmd.points });
+ return;
+ }
+ // 自动意图去重(fit/focus):相同签名且已应用/在途/排队则跳过(用户显式意图不去重)
+ if (cmd.isAuto && cmd.signature) {
+ const duplicate =
+ cmd.signature === this.currentSignature ||
+ cmd.signature === this.lastAppliedSignature ||
+ this.queue.some((q) => q.signature === cmd.signature);
+ if (duplicate) {
+ // mapPerfLog('skip:dedup', { signature: cmd.signature, points: cmd.points });
+ return;
+ }
+ }
+ // 最新意图优先:丢弃未执行的旧意图,只保留最新一个 pending
+ if (this.queue.length > 0) {
+ // mapPerfLog('supersede', { dropped: this.queue.length, points: cmd.points });
+ }
+ this.queue = [cmd];
+ this.processQueue();
+ }
+
+ private processQueue(): void {
+ if (this.running) {
+ return;
+ }
+ const cmd = this.queue.shift();
+ if (!cmd) {
+ return;
+ }
+ this.running = true;
+ this.currentSignature = cmd.signature;
+ cmd
+ .run()
+ .catch(() => {})
+ .finally(() => {
+ if (cmd.kind === 'fit' || cmd.signature) {
+ this.lastAppliedSignature = cmd.signature;
+ }
+ this.running = false;
+ this.currentSignature = '';
+ // fit 执行完成后读取原生地图的真实 scale,验证 includePoints 是否产生了预期缩放
+ if (cmd.kind === 'fit') {
+ this.getCtx().getScale({
+ success: (res: any) => {
+ // mapPerfLog('fit:result-scale', {
+ // signature: cmd.signature,
+ // scale: res?.scale,
+ // });
+ },
+ fail: () => {
+ // mapPerfLog('fit:result-scale', { signature: cmd.signature, scale: 'read-failed' });
+ },
+ } as any);
+ }
+ // 执行期间可能有更新的意图入队,继续处理
+ this.processQueue();
+ });
+ }
+
+ /**
+ * 执行一条原生命令:用 complete 回调结束;附带安全超时,避免回调不触发导致队列卡死。
+ */
+ private runNative(executor: (done: () => void) => void): Promise<void> {
+ return new Promise<void>((resolve) => {
+ let settled = false;
+ const done = () => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve();
+ };
+ const timer = setTimeout(done, this.commandTimeoutMs);
+ try {
+ executor(done);
+ } catch (error) {
+ done();
+ }
+ });
+ }
+}
+
+/** 便捷工厂。 */
+export const createMapCamera = (mapId: string, options?: MapCameraOptions): MapCamera =>
+ new MapCamera(mapId, options);
+
+/**
+ * 由点集构建去重签名(供策略生成 fit 的 signature)。
+ * 采样「数量 + 首/中/尾坐标」,坐标变化即签名变化。
+ */
+export const buildPointsSignature = (points: LatLng[]): string => {
+ if (!points || points.length === 0) {
+ return '';
+ }
+ const fmt = (v: number) => (Number(v) || 0).toFixed(5);
+ const first = points[0];
+ const mid = points[Math.floor(points.length / 2)];
+ const last = points[points.length - 1];
+ return [
+ points.length,
+ fmt(first.latitude),
+ fmt(first.longitude),
+ fmt(mid.latitude),
+ fmt(mid.longitude),
+ fmt(last.latitude),
+ fmt(last.longitude),
+ ].join('|');
+};
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/map-perf-log.ts b/src/robotaxi/pages/begin-route/hooks/camera/map-perf-log.ts
new file mode 100644
index 0000000..084c5fa
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/map-perf-log.ts
@@ -0,0 +1,12 @@
+/**
+ * 地图性能/渲染调试开关。
+ * 联调时置 true 观察日志;上线前改回 false(所有日志均受此开关控制,零残留)。
+ */
+export const MAP_PERF_LOG = true;
+
+/** 统一前缀日志(受 MAP_PERF_LOG 控制)。 */
+export const mapPerfLog = (...args: any[]): void => {
+ if (!MAP_PERF_LOG) return;
+ // eslint-disable-next-line no-console
+ console.log('[MapPerf]', ...args);
+};
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/policies/calling.policy.ts b/src/robotaxi/pages/begin-route/hooks/camera/policies/calling.policy.ts
new file mode 100644
index 0000000..5510007
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/policies/calling.policy.ts
@@ -0,0 +1,32 @@
+import { CameraContext, CameraIntent, CameraPolicy, NO_CAMERA_INTENT } from '../camera-policy.types';
+import { siteToPoint } from '../collect-viewport-points';
+
+/** 呼叫态空闲回中冷却。 */
+const IDLE_RECENTER_MS = 5000;
+
+/** 居中到上车点(focus + centerInVisibleArea:居中到扣除底部卡片后的可视区域)。
+ * 不用单点 fit:includePoints 只有一个点时不理会不对称 padding,
+ * 会把点放到整张地图几何中心,视觉上贴近底部卡片。 */
+const buildPickupFocusIntent = (ctx: CameraContext): CameraIntent => {
+ const point = siteToPoint(ctx.startSite);
+ if (!point) {
+ return NO_CAMERA_INTENT;
+ }
+ return {
+ type: 'focus',
+ point,
+ centerInVisibleArea: true,
+ signature: `pickup|${point.latitude.toFixed(5)}|${point.longitude.toFixed(5)}`,
+ };
+};
+
+/**
+ * 呼叫态(Publishing / CallingCar)相机策略:把上车点居中到视口。
+ * - 首帧 / 元素到齐 / 回中 / 空闲:均 focus 上车点
+ */
+export const callingPolicy: CameraPolicy = {
+ onInitial: buildPickupFocusIntent,
+ onElementsReady: buildPickupFocusIntent,
+ onRecenter: buildPickupFocusIntent,
+ idleRecenterMs: IDLE_RECENTER_MS,
+};
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/policies/drive.policy.ts b/src/robotaxi/pages/begin-route/hooks/camera/policies/drive.policy.ts
new file mode 100644
index 0000000..d4e70f9
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/policies/drive.policy.ts
@@ -0,0 +1,77 @@
+import { CameraContext, CameraIntent, CameraPolicy, NO_CAMERA_INTENT } from '../camera-policy.types';
+import { LatLng, buildPointsSignature } from '../map-camera';
+import { collectInServiceViewportPoints, siteToPoint } from '../collect-viewport-points';
+
+/** 送驾态空闲回中冷却(用户手势后 5s 无操作则回中)。 */
+const IDLE_RECENTER_MS = 5000;
+/** fit 至少需要的点数。 */
+const MIN_FIT_POINTS = 2;
+
+/**
+ * 送驾态稳定签名:基于「行驶路线点」生成。
+ * - 路线未到达时为空签名 → 早期兜底 fit 后,路线到达(签名变化)会重新 fit 完整行驶路线;
+ * - 路线到达后 routePoints 不变 → 签名稳定 → 车辆移动不反复 re-fit。
+ */
+const stableDropoffSignature = (prefix: string, ctx: CameraContext): string =>
+ `${prefix}:${buildPointsSignature(ctx.routePoints || [])}`;
+
+/**
+ * 送驾中(InService) fit 意图:fit 行驶路线点(ws polyline,车→下车点) + 下车点 (viewport:'route')。
+ * @param stable true=自动触发(带稳定签名,进入时一次);false=回中/空闲(不去重,重新 fit 当前路线)
+ */
+const buildInServiceIntent = (ctx: CameraContext, stable: boolean): CameraIntent => {
+ const points = collectInServiceViewportPoints(ctx);
+ if (points.length >= MIN_FIT_POINTS) {
+ return {
+ type: 'fit',
+ points,
+ signature: stable ? stableDropoffSignature('in-service', ctx) : undefined,
+ };
+ }
+ // 点不足 2 个(routePoints 被过渡性清空/尚未到达)时不动相机。
+ // 不能 focus(车/下车点):过渡性清空路线会触发该分支,moveToLocation 把中心挪走、
+ // 路线被推出视口,随后同签名的 fit 又被去重吞掉,视口停在切一半的状态。
+ // 保持现状等 baseline 路线到达(签名变化)再 fit 全貌即可。
+ return NO_CAMERA_INTENT;
+};
+
+/**
+ * 送驾中相机策略:进入时 fit 行驶路线一次;手势后空闲回中;回中按钮重新 fit。
+ */
+export const inServicePolicy: CameraPolicy = {
+ onInitial: (ctx) => buildInServiceIntent(ctx, true),
+ onElementsReady: (ctx) => buildInServiceIntent(ctx, true),
+ onRecenter: (ctx) => buildInServiceIntent(ctx, false),
+ idleRecenterMs: IDLE_RECENTER_MS,
+};
+
+/**
+ * 到达目的地(ArrivedDestination) 意图:一次性居中车+下车点 (viewport:'car_user')。
+ * 注意:不使用 userLocation——到达点时用户定位仍是上车时的旧值,会把视口撑歪。
+ * 车已到下车点,二者接近 → fit(车+下车点) 得到贴近的近景;缺一则 focus 单点。
+ */
+const buildArrivedDestinationIntent = (ctx: CameraContext, stable: boolean): CameraIntent => {
+ const dropoff = siteToPoint(ctx.endSite);
+ const car = siteToPoint(ctx.vehicleLocation) || dropoff; // 车辆缺失用下车点兜底
+ const points: LatLng[] = [];
+ if (car) points.push(car);
+ if (dropoff) points.push(dropoff);
+ if (points.length >= MIN_FIT_POINTS) {
+ return {
+ type: 'fit',
+ points,
+ signature: stable ? 'arrived-destination' : undefined,
+ };
+ }
+ return car ? { type: 'focus', point: car } : NO_CAMERA_INTENT;
+};
+
+/**
+ * 到达目的地相机策略:把车+下车点居中一次;手势后空闲回中。
+ */
+export const arrivedDestinationPolicy: CameraPolicy = {
+ onInitial: (ctx) => buildArrivedDestinationIntent(ctx, true),
+ onElementsReady: (ctx) => buildArrivedDestinationIntent(ctx, true),
+ onRecenter: (ctx) => buildArrivedDestinationIntent(ctx, false),
+ idleRecenterMs: IDLE_RECENTER_MS,
+};
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/policies/index.ts b/src/robotaxi/pages/begin-route/hooks/camera/policies/index.ts
new file mode 100644
index 0000000..b0f07da
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/policies/index.ts
@@ -0,0 +1,23 @@
+import { TripStatus } from '../../../../../constants/trip-status';
+import { CameraPolicy } from '../camera-policy.types';
+import { publishRoutePolicy } from './publish-route.policy';
+import { callingPolicy } from './calling.policy';
+import { approachingPolicy, arrivedPickupPolicy } from './pickup.policy';
+import { inServicePolicy, arrivedDestinationPolicy } from './drive.policy';
+import { orderDetailPolicy } from './order-detail.policy';
+
+/**
+ * begin-route 各状态相机策略映射(可拓展核心)。
+ * 新增状态(接驾页等)在此登记对应策略即可,编排层与相机无需改动。
+ */
+export const beginRoutePolicies: Partial<Record<TripStatus, CameraPolicy>> = {
+ [TripStatus.PublishRoute]: publishRoutePolicy,
+ [TripStatus.Publishing]: callingPolicy,
+ [TripStatus.CallingCar]: callingPolicy,
+ [TripStatus.VehicleApproaching]: approachingPolicy,
+ [TripStatus.VehicleArrivedPickup]: arrivedPickupPolicy,
+ [TripStatus.InService]: inServicePolicy,
+ [TripStatus.ArrivedDestination]: arrivedDestinationPolicy,
+ [TripStatus.TripFinished]: orderDetailPolicy,
+ [TripStatus.PaymentSuccess]: orderDetailPolicy,
+};
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/policies/order-detail.policy.ts b/src/robotaxi/pages/begin-route/hooks/camera/policies/order-detail.policy.ts
new file mode 100644
index 0000000..0693ff0
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/policies/order-detail.policy.ts
@@ -0,0 +1,35 @@
+import { CameraContext, CameraIntent, CameraPolicy, NO_CAMERA_INTENT } from '../camera-policy.types';
+import { buildPointsSignature } from '../map-camera';
+import { collectOrderDetailViewportPoints } from '../collect-viewport-points';
+
+/** 完成/支付态空闲回中冷却。 */
+const IDLE_RECENTER_MS = 5000;
+/** fit 至少需要的点数。 */
+const MIN_FIT_POINTS = 2;
+
+/**
+ * 完成/支付态(order_detail) fit 意图:fit 全程轨迹 + 上车点 + 下车点(无车)。
+ * 完成态路线是静态轨迹(行程已结束、不再移动),签名天然稳定 → 进入时 fit 一次;
+ * 用户主动回中走 onRecenter,不去重并重新 fit。
+ */
+const buildOrderDetailIntent = (ctx: CameraContext, stable: boolean): CameraIntent => {
+ const points = collectOrderDetailViewportPoints(ctx);
+ if (points.length < MIN_FIT_POINTS) {
+ return NO_CAMERA_INTENT;
+ }
+ return {
+ type: 'fit',
+ points,
+ signature: stable ? `order-detail:${buildPointsSignature(points)}` : undefined,
+ };
+};
+
+/**
+ * 完成/支付态相机策略:进入时 fit 整趟;纠偏/回中重新 fit。
+ */
+export const orderDetailPolicy: CameraPolicy = {
+ onInitial: (ctx) => buildOrderDetailIntent(ctx, true),
+ onElementsReady: (ctx) => buildOrderDetailIntent(ctx, true),
+ onRecenter: (ctx) => buildOrderDetailIntent(ctx, false),
+ idleRecenterMs: IDLE_RECENTER_MS,
+};
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/policies/pickup.policy.ts b/src/robotaxi/pages/begin-route/hooks/camera/policies/pickup.policy.ts
new file mode 100644
index 0000000..aa40764
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/policies/pickup.policy.ts
@@ -0,0 +1,78 @@
+import { CameraContext, CameraIntent, CameraPolicy, NO_CAMERA_INTENT } from '../camera-policy.types';
+import { buildPointsSignature } from '../map-camera';
+import { collectPickupViewportPoints, siteToPoint } from '../collect-viewport-points';
+import { mapPerfLog } from '../map-perf-log';
+
+/** 接驾态空闲回中冷却(用户手势后 5s 无操作则回中)。 */
+const IDLE_RECENTER_MS = 5000;
+/** fit 至少需要的点数。 */
+const MIN_FIT_POINTS = 2;
+
+/**
+ * 接驾态稳定签名:基于「接驾路线点」生成。
+ * - 路线未到达时为空签名 → 早期用车+上车点兜底 fit 后,路线到达(签名变化)会重新 fit 完整路线;
+ * - 路线到达后,增量擦除不改 routePoints → 签名稳定 → 车辆移动不会反复 re-fit(防抖动)。
+ */
+const stablePickupSignature = (prefix: string, ctx: CameraContext): string =>
+ `${prefix}:${buildPointsSignature(ctx.routePoints || [])}`;
+
+/**
+ * 接驾中(VehicleApproaching) fit 意图:fit 接驾路线点(ws polyline) + 上车点 (viewport:'route')。
+ * @param stable true=自动触发(带稳定签名,进入时一次);false=回中/空闲(不去重,重新 fit 当前路线)
+ */
+const buildApproachingFitIntent = (ctx: CameraContext, stable: boolean): CameraIntent => {
+ const points = collectPickupViewportPoints(ctx);
+ if (points.length >= MIN_FIT_POINTS) {
+ return {
+ type: 'fit',
+ points,
+ signature: stable ? stablePickupSignature('approaching', ctx) : undefined,
+ };
+ }
+ // 点不足 2 个(routePoints 被过渡性清空/尚未到达)时不动相机。
+ // 不能 focus(上车点):ORDER_STATUS 清空路线会触发该分支,moveToLocation 把中心
+ // 挪到上车点、路线被推出视口,随后同签名的 fit 又被去重吞掉,视口停在切一半的状态。
+ // 保持现状等 baseline 路线到达(签名变化)再 fit 全貌即可。
+ return NO_CAMERA_INTENT;
+};
+
+/**
+ * 接驾中相机策略:进入时 fit 接驾路线一次;手势后空闲回中;回中按钮重新 fit。
+ */
+export const approachingPolicy: CameraPolicy = {
+ onInitial: (ctx) => buildApproachingFitIntent(ctx, true),
+ onElementsReady: (ctx) => buildApproachingFitIntent(ctx, true),
+ onRecenter: (ctx) => buildApproachingFitIntent(ctx, false),
+ idleRecenterMs: IDLE_RECENTER_MS,
+};
+
+/**
+ * 车辆已到(VehicleArrivedPickup) 意图:focus 上车点,centerInVisibleArea 居中到
+ * 扣除底部卡片后的可视区域。车已到上车点,二者接近,直接居中上车点即可。
+ */
+const buildArrivedPickupIntent = (ctx: CameraContext, stable: boolean): CameraIntent => {
+ const pickup = siteToPoint(ctx.startSite);
+ // mapPerfLog('policy:arrived-pickup-points', {
+ // stable,
+ // pickup: { got: !!pickup, point: pickup },
+ // });
+ if (!pickup) {
+ return NO_CAMERA_INTENT;
+ }
+ return {
+ type: 'focus',
+ point: pickup,
+ centerInVisibleArea: true,
+ signature: stable ? 'arrived-pickup' : undefined,
+ };
+};
+
+/**
+ * 车辆已到上车点相机策略:focus 上车点到可视区域中心;手势后空闲回中。
+ */
+export const arrivedPickupPolicy: CameraPolicy = {
+ onInitial: (ctx) => buildArrivedPickupIntent(ctx, true),
+ onElementsReady: (ctx) => buildArrivedPickupIntent(ctx, true),
+ onRecenter: (ctx) => buildArrivedPickupIntent(ctx, false),
+ idleRecenterMs: IDLE_RECENTER_MS,
+};
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/policies/publish-route.policy.ts b/src/robotaxi/pages/begin-route/hooks/camera/policies/publish-route.policy.ts
new file mode 100644
index 0000000..eb2f037
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/policies/publish-route.policy.ts
@@ -0,0 +1,54 @@
+import { CameraContext, CameraIntent, CameraPolicy } from '../camera-policy.types';
+import { buildPointsSignature } from '../map-camera';
+import { collectRouteViewportPoints } from '../collect-viewport-points';
+
+/** 发单页空闲回中冷却。 */
+const IDLE_RECENTER_MS = 5000;
+/** fit 至少需要的点数(少于此仅靠 focus/兜底)。 */
+const MIN_FIT_POINTS = 2;
+
+/** 空意图。 */
+const NONE: CameraIntent = { type: 'none' };
+
+/** 构造四要素 fit 意图(点数不足返回 none)。 */
+const buildRouteFitIntent = (ctx: CameraContext, withSignature: boolean): CameraIntent => {
+ const points = collectRouteViewportPoints(ctx);
+ if (points.length < MIN_FIT_POINTS) {
+ return NONE;
+ }
+ return {
+ type: 'fit',
+ points,
+ signature: withSignature ? buildPointsSignature(points) : undefined,
+ };
+};
+
+/**
+ * 发单页(PublishRoute)相机策略。
+ * - 首帧:路线已到则直接 fit;否则 focus 定位处(scale 由 <Map> 挂载值 14 决定)
+ * - 元素到齐/变化:fit 起点+终点+车行+步行(自动,带签名去重)
+ * - 回中按钮:fit 当前四要素(用户显式,不去重)
+ * - 空闲 5s:复用回中
+ */
+export const publishRoutePolicy: CameraPolicy = {
+ onInitial(ctx) {
+ // 路线已到:直接 fit,避免 focus 用户位置覆盖已完成的路线居中
+ const routeFitIntent = buildRouteFitIntent(ctx, false);
+ if (routeFitIntent.type !== 'none') {
+ return routeFitIntent;
+ }
+ // 路线尚未到:先 focus 用户位置,让用户看到自己所在区域
+ if (ctx.userLocation) {
+ return { type: 'focus', point: ctx.userLocation };
+ }
+ return NONE;
+ },
+ onElementsReady(ctx) {
+ return buildRouteFitIntent(ctx, true);
+ },
+ onRecenter(ctx) {
+ return buildRouteFitIntent(ctx, false);
+ },
+ idleRecenterMs: IDLE_RECENTER_MS,
+ // onIdleRecenter 未配置 → 编排层默认复用 onRecenter
+};
diff --git a/src/robotaxi/pages/begin-route/hooks/camera/use-map-camera-controller.ts b/src/robotaxi/pages/begin-route/hooks/camera/use-map-camera-controller.ts
new file mode 100644
index 0000000..294f322
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/camera/use-map-camera-controller.ts
@@ -0,0 +1,258 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { TripStatus } from '../../../../constants/trip-status';
+import { getUserLocation } from '../../../../utils/user-location';
+import { CameraPadding, LatLng, MapCamera } from './map-camera';
+import { CameraContext, CameraIntent, CameraPolicy } from './camera-policy.types';
+import { mapPerfLog } from './map-perf-log';
+
+const DEFAULT_MAP_ID = 'map';
+const DEFAULT_IDLE_RECENTER_MS = 5000; // 手势后空闲回中默认冷却
+const HOLD_BUFFER_MS = 1000; // 避让窗口在空闲冷却基础上的缓冲
+const BASE_GESTURE_HOLD_MS = 2000; // 未启用空闲回中时,手势后仍短暂避让自动意图
+
+const USER_GESTURE_CAUSES = new Set(['drag', 'scale', 'gesture']);
+
+export interface UseMapCameraControllerParams {
+ /** <Map id>,默认 'map'。 */
+ mapId?: string;
+ /** 当前行程状态。 */
+ tripStatus: TripStatus;
+ /** 状态 → 策略映射(可拓展核心)。 */
+ policies: Partial<Record<TripStatus, CameraPolicy>>;
+ /** 上车点。 */
+ startSite: any;
+ /** 下车点。 */
+ endSite: any;
+ /** 车行路线点。 */
+ routePoints: LatLng[];
+ /** 步行路线点。 */
+ walkingPoints: LatLng[];
+ /** 车辆当前位置(接驾/行程态 fit 用)。 */
+ vehicleLocation: LatLng | null;
+ /** 最新用户定位(接驾态 fit 用,由外部刷新传入;为空时回退首帧定位值)。 */
+ userLocation?: LatLng | null;
+ /** includePoints 留白(按底部卡片/安全区算好;支持异步,即 fit 执行前即时测量)。 */
+ getPadding?: () => CameraPadding | Promise<CameraPadding>;
+ /**
+ * focus 可视区域居中专用留白(口径同 getPadding,但不做鸿蒙 padding 补偿)。
+ * focus 的可视区域居中换算(MapCamera.resolveFocusCenter)全程基于真实 DOM 像素
+ * 计算偏移,不经过 includePoints 原生 API,不能套用「补偿 includePoints 像素解释
+ * 偏差」的鸿蒙补偿系数,否则会导致偏移量在鸿蒙上被错误放大数倍。未提供时回退 getPadding。
+ */
+ getFocusPadding?: () => CameraPadding | Promise<CameraPadding>;
+ /** 是否启用首帧定位(默认 true)。 */
+ enableInitialLocate?: boolean;
+ /** 首帧定位拿到坐标后回调(用于移除进入页面的 loading 占位)。 */
+ onLocated?: (loc: { latitude: number; longitude: number; source: string }) => void;
+}
+
+export interface MapCameraController {
+ /** 相机实例(供需要时直接调用,如车辆动画避让 camera.isBusy())。 */
+ camera: MapCamera;
+ /** 绑定到 <Map onRegionChange>。 */
+ onRegionChange: (e: any) => void;
+ /** 回中按钮或业务纠偏(用户显式意图),source 用于 MapPerf 归因。 */
+ recenter: (source?: string) => void;
+ /** 用户切换路线后调用(用户显式意图,本质同回中)。 */
+ onRouteSwitched: () => void;
+ /** 读取当前真实视口。 */
+ getViewport: () => ReturnType<MapCamera['getViewport']>;
+}
+
+/**
+ * useMapCameraController —— 相机编排层。
+ *
+ * 职责:监听 首帧定位 / 元素变化 / 用户手势 / 回中按钮 等信号,查 policies[tripStatus]
+ * 得到相机意图,按触发来源决定 isAuto,交给唯一的 MapCamera 执行。
+ *
+ * - 首帧:getUserLocation() → policy.onInitial(isAuto:true)
+ * - 元素/状态变化:policy.onElementsReady(isAuto:true,相机按签名去重)
+ * - 回中按钮 / 切路线:policy.onRecenter(isAuto:false,必然生效)
+ * - 手势 begin:holdAuto 避让 + 清空闲定时器;end:起空闲定时器 → 空闲回中
+ *
+ * 说明:空闲回中执行时用 isAuto:false,其「被新手势打断」由定时器清除保证
+ * (手势 begin 会清掉定时器),无需依赖 holdAuto 抑制,避免被自身避让窗口误吞。
+ */
+export function useMapCameraController(params: UseMapCameraControllerParams): MapCameraController {
+ const { mapId = DEFAULT_MAP_ID, enableInitialLocate = true } = params;
+
+ const cameraRef = useRef<MapCamera | null>(null);
+ if (!cameraRef.current) {
+ cameraRef.current = new MapCamera(mapId);
+ }
+ const userLocationRef = useRef<LatLng | null>(null);
+ const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const initializedRef = useRef(false);
+ const prevTripStatusRef = useRef(params.tripStatus);
+
+ // 最新数据放 ref,供事件/定时器读取最新值,避免闭包过期
+ const dataRef = useRef(params);
+ dataRef.current = params;
+
+ const buildContext = useCallback((): CameraContext => {
+ const d = dataRef.current;
+ return {
+ tripStatus: d.tripStatus,
+ startSite: d.startSite,
+ endSite: d.endSite,
+ routePoints: d.routePoints || [],
+ walkingPoints: d.walkingPoints || [],
+ userLocation: d.userLocation ?? userLocationRef.current,
+ vehicleLocation: d.vehicleLocation ?? null,
+ };
+ }, []);
+
+ const getPolicy = useCallback((): CameraPolicy | undefined => {
+ const d = dataRef.current;
+ return d.policies?.[d.tripStatus];
+ }, []);
+
+ const executeIntent = useCallback(
+ (intent: CameraIntent | undefined, isAuto: boolean) => {
+ const camera = cameraRef.current;
+ if (!camera || !intent) return;
+ if (intent.type === 'focus') {
+ camera.focus(intent.point, {
+ isAuto,
+ signature: intent.signature,
+ // centerInVisibleArea:惰性传入 padding,相机执行时按底部卡片高度把点
+ // 居中到可视区域(而非整张地图几何中心)
+ padding: intent.centerInVisibleArea
+ ? () => dataRef.current.getFocusPadding?.() ?? dataRef.current.getPadding?.()
+ : undefined,
+ });
+ } else if (intent.type === 'fit') {
+ camera.fit(intent.points, {
+ isAuto,
+ signature: intent.signature,
+ // 惰性传入:相机在 includePoints 执行前才调用,保证拿到执行那一刻的最新留白
+ // (getPadding 可为异步的即时测量)。同时通过 dataRef 读取最新 getPadding,
+ // 避免 useCallback 依赖 getPadding 导致引用变化,进而触发首帧定位 effect
+ // 重跑、cancelled=true、onLocated 丢失的 bug
+ padding: () => dataRef.current.getPadding?.(),
+ });
+ }
+ },
+ [] // stable — getPadding 通过 dataRef.current 动态读取,无需作为依赖
+ );
+
+ const clearIdleTimer = useCallback(() => {
+ if (idleTimerRef.current) {
+ clearTimeout(idleTimerRef.current);
+ idleTimerRef.current = null;
+ }
+ }, []);
+
+ // 首帧定位 → onInitial(方案 B:<Map> 已用兜底坐标挂载,这里 focus 一次)
+ useEffect(() => {
+ if (!enableInitialLocate || initializedRef.current) return;
+ initializedRef.current = true;
+ let cancelled = false;
+ getUserLocation()
+ .then((loc) => {
+ if (cancelled) return;
+ userLocationRef.current = { latitude: loc.latitude, longitude: loc.longitude };
+ // mapPerfLog('trigger:initial', { source: loc.source });
+ executeIntent(getPolicy()?.onInitial?.(buildContext()), true);
+ // 拿到坐标后通知外部移除进入页面的 loading 占位
+ dataRef.current.onLocated?.(loc);
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, [enableInitialLocate, getPolicy, executeIntent, buildContext]);
+
+ // 元素/状态变化 → onElementsReady(自动 fit,相机按签名去重;数据不足由策略返回 none)
+ useEffect(() => {
+ // tripStatus 变化属于明确的应用事件:释放上一段手势遗留的避让窗口,
+ // 并清掉上一状态遗留的空闲回中定时器,保证进入新状态的居中/全览意图(focus/fit)
+ // 一定生效、且不会被上一状态的空闲定时器跨状态误触发。
+ if (prevTripStatusRef.current !== params.tripStatus) {
+ prevTripStatusRef.current = params.tripStatus;
+ cameraRef.current?.holdAuto(0);
+ cameraRef.current?.resetFitDedup();
+ clearIdleTimer();
+ }
+ executeIntent(getPolicy()?.onElementsReady?.(buildContext()), true);
+ }, [
+ params.tripStatus,
+ params.startSite,
+ params.endSite,
+ params.routePoints,
+ params.walkingPoints,
+ params.vehicleLocation,
+ params.userLocation,
+ getPolicy,
+ executeIntent,
+ buildContext,
+ clearIdleTimer,
+ ]);
+
+ // 卸载清理
+ useEffect(() => () => clearIdleTimer(), [clearIdleTimer]);
+
+ const recenter = useCallback((source = 'unspecified') => {
+ clearIdleTimer();
+ // mapPerfLog('trigger:recenter', {
+ // source,
+ // tripStatus: dataRef.current.tripStatus
+ // });
+ executeIntent(getPolicy()?.onRecenter?.(buildContext()), false);
+ }, [clearIdleTimer, getPolicy, executeIntent, buildContext]);
+
+ const onRouteSwitched = useCallback(() => {
+ clearIdleTimer();
+ // mapPerfLog('trigger:recenter', {
+ // source: 'route-switched',
+ // tripStatus: dataRef.current.tripStatus
+ // });
+ executeIntent(getPolicy()?.onRecenter?.(buildContext()), false);
+ }, [clearIdleTimer, getPolicy, executeIntent, buildContext]);
+
+ const onRegionChange = useCallback(
+ (e: any) => {
+ const camera = cameraRef.current;
+ if (!camera) return;
+ const causedBy = String(e?.causedBy || e?.detail?.causedBy || '').toLowerCase();
+ const eventType = String(e?.type || e?.detail?.type || '').toLowerCase();
+ if (!USER_GESTURE_CAUSES.has(causedBy)) return;
+
+ const policy = getPolicy();
+ const idleMs = policy?.idleRecenterMs ?? DEFAULT_IDLE_RECENTER_MS;
+ const holdMs = (idleMs > 0 ? idleMs : BASE_GESTURE_HOLD_MS) + HOLD_BUFFER_MS;
+
+ if (eventType === 'begin') {
+ // 用户开始拖/缩:避让自动意图,取消未触发的空闲回中
+ // mapPerfLog('trigger:gesture-begin', { causedBy });
+ camera.holdAuto(holdMs);
+ clearIdleTimer();
+ } else if (eventType === 'end') {
+ // 手势结束:继续避让到空闲窗口结束,并起空闲回中定时器
+ // mapPerfLog('trigger:gesture-end', { causedBy, idleMs });
+ camera.holdAuto(holdMs);
+ clearIdleTimer();
+ const canIdleRecenter = idleMs > 0 && !!(policy?.onIdleRecenter || policy?.onRecenter);
+ if (canIdleRecenter) {
+ idleTimerRef.current = setTimeout(() => {
+ idleTimerRef.current = null;
+ // mapPerfLog('trigger:idle-recenter');
+ const p = getPolicy();
+ const intent = (p?.onIdleRecenter || p?.onRecenter)?.(buildContext());
+ // 空闲回中:用 isAuto:false 保证不被避让窗口吞掉;被新手势打断已由清定时器保证
+ executeIntent(intent, false);
+ }, idleMs);
+ }
+ }
+ },
+ [clearIdleTimer, getPolicy, executeIntent, buildContext]
+ );
+
+ return {
+ camera: cameraRef.current,
+ onRegionChange,
+ recenter,
+ onRouteSwitched,
+ getViewport: () => cameraRef.current!.getViewport(),
+ };
+}
diff --git a/src/robotaxi/pages/begin-route/hooks/speed-scale-config.ts b/src/robotaxi/pages/begin-route/hooks/speed-scale-config.ts
deleted file mode 100644
index 3b113f7..0000000
--- a/src/robotaxi/pages/begin-route/hooks/speed-scale-config.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * 速度自动缩放配置(纯常量与映射,无副作用),供 hook 与调试面板共享,避免阈值散落多处。
- *
- * 速度分级与缩放等级(按 km/h,非 debug 模式,仅低速 / 中高速两档):
- * - 低速 0~20:scale 19(看清路况细节)
- * - 中高速 >20:scale 16(看清路线全貌)
- *
- * 注意:后端 ORDER_ROUTE 消息 route.speed 字段单位为 km/h(不是 m/s),全链路按 km/h 处理。
- */
-
-/**
- * 调试开关:实车难以跑到高速,开启后用更低的速度阈值方便测试,并启用页面调速面板。
- * 测试完成后改回 false 即可恢复线上正常阈值,并自动隐藏调试面板。
- * - false(线上):低速 0~20km/h,中高速 >20km/h
- * - true (测试):低速 0~10km/h,中高速 >10km/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;
-
-/** 低速挡缩放等级。 */
-export const LOW_SPEED_SCALE = SPEED_SCALE_DEBUG ? 19 : 19;
-
-/** 中高速挡缩放等级(合并原中速、高速两档)。 */
-export const HIGH_SPEED_SCALE = SPEED_SCALE_DEBUG ? 16 : 16;
-
-
-/** 速度挡位描述。 */
-export type SpeedTier = {
- key: 'low' | '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 };
- }
- return { key: 'high', label: '中高速', scale: HIGH_SPEED_SCALE };
-};
-
-/**
- * 根据速度(km/h)解析目标缩放等级。
- */
-export const resolveScaleByKmh = (kmh: number): number => resolveSpeedTierByKmh(kmh).scale;
diff --git a/src/robotaxi/pages/begin-route/hooks/use-active-trip-scale.ts b/src/robotaxi/pages/begin-route/hooks/use-active-trip-scale.ts
deleted file mode 100644
index 01ff6b2..0000000
--- a/src/robotaxi/pages/begin-route/hooks/use-active-trip-scale.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import Taro from '@tarojs/taro';
-import { useCallback, useMemo } from 'react';
-import { TripStatus } from '../../../constants/trip-status';
-import { ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING, ROUTE_VIEWPORT_RIGHT_SIDE_PADDING } from './use-route-manager.shared';
-
-type RoutePoint = {
- latitude: number;
- longitude: number;
-};
-
-type Deps = {
- /** 当前行程状态。 */
- tripStatus: TripStatus;
- /** 计算 includePoints 所需底部留白。 */
- computeBottomPadding: (points: any[], viewportType: string, tripStatus: TripStatus, disableAdaptive?: boolean) => number;
- /** 读取顶部安全区高度。 */
- resolveSafeTopInset: () => number;
- /** 暂停动态缩放。 */
- holdDynamicScale: (durationMs?: number) => void;
-};
-
-const ACTIVE_TRIP_STATUSES = [TripStatus.VehicleApproaching, TripStatus.InService]; // 启用视口编排的行程状态
-const USER_GESTURE_CAUSED_BY = new Set(['gesture', 'drag', 'scale']); // 需要暂停动态缩放的用户手势来源
-const USER_GESTURE_HOLD_MS = 8000; // 用户手势后暂停动态缩放时长
-const ORDER_UPDATE_LINE_HOLD_MS = 2500; // ORDER_UPDATE_LINE 全路线入屏后暂停动态缩放时长
-const ORDER_UPDATE_LINE_SIDE_PADDING = 50; // ORDER_UPDATE_LINE 路线入屏左右留白
-const ORDER_UPDATE_LINE_TOP_PADDING = 24; // ORDER_UPDATE_LINE 路线入屏最小顶部留白
-const ORDER_UPDATE_LINE_TOP_EXTRA_PADDING = 32; // ORDER_UPDATE_LINE 顶部安全区附加留白
-
-/**
- * 接驾中/行程中视口编排入口:仅保留视口编排职责。
- */
-export default function useActiveTripScale(deps: Deps) {
- const isActiveStatus = useMemo(() => {
- return ACTIVE_TRIP_STATUSES.includes(deps.tripStatus);
- }, [deps.tripStatus]);
-
- /**
- * 处理地图手势,手动缩放/拖拽后短暂暂停视口同步。
- */
- const handleRegionChange = useCallback((event: any) => {
- const causedBy = String(event?.causedBy || event?.detail?.causedBy || '').toLowerCase();
- if (!USER_GESTURE_CAUSED_BY.has(causedBy)) {
- return;
- }
- deps.holdDynamicScale(USER_GESTURE_HOLD_MS);
- }, [deps.holdDynamicScale]);
-
- /**
- * ORDER_UPDATE_LINE 渲染完成后,自动执行一次全路线入屏。
- * 注:后端推送order_update_line消息,代表车偏航,重新规划的路线
- */
- const handleOrderUpdateLineRendered = useCallback((points: RoutePoint[]) => {
- if (!isActiveStatus) {
- return;
- }
- const safePoints = Array.isArray(points) ? points : [];
- if (safePoints.length < 2) {
- return;
- }
- const mapCtx = Taro.createMapContext('map');
- const bottomPadding = deps.computeBottomPadding(safePoints, 'route', deps.tripStatus, true) + ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING;
- const topPadding = Math.max(ORDER_UPDATE_LINE_TOP_PADDING, deps.resolveSafeTopInset() + ORDER_UPDATE_LINE_TOP_EXTRA_PADDING);
- deps.holdDynamicScale(ORDER_UPDATE_LINE_HOLD_MS);
- Taro.nextTick(() => {
- setTimeout(() => {
- mapCtx.includePoints({
- points: safePoints,
- padding: [topPadding, ROUTE_VIEWPORT_RIGHT_SIDE_PADDING, bottomPadding, ORDER_UPDATE_LINE_SIDE_PADDING]
- });
- }, 0);
- });
- }, [deps.computeBottomPadding, deps.holdDynamicScale, deps.resolveSafeTopInset, isActiveStatus]);
-
- return {
- handleRegionChange,
- handleOrderUpdateLineRendered
- };
-}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-android-overlays.ts b/src/robotaxi/pages/begin-route/hooks/use-android-overlays.ts
index 04bb97a..0eb8e25 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-android-overlays.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-android-overlays.ts
@@ -6,7 +6,6 @@
shouldShowWaitingRadar: boolean;
isAndroid: boolean;
tripStatus: TripStatus;
- mapScale: number;
/** 修改目的地预览态下隐藏车辆气泡。 */
isModifyDestinationReviewVisible: boolean;
startSite: any;
@@ -232,12 +231,24 @@
useEffect(() => {
if (!deps.isAndroid || deps.tripStatus !== TripStatus.ArrivedDestination) return;
- if (deps.mapScale < 20) return;
- const timer = setTimeout(() => {
- updateArriveCalloutPosition();
- }, 80);
- return () => clearTimeout(timer);
- }, [deps.isAndroid, deps.tripStatus, deps.mapScale, deps.endSite?.latitude, deps.endSite?.longitude]);
+ let cancelled = false;
+ let timer: ReturnType<typeof setTimeout> | null = null;
+ // 改用原生 getScale 读取真实缩放:达到阈值再刷新到达气泡位置,不再依赖受控 mapScale。
+ Taro.createMapContext('map').getScale({
+ success: (res) => {
+ if (cancelled) return;
+ if (Number(res?.scale) < 20) return;
+ timer = setTimeout(() => {
+ updateArriveCalloutPosition();
+ }, 80);
+ },
+ fail: () => {}
+ });
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, [deps.isAndroid, deps.tripStatus, deps.endSite?.latitude, deps.endSite?.longitude]);
return {
waitingRadarStyle,
diff --git a/src/robotaxi/pages/begin-route/hooks/use-bluetooth-access.ts b/src/robotaxi/pages/begin-route/hooks/use-bluetooth-access.ts
index da79801..9168a40 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-bluetooth-access.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-bluetooth-access.ts
@@ -13,6 +13,8 @@
skipBluetoothAuth?: boolean;
/** 车端钥匙下发失败(getUnlocktToken 接口非 200),蓝牙不可解锁 */
unlockTokenFailed?: boolean;
+ /** 派单成功转场动效是否进行中:为 true 时接驾中(4)暂缓初始化蓝牙,待动效完成后再初始化 */
+ dispatchTransitionActive?: boolean;
};
type BluetoothAccessStatus = 'system_off' | 'unauthorized' | 'authorized';
@@ -148,7 +150,8 @@
bluetoothAuthSuccess,
startBluetoothAuth,
skipBluetoothAuth = false,
- unlockTokenFailed = false
+ unlockTokenFailed = false,
+ dispatchTransitionActive = false
}: Deps) {
const [status, setStatus] = useState<BluetoothAccessStatus>('system_off'); // 当前蓝牙访问态:系统未开 / 小程序未授权 / 已授权
const [statusReady, setStatusReady] = useState(false); // 蓝牙状态是否已完成首轮判定
@@ -556,6 +559,12 @@
logBluetoothAccess('等待 orderId 就绪');
return;
}
+ // 接驾中(4)派单成功转场期间:与车控卡片一致,待转场动效完成后再初始化蓝牙,
+ // 避免转场动画播放时触发系统蓝牙授权弹窗。到达上车点(5)不受此限制(需尽快可开门)。
+ if (tripStatus === TripStatus.VehicleApproaching && dispatchTransitionActive) {
+ logBluetoothAccess('派单成功转场动效未完成,暂缓初始化蓝牙');
+ return;
+ }
logBluetoothAccess('进入蓝牙 UI 维护态', {
tripStatus,
orderId
@@ -588,7 +597,7 @@
stopOpenSettingPollingRef.current();
setShowAuthBluetoothPopup(false);
setShowSuccessAuthPopup(false);
- }, [tripStatus, orderId, refreshBluetoothState]);
+ }, [tripStatus, orderId, refreshBluetoothState, dispatchTransitionActive]);
// 跳转系统蓝牙设置,并在返回后触发一次状态刷新
const handleOpenSystemBluetoothSetting = useCallback(async () => {
diff --git a/src/robotaxi/pages/begin-route/hooks/use-credit-pay-return.ts b/src/robotaxi/pages/begin-route/hooks/use-credit-pay-return.ts
index eb8f364..fae7f30 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-credit-pay-return.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-credit-pay-return.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react';
import Taro from '@tarojs/taro';
-import { showToast } from '../../../components/toast';
+import { showToast, hideToast } from '../../../components/toast';
import { queryLedgerCreditOrderResult } from '../../../api/begin-route';
type Deps = {
@@ -61,7 +61,7 @@
pollingOrderIdRef.current = '';
clearPollingTimer();
if (hideLoading) {
- Taro.hideLoading();
+ hideToast();
}
}, [clearPollingTimer]);
@@ -143,7 +143,7 @@
return;
}
- Taro.showLoading({ title: '授权结果查询中', mask: true });
+ showToast({ title: '授权结果查询中', icon: 'loading', mask: true });
const maxPollingCount = 120;
let currentPollingCount = 0;
@@ -169,7 +169,7 @@
if (status === 'DOING' || result?.code === 20710) {
stopPollingSession(true);
- Taro.showToast({ title: '授权成功', icon: 'none' });
+ showToast({ title: '授权成功', icon: 'none' });
void deps.onAuthorizedReturn(queryId);
return;
}
@@ -205,7 +205,7 @@
if (errorCode === '20710') {
console.log(`[信用分授权查询遇到20710] 第${currentPollingCount + 1}次,当作授权成功处理`);
stopPollingSession(true);
- Taro.showToast({ title: '授权成功', icon: 'none' });
+ showToast({ title: '授权成功', icon: 'none' });
void deps.onAuthorizedReturn(queryId);
return;
}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-defer-until-dispatch-settled.ts b/src/robotaxi/pages/begin-route/hooks/use-defer-until-dispatch-settled.ts
new file mode 100644
index 0000000..1af7290
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/use-defer-until-dispatch-settled.ts
@@ -0,0 +1,51 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { TripStatus } from '../../../constants/trip-status';
+
+/**
+ * 页面级「派单成功转场延后闸门」。
+ *
+ * 背景:真正的转场动画状态机 `useDispatchTransition` 位于子组件 `CallingCar` 内(绑定视频、且随
+ * `key={tripStatus}` 重挂载),页面级逻辑(如蓝牙初始化 `useBluetoothAccess`)读不到它的
+ * `transitionDone`。本 hook 在页面级维护一个布尔闸门,作为「是否要暂缓某些页面级逻辑」的信号:
+ *
+ * - **开闸时机(本 hook 自算)**:进入接驾中(4)边沿时,在渲染期同步置 `active=true`
+ * (必须同步——确保早于同一提交里的页面级 effect(如蓝牙)读取,避免竞态导致转场中弹系统弹窗)。
+ * - **关闸时机(外部告知)**:动画真正播完由子组件 `useDispatchTransition.transitionDone` 判定,
+ * 经 `CallingCar` 的 `onDispatchTransitionSettled` 回调调用本 hook 的 `markSettled` 置回 false。
+ * - 离开接驾态时自动复位为 false。
+ *
+ * 数据流:
+ * [子] useDispatchTransition.transitionDone
+ * └─(contentReady → markSettled)→ [页] useDeferUntilDispatchSettled.dispatchTransitionActive
+ * └─→ [页] useBluetoothAccess 据此决定是否暂缓初始化
+ *
+ * @param tripStatus 当前行程状态
+ * @returns dispatchTransitionActive 转场是否进行中(true 时应暂缓页面级逻辑);markSettled 动效完成回调
+ */
+export function useDeferUntilDispatchSettled(tripStatus: TripStatus) {
+ const [dispatchTransitionActive, setDispatchTransitionActive] = useState(false);
+ const prevTripStatusRef = useRef<TripStatus | undefined>(undefined);
+
+ // 渲染期同步置位:进入接驾中(4)边沿即开闸(早于 effect,规避竞态);离开接驾态则关闸复位。
+ if (
+ tripStatus === TripStatus.VehicleApproaching &&
+ prevTripStatusRef.current !== TripStatus.VehicleApproaching
+ ) {
+ if (!dispatchTransitionActive) {
+ setDispatchTransitionActive(true);
+ }
+ } else if (tripStatus !== TripStatus.VehicleApproaching && dispatchTransitionActive) {
+ setDispatchTransitionActive(false);
+ }
+
+ useEffect(() => {
+ prevTripStatusRef.current = tripStatus;
+ }, [tripStatus]);
+
+ // 转场动效完成(或本次未播放转场)时由子组件回调,放行被暂缓的页面级逻辑。
+ const markSettled = useCallback(() => {
+ setDispatchTransitionActive(false);
+ }, []);
+
+ return { dispatchTransitionActive, markSettled };
+}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-dispatch-transition.ts b/src/robotaxi/pages/begin-route/hooks/use-dispatch-transition.ts
new file mode 100644
index 0000000..d0895e0
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/use-dispatch-transition.ts
@@ -0,0 +1,170 @@
+import { useState, useEffect, useRef } from 'react';
+import { VEHICLE_APPROACHING_VIDEO, VEHICLE_APPROACHING_POSTER, VEHICLE_APPROACHING_END_POSTER } from '../../../constants/assets';
+
+/** 派单成功转场视频(源自 constants/assets.ts 单一数据源) */
+export const DISPATCH_VIDEO_SRC = VEHICLE_APPROACHING_VIDEO;
+export const DISPATCH_VIDEO_POSTER = VEHICLE_APPROACHING_POSTER;
+/** 转场视频尾帧:settle 阶段作结尾遮挡层,挡住原生 video 卸载黑帧 */
+export const DISPATCH_VIDEO_END_POSTER = VEHICLE_APPROACHING_END_POSTER;
+/** 兜底超时(ms):防卡死安全阀——视频加载/播放失败时 onTimeUpdate/onEnded 都不触发,超过此时长强制归位 */
+export const DISPATCH_FALLBACK_MS = 10000;
+/**
+ * 结尾遮挡层提前量(秒):结尾遮挡层只需在视频「真正快播放完」时才盖上(此值即距视频自然结束的秒数)。
+ * 必须显著小于 BG_SHRINK_LEAD_SEC——否则遮挡层会在背景刚开始收缩、视频仍在播时就盖住,抹掉视频尾段效果。
+ */
+export const END_COVER_LEAD_SEC = 0.4;
+
+export type DispatchPhase = 'idle' | 'video' | 'settle';
+
+/**
+ * 派单成功转场状态机:管理视频播放阶段、定时器、事件处理。
+ * 挂载时先保持 idle(面板处于最终位),下一帧再切 video 才能让面板下移产生缓动过渡。
+ * 父层已捕捉边沿并通过 playDispatchTransition 传入(本组件被 key 重挂载,无法自判边沿)。
+ */
+export function useDispatchTransition(playDispatchTransition: boolean) {
+ const [dispatchPhase, setDispatchPhase] = useState<DispatchPhase>('idle');
+ const [transitionDone, setTransitionDone] = useState(false);
+ // 结尾遮挡层是否可见。独立于 settle:settle 起即开始背景收缩(视频仍在播),
+ // 而遮挡层延后到视频「真正快播放完」(距结束 END_COVER_LEAD_SEC)才置真,避免过早盖住视频尾段。
+ const [endCoverVisible, setEndCoverVisible] = useState(false);
+ // shouldPlayEnter 控制转场期间的内容延迟、背景图门槛与整卡升起 keyframe。
+ // 过去依赖 CallingCar 的 key={tripStatus} 重挂载做「挂载快照」,去 key 后组件不再重挂载,
+ // 改为在渲染期同步捕捉 playDispatchTransition 的上升沿(false→true)置真——
+ // 同步置真而非放到 effect,是为避免整卡升起前先渲染一帧静止态导致的位移闪跳;
+ // 又不能直接绑定 playDispatchTransition(其在父层下一次渲染即翻 false,会把 keyframe 类中途卸掉扼杀动画),
+ // 故用独立状态 latch,转场完成后回落 false,使同一实例内下一单再次进入派单成功时能重新触发。
+ const [shouldPlayEnter, setShouldPlayEnter] = useState(false);
+ const prevPlayRef = useRef(false);
+ // 渲染期同步捕捉上升沿并置类(React 官方「prop 变化时调整 state」模式,重渲染早于绘制,无中间帧)
+ if (playDispatchTransition && !prevPlayRef.current) {
+ prevPlayRef.current = true;
+ if (!shouldPlayEnter) setShouldPlayEnter(true);
+ } else if (!playDispatchTransition && prevPlayRef.current) {
+ prevPlayRef.current = false;
+ }
+
+ const enterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const endCoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const settleTriggeredRef = useRef(false);
+
+ // 背景缩小精确计时:微信 video 的 timeupdate 间隔约 265ms,无法直接捕获目标时刻。
+ // 方案:timeupdate 记录 {播放时间, 墙上时钟} 作同步锚点,自适应 setTimeout 在两次事件间
+ // 通过墙上时钟插值估算播放进度,提前唤醒、递归逼近,最终触发精度约 4ms。
+ const lastTimeSyncRef = useRef({ currentTime: 0, wallClock: 0 });
+ const videoDurationRef = useRef(0);
+ const shrinkTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const shrinkTriggeredRef = useRef(false);
+
+ /** 背景缩小提前量(秒):在视频结束前触发 settle。视频全长 3.333s,即 currentTime ≈ 2.278s 处。 */
+ const BG_SHRINK_LEAD_SEC = 1.055;
+
+ // 进入归位落定阶段(幂等,只生效一次)
+ const enterSettle = () => {
+ if (settleTriggeredRef.current) return;
+ settleTriggeredRef.current = true;
+ if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
+ setDispatchPhase('settle');
+ // 结尾遮挡层延后显示:settle 起先跑背景收缩(视频照常播放),待收缩接近尾声、视频「真正快播放完」
+ // (距结束 END_COVER_LEAD_SEC)时才盖上遮挡层。settle 由精确自适应定时器在 duration-BG_SHRINK_LEAD_SEC
+ // 触发,故此处再等 (BG_SHRINK_LEAD_SEC-END_COVER_LEAD_SEC) 即精确落在 duration-END_COVER_LEAD_SEC。
+ // 该时刻仍早于本层卸载(settleTimer 800ms),遮挡层可先于视频卸载上屏、挡住卸载黑帧。
+ // 兜底路径(onError/超时触发的 settle)同样适用:遮挡层在 settle 后固定延时出现,早于 settleTimer。
+ if (endCoverTimerRef.current) clearTimeout(endCoverTimerRef.current);
+ endCoverTimerRef.current = setTimeout(
+ () => setEndCoverVisible(true),
+ Math.max((BG_SHRINK_LEAD_SEC - END_COVER_LEAD_SEC) * 1000, 0),
+ );
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
+ settleTimerRef.current = setTimeout(() => {
+ setDispatchPhase('idle');
+ setTransitionDone(true);
+ // 回落转场门槛:--enter keyframe 已在前 0.8s 播完(此时约 3.9s),清除类名不影响观感,
+ // 且为同一实例内下一次上升沿重新触发 keyframe 留出空间(false→true 才会重播)。
+ setShouldPlayEnter(false);
+ }, 800);
+ };
+
+ /**
+ * 自适应缩小定时器。每次 timeupdate 或定时器到期时调用,计算距目标还有多久:
+ * - 已到达 → 调用 enterSettle 进入 settle
+ * - 未到达 → setTimeout 提前 10ms 唤醒,到期后用墙上时钟插值修正、递归检查
+ */
+ const scheduleShrinkCheck = (currentTime: number) => {
+ if (shrinkTriggeredRef.current) return;
+
+ const targetSec = videoDurationRef.current - BG_SHRINK_LEAD_SEC;
+ const remaining = targetSec - currentTime;
+
+ if (remaining <= 0) {
+ shrinkTriggeredRef.current = true;
+ if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
+ enterSettle();
+ return;
+ }
+
+ if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
+ shrinkTimerRef.current = setTimeout(() => {
+ const { currentTime: lastCT, wallClock: lastWC } = lastTimeSyncRef.current;
+ if (lastWC === 0) return;
+ const estimated = lastCT + (Date.now() - lastWC) / 1000;
+ scheduleShrinkCheck(estimated);
+ }, Math.max(remaining * 1000 - 10,30));
+ };
+
+ const handleDispatchVideoTimeUpdate = (e: any) => {
+ const { currentTime, duration } = e?.detail || {};
+ if (typeof currentTime !== 'number' || typeof duration !== 'number' || duration <= 0) return;
+
+ lastTimeSyncRef.current = { currentTime, wallClock: Date.now() };
+ videoDurationRef.current = duration;
+
+ scheduleShrinkCheck(currentTime);
+ };
+
+ // shouldPlayEnter 置真(上升沿)后启动转场状态机;复位所有一次性标记,保证同一实例内可重复播放。
+ useEffect(() => {
+ if (!shouldPlayEnter) return;
+
+ if (enterTimerRef.current) clearTimeout(enterTimerRef.current);
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
+ if (endCoverTimerRef.current) clearTimeout(endCoverTimerRef.current);
+ if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
+ if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
+ settleTriggeredRef.current = false;
+ shrinkTriggeredRef.current = false;
+ lastTimeSyncRef.current = { currentTime: 0, wallClock: 0 };
+ videoDurationRef.current = 0;
+ setTransitionDone(false);
+ setEndCoverVisible(false);
+ setDispatchPhase('idle');
+
+ enterTimerRef.current = setTimeout(() => {
+ // 视频已在 32ms 内报错/结束(极端)时勿覆盖 settle 阶段
+ if (settleTriggeredRef.current) return;
+ setDispatchPhase('video');
+ }, 32);
+ fallbackTimerRef.current = setTimeout(() => enterSettle(), DISPATCH_FALLBACK_MS);
+ }, [shouldPlayEnter]);
+
+ // 卸载时清理所有定时器
+ useEffect(() => {
+ return () => {
+ if (enterTimerRef.current) clearTimeout(enterTimerRef.current);
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
+ if (endCoverTimerRef.current) clearTimeout(endCoverTimerRef.current);
+ if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
+ if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
+ };
+ }, []);
+
+ return {
+ dispatchPhase,
+ transitionDone,
+ shouldPlayEnter,
+ endCoverVisible,
+ enterSettle,
+ handleDispatchVideoTimeUpdate,
+ };
+}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-map-markers.ts b/src/robotaxi/pages/begin-route/hooks/use-map-markers.ts
index e413b24..3e0ff8a 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-map-markers.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-map-markers.ts
@@ -11,7 +11,7 @@
"https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/change-site-up.png"; // 行程中修改目的地时的上车点图标
// 接驾态(前往上车点)上车点三角图标
const PICKUP_APPROACHING_ICON_URL =
- "https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/up-triangle-20260722.png";
+ "https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/up-triangle-20260812.png";
// 送驾态(前往目的地)下车点三角图标
const DROPOFF_INSERVICE_ICON_URL =
"https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/down-triangle-20260722.png";
@@ -218,8 +218,11 @@
!shouldUseChangePickupIcon;
// 修改目的地预览:起点(上车点)统一用三角图标 up-triangle + 名称显示在图标下方
const isPickupModifyPreview = deps.isModifyDestinationReviewVisible;
+ // 发布路线态:上车点同样展示三角图标(仅 iOS,Android 不展示)
+ const isPickupPublishRoute =
+ deps.tripStatus === TripStatus.PublishRoute && !shouldUseChangePickupIcon && !deps.isAndroid;
// 接驾态路线切换卡片展示中:地图状态被切换为 PublishRoute 展示态,但上车点名称仍需按三角图标态展示
- const isPickupTriangle = isPickupApproaching || isPickupArrived || isPickupSettled || isPickupModifyPreview || deps.showTrip1StyleEndpoint;
+ const isPickupTriangle = isPickupApproaching || isPickupArrived || isPickupSettled || isPickupModifyPreview || isPickupPublishRoute || deps.showTrip1StyleEndpoint;
// 名称在图标下方:接驾态 11px,到达/待支付/完成态 22px
const pickupNameLabelConfig = isPickupApproaching
? SITE_NAME_LABEL_BASE
@@ -312,12 +315,14 @@
: dropoffSite.name || "目的地";
// 送驾态(前往目的地):下车点改用三角图标,名称移到图标下方,浮窗展示 end-site 图片;
// 待支付/完成态:下车点同样三角图标 + end-site 浮窗,名称移到图标左侧。
+ // 发布路线态:下车点同样展示三角图标。
const isDropoffInService = deps.tripStatus === TripStatus.InService;
const isDropoffSettled = [
TripStatus.TripFinished,
TripStatus.PaymentSuccess
].includes(deps.tripStatus);
- const isDropoffTriangle = isDropoffInService || isDropoffSettled;
+ const isDropoffPublishRoute = deps.tripStatus === TripStatus.PublishRoute && !deps.isAndroid;
+ const isDropoffTriangle = isDropoffInService || isDropoffSettled || isDropoffPublishRoute;
const useDropoffRichCallout = isDropoffTriangle; // cover-view 富浮窗
const getCustomCalloutAnchorY = () => {
if (!deps.isAndroid && isDropoffSettled) {
diff --git a/src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts b/src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts
index f6af6db..c5ac9bd 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts
@@ -1,18 +1,7 @@
import Taro from "@tarojs/taro";
-import { useState, useRef, useCallback, useMemo, useEffect } from "react";
-import {
- computeAdaptiveBottomPadding,
- debounce
-} from "../../../../utils/Robotaxi-public-method";
+import { useState, useRef, useCallback, useEffect } from "react";
import { TripStatus } from "../../../constants/trip-status";
-import { buildActiveTripSafeAreaMetrics } from "./active-trip-camera-policy";
-import {
- ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING,
- ROUTE_VIEWPORT_MIN_TOP_PADDING,
- ROUTE_VIEWPORT_RIGHT_SIDE_PADDING,
- ROUTE_VIEWPORT_SIDE_PADDING,
- ROUTE_VIEWPORT_TOP_EXTRA_PADDING
-} from "./use-route-manager.shared";
+import { mapPerfLog } from "./camera/map-perf-log";
type Deps = {
tripStatus: TripStatus;
@@ -24,12 +13,6 @@
vehicleLocation: { latitude: number; longitude: number } | null;
longitude: number | null;
latitude: number | null;
- setLongitude: (value: number | null) => void;
- setLatitude: (value: number | null) => void;
- recordLongitude: (value: number | null) => void;
- recordLatitude: (value: number | null) => void;
- orderDetailViewportRefreshVersion: number;
- markViewportFitSyncing?: () => void;
};
const defaultDeps: Deps = {
@@ -41,46 +24,11 @@
endSite: null,
vehicleLocation: null,
longitude: null,
- latitude: null,
- setLongitude: () => {},
- setLatitude: () => {},
- recordLongitude: () => {},
- recordLatitude: () => {},
- orderDetailViewportRefreshVersion: 0
+ latitude: null
};
-const DROPOFF_MAX_SCALE = 20;
-const DEFAULT_DYNAMIC_SCALE_HOLD_MS = 2500; // 一次性视口调整后,暂停速度缩放的默认冷却时长
-const ACTIVE_TRIP_VIEWPORT_SYNC_MIN_DELTA = 0.00001; // 车辆中心跟随的最小经纬度变化,约 1 米,避免重复写入中心点状态
-const ACTIVE_TRIP_VIEWPORT_SYNC_INTERVAL_MS = 600; // 行程安全区跟随的最小同步间隔,避免地图频繁漂移
-const ACTIVE_TRIP_VIEWPORT_SYNC_ANIMATION_MS = 300; // 跟车中心移动缓动时长,等待原生地图动画后再回写受控中心点
-const ORDER_DETAIL_SCALE_SYNC_DELAY_MS = 600; // 完成态 includePoints 后等待原生地图落稳,再同步实际 scale
-const VIEWPORT_FIT_DEBOUNCE_MS = 300; // 视口 fit 统一防抖:状态切换瞬间路线点/视口类型会先残留旧值再被正确值覆盖,
-// 防抖把窗口内的多次 includePoints/moveToLocation 合并为最后一次,避免连续命令式调用动画相互打断导致缩放卡死
-export const ACTIVE_TRIP_VIEWPORT_SAFE_X_MARGIN_RATIO = 0.2; // 横向两侧保留的边距比例,调大可让车辆更早触发横向回中,避免跑到贴边只剩一小段线路
-
-type ViewportPoint = {
- latitude: number;
- longitude: number;
-};
-
-type ViewportPayload = {
- points: ViewportPoint[];
- keyParts: any[];
-};
-
-/**
- * 将合法站点转换为 includePoints 需要的坐标点。
- */
-const buildSiteViewportPoint = (site: any) => {
- if (!site?.latitude || !site?.longitude) {
- return null;
- }
- return {
- latitude: site.latitude,
- longitude: site.longitude
- };
-};
+// 横向两侧保留的边距比例(供 debug-safearea-panel 复用,保留导出)。
+export const ACTIVE_TRIP_VIEWPORT_SAFE_X_MARGIN_RATIO = 0.2;
export default function useMapViewport(rawDeps?: Partial<Deps>) {
const deps = { ...defaultDeps, ...(rawDeps || {}) } as Deps;
@@ -91,104 +39,14 @@
const [carCalloutVisible, setCarCalloutVisible] = useState(false);
const [arriveCalloutStyle, setArriveCalloutStyle] = useState<any>({});
const [arriveCalloutVisible, setArriveCalloutVisible] = useState(false);
- const [mapScale, setMapScaleState] = useState(14);
const [originBoxHidden, setOriginBoxHidden] = useState(false);
const [originOpacity, setOriginOpacity] = useState(1);
const [waitingRadarStyle, setWaitingRadarStyle] = useState<any>({});
const [waitingRadarVisible, setWaitingRadarVisible] = useState(true);
const [showLocationFlag, setShowLocationFlag] = useState(true);
- const mapRectRef = useRef<any>(null);
- const viewportKeyRef = useRef<string>("");
- const orderDetailViewportLockedRef = useRef(false);
- const tripStatus4ViewportLockedRef = useRef(false); // 车辆接驾中,且视口类型为 route 时,若已锁定视口,则不再重复 updateViewportByPayload,避免反复触发地图缩放
- const lastRouteViewportPointsLengthRef = useRef(-1); // 接驾/送驾态上一次执行 updateViewportByPayload 时的 routePoints 长度:长度变化才再次执行
- const tripStatus6ViewportLockedRef = useRef(false); // 车辆送驾中,且视口类型为 route 时,若已锁定视口,则不再重复 updateViewportByPayload,避免反复触发地图缩放
- const arrivedPickupCenteredRef = useRef(false); // 到达上车点后仅在上车点坐标准备好时回中一次
- const orderDetailEnterSignatureRef = useRef("");
- const orderDetailRouteUpdatedAfterEnterRef = useRef(false);
- const dynamicScaleHoldUntilRef = useRef(0); // 速度缩放暂停截止时间
- const lastActiveTripViewportSyncAtRef = useRef(0); // 最近一次安全区跟随时间戳
- const activeTripViewportSyncingRef = useRef(false); // 当前是否有进行中的安全区跟随任务
- const activeTripViewportSyncTimerRef = useRef<ReturnType<
- typeof setTimeout
- > | null>(null); // 跟车中心移动延时回写定时器
- const orderDetailScaleSyncTimerRef = useRef<ReturnType<
- typeof setTimeout
- > | null>(null); // 完成态 includePoints 后同步原生 scale 的延时器
- const orderDetailScaleSyncVersionRef = useRef(0); // 完成态 scale 回写版本,只允许最后一次 includePoints 生效
- const vehicleLocationRef = useRef(deps.vehicleLocation); // 车辆位置 ref,供延时回调读取最新值
- const latitudeRef = useRef(deps.latitude); // 当前中心纬度 ref
- const longitudeRef = useRef(deps.longitude); // 当前中心经度 ref
-
- // 记录 scale 的最新值,供跨渲染读取;更新 ref 本身不会触发重新渲染
- const mapScaleRef = useRef<number | null>(null);
- // recordMapScale:仅记录最新值,不触发重新渲染,等下次渲染时直接读取 ref 中记录的最新值
- const recordMapScale = useCallback((value: number | null) => {
- mapScaleRef.current = value;
- }, []);
- // setMapScale:更新 state 触发渲染的同时同步记录最新值,保证 ref 始终为最新(兼容函数式更新)
- const setMapScale = useCallback(
- (value: number | ((prev: number) => number)) => {
- setMapScaleState(prev => {
- const next = typeof value === "function" ? value(prev) : value;
- mapScaleRef.current = next;
- return next;
- });
- },
- []
- );
- useEffect(() => {
- vehicleLocationRef.current = deps.vehicleLocation;
- }, [deps.vehicleLocation]);
- useEffect(() => {
- latitudeRef.current = deps.latitude;
- }, [deps.latitude]);
- useEffect(() => {
- longitudeRef.current = deps.longitude;
- }, [deps.longitude]);
- useEffect(
- () => () => {
- if (activeTripViewportSyncTimerRef.current) {
- clearTimeout(activeTripViewportSyncTimerRef.current);
- activeTripViewportSyncTimerRef.current = null;
- }
- if (orderDetailScaleSyncTimerRef.current) {
- clearTimeout(orderDetailScaleSyncTimerRef.current);
- orderDetailScaleSyncTimerRef.current = null;
- }
- },
- []
- );
-
- const isAndroid = useMemo(() => {
- try {
- const info = Taro.getSystemInfoSync();
- return (
- info?.platform === "android" || /android/i.test(info?.system || "")
- );
- } catch (error) {
- return false;
- }
- }, [deps.tripStatus]);
-
- /**
- * 暂停速度驱动缩放,避免一次性视口调整后立刻被比例尺逻辑抢回。
- */
- const holdDynamicScale = useCallback(
- (durationMs: number = DEFAULT_DYNAMIC_SCALE_HOLD_MS) => {
- const safeDurationMs = Math.max(0, Number(durationMs) || 0);
- dynamicScaleHoldUntilRef.current = Date.now() + safeDurationMs;
- },
- []
- );
-
- /**
- * 获取速度缩放暂停截止时间。
- */
- const getDynamicScaleHoldUntil = useCallback(() => {
- return dynamicScaleHoldUntilRef.current;
- }, []);
+ // 车辆送驾中视口锁定标记:仅供外部(trip-runtime)读写,保留兼容。
+ const tripStatus6ViewportLockedRef = useRef(false);
// 获取底部安全区高度
const resolveSafeBottomInset = () => {
@@ -205,58 +63,86 @@
return 0;
};
- // 获取顶部安全区高度
const resolveSafeTopInset = () => {
+ const DefaultSafeTop = 50;
try {
const info = Taro.getSystemInfoSync();
const safeTop = info?.safeArea?.top;
if (Number.isFinite(safeTop)) {
- return Math.max(0, safeTop);
+ return Math.max(DefaultSafeTop, safeTop);
}
} catch (error) {
- return 0;
+ return DefaultSafeTop;
}
- return 0;
+ return DefaultSafeTop;
};
- // 计算 includePoints 使用的底部 padding
+ // 计算 includePoints 使用的底部 padding:直接用实测的底部卡片高度,不做自适应缩放
const computeBottomPadding = useCallback(
- (
- points: any[],
- viewportType: string,
- tripStatus: TripStatus,
- disableAdaptive?: boolean
- ) => {
- let basePadding =
- viewportType === "order_detail"
- ? Math.max(200, mapBottomPaddingRef.current)
- : mapBottomPaddingRef.current;
- if (disableAdaptive) {
- // computeAdaptiveBottomPadding 的逻辑有时会造成问题,此时可以关闭
- return basePadding;
- }
- return computeAdaptiveBottomPadding(points, basePadding);
+ () => {
+ return mapBottomPaddingRef.current;
},
[mapBottomPaddingRef]
);
- // 计算 includePoints 使用的d底部 padding,考虑行程安全区
- const safeUpdateMapBottomPadding = () => {
- Taro.nextTick(() => {
- const query = Taro.createSelectorQuery();
- query.select(".container").boundingClientRect();
- query.exec(res => {
- const rect = res?.[0];
- const safeInset = resolveSafeBottomInset();
- const measured = rect?.height ? rect.height + safeInset + 16 : 0;
- const nextPadding = Math.max(280, Math.round(measured));
- if (Math.abs(mapBottomPaddingRef.current - nextPadding) > 20) {
- setMapBottomPadding(nextPadding);
- mapBottomPaddingRef.current = nextPadding;
+ /**
+ * 即时测量底部卡片高度(供相机 fit 前调用)。
+ * 与 updateMapViewportPadding 的区别:这是「按需」测量——在 includePoints 真正执行前
+ * 现测一次,保证拿到执行那一刻的卡片高度,消除「先 fit 后测量」的竞态。
+ * 测量失败或超时(selector 回调偶发不触发)时回退上次缓存值,绝不阻塞 fit。
+ *
+ * 首帧场景:.container 可能尚未渲染,首次测量 containerHeight 为 undefined。
+ * 此时若缓存值仍是初始默认 280,则 200ms 后重试一次,避免因 DOM 未就绪导致
+ * 后续 fit 使用过期 padding,进而引发视口跳变。
+ */
+ const measureBottomPadding = useCallback(() => {
+ return new Promise<number>(resolve => {
+ let settled = false;
+ const finish = (value: number, source: string) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ // mapPerfLog('padding:measure', { value, source });
+ resolve(value);
+ };
+ // 超时从 300ms 延长到 500ms,为首帧重试留出 200ms 窗口
+ const timer = setTimeout(() => finish(mapBottomPaddingRef.current, 'timeout'), 500);
+
+ const doMeasure = (attempt: number) => {
+ try {
+ const query = Taro.createSelectorQuery();
+ query.select(".container").boundingClientRect();
+ query.exec(res => {
+ const rect = res?.[0];
+ const safeInset = resolveSafeBottomInset();
+ const measured = rect?.height ? rect.height + safeInset : 0;
+ // mapPerfLog('padding:measure-raw', {
+ // attempt,
+ // containerHeight: rect?.height,
+ // safeBottomInset: safeInset,
+ // measuredRaw: measured,
+ // });
+ if (measured > 0) {
+ const nextPadding = Math.max(200, Math.round(measured));
+ setMapBottomPadding(nextPadding);
+ mapBottomPaddingRef.current = nextPadding;
+ finish(nextPadding, `measured-attempt${attempt}`);
+ } else if (attempt === 1 && mapBottomPaddingRef.current === 280) {
+ // 首帧 .container 尚未渲染,200ms 后重试一次
+ // mapPerfLog('padding:measure-retry', { reason: 'container-not-ready' });
+ setTimeout(() => doMeasure(2), 200);
+ } else {
+ finish(mapBottomPaddingRef.current, 'fallback-zero');
+ }
+ });
+ } catch (error) {
+ finish(mapBottomPaddingRef.current, 'error');
}
- });
+ };
+
+ doMeasure(1);
});
- };
+ }, []);
// 更新地图视口留白并同步起点卡片遮挡状态
const updateMapViewportPadding = () => {
@@ -267,13 +153,9 @@
query.select(".origin-box").boundingClientRect();
query.select(".map-entry").boundingClientRect();
query.exec(res => {
- const mapRect = res?.[0];
const rect = res?.[1];
const originRect = res?.[2];
const mapEntryRect = res?.[3];
- if (mapRect) {
- mapRectRef.current = mapRect;
- }
const safeInset = resolveSafeBottomInset();
const measured = rect?.height ? rect.height + safeInset + 16 : 0;
const nextPadding = Math.max(280, Math.round(measured));
@@ -303,771 +185,10 @@
};
/**
- * 获取并缓存地图容器矩形。
+ * 定位按钮点击处理:地图相机架构下,回中统一交由 useMapCameraController.recenter 处理,
+ * 此处保留空实现以兼容旧调用点(如未接入相机控制器的场景)。
*/
- const ensureMapRect = useCallback(() => {
- if (mapRectRef.current) {
- return Promise.resolve(mapRectRef.current);
- }
- return new Promise<any>(resolve => {
- Taro.createSelectorQuery()
- .select("#map")
- .boundingClientRect(rect => {
- if (rect) {
- mapRectRef.current = rect;
- }
- resolve(rect);
- })
- .exec();
- });
- }, []);
-
- /**
- * 获取当前地图视野范围。
- */
- const getMapRegion = useCallback(() => {
- return new Promise<any>(resolve => {
- Taro.createMapContext("map").getRegion({
- success: res => resolve(res),
- fail: () => resolve(null)
- });
- });
- }, []);
-
- /**
- * 获取当前原生地图中心点,避免视口刚被 includePoints/moveToLocation 调整后仍读取到旧的 React 中心点。
- */
- const getMapCenterLocation = useCallback(() => {
- return new Promise<any>(resolve => {
- Taro.createMapContext("map").getCenterLocation({
- success: res => resolve(res),
- fail: () => resolve(null)
- });
- });
- }, []);
-
- /**
- * 获取当前原生地图比例尺。
- */
- const getMapScale = useCallback(() => {
- return new Promise<any>(resolve => {
- Taro.createMapContext("map").getScale({
- success: res => resolve(res),
- fail: () => resolve(null)
- });
- });
- }, []);
-
- /**
- * 读取原生地图实际的中心点与缩放级别,并通过 record* 系列函数同步到对应 ref。
- * 仅记录最新值、不触发重新渲染,等下次渲染时由 context 读取这些 ref 中的最新值。
- * 适用于 includePoints 等命令式调整视口后,让受控的经纬度/缩放与原生地图保持一致。
- */
- const recordViewportFromNative = useCallback(() => {
- return Promise.all([getMapScale(), getMapCenterLocation()])
- .then(([scaleRes, centerRes]) => {
- const actualScale = Number(scaleRes?.scale);
- if (Number.isFinite(actualScale)) {
- recordMapScale(Math.round(actualScale));
- }
- const centerLatitude = Number(centerRes?.latitude);
- const centerLongitude = Number(centerRes?.longitude);
- if (Number.isFinite(centerLatitude)) {
- deps.recordLatitude(centerLatitude);
- }
- if (Number.isFinite(centerLongitude)) {
- deps.recordLongitude(centerLongitude);
- }
- })
- .catch(() => {});
- }, [
- getMapScale,
- getMapCenterLocation,
- recordMapScale,
- deps.recordLatitude,
- deps.recordLongitude
- ]);
-
- /**
- * 将当前原生地图视口同步到受控 state,避免纯 UI 重渲染把地图拉回旧的 scale/center。
- */
- const syncNativeViewportToControlledState = useCallback(() => {
- return Promise.all([getMapScale(), getMapCenterLocation()])
- .then(([scaleRes, centerRes]) => {
- const actualScale = Number(scaleRes?.scale);
- const roundedScale = Number.isFinite(actualScale)
- ? Math.round(actualScale)
- : null;
- if (Number.isFinite(actualScale)) {
- setMapScale(prev => (prev === roundedScale ? prev : roundedScale));
- }
- const centerLatitude = Number(centerRes?.latitude);
- const centerLongitude = Number(centerRes?.longitude);
- if (Number.isFinite(centerLatitude)) {
- deps.setLatitude(centerLatitude);
- }
- if (Number.isFinite(centerLongitude)) {
- deps.setLongitude(centerLongitude);
- }
- })
- .catch(() => {});
- }, [getMapScale, getMapCenterLocation, setMapScale, deps.setLatitude, deps.setLongitude, deps.tripStatus]);
-
- /**
- * 将经纬度映射为当前地图上的屏幕坐标。
- */
- const computeMapPointScreenPosition = useCallback(
- (target: ViewportPoint, rect: any, region: any) => {
- const southwest = region?.southwest;
- const northeast = region?.northeast;
- const latRange =
- Number(northeast?.latitude) - Number(southwest?.latitude);
- const lngRange =
- Number(northeast?.longitude) - Number(southwest?.longitude);
- if (
- !rect ||
- !Number.isFinite(latRange) ||
- !Number.isFinite(lngRange) ||
- latRange <= 0 ||
- lngRange <= 0
- ) {
- return null;
- }
- return {
- x: ((target.longitude - southwest.longitude) / lngRange) * rect.width,
- y: ((northeast.latitude - target.latitude) / latRange) * rect.height,
- latitudeSpan: latRange
- };
- },
- []
- );
-
- // 解析当前上下车点
- const resolveViewportSites = () => {
- const pickupSite =
- deps.startSite?.latitude && deps.startSite?.longitude
- ? deps.startSite
- : Taro.getStorageSync("startSite");
- const dropoffSite =
- deps.endSite?.latitude && deps.endSite?.longitude
- ? deps.endSite
- : Taro.getStorageSync("endSite");
- return { pickupSite, dropoffSite };
- };
-
- /**
- * 构建步行路线视口数据。
- */
- const buildRouteWalkingViewportPayload = useCallback(
- (viewportType: string): ViewportPayload => {
- const points: ViewportPoint[] = [];
- if (deps.routePoints.length > 0) {
- points.push(...deps.routePoints);
- }
- if (deps.walkingPoints.length > 0) {
- points.push(...deps.walkingPoints);
- }
- return {
- points,
- keyParts: [
- viewportType === "order_detail"
- ? TripStatus.TripFinished
- : deps.tripStatus,
- viewportType,
- deps.routePoints.length,
- deps.walkingPoints.length
- ]
- };
- },
- [deps.routePoints, deps.tripStatus, deps.walkingPoints]
- );
-
- /**
- * 构建上车点视口数据。
- */
- const buildPickupViewportPayload = useCallback(
- (viewportType: string, pickupSite: any): ViewportPayload => {
- const pickupPoint = buildSiteViewportPoint(pickupSite);
- return {
- points: pickupPoint ? [pickupPoint] : [],
- keyParts: [
- viewportType === "order_detail"
- ? TripStatus.TripFinished
- : deps.tripStatus,
- viewportType,
- pickupSite?.latitude,
- pickupSite?.longitude
- ]
- };
- },
- [deps.tripStatus]
- );
-
- /**
- * 构建主路线视口数据。
- */
- const buildRouteViewportPayload = useCallback(
- (viewportType: string): ViewportPayload => {
- const points: ViewportPoint[] = [];
- if (deps.routePoints.length > 0) {
- points.push(...deps.routePoints);
- }
- return {
- points,
- keyParts: [
- viewportType === "order_detail"
- ? TripStatus.TripFinished
- : deps.tripStatus,
- viewportType,
- deps.routePoints.length
- ]
- };
- },
- [deps.routePoints, deps.tripStatus]
- );
-
- /**
- * 构建车辆与用户双点视口数据。
- */
- const buildCarUserViewportPayload = useCallback(
- (viewportType: string): ViewportPayload => {
- const points: ViewportPoint[] = [];
- if (Number.isFinite(deps.latitude) && Number.isFinite(deps.longitude)) {
- points.push({
- latitude: deps.latitude as number,
- longitude: deps.longitude as number
- });
- }
- if (deps.vehicleLocation?.latitude && deps.vehicleLocation?.longitude) {
- points.push({
- latitude: deps.vehicleLocation.latitude,
- longitude: deps.vehicleLocation.longitude
- });
- }
- return {
- points,
- keyParts: [
- viewportType === "order_detail"
- ? TripStatus.TripFinished
- : deps.tripStatus,
- viewportType,
- deps.latitude,
- deps.longitude,
- deps.vehicleLocation?.latitude,
- deps.vehicleLocation?.longitude
- ]
- };
- },
- [
- deps.latitude,
- deps.longitude,
- deps.tripStatus,
- deps.vehicleLocation?.latitude,
- deps.vehicleLocation?.longitude
- ]
- );
-
- /**
- * 构建终点视口数据。
- */
- const buildDropoffViewportPayload = useCallback(
- (viewportType: string, dropoffSite: any): ViewportPayload => {
- const dropoffPoint = buildSiteViewportPoint(dropoffSite);
- return {
- points: dropoffPoint ? [dropoffPoint] : [],
- keyParts: [
- viewportType === "order_detail"
- ? TripStatus.TripFinished
- : deps.tripStatus,
- viewportType,
- dropoffSite?.latitude,
- dropoffSite?.longitude
- ]
- };
- },
- [deps.tripStatus]
- );
-
- /**
- * 构建订单详情视口数据。
- */
- const buildOrderDetailViewportPayload = useCallback(
- (
- viewportType: string,
- pickupSite: any,
- dropoffSite: any
- ): ViewportPayload => {
- const points: ViewportPoint[] = [];
- const pickupPoint = buildSiteViewportPoint(pickupSite);
- const dropoffPoint = buildSiteViewportPoint(dropoffSite);
- if (deps.routePoints.length > 0) {
- points.push(...deps.routePoints);
- }
- if (pickupPoint) {
- points.push(pickupPoint);
- }
- if (dropoffPoint) {
- points.push(dropoffPoint);
- }
- return {
- points,
- keyParts: [
- viewportType === "order_detail"
- ? TripStatus.TripFinished
- : deps.tripStatus,
- viewportType,
- deps.routePoints.length,
- pickupSite?.latitude,
- pickupSite?.longitude,
- dropoffSite?.latitude,
- dropoffSite?.longitude
- ]
- };
- },
- [deps.routePoints, deps.tripStatus]
- );
-
- // 按视口类型构建 includePoints 入参
- const buildViewportPayload = (
- viewportType: string,
- pickupSite: any,
- dropoffSite: any
- ) => {
- if (viewportType === "route_walking") {
- return buildRouteWalkingViewportPayload(viewportType);
- }
- if (viewportType === "pickup") {
- return buildPickupViewportPayload(viewportType, pickupSite);
- }
- if (viewportType === "route") {
- return buildRouteViewportPayload(viewportType);
- }
- if (viewportType === "car_user") {
- return buildCarUserViewportPayload(viewportType);
- }
- if (viewportType === "dropoff") {
- return buildDropoffViewportPayload(viewportType, dropoffSite);
- }
- if (viewportType === "order_detail") {
- return buildOrderDetailViewportPayload(
- viewportType,
- pickupSite,
- dropoffSite
- );
- }
- return {
- points: [],
- keyParts: [
- viewportType === "order_detail"
- ? TripStatus.TripFinished
- : deps.tripStatus,
- viewportType
- ]
- };
- };
-
- const buildOrderDetailRouteSignature = (
- pickupSite: any,
- dropoffSite: any
- ) => {
- const firstPoint = deps.routePoints[0];
- const lastPoint = deps.routePoints[deps.routePoints.length - 1];
- return [
- deps.routePoints.length,
- firstPoint?.latitude ?? "",
- firstPoint?.longitude ?? "",
- lastPoint?.latitude ?? "",
- lastPoint?.longitude ?? "",
- pickupSite?.latitude ?? "",
- pickupSite?.longitude ?? "",
- dropoffSite?.latitude ?? "",
- dropoffSite?.longitude ?? ""
- ].join("|");
- };
-
- /**
- * 完成态 includePoints 后回写原生地图实际比例与中心点,避免后续重渲染被旧受控视口覆盖。
- */
- const syncOrderDetailViewportFromNative = useCallback(() => {
- orderDetailScaleSyncVersionRef.current += 1;
- const currentSyncVersion = orderDetailScaleSyncVersionRef.current;
- if (orderDetailScaleSyncTimerRef.current) {
- clearTimeout(orderDetailScaleSyncTimerRef.current);
- }
- orderDetailScaleSyncTimerRef.current = setTimeout(() => {
- if (
- ![TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(
- deps.tripStatus
- )
- ) {
- orderDetailScaleSyncTimerRef.current = null;
- return;
- }
- Promise.all([getMapScale(), getMapCenterLocation()])
- .then(([scaleRes, centerRes]) => {
- if (currentSyncVersion !== orderDetailScaleSyncVersionRef.current) {
- return;
- }
- const actualScale = Number(scaleRes?.scale);
- if (Number.isFinite(actualScale)) {
- const roundedScale = Math.round(actualScale);
- recordMapScale(roundedScale);
- }
- const centerLatitude = Number(centerRes?.latitude);
- const centerLongitude = Number(centerRes?.longitude);
- const currentLatitude = Number(latitudeRef.current);
- const currentLongitude = Number(longitudeRef.current);
- if (
- Number.isFinite(centerLatitude) &&
- (!Number.isFinite(currentLatitude) ||
- currentLatitude !== centerLatitude)
- ) {
- deps.recordLatitude(centerLatitude);
- }
- if (
- Number.isFinite(centerLongitude) &&
- (!Number.isFinite(currentLongitude) ||
- currentLongitude !== centerLongitude)
- ) {
- deps.recordLongitude(centerLongitude);
- }
- })
- .finally(() => {
- orderDetailScaleSyncTimerRef.current = null;
- });
- }, ORDER_DETAIL_SCALE_SYNC_DELAY_MS);
- }, [
- deps.recordLatitude,
- deps.recordLongitude,
- recordMapScale,
- deps.tripStatus,
- getMapCenterLocation,
- getMapScale
- ]);
-
- const updateViewportByPayloadRaw = (viewportType: string, points: any[]) => {
- const mapCtx = Taro.createMapContext("map");
- const bottomPadding =
- computeBottomPadding(points, viewportType, deps.tripStatus, true) +
- ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING;
- const baseTopPadding =
- viewportType === "order_detail" ? 60 : ROUTE_VIEWPORT_MIN_TOP_PADDING;
- // order_detail 视口(行程已结束/支付成功)下的上车点 marker 会显示向上突出的 customCallout 气泡,
- // 需要在系统安全区之上再叠加 ≈灵动岛 + 气泡高度的缓冲,避免气泡被灵动岛/状态栏遮挡。
- // 数值与接驾态 ACTIVE_TRIP_CAMERA_TOP_EXTRA_PADDING 保持一致(80),其它视口沿用原有 32。
- const topInsetExtra = ROUTE_VIEWPORT_TOP_EXTRA_PADDING
- const topPadding = Math.max(
- baseTopPadding,
- resolveSafeTopInset() + topInsetExtra
- );
- const padding = [
- topPadding,
- ROUTE_VIEWPORT_RIGHT_SIDE_PADDING,
- bottomPadding,
- ROUTE_VIEWPORT_SIDE_PADDING
- ];
- holdDynamicScale();
- Taro.nextTick(() => {
- if (viewportType === "dropoff") {
- mapCtx.moveToLocation({
- latitude: points[0].latitude,
- longitude: points[0].longitude
- });
- setTimeout(() => {
- setMapScale(DROPOFF_MAX_SCALE);
- }, 50);
- return;
- } else {
- mapCtx.includePoints({ points, padding });
- safeUpdateMapBottomPadding();
- if (
- viewportType === "order_detail" &&
- [TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(
- deps.tripStatus
- )
- ) {
- syncOrderDetailViewportFromNative();
- } else {
- deps.markViewportFitSyncing?.();
- }
- }
- });
- };
-
- // 防抖包裹:状态切换瞬间多次连续调用只执行最后一次,避免 includePoints 动画相互打断
- const updateViewportByPayload = debounce(
- updateViewportByPayloadRaw,
- VIEWPORT_FIT_DEBOUNCE_MS
- );
-
- const toOrigin = () => {
- const mapCtx = Taro.createMapContext("map");
- const includeRouteToVisibleCenter = (
- targetSite: any,
- shouldLockInService: boolean
- ) => {
- const targetPoint = buildSiteViewportPoint(targetSite);
- const pointsToInclude = [...deps.routePoints];
- if (targetPoint) {
- pointsToInclude.push(targetPoint);
- }
- // 接驾/送驾点击回中时统一走整路线居中,并适当收窄底部留白,避免路线整体偏上。
- const bottomPadding =
- computeBottomPadding(pointsToInclude, "route", deps.tripStatus, true) +
- ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING;
- const topPadding = Math.max(
- ROUTE_VIEWPORT_MIN_TOP_PADDING,
- resolveSafeTopInset() + ROUTE_VIEWPORT_TOP_EXTRA_PADDING
- );
- holdDynamicScale();
- mapCtx.includePoints({
- points: pointsToInclude,
- padding: [
- topPadding,
- ROUTE_VIEWPORT_RIGHT_SIDE_PADDING,
- bottomPadding,
- ROUTE_VIEWPORT_SIDE_PADDING
- ]
- });
- if (shouldLockInService) {
- tripStatus6ViewportLockedRef.current = true;
- }
- // 标记 fit 同步,在 regionchange end 时捕获实际 scale/center
- deps.markViewportFitSyncing?.();
- };
- if (
- deps.tripStatus === TripStatus.InService &&
- deps.routePoints.length > 0
- ) {
- const { dropoffSite } = resolveViewportSites();
- includeRouteToVisibleCenter(dropoffSite, true);
- return;
- }
- if (
- deps.tripStatus === TripStatus.VehicleApproaching &&
- deps.routePoints.length > 0
- ) {
- const { pickupSite } = resolveViewportSites();
- includeRouteToVisibleCenter(pickupSite, false);
- return;
- }
- holdDynamicScale();
- mapCtx.moveToLocation();
- };
-
- /**
- * 获取当前用户位置并更新地图中心点。
- * 仅在页面恢复时使用
- */
- const getLocationInfo = () => {
- Taro.getLocation({ type: "gcj02" })
- .then(res => {
- if (deps.tripStatus === TripStatus.ArrivedDestination) {
- return;
- }
- deps.setLongitude(res.longitude);
- deps.setLatitude(res.latitude);
- const includeRouteToVisibleCenter = (
- targetSite: any,
- shouldLockInService: boolean
- ) => {
- const targetPoint = buildSiteViewportPoint(targetSite);
- const pointsToInclude = [...deps.routePoints];
- if (targetPoint) {
- pointsToInclude.push(targetPoint);
- }
- // 与 toOrigin 保持一致,接驾/送驾定位回中时统一按整路线居中。
- const bottomPadding =
- computeBottomPadding(
- pointsToInclude,
- "route",
- deps.tripStatus,
- true
- ) + ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING;
- const topPadding = Math.max(
- ROUTE_VIEWPORT_MIN_TOP_PADDING,
- resolveSafeTopInset() + ROUTE_VIEWPORT_TOP_EXTRA_PADDING
- );
- holdDynamicScale();
- Taro.createMapContext("map").includePoints({
- points: pointsToInclude,
- padding: [
- topPadding,
- ROUTE_VIEWPORT_RIGHT_SIDE_PADDING,
- bottomPadding,
- ROUTE_VIEWPORT_SIDE_PADDING
- ]
- });
- if (shouldLockInService) {
- tripStatus6ViewportLockedRef.current = true;
- }
- // 标记 fit 同步,在 regionchange end 时捕获实际 scale/center
- deps.markViewportFitSyncing?.();
- };
- if (
- deps.tripStatus === TripStatus.InService &&
- deps.routePoints.length > 0
- ) {
- const { dropoffSite } = resolveViewportSites();
- includeRouteToVisibleCenter(dropoffSite, true);
- return;
- }
- if (
- deps.tripStatus === TripStatus.VehicleApproaching &&
- deps.routePoints.length > 0
- ) {
- const { pickupSite } = resolveViewportSites();
- includeRouteToVisibleCenter(pickupSite, false);
- return;
- }
- holdDynamicScale();
- Taro.createMapContext("map").moveToLocation({
- latitude: res.latitude,
- longitude: res.longitude
- });
- })
- .catch(() => {});
- };
-
- /**
- * 行程动态缩放生效时,将地图中心跟随到车辆位置,确保车辆留在当前视野内。
- * @param options.force 跳过节流/冷却/syncing-in-progress/死区四道闸,强制按安全区目标重定位。
- * 仅供速度驱动缩放后强制居中使用,其他调用点不传该参数即沿用原有行为。
- *
- * 已没有使用
- */
- const syncActiveTripViewportToVehicle = useCallback(
- (options?: { force?: boolean }) => {
- const force = options?.force === true;
- if (
- ![TripStatus.VehicleApproaching, TripStatus.InService].includes(
- deps.tripStatus
- )
- ) {
- return;
- }
- const vehicleLatitude = Number(vehicleLocationRef.current?.latitude);
- const vehicleLongitude = Number(vehicleLocationRef.current?.longitude);
- if (
- !Number.isFinite(vehicleLatitude) ||
- !Number.isFinite(vehicleLongitude)
- ) {
- return;
- }
- const now = Date.now();
- 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()])
- .then(([mapRect, region, mapCenterLocation]) => {
- const safeAreaMetrics = buildActiveTripSafeAreaMetrics({
- tripStatus: deps.tripStatus,
- mapHeight: Number(mapRect?.height),
- bottomPadding: mapBottomPaddingRef.current,
- safeTopInset: resolveSafeTopInset()
- });
- if (!safeAreaMetrics) {
- return;
- }
- const vehicleScreenPosition = computeMapPointScreenPosition(
- {
- latitude: vehicleLatitude,
- longitude: vehicleLongitude
- },
- mapRect,
- region
- );
- if (!vehicleScreenPosition) {
- return;
- }
- // 横向死区:仅保留左右较小边距,让大部分可见区域都属于安全区,减少无操作情况下的突然横向纠偏。
- const mapWidth = Number(mapRect?.width) || 0;
- const safeXMargin =
- mapWidth * ACTIVE_TRIP_VIEWPORT_SAFE_X_MARGIN_RATIO;
- const isXOutOfBounds =
- mapWidth > 0 &&
- (vehicleScreenPosition.x < safeXMargin ||
- vehicleScreenPosition.x > mapWidth - safeXMargin);
- // 纵向死区检查
- const isYInDeadZone =
- vehicleScreenPosition.y >= safeAreaMetrics.deadZoneTopY &&
- vehicleScreenPosition.y <= safeAreaMetrics.deadZoneBottomY;
- if (!force && isYInDeadZone && !isXOutOfBounds) {
- return;
- }
- const nativeCenterLatitude = Number(mapCenterLocation?.latitude);
- const nativeCenterLongitude = Number(mapCenterLocation?.longitude);
- const fallbackCenterLatitude = Number(latitudeRef.current);
- const fallbackCenterLongitude = Number(longitudeRef.current);
- const currentLatitude = Number.isFinite(nativeCenterLatitude)
- ? nativeCenterLatitude
- : fallbackCenterLatitude;
- const currentLongitude = Number.isFinite(nativeCenterLongitude)
- ? nativeCenterLongitude
- : fallbackCenterLongitude;
- // 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) {
- return;
- }
- lastActiveTripViewportSyncAtRef.current = Date.now();
- const mapCtx = Taro.createMapContext("map");
- mapCtx.moveToLocation({
- latitude: desiredCenterLatitude,
- longitude: desiredCenterLongitude
- });
- if (activeTripViewportSyncTimerRef.current) {
- clearTimeout(activeTripViewportSyncTimerRef.current);
- }
- activeTripViewportSyncTimerRef.current = setTimeout(() => {
- deps.setLatitude(desiredCenterLatitude);
- deps.setLongitude(desiredCenterLongitude);
- activeTripViewportSyncTimerRef.current = null;
- }, ACTIVE_TRIP_VIEWPORT_SYNC_ANIMATION_MS);
- })
- .finally(() => {
- activeTripViewportSyncingRef.current = false;
- });
- },
- [
- computeMapPointScreenPosition,
- deps.setLatitude,
- deps.setLongitude,
- deps.tripStatus,
- ensureMapRect,
- getMapCenterLocation,
- getDynamicScaleHoldUntil,
- getMapRegion
- ]
- );
+ const toOrigin = () => {};
useEffect(() => {
if (
@@ -1084,287 +205,6 @@
setShowLocationFlag(true);
}, [deps.tripStatus]);
- // 车辆到达上车点后,自动把上车点(车辆)居中到可用视区中央。
- // 注意:原生 moveToLocation 只是命令式移动,必须同时回写受控的 latitude/longitude,
- // 否则到达态每秒刷新的倒计时会触发重渲染,把受控 <Map> 拉回接驾态遗留的旧中心点(表现为车辆贴顶)。
- useEffect(() => {
- if (deps.tripStatus !== TripStatus.VehicleArrivedPickup) {
- arrivedPickupCenteredRef.current = false;
- return;
- }
- const pickupLatitude = Number(deps.startSite?.latitude);
- const pickupLongitude = Number(deps.startSite?.longitude);
- if (!Number.isFinite(pickupLatitude) || !Number.isFinite(pickupLongitude)) {
- return;
- }
- if (arrivedPickupCenteredRef.current) {
- return;
- }
- arrivedPickupCenteredRef.current = true; // 提前置位,避免异步回中期间重复触发
- holdDynamicScale();
- const moveAndCommitCenter = (
- centerLatitude: number,
- centerLongitude: number
- ) => {
- Taro.createMapContext("map").moveToLocation({
- latitude: centerLatitude,
- longitude: centerLongitude
- });
- // 等动画结束后一次性回写受控中心点和 scale,避免多次 setState 触发重渲染
- setTimeout(() => {
- getMapScale().then(res => {
- const actualScale = Number(res?.scale);
- if (Number.isFinite(actualScale)) {
- const rounded = Math.round(actualScale);
- setMapScale(prev => (prev === rounded ? prev : rounded));
- }
- deps.setLatitude(centerLatitude);
- deps.setLongitude(centerLongitude);
- });
- }, ACTIVE_TRIP_VIEWPORT_SYNC_ANIMATION_MS);
- };
- Promise.all([ensureMapRect(), getMapRegion()])
- .then(([mapRect, region]) => {
- const safeAreaMetrics = buildActiveTripSafeAreaMetrics({
- tripStatus: TripStatus.VehicleArrivedPickup,
- mapHeight: Number(mapRect?.height),
- bottomPadding: mapBottomPaddingRef.current,
- safeTopInset: resolveSafeTopInset()
- });
- const pickupScreenPosition = computeMapPointScreenPosition(
- { latitude: pickupLatitude, longitude: pickupLongitude },
- mapRect,
- region
- );
- // 安全区与视野可解析时,按可用视区中央纵向偏移中心点(与 syncActiveTripViewportToVehicle 一致),
- // 避免上车点落在被底部卡片遮挡的几何中心;否则兜底为上车点几何居中。
- const centerLatitude =
- safeAreaMetrics && pickupScreenPosition
- ? pickupLatitude -
- (0.5 - safeAreaMetrics.targetMapRatio) *
- pickupScreenPosition.latitudeSpan
- : pickupLatitude;
- moveAndCommitCenter(centerLatitude, pickupLongitude);
- })
- .catch(() => {
- moveAndCommitCenter(pickupLatitude, pickupLongitude);
- });
- }, [
- deps.tripStatus,
- deps.startSite?.latitude,
- deps.startSite?.longitude,
- holdDynamicScale,
- ensureMapRect,
- getMapRegion,
- computeMapPointScreenPosition,
- deps.setLatitude,
- deps.setLongitude
- ]);
-
- // 疑似死代码,已经没有 viewportType === 'dropoff' 这种情况了
- useEffect(() => {
- const viewportType = deps.statusRule.viewport;
- if (viewportType !== "dropoff") return;
- const { pickupSite, dropoffSite } = resolveViewportSites();
- const { points, keyParts } = buildViewportPayload(
- viewportType,
- pickupSite,
- dropoffSite
- );
- if (points.length === 0) return;
- const nextKey = keyParts.join("|");
- // 处理的是到达下车点时,如果缩放的比例不对,就重置回最大比例
- if (nextKey === viewportKeyRef.current) {
- if (mapScale < DROPOFF_MAX_SCALE) {
- setMapScale(DROPOFF_MAX_SCALE);
- }
- return;
- }
- viewportKeyRef.current = nextKey;
- updateViewportByPayload(viewportType, points);
- }, [
- deps.tripStatus,
- deps.statusRule?.viewport,
- deps.endSite?.latitude,
- deps.endSite?.longitude,
- mapScale
- ]);
-
- useEffect(() => {
- if (
- ![TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(
- deps.tripStatus
- )
- ) {
- orderDetailViewportLockedRef.current = false;
- orderDetailRouteUpdatedAfterEnterRef.current = false;
- orderDetailEnterSignatureRef.current = "";
- orderDetailScaleSyncVersionRef.current = 0;
- return;
- }
- const { pickupSite, dropoffSite } = resolveViewportSites();
- orderDetailEnterSignatureRef.current = buildOrderDetailRouteSignature(
- pickupSite,
- dropoffSite
- );
- orderDetailRouteUpdatedAfterEnterRef.current = false;
- orderDetailViewportLockedRef.current = false;
- viewportKeyRef.current = "";
- orderDetailScaleSyncVersionRef.current = 0;
- }, [deps.tripStatus]);
-
- useEffect(() => {
- if (
- ![TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(
- deps.tripStatus
- )
- )
- return;
- const { pickupSite, dropoffSite } = resolveViewportSites();
- const currentSignature = buildOrderDetailRouteSignature(
- pickupSite,
- dropoffSite
- );
- if (!orderDetailEnterSignatureRef.current) {
- orderDetailEnterSignatureRef.current = currentSignature;
- return;
- }
- if (currentSignature === orderDetailEnterSignatureRef.current) return;
- const hasOrderDetailRoute = deps.routePoints.length > 0;
- orderDetailRouteUpdatedAfterEnterRef.current = hasOrderDetailRoute;
- orderDetailViewportLockedRef.current = false;
- viewportKeyRef.current = "";
- }, [
- deps.tripStatus,
- deps.routePoints,
- deps.startSite?.latitude,
- deps.startSite?.longitude,
- deps.endSite?.latitude,
- deps.endSite?.longitude
- ]);
-
- useEffect(() => {
- if (
- ![TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(
- deps.tripStatus
- )
- ) {
- return;
- }
- if (deps.orderDetailViewportRefreshVersion <= 0) {
- return;
- }
- orderDetailViewportLockedRef.current = false;
- viewportKeyRef.current = "";
- }, [deps.tripStatus, deps.orderDetailViewportRefreshVersion]);
-
- useEffect(() => {
- const { pickupSite, dropoffSite } = resolveViewportSites();
- const viewportType = deps.statusRule.viewport;
- if (viewportType === "dropoff") return;
- // 车辆已到达上车点:交由专用的上车点居中逻辑(按安全区居中并回写受控中心点)处理,
- // 跳过 car_user includePoints,避免其因底部大留白把车辆挤到地图顶部。
- // 注意 car_user 同样用于「到达目的地」状态,此处仅对到达上车点生效。
- if (
- viewportType === "car_user" &&
- deps.tripStatus === TripStatus.VehicleArrivedPickup
- )
- return;
- const { points, keyParts } = buildViewportPayload(
- viewportType,
- pickupSite,
- dropoffSite
- );
- if (points.length === 0) return;
- const shouldWaitForOrderDetailRoute =
- [TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(
- deps.tripStatus
- ) &&
- viewportType === "order_detail" &&
- deps.routePoints.length === 0 &&
- deps.orderDetailViewportRefreshVersion <= 0;
- if (shouldWaitForOrderDetailRoute) {
- return;
- }
- if (
- [TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(
- deps.tripStatus
- ) &&
- viewportType === "order_detail" &&
- orderDetailViewportLockedRef.current &&
- orderDetailRouteUpdatedAfterEnterRef.current
- ) {
- return;
- }
- // 车辆接驾/送驾中:已运行过 updateViewportByPayload 后,仅当 routePoints 长度发生变化(线路变化)
- // 时才再次运行;长度相同说明线路未变,交由 useSpeedBasedScale 的 fitRouteIntoView 主导视口,避免相互干扰。
- const hasRunUpdateViewport =
- (deps.tripStatus === TripStatus.VehicleApproaching &&
- tripStatus4ViewportLockedRef.current) ||
- (deps.tripStatus === TripStatus.InService &&
- tripStatus6ViewportLockedRef.current);
- if (
- hasRunUpdateViewport &&
- viewportType === "route" &&
- deps.routePoints.length === lastRouteViewportPointsLengthRef.current
- ) {
- return;
- }
- const nextKey = keyParts.join("|");
- if (nextKey === viewportKeyRef.current) return;
- viewportKeyRef.current = nextKey;
- // 接驾中/行程中路线点不足 3 个时跳过,避免不完整路线导致地图异常放大,
- // 且不锁定视口,等路线就绪后仍可再次进入
- if (
- [TripStatus.InService, TripStatus.VehicleApproaching].includes(
- deps.tripStatus
- ) &&
- viewportType === "route" &&
- points.length < 3
- ) {
- return;
- }
- if (
- deps.tripStatus === TripStatus.VehicleApproaching &&
- viewportType === "route"
- ) {
- tripStatus4ViewportLockedRef.current = true;
- lastRouteViewportPointsLengthRef.current = deps.routePoints.length;
- }
- if (deps.tripStatus === TripStatus.InService && viewportType === "route") {
- tripStatus6ViewportLockedRef.current = true;
- lastRouteViewportPointsLengthRef.current = deps.routePoints.length;
- }
- // updateViewportByPayload 已用 debounce 包裹:状态切换瞬间多次调用只执行最后一次
- updateViewportByPayload(viewportType, points);
- if (
- [TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(
- deps.tripStatus
- ) &&
- viewportType === "order_detail"
- ) {
- const hasOrderDetailRouteAfterEnter =
- orderDetailRouteUpdatedAfterEnterRef.current &&
- deps.routePoints.length > 0;
- orderDetailViewportLockedRef.current = hasOrderDetailRouteAfterEnter;
- }
- }, [
- deps.tripStatus,
- deps.statusRule,
- deps.routePoints,
- deps.walkingPoints,
- deps.startSite?.latitude,
- deps.startSite?.longitude,
- deps.endSite?.latitude,
- deps.endSite?.longitude,
- deps.vehicleLocation,
- deps.longitude,
- deps.latitude,
- mapBottomPadding,
- deps.orderDetailViewportRefreshVersion,
- tripStatus6ViewportLockedRef
- ]);
-
return {
mapBottomPadding,
carCalloutStyle,
@@ -1375,10 +215,6 @@
setArriveCalloutStyle,
arriveCalloutVisible,
setArriveCalloutVisible,
- mapScale,
- setMapScale,
- mapScaleRef,
- recordMapScale,
originBoxHidden,
setOriginBoxHidden,
originOpacity,
@@ -1389,20 +225,12 @@
setWaitingRadarVisible,
showLocationFlag,
setShowLocationFlag,
- mapRectRef,
- viewportKeyRef,
- orderDetailViewportLockedRef,
tripStatus6ViewportLockedRef,
- isAndroid,
resolveSafeBottomInset,
resolveSafeTopInset,
computeBottomPadding,
- holdDynamicScale,
- getDynamicScaleHoldUntil,
- syncActiveTripViewportToVehicle,
- syncNativeViewportToControlledState,
+ measureBottomPadding,
updateMapViewportPadding,
- toOrigin,
- getLocationInfo
+ toOrigin
};
}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-modify-destination-flow.ts b/src/robotaxi/pages/begin-route/hooks/use-modify-destination-flow.ts
index 1907346..5c946e3 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-modify-destination-flow.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-modify-destination-flow.ts
@@ -1,5 +1,5 @@
import Taro from '@tarojs/taro';
-import { showToast } from '../../../components/toast';
+import { showToast, hideToast } from '../../../components/toast';
import { useEffect, useMemo, useState } from 'react';
import { orderChange } from '../../../api/begin-route';
import { TripStatus } from '../../../constants/trip-status';
@@ -385,7 +385,7 @@
try {
setIsConfirmingModifyDestination(true);
setFlowState(FLOW_STATES.CONFIRMING);
- Taro.showLoading({ title: '正在修改目的地', mask: true }); // 修改目的地请求进行中全局 loading
+ showToast({ title: '正在修改目的地', icon: 'loading', mask: true }); // 修改目的地请求进行中全局 loading
// 如果后端需要字符串格式可以显式转String,如果后端支持number直接传值,保留原始格式防止隐式转换截断
// 如果原本因为 Number() 导致大整数丢失精度,去掉 Number() 即可
@@ -416,7 +416,7 @@
exitModifyDestinationReview({ shouldRestorePreviousEndSite: false });
- Taro.hideLoading();
+ hideToast();
showToast({
title: '行程已更新',
icon: 'none'
@@ -426,7 +426,7 @@
deps.onModifySuccess(oldRoutePoints);
}
} catch (e: any) {
- Taro.hideLoading();
+ hideToast();
throw e;
} finally {
setIsConfirmingModifyDestination(false);
diff --git a/src/robotaxi/pages/begin-route/hooks/use-order-actions.shared.ts b/src/robotaxi/pages/begin-route/hooks/use-order-actions.shared.ts
index 05cf4d7..cb3417c 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-order-actions.shared.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-order-actions.shared.ts
@@ -1,6 +1,6 @@
import { SignedOrderPaymentValue } from '../../../api/types/begin-route';
import type { OrderDistanceConfig } from './use-order-distance-config';
-import { TripStatus } from '../constants/trip-status';
+import { TripStatus, CancelType } from '../constants/trip-status';
/** 下单防抖时长,单位毫秒。 */
export const ORDER_SUBMIT_DEBOUNCE_MS = 500;
@@ -29,7 +29,7 @@
/** 取消前页面真实行程状态。 */
tripStatus: TripStatus;
/** 期望展示的取消弹窗类型。 */
- cancelType: number;
+ cancelType: CancelType;
/** 取消来源。 */
source: PendingCancelSource;
/** 上下文创建时间戳。 */
@@ -68,7 +68,7 @@
/** 下单提交中。 */
callSubmitting: boolean;
/** 取消类型。 */
- cancelType: number;
+ cancelType: CancelType;
/** 待确认取消上下文。 */
pendingCancelContextRef: React.MutableRefObject<PendingCancelContext | null>;
/** 当前车辆信息。 */
@@ -89,7 +89,7 @@
/** 设置下单提交状态。 */
setCallSubmitting: (value: boolean) => void;
/** 设置取消类型。 */
- setCancelType: (value: number) => void;
+ setCancelType: (value: CancelType) => void;
/** 设置取消弹窗。 */
setShowCancelPopup: (value: boolean) => void;
/** 设置取消弹窗等待分钟数。 */
@@ -98,10 +98,6 @@
setWalkingPoints: (value: any[]) => void;
/** 设置步行时长。 */
setWalkingTime: (value: number) => void;
- /** 设置纬度。 */
- setLatitude: (value: number) => void;
- /** 设置经度。 */
- setLongitude: (value: number) => void;
/** 设置结束行程弹窗。 */
setShowOverRoutePopup: (value: boolean) => void;
/** 打开隐私弹窗。 */
diff --git a/src/robotaxi/pages/begin-route/hooks/use-order-cancel.ts b/src/robotaxi/pages/begin-route/hooks/use-order-cancel.ts
index 5db4f7b..65a8ca2 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-order-cancel.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-order-cancel.ts
@@ -3,7 +3,7 @@
import { clearOrderTripStorage, reportEvent } from '../../../../utils/Robotaxi-public-method';
import { cancelOrder } from '../../../api/begin-route';
import { getOrderId } from '../../../utils/order';
-import { TripStatus } from '../../../constants/trip-status';
+import { TripStatus, CancelType } from '../../../constants/trip-status';
import {
WAITING_FOR_DISPATCH_CANCEL_BUTTON_EVENT_ID,
WAITING_FOR_PICKUP_CANCEL_BUTTON_EVENT_ID,
@@ -30,7 +30,7 @@
/** 重启发单规划。 */
restartPlanningRoute: () => void;
/** 订单超时再来一单。 */
- handleOpenCancelPopupWithType: (type: number) => void;
+ handleOpenCancelPopupWithType: (type: CancelType) => void;
/** 根据状态打开取消弹窗。 */
handleOpenCancelPopupByTripStatus: (status: TripStatus, displayInfo?: any) => void;
/** 上车倒计时超时后发起自动取消。 */
@@ -121,37 +121,19 @@
* 重启发单规划。
*/
const restartPlanningRoute = () => {
+ // 重置到发单态:全新挂载 begin-route 页面(不携带上一次的受控地图状态,避免受控属性弹回),
+ // 保留起终点、清订单态,让新页面按发单态重新请求并绘制路线。
deps.setShowCancelPopup(false);
deps.setCancelPopupWaitMinutes(null);
- deps.setTripStatus(TripStatus.PublishRoute);
- const startSite = Taro.getStorageSync('startSite');
- const endSite = Taro.getStorageSync('endSite');
-
- // 如果缓存中有合法的上车点,将其同步到内存状态
- if (startSite?.latitude && startSite?.longitude) {
- // 触发内存状态同步,避免再来一单时仍使用旧订单取消前的闭包旧上车点
- if (typeof deps.setStartSite === 'function') {
- deps.setStartSite(startSite);
- }
- deps.routeUpdateWalkingRouteForStart(startSite);
- }
-
- if (startSite?.latitude && startSite?.longitude && endSite?.longitude && endSite?.latitude) {
- deps.routeRequestRouteAndFare(
- startSite.latitude,
- startSite.longitude,
- endSite.latitude,
- endSite.longitude,
- startSite?.id,
- true
- );
- }
+ Taro.setStorageSync('tripStatus', TripStatus.PublishRoute);
+ Taro.removeStorageSync('orderId');
+ Taro.redirectTo({ url: '/robotaxi/pages/begin-route/index' });
};
/**
* 订单超时再来一单。
*/
- const handleOpenCancelPopupWithType = (type: number) => {
+ const handleOpenCancelPopupWithType = (type: CancelType) => {
clearActiveOrderRuntime();
deps.setCancelType(type);
restartPlanningRoute();
@@ -165,11 +147,11 @@
deps.pendingCancelContextRef.current = {
orderId: targetOrderId,
tripStatus: deps.tripStatus,
- cancelType: 4,
+ cancelType: CancelType.OrderCanceled,
source: 'pickup_timeout',
createdAt: Date.now()
};
- handleOpenCancelPopupWithType(4);
+ handleOpenCancelPopupWithType(CancelType.OrderCanceled);
};
/**
@@ -177,30 +159,33 @@
*/
const handleOpenCancelPopupByTripStatus = (status: TripStatus, displayInfo?: any) => {
const waitMinutes = Number(displayInfo?.waitMinutes);
- // if ([TripStatus.CallingCar, TripStatus.VehicleApproaching].includes(status) && Number.isFinite(waitMinutes)) {
- if (status === TripStatus.VehicleApproaching && Number.isFinite(waitMinutes)) {
+ if ([TripStatus.CallingCar, TripStatus.VehicleApproaching].includes(status) && Number.isFinite(waitMinutes)) {
+ // if (status === TripStatus.VehicleApproaching && Number.isFinite(waitMinutes)) {
deps.setCancelPopupWaitMinutes(waitMinutes);
} else {
deps.setCancelPopupWaitMinutes(null);
}
if ([TripStatus.Publishing, TripStatus.CallingCar].includes(status)) {
- deps.setCancelType(1);
+ deps.setCancelType(CancelType.CallingCar);
deps.setShowCancelPopup(true);
return;
}
if (displayInfo?.durationDisplay && ![TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(status)) {
- deps.setCancelType(2);
+ deps.setCancelType(CancelType.VehicleApproaching);
deps.setShowCancelPopup(true);
return;
}
if (status === TripStatus.VehicleApproaching) {
- deps.setCancelType(2);
+ deps.setCancelType(CancelType.VehicleApproaching);
}
if (status === TripStatus.VehicleArrivedPickup) {
- deps.setCancelType(3);
+ deps.setCancelType(CancelType.VehicleArrivedPickup);
+ }
+ if (status === TripStatus.InService) {
+ deps.setCancelType(CancelType.InService);
}
deps.setShowCancelPopup(true);
};
@@ -209,13 +194,13 @@
* 确认取消。
*/
const handleConfirmCancel = async () => {
- if (deps.cancelType === 4) {
+ if (deps.cancelType === CancelType.OrderCanceled) {
restartPlanningRoute();
return;
}
- if (deps.cancelType === 5) {
- handleOpenCancelPopupWithType(5);
+ if (deps.cancelType === CancelType.NoAvailableVehicle) {
+ handleOpenCancelPopupWithType(CancelType.NoAvailableVehicle);
return;
}
@@ -226,15 +211,15 @@
}
const shouldReportWaitingForDispatchCancel = (
- deps.cancelType === 1
+ deps.cancelType === CancelType.CallingCar
&& [TripStatus.Publishing, TripStatus.CallingCar].includes(deps.tripStatus)
);
const shouldReportWaitingForPickupCancel = (
- deps.cancelType === 2
+ deps.cancelType === CancelType.VehicleApproaching
&& deps.tripStatus === TripStatus.VehicleApproaching
);
const shouldReportPickupArrivedCancelTrip = (
- deps.cancelType === 3
+ deps.cancelType === CancelType.VehicleArrivedPickup
&& deps.tripStatus === TripStatus.VehicleArrivedPickup
);
@@ -252,7 +237,7 @@
showToast({ title: '行程已取消', icon: 'success' });
clearActiveOrderRuntime();
deps.setTripStatus(TripStatus.CancelOnlyPickup);
- deps.setCancelType(4);
+ deps.setCancelType(CancelType.OrderCanceled);
} catch (error) {
showToast({ title: '取消订单失败,请重试', icon: 'none' });
}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-order-payment-result-query.ts b/src/robotaxi/pages/begin-route/hooks/use-order-payment-result-query.ts
index b4ee7be..6e57ba8 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-order-payment-result-query.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-order-payment-result-query.ts
@@ -1,6 +1,5 @@
import { useCallback } from 'react';
-import Taro from '@tarojs/taro';
-import { showToast } from '../../../components/toast';
+import { showToast, hideToast } from '../../../components/toast';
import { queryOrderPaymentResultFromOrderId } from '../../../api/begin-route';
import { RobotaxiOrderStatus } from '../../../api/types/begin-route';
@@ -24,7 +23,7 @@
return;
}
- Taro.showLoading({ title: '支付结果查询中' });
+ showToast({ title: '支付结果查询中', icon: 'loading' });
try {
const result = await queryOrderPaymentResultFromOrderId(deps.orderId);
const nextStatus = Number(result?.status);
@@ -43,7 +42,7 @@
} catch (error) {
showToast({ title: '支付结果查询失败,请稍后重试', icon: 'none' });
} finally {
- Taro.hideLoading();
+ hideToast();
}
}, [deps.onPaySuccess, deps.orderId]);
diff --git a/src/robotaxi/pages/begin-route/hooks/use-order-payment.ts b/src/robotaxi/pages/begin-route/hooks/use-order-payment.ts
index 42a9c03..15f817a 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-order-payment.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-order-payment.ts
@@ -1,5 +1,5 @@
import Taro from '@tarojs/taro';
-import { showToast } from '../../../components/toast';
+import { showToast, hideToast } from '../../../components/toast';
import { useRef } from 'react';
import { reportEvent } from '../../../../utils/Robotaxi-public-method';
import {
@@ -56,8 +56,9 @@
* 根据订单详情状态决定结束行程后的页面态。
*/
const resolvePostEndOrderTripStatus = async (targetOrderId: string) => {
- Taro.showLoading({
+ showToast({
title: '订单明细查询中',
+ icon: 'loading',
mask: true
});
try {
@@ -79,7 +80,7 @@
nextTripStatus: TripStatus.TripFinished
};
} finally {
- Taro.hideLoading();
+ hideToast();
}
};
@@ -199,8 +200,9 @@
return;
}
paySubmittingRef.current = true;
- Taro.showLoading({
+ showToast({
title: '支付中',
+ icon: 'loading',
mask: false
});
try {
@@ -214,7 +216,7 @@
if (paymentStatus === RobotaxiPaymentEntryStatus.Unsigned) {
const cashierToken = paymentResult?.value?.token;
if (!cashierToken) {
- Taro.showToast({ title: '支付凭证缺失,请稍后重试', icon: 'none' });
+ showToast({ title: '支付凭证缺失,请稍后重试', icon: 'none' });
return;
}
await deps.handleUnsignedPayment(cashierToken);
@@ -224,18 +226,18 @@
if (paymentStatus === RobotaxiPaymentEntryStatus.Signed) {
const signedPaymentValue = paymentResult?.value as SignedOrderPaymentValue;
if (!signedPaymentValue?.packageValue) {
- Taro.showToast({ title: '支付参数缺失,请稍后重试', icon: 'none' });
+ showToast({ title: '支付参数缺失,请稍后重试', icon: 'none' });
return;
}
await deps.handleSignedCreditPayment(signedPaymentValue);
return;
}
- Taro.showToast({ title: '暂不支持当前支付方式', icon: 'none' });
+ showToast({ title: '暂不支持当前支付方式', icon: 'none' });
} catch (error) {
showToast({ title: '支付失败,请重试', icon: 'none' });
} finally {
- Taro.hideLoading();
+ hideToast();
paySubmittingRef.current = false;
}
};
diff --git a/src/robotaxi/pages/begin-route/hooks/use-order-status.ts b/src/robotaxi/pages/begin-route/hooks/use-order-status.ts
index 10a10b6..490de8e 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-order-status.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-order-status.ts
@@ -1,5 +1,6 @@
import Taro from '@tarojs/taro';
import { useState, useRef, useEffect } from 'react';
+import { CancelType } from '../../../constants/trip-status';
export default function useOrderStatus() {
const [tripStatus, setTripStatus] = useState<number>(Number(Taro.getStorageSync('tripStatus')) || 1);
@@ -9,7 +10,7 @@
const [vehicleInfo, setVehicleInfo] = useState<any>({});
const [trunkStatus, setTrunkStatus] = useState(false);
const [finishCountdownEnabled, setFinishCountdownEnabled] = useState(false);
- const [cancelType, setCancelType] = useState(1);
+ const [cancelType, setCancelType] = useState<CancelType>(CancelType.CallingCar);
const tripStatusRef = useRef(tripStatus);
const lastTripStatusRef = useRef(tripStatus);
diff --git a/src/robotaxi/pages/begin-route/hooks/use-order-submit.ts b/src/robotaxi/pages/begin-route/hooks/use-order-submit.ts
index b00f9c2..def0312 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-order-submit.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-order-submit.ts
@@ -1,5 +1,5 @@
import Taro from '@tarojs/taro';
-import { showToast } from '../../../components/toast';
+import { showToast, hideToast } from '../../../components/toast';
import { useCallback, useRef } from 'react';
import { formatDateTime, debounce, reportEvent } from '../../../../utils/Robotaxi-public-method';
import { createCreditPayOrder, saveOrder, routeStaleCheck, queryCreditPaySign } from '../../../api/begin-route';
@@ -141,16 +141,16 @@
* 查询长期签约回跳后的最新支付分状态,并决定是否继续信用下单。
*/
const resolveCreditPayModeAfterSignReturn = useCallback(async () => {
- Taro.showLoading({ title: '授权结果查询中', mask: true });
+ showToast({ title: '授权结果查询中', icon: 'loading', mask: true });
let shouldHideLoading = true;
try {
const signResult = await queryCreditPaySign();
const latestStatus = signResult?.data?.status ?? signResult?.status;
const resolvedCreditPaySubmitMode = resolveCreditPaySubmitMode(latestStatus);
if (resolvedCreditPaySubmitMode) {
- Taro.hideLoading();
+ hideToast();
shouldHideLoading = false;
- Taro.showToast({ title: '授权成功', icon: 'none' });
+ showToast({ title: '授权成功', icon: 'none' });
return resolvedCreditPaySubmitMode;
}
return resolvedCreditPaySubmitMode;
@@ -158,7 +158,7 @@
return null;
} finally {
if (shouldHideLoading) {
- Taro.hideLoading();
+ hideToast();
}
}
}, []);
@@ -499,7 +499,7 @@
const nextCreditPaySubmitMode = await resolveCreditPayModeAfterSignReturn();
if (!nextCreditPaySubmitMode) {
stopCallSubmitting();
- Taro.showToast({ title: '支付分授权未完成,请重试', icon: 'none' });
+ showToast({ title: '支付分授权未完成,请重试', icon: 'none' });
return;
}
@@ -511,7 +511,7 @@
onLaunchFail: () => {
waitingCreditPayReturnRef.current = false;
stopCallSubmitting();
- Taro.showToast({ title: '拉起签约失败', icon: 'none' });
+ showToast({ title: '拉起签约失败', icon: 'none' });
}
});
@@ -640,7 +640,7 @@
|| submitMode === 'credit_pay_skip_auth_submit';
if (isCreditPaySubmit) {
- Taro.showLoading({ title: '加载中', mask: true });
+ showToast({ title: '加载中', icon: 'loading', mask: true });
}
const submitResult = isCreditPaySubmit
@@ -648,7 +648,7 @@
: await submitOrder(startSite, openCityId);
if (!submitResult?.orderId) {
- Taro.hideLoading();
+ hideToast();
stopCallSubmitting();
return;
}
@@ -663,7 +663,7 @@
if (isCreditPaySubmit) {
Taro.setStorageSync('creditPayOrderId', submitResult.orderId);
if (submitMode === 'credit_pay_skip_auth_submit') {
- Taro.hideLoading();
+ hideToast();
// status=2 代表当前已是免确认模式,信用下单成功后直接进入发单态。
deps.setTripStatus(TripStatus.Publishing);
return;
@@ -671,7 +671,7 @@
const creditPayPackageValue = String(submitResult.creditPayPackageValue || '').trim();
if (!creditPayPackageValue) {
- Taro.hideLoading();
+ hideToast();
showToast({ title: '支付参数缺失,请稍后重试', icon: 'none' });
return;
}
@@ -684,7 +684,7 @@
packageValue: creditPayPackageValue
}, submitResult.orderId);
- Taro.hideLoading();
+ hideToast();
// 只有拉起失败才立刻进入发单状态(或者依靠 abort 逻辑终止)
if (!launched) {
@@ -694,10 +694,10 @@
}
deps.setTripStatus(TripStatus.Publishing);
} catch (error) {
- Taro.hideLoading();
+ hideToast();
const errorMessage = String((error as any)?.msg || (error as any)?.message || '下单失败,请重试');
if (submitMode === 'credit_pay_submit' || submitMode === 'credit_pay_skip_auth_submit') {
- Taro.showToast({ title: errorMessage, icon: 'none' });
+ showToast({ title: errorMessage, icon: 'none' });
}
reportPayResultEvent(resolvePayResultStatus(errorMessage));
stopCallSubmitting();
diff --git a/src/robotaxi/pages/begin-route/hooks/use-page-restore.ts b/src/robotaxi/pages/begin-route/hooks/use-page-restore.ts
index d5d6251..68afa7c 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-page-restore.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-page-restore.ts
@@ -1,4 +1,5 @@
-import Taro from '@tarojs/taro';
+import Taro from '@tarojs/taro';
+import { useRef } from 'react';
import { showToast } from '../../../components/toast';
import wsManager from '../../../../utils/websocket';
import { mapOrderStatusToTripStatus, clearOrderTripStorage } from '../../../../utils/Robotaxi-public-method';
@@ -42,9 +43,6 @@
setEndSite: (value: any) => void;
setVehicleMoveAlongPoints: (value: any[]) => void;
setTrunkStatus: (value: boolean) => void;
- setLatitude: (value: number | null) => void;
- setLongitude: (value: number | null) => void;
- getLocationInfo: () => void;
routeRequestRouteAndFare: (
originLat: number,
originLng: number,
@@ -83,6 +81,8 @@
* 页面恢复与初始化编排:负责缓存恢复、订单同步、定位初始化与 socket 准备。
*/
export default function usePageRestore(deps: Deps) {
+ const hasShownActiveTripToastRef = useRef(false); // 同一页面实例内只提示一次,避免回前台重复闪 toast
+
/**
* 清理已选优惠券缓存。
*/
@@ -234,6 +234,8 @@
openCityId,
startPointId: restoredStartSite?.id || '',
orderId: restoredOrderId
+ }, {
+ showToast: false
});
const responseRoutes = Array.isArray(response?.routes)
? response.routes
@@ -385,28 +387,10 @@
/**
* 同步地图定位并按状态初始化路线。
+ * 中心点逻辑已移除:地图中心不再由代码定位(走 <Map> 的兜底坐标 + useMapCameraController
+ * 首帧定位),此处仅保留按状态初始化路线。
*/
const syncLocationAndInitRoute = async (s: any, e: any, cachedOrderId: string, cachedTripStatus: TripStatus) => {
- // 到达目的地阶段直接以下车点为中心,避免 getLocationInfo 抢占视口中心
- if (cachedTripStatus === TripStatus.ArrivedDestination) {
- const dropoffLat = Number(e?.latitude);
- const dropoffLng = Number(e?.longitude);
- if (Number.isFinite(dropoffLat) && Number.isFinite(dropoffLng)) {
- deps.setLatitude(dropoffLat);
- deps.setLongitude(dropoffLng);
- }
- } else if (s && s.latitude && s.longitude) {
- if([TripStatus.VehicleApproaching, TripStatus.InService].includes(cachedTripStatus)) {
- deps.getLocationInfo();
- } else if (![TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(cachedTripStatus)) {
- // 完成态(TripFinished/PaymentSuccess)地图中心由 useMapViewport 的 order_detail includePoints 主导,
- // 这里不再用上车点覆盖,否则受控中心会被拉到路线一端导致路线偏离屏幕中央。
- deps.setLatitude(s.latitude);
- deps.setLongitude(s.longitude);
- }
- } else {
- deps.getLocationInfo();
- }
const originLng = s?.longitude || deps.getCurrentLongitude();
const originLat = s?.latitude || deps.getCurrentLatitude();
if (cachedTripStatus === TripStatus.PublishRoute && originLng && originLat && e?.longitude && e?.latitude) {
@@ -479,7 +463,13 @@
const isModifyDestinationSessionActive = !!Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY)?.active; // 修改目的地返回链路不提示“已有进行中订单”
const isModifyDestinationReturn = String(fromModifyDestination || '') === '1';
const shouldShowPendingSameSiteToast = isModifyDestinationReturn && !!Taro.getStorageSync(MODIFY_DESTINATION_PENDING_SAME_SITE_TOAST_KEY);
- if (skipPageFlag && hasActiveTripOrderInStorage() && !isModifyDestinationReturn && !isModifyDestinationSessionActive) {
+ const shouldShowActiveTripToast = Boolean(skipPageFlag)
+ && !hasShownActiveTripToastRef.current
+ && hasActiveTripOrderInStorage()
+ && !isModifyDestinationReturn
+ && !isModifyDestinationSessionActive;
+ if (shouldShowActiveTripToast) {
+ hasShownActiveTripToastRef.current = true;
showToast({
title: getActiveTripOrderToastTitle(Taro.getStorageSync('tripStatus')),
icon: 'none',
diff --git a/src/robotaxi/pages/begin-route/hooks/use-popup-manager.ts b/src/robotaxi/pages/begin-route/hooks/use-popup-manager.ts
index 2c3f6b2..988b878 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-popup-manager.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-popup-manager.ts
@@ -1,4 +1,5 @@
import { useState } from 'react';
+import { CancelType } from '../../../constants/trip-status';
export default function usePopupManager() {
const [showCustomerService, setShowCustomerService] = useState(false);
@@ -7,7 +8,7 @@
const [showAnnouncementsPopup, setShowAnnouncementsPopup] = useState(false);
const [showOverRoutePopup, setShowOverRoutePopup] = useState(false);
const [showPassengerNotice, setShowPassengerNotice] = useState(false);
- const [cancelType, setCancelType] = useState(1);
+ const [cancelType, setCancelType] = useState<CancelType>(CancelType.CallingCar);
const [showEvaluatePopup, setShowEvaluatePopup] = useState(false);
const [showTipPopup, setShowTipPopup] = useState(false);
const [tipType, setTipType] = useState('');
diff --git a/src/robotaxi/pages/begin-route/hooks/use-route-interaction.ts b/src/robotaxi/pages/begin-route/hooks/use-route-interaction.ts
index a5595b1..b1a9315 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-route-interaction.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-route-interaction.ts
@@ -8,11 +8,6 @@
Deps,
ROUTE_POLYLINE_BASE_ID,
ROUTE_TAP_MAX_DISTANCE_METERS,
- ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING,
- ROUTE_VIEWPORT_MIN_TOP_PADDING,
- ROUTE_VIEWPORT_RIGHT_SIDE_PADDING,
- ROUTE_VIEWPORT_SIDE_PADDING,
- ROUTE_VIEWPORT_TOP_EXTRA_PADDING,
RouteDistanceValue,
} from './use-route-manager.shared';
@@ -46,12 +41,10 @@
setRouteDuration: React.Dispatch<React.SetStateAction<number | null>>;
};
-/** 路线交互 Hook 返回值。 */
+/** 路线交互 Hook 入参。 */
type HandleSelectRouteOptions = {
/** 是否同步更新起终点。 */
syncSites?: boolean;
- /** 是否同步更新地图视口。 */
- syncViewport?: boolean;
};
/** 路线交互 Hook 返回值。 */
@@ -74,16 +67,12 @@
handlePolylineTap: (event: any) => void;
/** 处理地图点击。 */
handleMapTap: (event: any) => void;
- /** 同步多路线视口。 */
- syncRouteViewport: (routePointGroups: any[][], nextWalkingPoints?: any[]) => void;
};
/** 统一获取路线降采样上限。 */
const resolveRoutePointLimit = (isAndroid: boolean) => (
isAndroid ? ANDROID_ROUTE_POINT_LIMIT : DEFAULT_ROUTE_POINT_LIMIT
);
-
-const ROUTE_VIEWPORT_SYNC_DELAY_MS = 320;
/**
* 管理路线选择、点击交互与视口联动。
@@ -97,7 +86,6 @@
setSelectedRouteIndex,
allRoutePoints,
setAllRoutePoints,
- walkingPoints,
applyRoutePoints,
reducePoints,
setFareEstimate,
@@ -106,45 +94,13 @@
} = params;
/**
- * 同步地图视口,确保路线和步行线都在可视范围内。
- * 注:仅在获取到地图数据、切换路线时使用
- */
- const syncRouteViewport = (routePointGroups: any[][], nextWalkingPoints: any[] = walkingPoints) => {
- if (!deps.allowSelectableRoutes) {
- return;
- }
- const routeViewportPoints: any[] = [];
- routePointGroups.forEach((points) => {
- if (Array.isArray(points)) {
- routeViewportPoints.push(...points);
- }
- });
- const allPoints = [...routeViewportPoints, ...(Array.isArray(nextWalkingPoints) ? nextWalkingPoints : [])];
- if (allPoints.length === 0) {
- return;
- }
- const topPadding = Math.max(
- ROUTE_VIEWPORT_MIN_TOP_PADDING,
- deps.resolveSafeTopInset() + ROUTE_VIEWPORT_TOP_EXTRA_PADDING
- );
- const bottomPadding = deps.computeBottomPadding(allPoints, 'route_walking', deps.tripStatus, true) + ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING;
- Taro.createMapContext('map').includePoints({
- points: allPoints,
- padding: [topPadding, ROUTE_VIEWPORT_RIGHT_SIDE_PADDING, bottomPadding, ROUTE_VIEWPORT_SIDE_PADDING]
- });
- setTimeout(() => {
- deps.syncMapCenterAfterViewportFit?.();
- }, ROUTE_VIEWPORT_SYNC_DELAY_MS);
- };
-
- /**
* 处理路线选择后的业务联动。
*/
const handleSelectRoute = (index: number, options: HandleSelectRouteOptions = {}) => {
if (!routeOptions[index] || index === selectedRouteIndex) {
return;
}
- const { syncSites = true, syncViewport = true } = options;
+ const { syncSites = true } = options;
const selectedRoute = routeOptions[index];
setSelectedRouteIndex(index);
@@ -224,9 +180,6 @@
if (points.length > 0) {
applyRoutePoints(points);
- if (syncViewport) {
- syncRouteViewport(allRoutePoints);
- }
return;
}
applyRoutePoints([]);
@@ -367,7 +320,6 @@
setAllRoutePoints,
handleSelectRoute,
handlePolylineTap,
- handleMapTap,
- syncRouteViewport
+ handleMapTap
};
}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-route-manager.ts b/src/robotaxi/pages/begin-route/hooks/use-route-manager.ts
index 7458e82..215bd45 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-route-manager.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-route-manager.ts
@@ -78,7 +78,6 @@
currentFullRouteRef: renderState.currentFullRouteRef,
lastErasedIndexRef: renderState.lastErasedIndexRef,
orderRouteInitializedRef: renderState.orderRouteInitializedRef,
- syncRouteViewport: interactionState.syncRouteViewport,
syncSmoothRouteBaselineRef: options?.syncSmoothRouteBaselineRef,
lastSocketDirectionAtRef: options?.lastSocketDirectionAtRef
});
diff --git a/src/robotaxi/pages/begin-route/hooks/use-route-request.ts b/src/robotaxi/pages/begin-route/hooks/use-route-request.ts
index d37c3b4..f801aee 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-route-request.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-route-request.ts
@@ -55,8 +55,6 @@
lastErasedIndexRef: React.MutableRefObject<number>;
/** 订单路线初始化引用。 */
orderRouteInitializedRef: React.MutableRefObject<boolean>;
- /** 同步多路线视口。 */
- syncRouteViewport: (routePointGroups: any[][], nextWalkingPoints?: any[]) => void;
/** 接口回显主路线后,同步初始化平滑擦线基线。 */
syncSmoothRouteBaselineRef?: React.MutableRefObject<((snapshot: RouteSnapshot) => void) | null>;
/** 最近一次由 socket 写入车辆朝向的时间。 */
@@ -221,7 +219,6 @@
currentFullRouteRef,
lastErasedIndexRef,
orderRouteInitializedRef,
- syncRouteViewport,
syncSmoothRouteBaselineRef,
lastSocketDirectionAtRef
} = params;
@@ -538,7 +535,6 @@
const points = Array.isArray(route?.polyline) ? route.polyline : [];
if (points.length > 0) {
applyRoutePoints(points);
- syncRouteViewport(allRoutePointGroups, walkingPoints);
} else {
applyRoutePoints([]);
}
@@ -654,7 +650,6 @@
if (Array.isArray(points) && points.length > 0) {
setWalkingPoints(points);
setWalkingTime(Math.ceil(Number(duration) / 60));
- syncRouteViewport([], points);
return;
}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-speed-based-scale.ts b/src/robotaxi/pages/begin-route/hooks/use-speed-based-scale.ts
deleted file mode 100644
index ee6127e..0000000
--- a/src/robotaxi/pages/begin-route/hooks/use-speed-based-scale.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import Taro from '@tarojs/taro';
-import { useCallback, useEffect, useRef } from 'react';
-import { TripStatus } from '../../../constants/trip-status';
-import { subscribeDisplayRoutePoints } from './route-display-channel';
-import { ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING, ROUTE_VIEWPORT_MIN_TOP_PADDING, ROUTE_VIEWPORT_RIGHT_SIDE_PADDING, ROUTE_VIEWPORT_SIDE_PADDING, ROUTE_VIEWPORT_TOP_EXTRA_PADDING } from './use-route-manager.shared';
-
-type Deps = {
- /** 当前行程状态。 */
- tripStatus: TripStatus;
- /** 获取速度缩放暂停截止时间,用于和一次性视口调整/用户手势冷却互斥。 */
- getDynamicScaleHoldUntil: () => number;
- /** 车辆当前位置:仅用于短路线兜底把车辆居中(不参与 fit 签名)。 */
- vehicleLocation: { latitude: number; longitude: number } | null;
- /** 后端推送的剩余距离(来自 ORDER_ROUTE.route.distance,单位 m 或 km)。 */
- routeDistance: { value: number; unit: 'km' | 'm' } | null;
- /** 计算 includePoints 所需底部留白。 */
- computeBottomPadding: (points: any[], viewportType: string) => number;
- /** 读取顶部安全区高度。 */
- resolveSafeTopInset: () => number;
- /** 设置受控地图中心纬度。 */
- setLatitude: (latitude: number) => void;
- /** 设置受控地图中心经度。 */
- setLongitude: (longitude: number) => void;
-};
-
-/** 启用自动 fit 的行程状态:仅接驾中、行程中。 */
-const ACTIVE_STATUSES = [TripStatus.VehicleApproaching, TripStatus.InService];
-
-const ROUTE_DISTANCE_BUCKET_METERS = 10;
-const SIDE_PADDING = 50;
-const TOP_PADDING_MIN = 32;
-const TOP_EXTRA_PADDING = 50;
-const SHORT_ROUTE_FALLBACK_POINT_THRESHOLD = 3;
-const USER_MAP_INTERACTION_HOLD_MS = 8000;
-/**
- * 两次 includePoints 之间的最小间隔(毫秒)。
- *
- * 受控 <Map scale/lat/lng> 与命令式 includePoints 之间存在竞争:
- * fit 后异步用 getScale/getCenterLocation 同步 React 状态需要 ~100ms,
- * 间隔太短会让多次 fit 的 race 窗口叠加。这里取 1500ms 让每次 fit 有充裕
- * 的同步时间,相当于把抖动从"持续震"压成"偶尔轻微闪一下"。
- */
-const MIN_FIT_INTERVAL_MS = 5000;
-
-const buildRouteSignature = (points: { latitude: number; longitude: number }[]) => {
- if (!Array.isArray(points) || points.length === 0) return '';
- const first = points[0];
- const middle = points[Math.floor(points.length / 2)];
- const last = points[points.length - 1];
- const fmt = (value: number) => Number(value || 0).toFixed(6);
- return [
- points.length,
- fmt(first?.latitude), fmt(first?.longitude),
- fmt(middle?.latitude), fmt(middle?.longitude),
- fmt(last?.latitude), fmt(last?.longitude),
- ].join('|');
-};
-
-const buildRouteDistanceBucket = (
- routeDistance: { value: number; unit: 'km' | 'm' } | null
-) => {
- if (!routeDistance) return 'null';
- const meters = routeDistance.unit === 'km'
- ? Number(routeDistance.value) * 1000
- : Number(routeDistance.value);
- if (!Number.isFinite(meters) || meters < 0) return 'invalid';
- return String(Math.floor(meters / ROUTE_DISTANCE_BUCKET_METERS));
-};
-
-const buildFitSignature = (
- points: { latitude: number; longitude: number }[],
- routeDistance: { value: number; unit: 'km' | 'm' } | null
-) => {
- const routeSig = buildRouteSignature(points);
- if (!routeSig) return '';
- return `${routeSig}|d:${buildRouteDistanceBucket(routeDistance)}`;
-};
-
-const fitRouteIntoView = (
- points: { latitude: number; longitude: number }[],
- computeBottomPadding: (points: any[], viewportType: string, tripStatus: TripStatus, disableAdaptive?: boolean) => number,
- resolveSafeTopInset: () => number,
- tripStatus: TripStatus
-) => {
- if (!Array.isArray(points) || points.length < 2) {
- return false;
- }
- const mapCtx = Taro.createMapContext('map');
- const bottomPadding = computeBottomPadding(points, 'route', tripStatus, true) + ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING ;
- const topPadding = Math.max(ROUTE_VIEWPORT_MIN_TOP_PADDING, resolveSafeTopInset() + ROUTE_VIEWPORT_TOP_EXTRA_PADDING);
- mapCtx.includePoints({
- points,
- padding: [topPadding, ROUTE_VIEWPORT_RIGHT_SIDE_PADDING, bottomPadding, ROUTE_VIEWPORT_SIDE_PADDING]
- });
- return true;
-};
-
-/**
- * 接驾中/行程中自动整路线入屏:当路线签名或剩余距离桶变化时,自动重算视野。
- */
-export default function useSpeedBasedScale(deps: Deps) {
- const lastFittedFitSignatureRef = useRef<string>('');
- const idleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
- /** 由 channel 推送过来的最新 displayRoutePoints,存在 ref 中避免触发重渲染。 */
- const liveRoutePointsRef = useRef<{ latitude: number; longitude: number }[]>([]);
- /** 上次 includePoints 触发的时间戳,用于最小 fit 间隔节流。 */
- const lastFitAtRef = useRef<number>(0);
- /**
- * fit 同步待办标志:performFit 调用 includePoints 后置为 true,
- * 下一次 onRegionChange 'end' 事件读取实际 scale/center 同步回 React state,
- * 然后清旗。比异步 getScale 时机更准(fit 动画结束才 end)。
- */
- const pendingFitSyncRef = useRef<boolean>(false);
-
- const depsRef = useRef(deps);
- useEffect(() => {
- depsRef.current = deps;
- });
-
- const performFit = useCallback((force: boolean, points?: { latitude: number; longitude: number }[]) => {
- const d = depsRef.current;
- if (!ACTIVE_STATUSES.includes(d.tripStatus)) {
- return;
- }
-
- const routePoints = points ?? liveRoutePointsRef.current;
- const currentFitSig = buildFitSignature(routePoints, d.routeDistance);
- if (!currentFitSig) {
- return;
- }
-
- if (!force) {
- if (currentFitSig === lastFittedFitSignatureRef.current) {
- return;
- }
- const holdUntil = d.getDynamicScaleHoldUntil();
- if (holdUntil > Date.now()) {
- return;
- }
- // 最小 fit 间隔节流:上次 includePoints 不到 MIN_FIT_INTERVAL_MS 不再 fit,
- // 避免 emit 高频触发 → 频繁 includePoints → 与受控 Map 属性的 race 叠加。
- if (Date.now() - lastFitAtRef.current < MIN_FIT_INTERVAL_MS) {
- return;
- }
- }
-
- if (
- routePoints.length < SHORT_ROUTE_FALLBACK_POINT_THRESHOLD
- && d.vehicleLocation
- && Number.isFinite(Number(d.vehicleLocation.latitude))
- && Number.isFinite(Number(d.vehicleLocation.longitude))
- ) {
- d.setLatitude(Number(d.vehicleLocation.latitude));
- d.setLongitude(Number(d.vehicleLocation.longitude));
- lastFittedFitSignatureRef.current = currentFitSig;
- return;
- }
-
- const fitted = fitRouteIntoView(routePoints, d.computeBottomPadding, d.resolveSafeTopInset, d.tripStatus);
- if (!fitted) {
- return;
- }
- lastFittedFitSignatureRef.current = currentFitSig;
- lastFitAtRef.current = Date.now();
- // includePoints 是命令式调用,会修改原生地图的 scale/center,
- // 但受控 <Map scale={mapScale} latitude={mapLatitude} longitude={mapLongitude}> 在
- // 之后任意一次 React 重渲染都会把原生视口拉回 React 旧值,造成视觉抖动。
- // 这里只置一个标志位,等 onRegionChange 'end'(fit 动画真正结束)时再读取
- // 实际值同步回 React state,让受控属性与原生视口对齐,后续重渲染不再回弹。
- pendingFitSyncRef.current = true;
- }, []);
-
- const clearIdleRestoreTimer = useCallback(() => {
- if (idleRestoreTimerRef.current) {
- clearTimeout(idleRestoreTimerRef.current);
- idleRestoreTimerRef.current = null;
- }
- }, []);
-
- const notifyUserMapGesture = useCallback(() => {
- const d = depsRef.current;
- if (!ACTIVE_STATUSES.includes(d.tripStatus)) {
- return;
- }
- clearIdleRestoreTimer();
- idleRestoreTimerRef.current = setTimeout(() => {
- idleRestoreTimerRef.current = null;
- performFit(true);
- }, USER_MAP_INTERACTION_HOLD_MS);
- }, [clearIdleRestoreTimer, performFit]);
-
- // 订阅全局 displayRoutePoints channel,收到新路线点时直接触发 fit 判断
- useEffect(() => {
- return subscribeDisplayRoutePoints((points) => {
- liveRoutePointsRef.current = points;
- performFit(false, points);
- });
- }, [performFit]);
-
- // tripStatus 退出激活态时清空记录的签名和定时器
- useEffect(() => {
- if (!ACTIVE_STATUSES.includes(deps.tripStatus)) {
- lastFittedFitSignatureRef.current = '';
- liveRoutePointsRef.current = [];
- lastFitAtRef.current = 0;
- pendingFitSyncRef.current = false;
- clearIdleRestoreTimer();
- }
- }, [deps.tripStatus, clearIdleRestoreTimer]);
-
- useEffect(() => clearIdleRestoreTimer, [clearIdleRestoreTimer]);
-
- return {
- notifyUserMapGesture,
- pendingFitSyncRef
- };
-}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-transition-asset-preload.ts b/src/robotaxi/pages/begin-route/hooks/use-transition-asset-preload.ts
new file mode 100644
index 0000000..ef918f5
--- /dev/null
+++ b/src/robotaxi/pages/begin-route/hooks/use-transition-asset-preload.ts
@@ -0,0 +1,54 @@
+import { useEffect, useRef } from 'react';
+import { TripStatus } from '../../../constants/trip-status';
+import { preloadAssets } from '../../../../utils/preload-assets';
+import { preloadVideo } from '../../../utils/video-preload';
+import { VEHICLE_APPROACHING_VIDEO } from '../../../constants/assets';
+import {
+ DISPATCH_SUCCESS_ASSETS,
+ TRIP_FINISHED_ASSETS,
+} from '../../../constants/preload-assets';
+
+/** Hook 入参。 */
+type Params = {
+ /** 当前行程状态。 */
+ tripStatus: TripStatus;
+};
+
+/**
+ * 转场动效素材预加载(按需触发)。
+ *
+ * ## 触发策略(必要时才预载,避免无谓消耗)
+ * - 进入「发布行程(1) / 发布中(2) / 呼叫中(3)」→ 预载「派单成功」素材 + 转场视频(downloadFile 落地本地)
+ * (越早进入下单流程越早预热,第三次之后动效明显更顺畅正是因为素材已有缓存;
+ * 提前到发布行程即开始,尽量把预热窗口拉满)。
+ * - 进入「送驾中(6)」→ 预载「行程结束」素材(距行程结束有整段行程)。
+ *
+ * ## 视频预下载(黑帧消除关键)
+ * wx.preloadAssets 不支持视频,转场 mp4 直接用 CDN URL 播放时首帧要经网络缓冲、露出黑帧。
+ * 此处额外用 preloadVideo(wx.downloadFile)把 mp4 提前下到本地临时文件,转场组件优先用本地路径播放。
+ *
+ * ## 去重
+ * 各阶段用 ref 标记「已发起预载」,状态反复切换不重复调用。
+ * 预加载底层失败静默(见 preloadAssets),此处无需错误处理。
+ */
+export default function useTransitionAssetPreload(params: Params): void {
+ const { tripStatus } = params;
+
+ const orderingPreloadedRef = useRef(false); // 派单成功素材是否已发起预载
+ const finishedPreloadedRef = useRef(false); // 行程结束素材是否已发起预载
+
+ useEffect(() => {
+ // 进入下单流程(发布行程/发布中/呼叫中):预载派单成功素材 + 转场视频
+ if ([TripStatus.PublishRoute, TripStatus.Publishing, TripStatus.CallingCar].includes(tripStatus) && !orderingPreloadedRef.current) {
+ orderingPreloadedRef.current = true;
+ preloadAssets(DISPATCH_SUCCESS_ASSETS);
+ preloadVideo(VEHICLE_APPROACHING_VIDEO); // downloadFile 提前落地本地,转场首帧无网络缓冲、消除黑帧
+ }
+
+ // 进入送驾中:预载行程结束素材
+ if (tripStatus === TripStatus.InService && !finishedPreloadedRef.current) {
+ finishedPreloadedRef.current = true;
+ preloadAssets(TRIP_FINISHED_ASSETS);
+ }
+ }, [tripStatus]);
+}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-trip-runtime.ts b/src/robotaxi/pages/begin-route/hooks/use-trip-runtime.ts
index 3d6bdfb..f02b3ac 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-trip-runtime.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-trip-runtime.ts
@@ -2,7 +2,7 @@
import { useEffect } from 'react';
import { getOrderDetail } from '../../../api/order';
import { parseLocationCoordinateString } from '../../../../utils/Robotaxi-public-method';
-import { TripStatus } from '../../../constants/trip-status';
+import { TripStatus, CancelType } from '../../../constants/trip-status';
type Deps = {
/** 等待计时开始 key */
@@ -45,10 +45,6 @@
elapsedSeconds: number;
/** 等待秒数设置 */
setElapsedSeconds: (value: number) => void;
- /** 设置纬度 */
- setLatitude: (value: number) => void;
- /** 设置经度 */
- setLongitude: (value: number) => void;
/** 设置步行点 */
setWalkingPoints: (value: any[]) => void;
/** 设置步行时长 */
@@ -74,7 +70,7 @@
/** 设置路线时长 */
setRouteDuration: (value: any) => void;
/** 设置取消类型 */
- setCancelType: (value: number) => void;
+ setCancelType: (value: CancelType) => void;
/** 设置取消弹窗 */
setShowCancelPopup: (value: boolean) => void;
/** 显示乘客须知 */
@@ -175,12 +171,6 @@
* 启动等待计时。
*/
const startWaitingTimer = () => {
- const s = Taro.getStorageSync('startSite');
- const hasPlannedRoute = Array.isArray(deps.routePoints) && deps.routePoints.length > 0;
- if (!hasPlannedRoute && s && s.latitude && s.longitude) {
- deps.setLatitude(s.latitude);
- deps.setLongitude(s.longitude);
- }
const cachedStartAt = Number(Taro.getStorageSync(deps.waitingTimerStartKey));
const cachedElapsed = Number(Taro.getStorageSync(deps.waitingTimerElapsedKey));
let startAt = cachedStartAt;
@@ -276,11 +266,6 @@
deps.requoteTriggeredRef.current = false;
deps.setWalkingPoints([]);
deps.setWalkingTime(0);
- const e = Taro.getStorageSync('endSite');
- if (e && e.latitude && e.longitude) {
- deps.setLatitude(e.latitude);
- deps.setLongitude(e.longitude);
- }
deps.setElapsedSeconds(0);
clearWaitingTimerStorage();
} else if ([TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(deps.tripStatus)) {
diff --git a/src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx b/src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
index 71ddae5..ed30bf8 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
+++ b/src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
@@ -23,8 +23,8 @@
isAndroid: boolean;
/** 行程状态 */
tripStatus: TripStatus;
- /** 地图缩放等级 */
- mapScale: number;
+ /** 派单成功转场动效完成(或本次未播放转场)时回调,用于放行页面级蓝牙初始化 */
+ onDispatchTransitionSettled?: () => void;
/** 地图 markers */
markers: any[];
/** 是否显示等待雷达 */
@@ -142,6 +142,8 @@
trunkStatus: boolean;
/** 当前页面内是否已经解锁过车门 */
doorUnlockedInCurrentPage: boolean;
+ /** 后端推送 ORDER_ROUTE.content.oddOut === 1 时,强制展示取消行程按钮 */
+ forceShowCancelTrip?: boolean;
/** 手动开门成功后同步页面级开门状态 */
onDoorUnlockSuccess: () => void;
/** 路线选项集合 */
@@ -261,9 +263,7 @@
{routeDistance?.value ?? 0}
</View>
<View style={{ flexShrink: 0 }}>
- {isPickup
- ? routeDistance?.unit === "m" ? "M" : "KM"
- : routeDistance?.unit === "m" ? "米" : "公里"}
+ {routeDistance?.unit === "m" ? "米" : "公里"}
</View>
{!isPickup && (
<>
@@ -311,6 +311,34 @@
priceUpdateVersion: deps.priceUpdateVersion,
hasPickupException: deps.hasPickupException
});
+ // 捕捉"首次进入派单成功(4)"边沿:CallingCar 用 key={tripStatus} 会随状态切换重挂载,
+ // 组件内部 ref 会被重置而无法自判边沿,故在不会重挂载的父 hook 中记录上一状态,
+ // 通过 playDispatchTransition 告知 CallingCar 挂载时是否播放派单成功转场。
+ // 触发条件:进入 VehicleApproaching(4) 且上一态不是 VehicleApproaching(覆盖正常单/快速派单/改派)。
+ // 用「真实初始状态」初始化 prev(tripStatus 在页面挂载时已同步从缓存读入,见 index useState 初值),
+ // 使「进入即已是某状态」时首帧为 B→B 无边沿、不播动效;仅页内真实 A→B 流转才触发。
+ const prevTripStatusRef = useRef<TripStatus | undefined>(deps.tripStatus);
+ const playDispatchTransition =
+ deps.tripStatus === TripStatus.VehicleApproaching &&
+ prevTripStatusRef.current !== TripStatus.VehicleApproaching;
+ // 捕捉「送驾中(6) 直达待评价(9)」边沿:内部免费订单无需支付,行程结束后跳过待支付(8)直接进入待评价。
+ // 该路径原本没有任何入场动效,且待评价态内容比送驾态更高(多出费用明细卡与评价卡),
+ // 底部锚定容器(.container position:fixed bottom:0)会瞬间向上顶,观感生硬。
+ // 与 playDispatchTransition 同理:CallingCar 被 key 重挂载无法自判边沿,由父层捕捉后传入,
+ // 组件内做挂载快照,再用挂载即播的 keyframes 实现整卡升起 + 内容分层浮现。
+ // 限定上一态为 InService:不影响支付后 8→9 的既有表现;进入即已是待评价态时 prev 也为该态、不会误播。
+ const playEvaluateEnter =
+ deps.tripStatus === TripStatus.PaymentSuccess &&
+ prevTripStatusRef.current === TripStatus.InService;
+ // 捕捉「进入行程结束(8)」边沿:控制结束态背景卡升起(finished-bg-rise)与里程渐显(finished-distance-fade)。
+ // 进入即已是结束态时首帧为 8→8 无边沿、不播升起;仅真实 live 跃迁到 8 才播放。
+ const playFinishedEnter =
+ deps.tripStatus === TripStatus.TripFinished &&
+ prevTripStatusRef.current !== TripStatus.TripFinished;
+ useEffect(() => {
+ prevTripStatusRef.current = deps.tripStatus;
+ }, [deps.tripStatus]);
+
/** 时长文案 */
const durationDisplay = useMemo(
() => formatRemainingDurationDisplay(deps.routeDuration),
@@ -375,11 +403,19 @@
.join("|"),
[deps.markers]
);
+ /**
+ * 触发一次 customCallout 回流重绘:翻转 borderRadius 兜底原生地图对该 cover-view 的重绘。
+ */
const toggleCalloutRepaint = useCallback(() => {
setCalloutRepaintStyle((prev) => ({ borderRadius: prev.borderRadius === '0px' ? '0.5px' : '0px' }));
}, []);
useEffect(() => {
// 仅 Android 保留一次性重绘兜底;iOS 完全不做 borderRadius 重绘(避免闪动)。
+ // 依赖里额外加入 isModifyDestinationReviewVisible / isPickupRouteChangeVisible:
+ // 这两个状态切换时 customCallout 内容会切成目的地黑色浮窗(endpoint-summary),
+ // 但 marker 的 id/name(markersRepaintSignature)和 tripStatus 通常不变,
+ // 若不监听会导致本次内容切换错过重绘时机,出现"气泡背景已渲染但内部文字未绘制"。
+
if (!deps.isAndroid) {
return undefined;
}
@@ -396,7 +432,14 @@
calloutRepaintTimerRef.current = null;
}
};
- }, [markersRepaintSignature, deps.tripStatus, deps.isAndroid, toggleCalloutRepaint]);
+ }, [
+ markersRepaintSignature,
+ deps.tripStatus,
+ deps.isAndroid,
+ deps.isModifyDestinationReviewVisible,
+ deps.isPickupRouteChangeVisible,
+ toggleCalloutRepaint
+ ]);
/**
@@ -418,6 +461,27 @@
toggleCalloutRepaint();
return undefined;
}, [deps.elapsedSeconds, deps.tripStatus, deps.isAndroid, toggleCalloutRepaint]);
+
+ /**
+ * Android 接驾到达态(VehicleArrivedPickup)上车点倒计时气泡(site-countdown-callout)
+ * 同样存在偶现不绘制、倒计时文案不刷新的问题:pickupCountdownSeconds 按秒递减更新,
+ * 但此前只有 Publishing/CallingCar 的 elapsedSeconds 变化会触发每秒重绘兜底,
+ * VehicleArrivedPickup 未被覆盖,导致该气泡文案长时间卡住不变,直到其他依赖
+ * (markers/tripStatus 等)触发的单次重绘才一次性跳变。这里单独为该状态按秒重绘兜底。
+ */
+ useEffect(() => {
+ if (!deps.isAndroid) {
+ return undefined;
+ }
+ if (deps.tripStatus !== TripStatus.VehicleArrivedPickup) {
+ return undefined;
+ }
+ if (pickupCountdownSeconds === null || pickupCountdownSeconds === undefined) {
+ return undefined;
+ }
+ toggleCalloutRepaint();
+ return undefined;
+ }, [pickupCountdownSeconds, deps.tripStatus, deps.isAndroid, toggleCalloutRepaint]);
@@ -700,6 +764,7 @@
{item.id === 1 &&
deps.tripStatus === TripStatus.VehicleApproaching &&
!deps.isModifyDestinationReviewVisible &&
+ !deps.isPickupRouteChangeVisible &&
!item.hideNameCallout && (
<>
<CoverView
@@ -711,7 +776,7 @@
src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/golden-car-icon-20260708.png"
/>
<CoverView className="site-status-time">
- 限停{deps.startSite?.time}MIN
+ 限停{deps.startSite?.time}分钟
</CoverView>
</CoverView>
<CoverImage
@@ -723,6 +788,7 @@
{item.id === 1 &&
deps.tripStatus === TripStatus.VehicleArrivedPickup &&
!deps.isModifyDestinationReviewVisible &&
+ !deps.doorUnlockedInCurrentPage &&
!item.hideNameCallout &&
Number(pickupCountdownSeconds) > 0 && (
<CoverView
@@ -741,7 +807,8 @@
{item.id === 1 &&
([TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(
deps.tripStatus
- ) || deps.isModifyDestinationReviewVisible) &&
+ ) || deps.isModifyDestinationReviewVisible
+ || (deps.tripStatus === TripStatus.VehicleApproaching && deps.isPickupRouteChangeVisible)) &&
!item.hideNameCallout && (
<CoverImage
className="site-status-end-img"
@@ -758,7 +825,7 @@
!item.hideNameCallout && (
<CoverImage
className="site-status-end-img"
- src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/end-site-20260722.png"
+ src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/end-site-20260812.png"
/>
)}
{item.name === "车辆位置" &&
@@ -788,7 +855,7 @@
{deps.routeDistance?.value ?? 0}
</CoverView>
<CoverView className="car-before-order-tip-unit">
- {deps.routeDistance?.unit === "m" ? "M" : "KM"}
+ {deps.routeDistance?.unit === "m" ? "米" : "公里"}
</CoverView>
<CoverView className="car-before-order-tip-value">
{vehicleMetricDurationDisplay.value}
@@ -803,7 +870,7 @@
{deps.routeDistance?.value ?? 0}
</CoverView>
<CoverView>
- {deps.routeDistance?.unit === "m" ? "M" : "KM"}
+ {deps.routeDistance?.unit === "m" ? "米" : "公里"}
</CoverView>
</CoverView>
)
@@ -823,6 +890,7 @@
[
deps.arrivedTooltipReady,
deps.customCalloutIconSrc,
+ deps.doorUnlockedInCurrentPage,
deps.hasBeforeUserEndSite,
deps.isAndroid,
deps.isModifyDestinationReviewVisible,
@@ -1019,7 +1087,10 @@
) {
return (
<CallingCar
- key={deps.tripStatus}
+ playDispatchTransition={playDispatchTransition}
+ playEvaluateEnter={playEvaluateEnter}
+ playFinishedEnter={playFinishedEnter}
+ onDispatchTransitionSettled={deps.onDispatchTransitionSettled}
orderId={deps.orderId}
hasBeforeUserEndSite={deps.hasBeforeUserEndSite}
hasPreviousOrder={deps.hasPreviousOrder}
@@ -1058,6 +1129,7 @@
onToggleDetailFade={deps.handleToggleDetailFade}
orderTrunkStatus={deps.trunkStatus}
doorUnlockedInCurrentPage={deps.doorUnlockedInCurrentPage}
+ forceShowCancelTrip={deps.forceShowCancelTrip}
onDoorUnlockSuccess={deps.onDoorUnlockSuccess}
bluetoothStatusReady={deps.bluetoothStatusReady}
bluetoothCardDescription={deps.bluetoothCardDescription}
diff --git a/src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts b/src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts
index 3394644..c39eb66 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
+import { CancelType } from '../../../constants/trip-status';
import wsManager from '../../../../utils/websocket';
import {
createOpenTrunkSocketHandler,
@@ -47,6 +48,8 @@
setPriceUpdateVersion: (updater: (v: number) => number) => void;
setFinishCountdownEnabled: (value: boolean) => void;
setDoorUnlockedInCurrentPage: React.Dispatch<React.SetStateAction<boolean>>;
+ /** 后端推送 ORDER_ROUTE.content.oddOut === 1 时,强制展示取消行程按钮(无视原有行程状态/车门解锁判断)。 */
+ setForceShowCancelTrip?: (value: boolean) => void;
setShowAnnouncementsPopup: (value: boolean) => void;
setRoutePoints: (points: any[]) => void;
setPolyline: (lines: any[]) => void;
@@ -59,9 +62,9 @@
currentFullRouteRef: any;
lastErasedIndexRef: any;
orderRouteInitializedRef: any;
- setCancelType: (value: number) => void;
+ setCancelType: (value: CancelType) => void;
setShowCancelPopup: (value: boolean) => void;
- cancelTypeRef: React.MutableRefObject<number>;
+ cancelTypeRef: React.MutableRefObject<CancelType>;
showCancelPopupRef: React.MutableRefObject<boolean>;
checkAndShowPassengerNotice: () => void;
handlePickupException?: (pickupException: PickupExceptionInfo | null) => void;
diff --git a/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-meta-socket-handlers.ts b/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-meta-socket-handlers.ts
index c40f904..d752a8a 100644
--- a/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-meta-socket-handlers.ts
+++ b/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-meta-socket-handlers.ts
@@ -1,7 +1,7 @@
import Taro from '@tarojs/taro';
import { queryVehicleByOrderId } from '../../../../api/begin-route';
import { MODIFY_DESTINATION_SOCKET_STATUS_KEY } from '../../../../constants/modify-destination';
-import { TripStatus } from '../../../../constants/trip-status';
+import { TripStatus, CancelType } from '../../../../constants/trip-status';
import {
executeWhenOrderMatched,
resolvePayloadOrderIdFromContentOnly,
@@ -110,8 +110,8 @@
onMatched: ({ currentOrderId }) => {
const currentDeps = depsRef.current;
lastFetchedVehicleOrderIdRef.current = null;
- // 改派成功,仅在正展示"正在为您重新派车中"弹窗(cancelType=6)时关闭,避免误关其他取消弹窗
- if (currentDeps.showCancelPopupRef.current && currentDeps.cancelTypeRef.current === 6) {
+ // 改派成功,仅在正展示"正在为您重新派车中"弹窗(CancelType.Redispatching)时关闭,避免误关其他取消弹窗
+ if (currentDeps.showCancelPopupRef.current && currentDeps.cancelTypeRef.current === CancelType.Redispatching) {
currentDeps.setShowCancelPopup(false);
}
const requestedOrderId = String(currentOrderId || '');
@@ -146,7 +146,7 @@
}
lastFetchedVehicleOrderIdRef.current = null;
rollbackOrderToCallingCar(currentDeps);
- currentDeps.setCancelType(6);
+ currentDeps.setCancelType(CancelType.Redispatching);
currentDeps.setShowCancelPopup(true);
}
});
diff --git a/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-route-socket-handler.ts b/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-route-socket-handler.ts
index 7c0d7b9..e834f94 100644
--- a/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-route-socket-handler.ts
+++ b/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-route-socket-handler.ts
@@ -134,6 +134,10 @@
}
const beforeUserSite = parseBeforeUserEndSite(payload);
currentDeps.setBeforeUserEndSite(beforeUserSite);
+ // 后端推送 oddOut === 1 时,无视行程状态/车门解锁判断,强制展示取消行程按钮;
+ // oddOut 恢复为非 1 时同步写回 false,避免同一订单内状态无法自动复位(该状态仅在订单切换时被重置)。
+ const oddOut = Number(payload?.content?.oddOut);
+ currentDeps.setForceShowCancelTrip?.(oddOut === 1);
const currentTripStatus = currentDeps.tripStatusRef.current;
const isModifyDestinationPreviewActive = Boolean(currentDeps.isModifyDestinationReviewVisible);
const isPickupRouteChangePreviewActive = Boolean(
diff --git a/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-status-socket-handler.ts b/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-status-socket-handler.ts
index cda82e7..8ceace3 100644
--- a/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-status-socket-handler.ts
+++ b/src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-status-socket-handler.ts
@@ -3,7 +3,7 @@
import { queryVehicleByOrderId } from '../../../../api/begin-route';
import { clearOrderTripStorage, reportEvent } from '../../../../../utils/Robotaxi-public-method';
import { getOrderId } from '../../../../utils/order';
-import { SocketOrderStatus, TripStatus, toTripStatus } from '../../../../constants/trip-status';
+import { SocketOrderStatus, TripStatus, toTripStatus, CancelType } from '../../../../constants/trip-status';
import { ARRIVED_DROPOFF_PAGE_VIEW_EVENT_ID, PICKUP_ARRIVED_PAGE_VIEW_EVENT_ID, TRIP_IN_PROGRESS_PAGE_VIEW_EVENT_ID, TRIP_IN_PROGRESS_COMPLETE_TRIP_BUTTON_EVENT_ID } from '../../../../constants/report-events';
import { createTripInProgressCompleteTripReportPayload } from '../../../../constants/confirm-ride-report';
import { clearPassedRouteCache } from '../passed-route-cache';
@@ -194,7 +194,7 @@
}
const matchedPendingCancelContext = isPendingCancelContextMatched(payload, pendingCancelContext);
if (matchedPendingCancelContext) {
- deps.setCancelType(pendingCancelContext?.cancelType || 4);
+ deps.setCancelType(pendingCancelContext?.cancelType || CancelType.OrderCanceled);
deps.pendingCancelContextRef.current = null;
} else if (changeStatusOld === TripStatus.VehicleApproaching || changeStatusOld === TripStatus.VehicleArrivedPickup) {
deps.setOrderId('');
@@ -203,9 +203,9 @@
Taro.removeStorageSync('pickupEndTime');
Taro.removeStorageSync('waitingTimerStartAt');
Taro.removeStorageSync('waitingTimerElapsed');
- deps.setCancelType(4);
+ deps.setCancelType(CancelType.OrderCanceled);
} else {
- deps.setCancelType(5);
+ deps.setCancelType(CancelType.NoAvailableVehicle);
}
deps.setTripStatus(TripStatus.CancelOnlyPickup);
deps.setShowCancelPopup(true);
@@ -403,10 +403,10 @@
reportTripInProgressEventIfNeeded(effectiveTripStatus, changeStatusOld, effectiveOrderId, currentDeps);
reportTripCompleteEventIfNeeded(effectiveTripStatus, changeStatusOld, effectiveOrderId, currentDeps);
handleStatusSideEffects(effectiveTripStatus, changeStatusOld, currentDeps);
- // 改派场景兜底:若当前展示的是"正在为您重新派车中"弹窗(cancelType=6),
+ // 改派场景兜底:若当前展示的是"正在为您重新派车中"弹窗(CancelType.Redispatching),
// 一旦订单状态推进回接驾中/已到达上车点,说明改派成功,自动关闭弹窗。
// 用同步 ref 镜像判断,避免依赖经 useEffect 异步回流的 cancelType 造成漏关。
- const isReassignPopupShowing = currentDeps.showCancelPopupRef.current && currentDeps.cancelTypeRef.current === 6;
+ const isReassignPopupShowing = currentDeps.showCancelPopupRef.current && currentDeps.cancelTypeRef.current === CancelType.Redispatching;
const isAdvancingIntoPickup = (
effectiveTripStatus === TripStatus.VehicleApproaching
|| effectiveTripStatus === TripStatus.VehicleArrivedPickup
diff --git a/src/robotaxi/pages/begin-route/index.less b/src/robotaxi/pages/begin-route/index.less
index 52c555b..97cc77f 100644
--- a/src/robotaxi/pages/begin-route/index.less
+++ b/src/robotaxi/pages/begin-route/index.less
@@ -584,3 +584,16 @@
.pr-0 {
padding-right: 0;
}
+
+// 进入页面的地图 loading 占位(网格图,样式与首页保持一致),覆盖在 <Map> 上,拿到定位后移除
+.map-placeholder-grid {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 99;
+ background-color: #F2F3F7;
+ background-image: linear-gradient(#E5E7EB 2px, transparent 2px), linear-gradient(90deg, #E5E7EB 2px, transparent 2px);
+ background-size: 40px 40px;
+}
diff --git a/src/robotaxi/pages/begin-route/index.tsx b/src/robotaxi/pages/begin-route/index.tsx
index cef976b..d4ed4b6 100644
--- a/src/robotaxi/pages/begin-route/index.tsx
+++ b/src/robotaxi/pages/begin-route/index.tsx
@@ -1,4 +1,5 @@
import Taro, { useDidShow, useShareAppMessage } from "@tarojs/taro"
+import { View } from '@tarojs/components';
import { useEffect, useState, useRef, useMemo,useCallback } from 'react';
import './index.less';
@@ -8,9 +9,12 @@
import ModifyDestinationFailPopup from './components/modify-destination-fail-popup';
import PickupExceptionPopup from './components/pickup-exception-popup';
import CreditPayPopup from './components/credit-pay-popup';
-import CouponSelectPopup from '../../components/coupon-select-popup';
// 调试面板:模拟完整行程流转(仅 dev 构建可见)
// import DebugOrderSimulatorPanel from './components/debug-order-simulator-panel';
+// 转场动效调试面板:呼叫中→派单成功、送驾中→行程结束(仅 dev 构建可见)
+// import DebugTransitionPanel from './components/debug-transition-panel';
+// 到达结束态全屏礼花动效
+import TripFinishedFireworks from './components/trip-finished-fireworks';
// 弹窗状态管理
import usePopupManager from './hooks/use-popup-manager';
// WebSocket 事件编排
@@ -28,9 +32,12 @@
// 地图视口状态管理
import { ROUTE_POLYLINE_BASE_ID } from './hooks/use-route-manager.shared';
import useMapViewport from './hooks/use-map-viewport';
-import useActiveTripScale from './hooks/use-active-trip-scale';
-// 速度驱动的地图自动缩放
-import useSpeedBasedScale from './hooks/use-speed-based-scale';
+// 地图相机(统一编排原生视口操作)
+import { useMapCameraController } from './hooks/camera/use-map-camera-controller';
+import { beginRoutePolicies } from './hooks/camera/policies';
+import { CameraPadding } from './hooks/camera/map-camera';
+import { applyHarmonyPaddingCompensation } from '../../utils/platform-env';
+import { mapPerfLog } from './hooks/camera/map-perf-log';
// 页面恢复与初始化编排
import usePageRestore from './hooks/use-page-restore';
// 页面动作编排
@@ -54,8 +61,11 @@
import useModifyDestinationFlow from './hooks/use-modify-destination-flow';
import useModifyDestinationPreviewRoute from './hooks/use-modify-destination-preview-route';
import usePreloadedMapAssets from './hooks/use-preloaded-map-assets';
+import useTransitionAssetPreload from './hooks/use-transition-asset-preload';
// 蓝牙状态与弹窗编排
import useBluetoothAccess from './hooks/use-bluetooth-access';
+// 页面级派单转场延后闸门(转场进行中暂缓蓝牙初始化)
+import { useDeferUntilDispatchSettled } from './hooks/use-defer-until-dispatch-settled';
// 蓝牙车控连接与鉴权编排
import useBluetoothCarControl from './hooks/use-bluetooth-car-control';
import { toTripStatus, TripStatus } from '../../constants/trip-status';
@@ -87,12 +97,10 @@
resetConfirmRideSelectionContext
} from '../../constants/confirm-ride-report';
import { withRobotaxiWhitelist } from '../../../utils/whitelist';
-import { withToast } from '../../components/toast/with-toast';
-import { showToast } from '../../components/toast';
+import { showToast, hideToast } from '../../components/toast';
import type { PickupExceptionInfo } from './pickup-exception';
const MODIFY_DESTINATION_PICKUP_STATUS_GUARD_MS = 5000;
-const COUPON_POPUP_CLOSE_VIEWPORT_REFRESH_DELAY_MS = 320; // 优惠券弹窗退场动画结束后,再刷新完成态视口
const ROUTE_SELECTABLE_TRIP_STATUSES = [TripStatus.PublishRoute, TripStatus.VehicleApproaching] as const; // 允许展示并切换候选路线的行程状态
const PICKUP_ROUTE_CHANGE_ELIGIBLE_ORDER_ID_KEY = 'pickupRouteChangeEligibleOrderId'; // 接驾态允许切路线的订单 ID,仅在当前订单生命周期内有效
const PASSENGER_NOTICE_REGIONCHANGE_COOLDOWN_MS = 1000;
@@ -241,45 +249,13 @@
handleCloseNoRoutePopup,
privacyUpdateInfo
} = usePopupManager(); // 弹窗状态编排
- const [longitude, setLongitudeState] = useState<number | null>(null); // 经度
- const [latitude, setLatitudeState] = useState<number | null>(null); // 纬度
- // 记录 latitude / longitude 的最新值,供跨渲染读取;更新 ref 本身不会触发重新渲染
- // (mapScale 的同类 ref / recordMapScale / setMapScale 已下沉到 useMapViewport 内导出)
- const latitudeRef = useRef<number | null>(null);
- const longitudeRef = useRef<number | null>(null);
- // record* 系列:仅记录最新值,不触发重新渲染,等下次渲染时直接读取 ref 中记录的最新值
- const recordLatitude = useCallback((value: number | null) => {
- latitudeRef.current = value;
- }, []);
- const recordLongitude = useCallback((value: number | null) => {
- longitudeRef.current = value;
- }, []);
- const globalFitSyncRef = useRef(false); // 标记全局 includePoints 触发,用于在 regionchange end 时同步原生缩放
+ const [longitude] = useState<number | null>(null); // 经度(中心点业务 state 已移除,首帧使用业务点位兜底)
+ const [latitude] = useState<number | null>(null); // 纬度(中心点业务 state 已移除,首帧使用业务点位兜底)
+ const initialMapCenterRef = useRef<{ longitude: number; latitude: number } | null>(null); // 仅首次挂载初始化,运行时相机变化不回写
const passengerNoticeRegionChangeIgnoreUntilRef = useRef(0); // 乘车须知开关后的地图 regionchange(update) 冷却截止时间
- const markViewportFitSyncing = useCallback(() => {
- globalFitSyncRef.current = true;
- }, []);
// 用户手势进行中标记:从 regionchange 'begin'(用户拖拽/缩放手势) 到 'end' 之间为 true。
// 供平滑运动在手势期间暂停车辆位置回灌,避免高频 setState 触发重渲染把受控相机弹回。
const userGestureActiveRef = useRef(false);
- // 经纬度 setter:更新 state 触发渲染的同时同步记录最新值,保证 ref 始终为最新(兼容函数式更新)
- const setLatitude = useCallback((value: number | null | ((prev: number | null) => number | null)) => {
- setLatitudeState((prev) => {
- const next = typeof value === 'function' ? value(prev) : value;
- latitudeRef.current = next;
- return next;
- });
- }, []);
- const setLongitude = useCallback((value: number | null | ((prev: number | null) => number | null)) => {
- setLongitudeState((prev) => {
- const next = typeof value === 'function' ? value(prev) : value;
- longitudeRef.current = next;
- return next;
- });
- }, []);
- const syncMapCenterAfterViewportFit = useCallback(() => {
- markViewportFitSyncing();
- }, [markViewportFitSyncing]);
const [startSite, setStartSite] = useState<any>(null); // 上车点
const [endSite, setEndSite] = useState<any>({}); // 下车点
const [tripStatus, setTripStatus] = useState<TripStatus>(toTripStatus(Taro.getStorageSync('tripStatus'))); // 当前行程状态
@@ -297,6 +273,7 @@
const [callSubmitting, setCallSubmitting] = useState(false); // 呼叫车辆提交中状态
const [orderId, setOrderId] = useState<string>(''); // 订单ID
const [doorUnlockedInCurrentPage, setDoorUnlockedInCurrentPage] = useState(false); // 当前页面内是否已解锁过车门
+ const [forceShowCancelTrip, setForceShowCancelTrip] = useState(false); // 后端推送 ORDER_ROUTE.content.oddOut === 1 时,强制展示取消行程按钮
const [finishCountdownEnabled, setFinishCountdownEnabled] = useState(false); // 完成倒计时是否启用
const customCalloutIconUrl = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/maker-tooltip-right.png'; // 自定义气泡图标URL
const waitingCalloutBgUrl = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/tooltip-bg-20260715.png'; // 等待气泡背景图URL
@@ -368,7 +345,6 @@
}, [orderId]);
const pendingPickupRouteChangeEligibilityRef = useRef(false); // 预估态多路线资格待绑定到订单的标记
const pickupRouteChangeSnapshotRef = useRef<PickupRouteChangeSnapshot | null>(null); // 接驾态打开路线切换前的页面快照,用于取消切换后恢复
- const couponCloseViewportRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 优惠券弹窗关闭后的完成态视口刷新定时器
const pickupArrivedReportedOrderIdRef = useRef(''); // 到达上车点页面浏览埋点去重
const tripInProgressReportedOrderIdRef = useRef(''); // 行程中页面浏览埋点去重
const arrivedDestinationReportedOrderIdRef = useRef(''); // 到达目的地完成行程埋点去重,避免同一订单重复 socket 上报
@@ -460,6 +436,9 @@
waitingRadarUrl
}
}); // 地图气泡资源预加载
+
+ // 转场动效素材预加载(按行程状态空闲预载:呼叫中→派单成功素材、送驾中→行程结束素材)
+ useTransitionAssetPreload({ tripStatus });
const {
openService,
toCoupon,
@@ -696,7 +675,6 @@
setEndSite,
setBeforeUserEndSite,
setHasPreviousOrder,
- syncMapCenterAfterViewportFit,
updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled,
vehicleMoveAlongPoints,
setVehicleDirection,
@@ -915,8 +893,9 @@
try {
setIsConfirmingPickupRouteChange(true);
- Taro.showLoading({
+ showToast({
title: '正在修改路线',
+ icon: 'loading',
mask: true
});
await selectNavigationRoute({
@@ -927,7 +906,7 @@
setIsPickupRouteChangeVisible(false);
pickupRouteChangeSnapshotRef.current = null;
routeRequestOrderRoute(orderId, tripStatus);
- Taro.hideLoading();
+ hideToast();
setTimeout(() => {
showToast({
title: '切换成功,将按新路线行驶',
@@ -935,7 +914,7 @@
});
}, 0);
} catch (error) {
- Taro.hideLoading();
+ hideToast();
} finally {
setIsConfirmingPickupRouteChange(false);
}
@@ -1020,52 +999,92 @@
endSite: effectiveEndSite,
vehicleLocation,
longitude,
- latitude,
- setLongitude,
- setLatitude,
- recordLongitude,
- recordLatitude,
- orderDetailViewportRefreshVersion,
- markViewportFitSyncing
+ latitude
}); // 地图视口状态容器
const {
mapBottomPadding,
- mapScale,
- setMapScale,
- mapScaleRef,
- recordMapScale,
originBoxHidden,
originOpacity,
setOriginOpacity,
showLocationFlag,
tripStatus6ViewportLockedRef,
- holdDynamicScale,
- getDynamicScaleHoldUntil,
updateMapViewportPadding,
- toOrigin,
- getLocationInfo,
- computeBottomPadding,
- resolveSafeTopInset,
- syncNativeViewportToControlledState
+ measureBottomPadding,
+ resolveSafeTopInset
} = viewportState; // 视口状态解构
- const activeTripScale = useActiveTripScale({
+
+ // 进入页面的地图 loading 占位(网格图):挂载即 true,首帧定位拿到坐标后置 false
+ const [mapLoading, setMapLoading] = useState(true);
+ // 地图相机:统一编排原生视口操作(当前仅发单页策略生效,其它状态无策略则不动地图)
+ // includePoints 执行前即时测量底部卡片高度,消除「先 fit 后测量」的竞态;
+ // 测量失败/超时回退上次缓存值(computeBottomPadding),不阻塞 fit。
+ // 注:resolveSafeTopInset()/measureBottomPadding() 内部已按鸿蒙补偿系数处理,此处的
+ // 固定 50(左右留白)在鸿蒙下同样需要补偿,避免和 top/bottom 的像素基准不一致。
+ const cameraGetPadding = useCallback(async (): Promise<CameraPadding> => {
+ const sidePadding = 60;
+ const topPadding = 70;
+ const sideInset = applyHarmonyPaddingCompensation(sidePadding, 'side');
+ const top = applyHarmonyPaddingCompensation(resolveSafeTopInset() + topPadding, 'top');
+ const bottomTemp = await measureBottomPadding();
+ const bottom = applyHarmonyPaddingCompensation(bottomTemp, 'bottom');
+ return [top, sideInset, bottom, sideInset];
+ }, [measureBottomPadding, resolveSafeTopInset]);
+ // focus 可视区域居中专用 padding:与 cameraGetPadding 参数口径一致,但不做鸿蒙补偿。
+ // focus 的可视区域居中换算(resolveFocusCenter,见 map-camera.ts)全程基于真实 DOM
+ // 像素(getRegion/getMapHeightPx)计算纵向偏移,不经过 includePoints 原生 API,因此
+ // 不能套用「补偿 includePoints 像素解释偏差」的鸿蒙补偿系数——否则会导致偏移量在
+ // 鸿蒙上被错误放大数倍,上车点无法正确居中到可视区域(这正是鸿蒙上呼单态上车点
+ // 未居中、而 iOS/安卓正常的原因:两端未做该补偿,偏移换算是准的)。
+ const cameraGetFocusPadding = useCallback(async (): Promise<CameraPadding> => {
+ const sidePadding = 60;
+ const topPadding = 70;
+ const top = resolveSafeTopInset() + topPadding;
+ const bottom = await measureBottomPadding();
+ return [top, sidePadding, bottom, sidePadding];
+ }, [measureBottomPadding, resolveSafeTopInset]);
+ const cameraController = useMapCameraController({
+ mapId: 'map',
tripStatus: mapPresentationTripStatus,
- computeBottomPadding,
- resolveSafeTopInset,
- holdDynamicScale
- }); // 行程中 ORDER_UPDATE_LINE 视口编排
- const { notifyUserMapGesture, pendingFitSyncRef } = useSpeedBasedScale({
- tripStatus: mapPresentationTripStatus,
- getDynamicScaleHoldUntil,
+ policies: beginRoutePolicies,
+ startSite,
+ endSite: effectiveEndSite,
+ routePoints,
+ walkingPoints,
vehicleLocation,
- routeDistance,
- computeBottomPadding,
- resolveSafeTopInset,
- setLatitude,
- setLongitude
- }); // 接驾中/行程中按路线缩短自动调整视图
+ userLocation: currentUserLocation,
+ getPadding: cameraGetPadding,
+ getFocusPadding: cameraGetFocusPadding,
+ onLocated: () => setMapLoading(false),
+ });
+ // 关闭取消弹窗(再等等/关闭)会触发重渲染,受控 <Map> 可能被弹回兜底(北京);
+ // 关闭后按当前状态主动回中一次纠正(effect 在提交后执行,晚于弹回)。
+ const prevShowCancelPopupRef = useRef(showCancelPopup);
+ useEffect(() => {
+ const wasOpen = prevShowCancelPopupRef.current;
+ prevShowCancelPopupRef.current = showCancelPopup;
+ if (wasOpen && !showCancelPopup) {
+ cameraController.recenter('cancel-popup-close');
+ }
+ }, [showCancelPopup]);
const arrivedMarkerReady = mapPresentationTripStatus !== TripStatus.ArrivedDestination || typeof vehicleDirection === 'number';
- const arrivedTooltipReady = mapPresentationTripStatus !== TripStatus.ArrivedDestination || (arrivedMarkerReady && mapScale >= 20);
+ // 到达气泡的缩放就绪判断改用原生 getScale 读取真实缩放,不再依赖受控 mapScale state。
+ const [arrivedTooltipScaleReady, setArrivedTooltipScaleReady] = useState(false);
+ const refreshArrivedTooltipScaleReady = useCallback(() => {
+ Taro.createMapContext('map').getScale({
+ success: (res) => {
+ setArrivedTooltipScaleReady(Number(res?.scale) >= 20);
+ },
+ fail: () => {}
+ });
+ }, []);
+ useEffect(() => {
+ if (mapPresentationTripStatus !== TripStatus.ArrivedDestination) {
+ setArrivedTooltipScaleReady(false);
+ return;
+ }
+ refreshArrivedTooltipScaleReady();
+ }, [mapPresentationTripStatus, refreshArrivedTooltipScaleReady]);
+ const arrivedTooltipReady = mapPresentationTripStatus !== TripStatus.ArrivedDestination || (arrivedMarkerReady && arrivedTooltipScaleReady);
const markerState = useMapMarkers({
tripStatus: mapPresentationTripStatus,
statusRule: effectiveStatusRule,
@@ -1099,7 +1118,6 @@
shouldShowWaitingRadar,
isAndroid,
tripStatus: mapPresentationTripStatus,
- mapScale,
isModifyDestinationReviewVisible,
startSite,
endSite: effectiveEndSite,
@@ -1230,9 +1248,6 @@
setEndSite,
setVehicleMoveAlongPoints,
setTrunkStatus,
- setLatitude,
- setLongitude,
- getLocationInfo,
routeRequestRouteAndFare,
routeRequestOrderRoute,
routeUpdateWalkingRouteForStart,
@@ -1243,6 +1258,13 @@
updatePickupRouteChangeEligibleOrder
}); // 页面恢复与初始化编排
const router = Taro.useRouter();
+ const didShowRouteParamsRef = useRef<{
+ skipPageFlag?: string;
+ fromModifyDestination?: string;
+ }>({
+ skipPageFlag: router.params?.skipPageFlag,
+ fromModifyDestination: router.params?.fromModifyDestination
+ }); // begin-route 路由参数只消费一次,避免回前台时重复触发“已有订单”提示
const tripRuntime = useTripRuntime({
waitingTimerStartKey: WAITING_TIMER_START_KEY,
waitingTimerElapsedKey: WAITING_TIMER_ELAPSED_KEY,
@@ -1264,8 +1286,6 @@
latitude,
elapsedSeconds,
setElapsedSeconds,
- setLatitude,
- setLongitude,
setWalkingPoints,
setWalkingTime,
setRoutePoints,
@@ -1304,10 +1324,10 @@
lastPolylineUpdateAtRef.current = 0;
};
/**
- * 手动切线前先暂停速度缩放,避免一次性全览后立即被速度接管。
+ * 手动切线前先避让相机自动意图,避免一次性全览后立即被自动 fit 接管。
*/
const handleSelectRoute = (index: number, options?: any) => {
- holdDynamicScale();
+ cameraController.camera.holdAuto(0);
baseHandleSelectRoute(index, options);
};
@@ -1315,7 +1335,7 @@
* 处理路线 polyline 点击切换。
*/
const handlePolylineTap = (event: any) => {
- holdDynamicScale();
+ cameraController.camera.holdAuto(0);
baseHandlePolylineTap(event);
};
@@ -1323,7 +1343,7 @@
* 处理地图点击切换路线。
*/
const handleMapTap = (event: any) => {
- holdDynamicScale();
+ cameraController.camera.holdAuto(0);
baseHandleMapTap(event);
};
@@ -1489,6 +1509,7 @@
setPriceUpdateVersion,
setFinishCountdownEnabled,
setDoorUnlockedInCurrentPage,
+ setForceShowCancelTrip,
setShowAnnouncementsPopup,
setRoutePoints,
setPolyline,
@@ -1524,7 +1545,6 @@
erasePassedRouteSegments: routeErasePassedRouteSegments,
applyRoutePoints: routeApplyRoutePoints,
updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled,
- onOrderUpdateLineRendered: activeTripScale.handleOrderUpdateLineRendered,
applySmoothRouteSnapshot,
resetSmoothRouteMotion,
modifyDestinationPickupStatusGuardMs: MODIFY_DESTINATION_PICKUP_STATUS_GUARD_MS,
@@ -1574,6 +1594,13 @@
setDoorUnlockedInCurrentPage(readDoorUnlockedStateForOrder(orderId));
}, [orderId, readDoorUnlockedStateForOrder]);
+ /**
+ * 订单切换后重置强制展示取消行程按钮的标记,避免串单。
+ */
+ useEffect(() => {
+ setForceShowCancelTrip(false);
+ }, [orderId]);
+
useEffect(() => {
if (!doorUnlockedInCurrentPage || !orderId) {
return;
@@ -1605,7 +1632,7 @@
}
creditPayFallbackActiveOrderIdRef.current = '';
if (hideLoading) {
- Taro.hideLoading();
+ hideToast();
}
}, []);
@@ -1624,7 +1651,7 @@
clearCreditPayFallbackRuntime(false);
creditPayFallbackActiveOrderIdRef.current = resolvedOrderId;
- Taro.showLoading({ title: '扣款结果查询中,请勿重复支付', mask: true });
+ showToast({ title: '扣款结果查询中,请勿重复支付', icon: 'loading', mask: true });
const checkCreditPayFallback = async () => {
try {
@@ -1633,7 +1660,7 @@
Taro.removeStorageSync('creditPayOrderId');
clearCreditPayFallbackRuntime();
setCreditPayFallbackRefreshVersion((prev) => prev + 1);
- Taro.showToast({ title: '免密扣款失败,请手动支付', icon: 'none' });
+ showToast({ title: '免密扣款失败,请手动支付', icon: 'none' });
}
} catch (error) {
}
@@ -1658,8 +1685,13 @@
useEffect(() => {
if (tripStatus === TripStatus.TripFinished) {
- void handleCreditPayTripFinished(orderId);
+ // 等行程结束动效(礼花 + 卡片/里程升起,约 700ms 延迟 + 1s 升起 ≈ 1.7s)播完再调起免密支付,
+ // 避免「扣款结果查询中」Toast/遮罩在动效期间弹出打断观感。
+ const payTimer = setTimeout(() => {
+ void handleCreditPayTripFinished(orderId);
+ }, 2100);
return () => {
+ clearTimeout(payTimer);
clearCreditPayFallbackRuntime();
};
} else {
@@ -1695,7 +1727,7 @@
try {
await cancelOrder(targetOrderId);
- // Taro.showToast({
+ // showToast({
// title: reason === 'launch_failed' ? '拉起信用分失败,已取消发单' : '未完成信用分授权,已取消发单',
// icon: 'none'
// });
@@ -1712,7 +1744,7 @@
reason,
error
});
- Taro.showToast({ title: '取消订单失败,请重试', icon: 'none' });
+ showToast({ title: '取消订单失败,请重试', icon: 'none' });
}
}, [orderId, syncWaitingSignedCreditPayResult]);
@@ -1808,8 +1840,6 @@
setCancelPopupWaitMinutes,
setWalkingPoints,
setWalkingTime,
- setLatitude,
- setLongitude,
setShowOverRoutePopup,
handleOpenPrivacyPopup,
handleOpenCreditPayPopup,
@@ -1849,6 +1879,9 @@
tripStatus
});
const shouldSkipBluetoothAuth = doorUnlockedInCurrentPage || bluetoothDoorUnlockSuccess;
+ // 页面级派单转场延后闸门:进入接驾中(4)暂缓蓝牙初始化,避免转场动画中弹系统授权弹窗;
+ // 动效完成由 CallingCar 的 onDispatchTransitionSettled 回调 markSettled 放行。
+ const { dispatchTransitionActive, markSettled: handleDispatchTransitionSettled } = useDeferUntilDispatchSettled(tripStatus);
const {
bluetoothStatusReady,
bluetoothCardDescription,
@@ -1868,7 +1901,8 @@
bluetoothAuthSuccess,
startBluetoothAuth,
skipBluetoothAuth: shouldSkipBluetoothAuth,
- unlockTokenFailed
+ unlockTokenFailed,
+ dispatchTransitionActive
});
const { handleConfirmRequoteIfNeeded } = useModifyDestinationPreviewRoute({
tripStatus,
@@ -1902,13 +1936,13 @@
updateMapViewportPadding();
return;
}
- handleDidShow(router.params?.skipPageFlag, router.params?.fromModifyDestination);
+ const didShowRouteParams = didShowRouteParamsRef.current;
+ handleDidShow(didShowRouteParams.skipPageFlag, didShowRouteParams.fromModifyDestination);
+ didShowRouteParamsRef.current = {
+ skipPageFlag: undefined,
+ fromModifyDestination: undefined
+ };
updateMapViewportPadding();
- // 完成态下隐藏小程序再恢复时,受控中心点可能与原生地图实际中心脱钩,主动触发一次
- // order_detail includePoints 纠偏,避免路线被压在底部支付卡片附近。
- if ([TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatusRef.current)) {
- setOrderDetailViewportRefreshVersion((v) => v + 1);
- }
});
/**
@@ -1983,20 +2017,16 @@
};
const handleOpenPassengerNotice = useCallback(() => {
- pendingFitSyncRef.current = false;
- globalFitSyncRef.current = false;
passengerNoticeRegionChangeIgnoreUntilRef.current =
Date.now() + PASSENGER_NOTICE_REGIONCHANGE_COOLDOWN_MS;
rawHandleOpenPassengerNotice();
- }, [rawHandleOpenPassengerNotice, pendingFitSyncRef]);
+ }, [rawHandleOpenPassengerNotice]);
const handleClosePassengerNotice = useCallback(() => {
- pendingFitSyncRef.current = false;
- globalFitSyncRef.current = false;
passengerNoticeRegionChangeIgnoreUntilRef.current =
Date.now() + PASSENGER_NOTICE_REGIONCHANGE_COOLDOWN_MS;
rawHandleClosePassengerNotice();
- }, [rawHandleClosePassengerNotice, pendingFitSyncRef]);
+ }, [rawHandleClosePassengerNotice]);
const handleCloseEvaluatePopup = () => {
setEvaluateCloseVersion(v => v + 1);
@@ -2015,17 +2045,24 @@
const handleRegionChange = (e) => {
const causedBy = normalizeRegionChangeCause(e?.causedBy || e?.detail?.causedBy || '');
+ const eventType = String(e?.type || e?.detail?.type || '').toLowerCase();
+ const nativeCenter = e?.detail?.centerLocation;
+ // 诊断:打印每个 regionchange 的类型/来源 + 原生实际 scale/center,用于定位"弹回北京/14"的触发。
+ // mapPerfLog('regionchange', {
+ // type: eventType,
+ // causedBy,
+ // scale: e?.detail?.scale,
+ // center: nativeCenter,
+ // });
+
const isPassengerNoticeRegionChangeCoolingDown =
passengerNoticeRegionChangeIgnoreUntilRef.current > Date.now();
if (causedBy === 'update' && isPassengerNoticeRegionChangeCoolingDown) {
return;
}
- activeTripScale.handleRegionChange(e);
handleAndroidOverlayRegionChange(e);
+ cameraController.onRegionChange(e);
- // 用户手势导致的缩放/拖拽结束后,把原生地图的实际 scale 同步回 React 状态,
- // 避免受控 <Map scale={mapScale}> 与原生实际缩放漂移,影响后续视口编排判断。
- const eventType = String(e?.type || e?.detail?.type || '').toLowerCase();
// 维护用户手势进行中标记:手势 begin 置位、end 复位,供平滑运动在手势期间暂停车辆位置回灌。
const isUserGesture = causedBy === 'scale' || causedBy === 'drag' || causedBy === 'gesture';
if (eventType === 'begin') {
@@ -2038,53 +2075,9 @@
userGestureActiveRef.current = false;
}
-
- if(eventType === 'begin' && isUserGesture) {
- notifyUserMapGesture();
- }
-
- if (eventType === 'end' && isUserGesture) {
-
- const actualScale = Number(e?.detail?.scale);
- if (!Number.isFinite(actualScale)) {
- return;
- }
- const rounded = Math.round(actualScale);
- if (rounded !== mapScaleRef.current) {
- recordMapScale(rounded);
- }
-
- const center = e?.detail?.centerLocation;
- const lat = Number(center?.latitude);
- const lng = Number(center?.longitude);
- if (Number.isFinite(lat) && Number.isFinite(lng)) {
- recordLatitude(lat);
- recordLongitude(lng);
- }
- }
-
- // useSpeedBasedScale.performFit 调用 includePoints 后会置 pendingFitSyncRef=true,
- // globalFitSyncRef.current = true 表示全局其他地方(如 PublishRoute 的 includePoints)
- // 等本次 fit 动画真正结束(type==='end')时直接从事件 detail 读取实际 scale/center
- // 同步回 React 受控状态,避免后续 re-render 把原生视口拉回 React 旧值造成抖动。
- if (eventType === 'end' && (pendingFitSyncRef.current || globalFitSyncRef.current)) {
- pendingFitSyncRef.current = false;
- globalFitSyncRef.current = false;
- const detail = e?.detail || e;
- const fittedScale = Number(detail?.scale);
- if (Number.isFinite(fittedScale)) {
- const rounded = Math.floor(fittedScale);
- if (rounded !== mapScaleRef.current) {
- recordMapScale(rounded);
- }
- }
- const fittedCenter = detail?.centerLocation;
- const fittedLat = Number(fittedCenter?.latitude);
- const fittedLng = Number(fittedCenter?.longitude);
- if (Number.isFinite(fittedLat) && Number.isFinite(fittedLng)) {
- recordLatitude(fittedLat);
- recordLongitude(fittedLng);
- }
+ // 到达目的地态下,视口变化结束后用原生 getScale 读取真实缩放,刷新到达气泡就绪判断。
+ if (eventType === 'end' && mapPresentationTripStatus === TripStatus.ArrivedDestination) {
+ refreshArrivedTooltipScaleReady();
}
};
@@ -2256,17 +2249,7 @@
};
// 关闭优惠券选择弹窗
const handleCloseCouponSelect = () => {
- if (couponCloseViewportRefreshTimerRef.current) {
- clearTimeout(couponCloseViewportRefreshTimerRef.current);
- couponCloseViewportRefreshTimerRef.current = null;
- }
setShowCouponSelectPopup(false);
- if ([TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus)) {
- couponCloseViewportRefreshTimerRef.current = setTimeout(() => {
- setOrderDetailViewportRefreshVersion((version) => version + 1);
- couponCloseViewportRefreshTimerRef.current = null;
- }, COUPON_POPUP_CLOSE_VIEWPORT_REFRESH_DELAY_MS);
- }
};
// 支付成功后关闭优惠券选择弹窗
useEffect(() => {
@@ -2275,12 +2258,6 @@
}
}, [tripStatus]);
- useEffect(() => () => {
- if (couponCloseViewportRefreshTimerRef.current) {
- clearTimeout(couponCloseViewportRefreshTimerRef.current);
- couponCloseViewportRefreshTimerRef.current = null;
- }
- }, []);
// 选择优惠券后仅刷新价格,不刷新地图路线
const handleSelectCoupon = (item: any) => {
const selectedCouponId = String(item?.operationCouponId || item?.couponId || '');
@@ -2408,7 +2385,7 @@
const { renderMapOverlays, renderCarCalloutOverlay, renderTripCard, fareBreakdown } = useViewDelegates({
isAndroid,
tripStatus,
- mapScale,
+ onDispatchTransitionSettled: handleDispatchTransitionSettled,
markers: markers,
isRouteSelectExpanded,
isPickupRouteChangeVisible,
@@ -2468,6 +2445,7 @@
handleShareTrip,
trunkStatus,
doorUnlockedInCurrentPage,
+ forceShowCancelTrip,
onDoorUnlockSuccess: handleDoorUnlockSuccess,
routeOptions,
selectedRouteIndex,
@@ -2523,15 +2501,20 @@
}
pendingFareCouponSelectionRef.current = { couponId: '', couponName: '' };
}, [tripStatus, fareBreakdown?.userCouponId]);
- // 渲染时优先使用 record*/set* 记录的最新值(ref),ref 为空再回退到 state,保证下次渲染拿到最新值
- // 【修复频繁切换目的地后地图闪现北京故宫的问题】
- // 当页面重新挂载且定位接口(getLocation)还未返回时,longitude/latitude 状态为 null。
- // 若直接使用 116.397128(故宫) 兜底,地图会先飞到北京,等定位回来后又瞬间拉回,造成视觉跳跃。
- // 解决方案:在定位空窗期,优先复用本地缓存已恢复的业务点位(上车点 -> 下车点 -> 车辆位置)作为过渡中心点。
- const fallbackLongitude = startSite?.longitude ?? effectiveEndSite?.longitude ?? vehicleLocation?.longitude ?? 116.397128;
- const fallbackLatitude = startSite?.latitude ?? effectiveEndSite?.latitude ?? vehicleLocation?.latitude ?? 39.916527;
- const mapLongitude = longitudeRef.current ?? longitude ?? fallbackLongitude;
- const mapLatitude = latitudeRef.current ?? latitude ?? fallbackLatitude;
+ // <Map> 经纬度只用于首次挂载:优先使用已恢复的业务点位,之后保持引用不变。
+ // 运行时视口统一交给 MapContext(includePoints / moveToLocation / recenter)和用户手势,
+ // 不再把 regionchange centerLocation 回写给 React,避免原生 update 与页面渲染形成反馈漂移。
+ const fallbackCenterLongitude = startSite?.longitude ?? effectiveEndSite?.longitude ?? vehicleLocation?.longitude;
+ const fallbackCenterLatitude = startSite?.latitude ?? effectiveEndSite?.latitude ?? vehicleLocation?.latitude;
+ const hasFallbackCenter = Number.isFinite(Number(fallbackCenterLatitude)) && Number.isFinite(Number(fallbackCenterLongitude));
+ if (!initialMapCenterRef.current && (longitude !== null || latitude !== null || hasFallbackCenter)) {
+ initialMapCenterRef.current = {
+ longitude: longitude ?? (hasFallbackCenter ? Number(fallbackCenterLongitude) : 116.397128),
+ latitude: latitude ?? (hasFallbackCenter ? Number(fallbackCenterLatitude) : 39.916527),
+ };
+ }
+ const mapLongitude = initialMapCenterRef.current?.longitude ?? 116.397128;
+ const mapLatitude = initialMapCenterRef.current?.latitude ?? 39.916527;
const canToggleRouteSelect = isPickupRouteChangeMode
? isPickupRouteChangeEligible
: allowSelectableRoutes && routeOptions.length > 1;
@@ -2584,10 +2567,10 @@
const beginRouteViewContextValue = {
mapLongitude,
mapLatitude,
- mapScale: mapScaleRef.current ?? mapScale,
markers: markers,
polyline: displayPolyline,
- showLocation: !!longitude && showLocationFlag,
+ showLocation: showLocationFlag,
+ mapLoading,
handleCalloutTap,
handleRegionChange,
onPolylineTap: handlePolylineTap,
@@ -2605,7 +2588,7 @@
shouldShowOrigin,
originBoxHidden,
originOpacity,
- toOrigin,
+ toOrigin: () => cameraController.recenter('user-location-button'),
handleWalkNavigation,
showWalkNavigationButton,
selectRouteIcon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/select-route-20260716-2.png',
@@ -2626,6 +2609,11 @@
walkIcon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/walk-icon-20260716.png',
originIcon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/origin-icon-20260716.png',
renderTripCard,
+ showCouponSelectPopup,
+ couponOrderMoney,
+ activeCouponId,
+ handleCloseCouponSelect,
+ handleSelectCoupon,
showCustomerService,
handleCloseCustomerService,
showCancelPopup,
@@ -2667,31 +2655,37 @@
<BeginRouteViewProvider value={beginRouteViewContextValue}>
<BeginRouteView />
</BeginRouteViewProvider>
- <CouponSelectPopup
- show={showCouponSelectPopup}
- orderMoney={couponOrderMoney}
- initialSelectedCouponId={activeCouponId}
- onClose={handleCloseCouponSelect}
- onSelect={handleSelectCoupon}
- />
- <ModifyDestinationFailPopup
- show={showModifyDestinationFailPopup}
- content={modifyDestinationFailMessage}
- onConfirm={handleAcknowledgeModifyDestinationFail}
- />
- <PickupExceptionPopup
- show={showPickupExceptionPopup}
- pickupExceptionInfo={pickupExceptionInfo}
- onClose={handleClosePickupExceptionPopup}
- onConfirm={handleConfirmPickupExceptionNavigation}
- />
- <CreditPayPopup
- show={showCreditPayPopup}
- onClose={handleCloseCreditPayPopup}
- onConfirm={handleConfirmCreditPayPopup}
- />
- {/* 模拟器入口已关闭,如需本地调试可取消下方注释
- <DebugOrderSimulatorPanel
+ <View>
+ {/* 与 BeginRoutePopupArea 同理:以下弹窗组件隐藏时 return null,节点挂载/卸载
+ 不再连坐 <BeginRouteView> 中的 <Map>,避免 map 全量重应用 lat/lng/scale
+ 导致视口跳变。所有弹窗均为 position:fixed,外层 View 不影响定位。 */}
+ <ModifyDestinationFailPopup
+ show={showModifyDestinationFailPopup}
+ content={modifyDestinationFailMessage}
+ onConfirm={handleAcknowledgeModifyDestinationFail}
+ />
+ <PickupExceptionPopup
+ show={showPickupExceptionPopup}
+ pickupExceptionInfo={pickupExceptionInfo}
+ onClose={handleClosePickupExceptionPopup}
+ onConfirm={handleConfirmPickupExceptionNavigation}
+ />
+ <CreditPayPopup
+ show={showCreditPayPopup}
+ onClose={handleCloseCreditPayPopup}
+ onConfirm={handleConfirmCreditPayPopup}
+ />
+ <TripFinishedFireworks tripStatus={tripStatus} />
+ </View>
+ {/* 转场动效调试面板 — 固定在地图上方,始终可见(临时开启,调试完成后请重新注释) */}
+ {/* <DebugTransitionPanel
+ orderId={orderId}
+ setOrderId={setOrderId}
+ setTripStatus={setTripStatus}
+ bindSocketListeners={bindSocketListeners}
+ /> */}
+ {/* 模拟器入口已关闭,如需本地调试可取消下方注释 */}
+ {/* <DebugOrderSimulatorPanel
orderId={orderId}
setOrderId={setOrderId}
setTripStatus={setTripStatus}
@@ -2702,10 +2696,10 @@
setShowCancelPopup={setShowCancelPopup}
setShowModifyDestinationFailPopup={setShowModifyDestinationFailPopup}
setModifyDestinationFailMessage={setModifyDestinationFailMessage}
- />
- */}
+ /> */}
+
</>
)
}
-export default withRobotaxiWhitelist(withToast(BeginRoute));
+export default withRobotaxiWhitelist(BeginRoute);
diff --git a/src/robotaxi/pages/coupon/index.tsx b/src/robotaxi/pages/coupon/index.tsx
index 54383ce..1439991 100644
--- a/src/robotaxi/pages/coupon/index.tsx
+++ b/src/robotaxi/pages/coupon/index.tsx
@@ -12,7 +12,7 @@
CouponDetail
} from "../../../robotaxi/api/coupon";
import { withRobotaxiWhitelist } from "../../../utils/whitelist";
-import { showToast } from "../../components/toast";
+import { showToast, hideToast } from "../../components/toast";
import { withToast } from "../../components/toast/with-toast";
function CouponPage() {
@@ -126,7 +126,7 @@
const openPopup = async (item: CouponItem) => {
try {
- Taro.showLoading({ title: "加载中..." });
+ showToast({ title: "加载中...", icon: 'loading' });
const detail = await getCouponDetail(item.couponId);
detail.couponDesc = decodeURIComponent(detail.couponDesc || "");
@@ -137,7 +137,7 @@
);
}
setCurrentCoupon(detail);
- Taro.hideLoading();
+ hideToast();
const animation = Taro.createAnimation({
duration: 300,
@@ -147,7 +147,7 @@
setAnimationData(animation.export());
setVisible(true);
} catch (error) {
- Taro.hideLoading();
+ hideToast();
showToast({ title: "获取详情失败", icon: "none" });
}
};
diff --git a/src/robotaxi/pages/expense-description/index.less b/src/robotaxi/pages/expense-description/index.less
index 2f621a3..1c66afc 100644
--- a/src/robotaxi/pages/expense-description/index.less
+++ b/src/robotaxi/pages/expense-description/index.less
@@ -1,12 +1,14 @@
+@import '../../style/ui-variables.less';
+
.expense-detail-page {
- background: #F2F3F7;
+ background: @gary-theme;
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 32px 24px;
.order-info {
- border-radius: 24px;
+ border-radius: 60px;
background: #FFFFFF;
padding: 32px 28px 30px 28px;
@@ -16,7 +18,7 @@
font-weight: 500;
line-height: 48px;
font-variation-settings: "opsz" auto;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
margin-left: 4px;
}
@@ -31,7 +33,7 @@
font-weight: normal;
line-height: 36px;
text-align: right;
- color: rgba(0, 0, 0, 0.4);
+ color: @black-text-04;
}
.arrow-icon {
@@ -46,8 +48,8 @@
.price-card {
margin-top: 24px;
- border-radius: 16px;
- background: #F5F6FA;
+ border-radius: 40px;
+ background: @gary-theme;
padding: 24px 32px;
.mb-16 {
@@ -60,7 +62,7 @@
font-weight: normal;
line-height: 40px;
/* 文字色/黑色/重要字 90% */
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
}
@@ -72,7 +74,7 @@
text-align: right;
font-variation-settings: "opsz" auto;
/* 文字色/黑色/重要字 90% */
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
}
.price-notice-label {
@@ -84,7 +86,7 @@
font-variation-settings: "opsz" auto;
/* 文字色/黑色/辅助文字 40% */
- color: rgba(0, 0, 0, 0.4);
+ color: @black-text-04;
}
@@ -96,13 +98,14 @@
text-align: right;
font-variation-settings: "opsz" auto;
/* 文字色/黑色/辅助文字 40% */
- color: rgba(0, 0, 0, 0.4);
+ color: @black-text-04;
}
}
.total {
margin-top: 32px;
justify-content: flex-end;
+ align-items: first baseline;
.total-text {
font-family: PingFang SC;
@@ -110,16 +113,15 @@
font-weight: normal;
line-height: 40px;
font-variation-settings: "opsz" auto;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
}
.num {
-
font-size: 48px;
line-height: 40px;
font-variation-settings: "opsz" auto;
- color: rgba(0, 0, 0, 0.9);
- margin: 0 4px;
+ color: @black-text-main;
+ margin: 0 4px;
}
.unit {
@@ -128,12 +130,12 @@
font-weight: normal;
line-height: 40px;
font-variation-settings: "opsz" auto;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
}
}
.faq {
- border-radius: 24px;
+ border-radius: 60px;
background: #FFFFFF;
padding: 32px;
margin-top: 24px;
@@ -143,7 +145,7 @@
font-size: 32px;
font-weight: 500;
line-height: 48px;
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
}
@@ -164,7 +166,7 @@
line-height: 40px;
font-variation-settings: "opsz" auto;
/* 文字色/黑色/重要字 90% */
- color: rgba(0, 0, 0, 0.9);
+ color: @black-text-main;
}
.arrow-icon {
@@ -199,13 +201,14 @@
font-size: 24px;
font-weight: normal;
line-height: 36px;
- color: rgba(0, 0, 0, 0.4);
+ color: @black-text-04;
}
.arrow-icon {
width: 20px;
height: 13.6px;
margin-left: 14px;
+ margin-top: 2px;
transition: transform 0.3s ease;
&.rotated {
@@ -216,10 +219,25 @@
}
.complaint {
+ display: flex;
+ align-items: center;
+ justify-content: center;
margin: 0 auto;
- margin-top: 30px;
- width: 549px;
- height: 92px;
+ margin-top: 42px;
+ width: 654px;
+ height: 102px;
+ border-radius: 2000px;
+ box-sizing: border-box;
+ border: 4px solid @black-text-main;
+ font-size: 32px;
+ font-weight: 500;
+ line-height: normal;
+ text-align: center;
+ color: @black-text-main;
+ &.disabled {
+ border: 4px solid @black-text-04;
+ color: @black-text-04;
+ }
}
}
@@ -237,6 +255,10 @@
display: flex;
align-items: center;
justify-content: space-between;
+ margin-bottom: 4px;
+ &.flex-between-card:nth-last-of-type(1) {
+ margin-bottom: 0;
+ }
}
.color-red {
@@ -245,4 +267,4 @@
.mt-16 {
margin-top: 16px;
-}
\ No newline at end of file
+}
diff --git a/src/robotaxi/pages/expense-description/index.tsx b/src/robotaxi/pages/expense-description/index.tsx
index fb0486a..b952f77 100644
--- a/src/robotaxi/pages/expense-description/index.tsx
+++ b/src/robotaxi/pages/expense-description/index.tsx
@@ -20,6 +20,7 @@
const [faqData, setFaqData] = useState<FAQItem[]>([]);
const [showEvaluatePopup, setShowEvaluatePopup] = useState(false);
const [orderId, setOrderId] = useState('');
+ const [disabledComplaint, setDisabledComplaint] = useState(false);
useEffect(() => {
setOrderId(router.params?.orderId || '');
@@ -36,6 +37,7 @@
}
const res = await getFareBreakdown(payload);
setFareData(res);
+ setDisabledComplaint(res && res.hasComplaint || false);
} catch (error) {
console.error('获取费用明细失败', error);
showToast({ title: '获取费用明细失败', icon: 'none' });
@@ -98,6 +100,17 @@
const toRulePage = () => {
Taro.navigateTo({ url: '/robotaxi/pages/pricing-rules/index' });
};
+
+ const handleClickComplaint = () => {
+ if (disabledComplaint) {
+ showToast({
+ title: '订单已有车费反馈记录,无需重复操作',
+ icon: 'none'
+ });
+ return;
+ }
+ setShowEvaluatePopup(true);
+ }
if (!fareData) return null;
@@ -258,18 +271,22 @@
</View>
)}
{/* 去投诉 */}
- <Image className='complaint' onClick={() => setShowEvaluatePopup(true)} src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/complaint-btn.png' />
+ <View className={`complaint ${disabledComplaint ? 'disabled': ''} `} onClick={handleClickComplaint}>
+ 不认可车费,去投诉
+ </View>
{/* 评价弹窗 */}
<EvaluatePopup
show={showEvaluatePopup}
orderId={orderId}
orderRating={1}
- submitMode="complaint"
+ submitMode="orderComplaint"
hideFeedbackType
disableRatingSwitch
+ isFareFeedback
onClose={() => setShowEvaluatePopup(false)}
onConfirm={() => {
+ setDisabledComplaint(true);
setShowEvaluatePopup(false);
}}
/>
diff --git a/src/robotaxi/pages/feedback/hooks/use-media-upload.ts b/src/robotaxi/pages/feedback/hooks/use-media-upload.ts
index afbba91..d5e82ae 100644
--- a/src/robotaxi/pages/feedback/hooks/use-media-upload.ts
+++ b/src/robotaxi/pages/feedback/hooks/use-media-upload.ts
@@ -1,5 +1,6 @@
import Taro from '@tarojs/taro';
import { useCallback, useMemo, useState } from 'react';
+import { showToast } from '../../../components/toast';
import { getStorage } from '../../../../utils/tools';
/**
@@ -162,7 +163,7 @@
...item,
status: 'failed'
}));
- Taro.showToast({
+ showToast({
title: error?.message || '上传失败,请重试',
icon: 'none'
});
@@ -186,7 +187,7 @@
const chooseAndUpload = useCallback(async () => {
const remainCount = Math.max(0, options.maxCount - items.length);
if (remainCount <= 0) {
- Taro.showToast({
+ showToast({
title: `最多可上传${options.maxCount}个图片或视频`,
icon: 'none'
});
@@ -207,7 +208,7 @@
for (const file of files) {
const mediaType: MediaType = file.fileType === 'video' ? 'video' : 'image';
if (mediaType === 'video' && Number(file.size || 0) > videoSizeLimitBytes) {
- Taro.showToast({
+ showToast({
title: '视频不能超过20MB',
icon: 'none'
});
diff --git a/src/robotaxi/pages/feedback/index.tsx b/src/robotaxi/pages/feedback/index.tsx
index 6bc49b8..2cdda2f 100644
--- a/src/robotaxi/pages/feedback/index.tsx
+++ b/src/robotaxi/pages/feedback/index.tsx
@@ -3,6 +3,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import CustomNavbar from '../../../components/custom-navbar';
import { withRobotaxiWhitelist } from '../../../utils/whitelist';
+import { showToast, hideToast } from '../../components/toast';
+import { withToast } from '../../components/toast/with-toast';
import { getFeedbackTypeList, submitComplaint } from '../../api/feedback';
import { _ROBOTAXI_HOST } from '../../../constants/api';
import useMediaUpload from './hooks/use-media-upload';
@@ -149,7 +151,7 @@
}
isSubmittingRef.current = true;
- Taro.showLoading({ title: '提交中...', mask: true });
+ showToast({ title: '提交中...', icon: 'loading', mask: true });
try {
const networkTypeRes = await Taro.getNetworkType();
if (networkTypeRes.networkType === 'none') {
@@ -191,7 +193,7 @@
showFeedbackToast(errorTitle);
} finally {
isSubmittingRef.current = false;
- Taro.hideLoading();
+ hideToast();
}
};
@@ -447,4 +449,4 @@
);
}
-export default withRobotaxiWhitelist(FeedbackPage);
+export default withRobotaxiWhitelist(withToast(FeedbackPage));
diff --git a/src/robotaxi/pages/order/detail/index.tsx b/src/robotaxi/pages/order/detail/index.tsx
index 80c4c18..7d03386 100644
--- a/src/robotaxi/pages/order/detail/index.tsx
+++ b/src/robotaxi/pages/order/detail/index.tsx
@@ -149,10 +149,10 @@
id: 1,
longitude: displayStartPoint.longitude,
latitude: displayStartPoint.latitude,
- width: 34,
- height: 46,
+ width: 36,
+ height: 56,
anchor: { x: 0.7, y: 0.8 },
- iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/start-site-order-20260804-v2.png',
+ iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/start-site-order-20260812.png',
viewportLongitude: startPoint?.longitude,
viewportLatitude: startPoint?.latitude,
} : null,
@@ -160,10 +160,10 @@
id: 2,
longitude: endPoint.longitude,
latitude: endPoint.latitude,
- width: 34,
- height: 46,
+ width: 36,
+ height: 56,
anchor: { x: 0.7, y: 0.8 },
- iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/end-site-order-20260804-v2.png',
+ iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/end-site-order-20260812.png',
} : null
].filter(Boolean) as any[];
setMarkers(markerList);
diff --git a/src/robotaxi/pages/payment-setting-detail/index.tsx b/src/robotaxi/pages/payment-setting-detail/index.tsx
index efe5863..6ba7b67 100644
--- a/src/robotaxi/pages/payment-setting-detail/index.tsx
+++ b/src/robotaxi/pages/payment-setting-detail/index.tsx
@@ -41,7 +41,7 @@
try {
const activeOrder = await getOrderActive();
if (activeOrder) {
- Taro.showToast({
+ showToast({
title: '有进行中的订单,暂无法关闭服务',
icon: 'none'
});
@@ -49,7 +49,7 @@
}
return true;
} catch (error) {
- Taro.showToast({
+ showToast({
title: '查询订单状态失败,请稍后重试',
icon: 'none'
});
diff --git a/src/robotaxi/pages/personal-center/index.tsx b/src/robotaxi/pages/personal-center/index.tsx
index 8ab905d..557a4aa 100644
--- a/src/robotaxi/pages/personal-center/index.tsx
+++ b/src/robotaxi/pages/personal-center/index.tsx
@@ -145,7 +145,7 @@
return;
}
- Taro.showToast({ title: "功能建设中", icon: "none" });
+ showToast({ title: "功能建设中", icon: "none" });
};
return (
diff --git a/src/robotaxi/pages/select-map-site/components/select-map-site-view.tsx b/src/robotaxi/pages/select-map-site/components/select-map-site-view.tsx
index 842aed2..2e19c82 100644
--- a/src/robotaxi/pages/select-map-site/components/select-map-site-view.tsx
+++ b/src/robotaxi/pages/select-map-site/components/select-map-site-view.tsx
@@ -97,13 +97,13 @@
</View>
</View>
)}
- {/* 地图浮层入口 - 选点页不展示 */}
- {deps.nearbyLoading && deps.isAndroid && (
- <Image className="waiting-radar-img" src={deps.waitingRadarSrc} />
- )}
{/* 指针 */}
<View className="pointer-box">
- {deps.nearbyLoading && !deps.isAndroid && (
+ {/* 雷达只在真正命中吸附、指针跳动到具体站点时才展示(由 bounce 驱动),与首页保持一致 */}
+ {deps.bounce && deps.isAndroid && (
+ <Image className="waiting-radar-img" src={deps.waitingRadarSrc} />
+ )}
+ {deps.bounce && !deps.isAndroid && (
<Image className="waiting-radar" src={deps.waitingRadarSrc} />
)}
{((deps.showToolTip && shouldShowSelectedSiteEcho) || showRangeWarning) && (
diff --git a/src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts b/src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts
index b61cb19..a5982f9 100644
--- a/src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts
+++ b/src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts
@@ -59,6 +59,8 @@
const AUTO_SCALE_MAX = 17; // 自动缩放最大等级
const AUTO_SCALE_MIN = 11; // 自动缩放最小等级
const INCLUDE_POINTS_PADDING = [100, 100, 100, 100] as [number, number, number, number]; // 地图回视图内边距
+/** 指针跳动到附近站点的动效总时长:指针跳动(600ms)+ 雷达扫描确认(320ms),需与 index.less 中对应动画时长保持一致,与首页保持一致。 */
+const POINTER_BOUNCE_SEQUENCE_DURATION_MS = 920;
const CROSS_OPERATING_AREA_FILL_COLOR = '#0000001a'; // 非可选运营区围栏填充色
const CROSS_OPERATING_AREA_STROKE_COLOR = '#DFB369'; // 非可选运营区围栏描边色
const CROSS_OPERATING_AREA_STROKE_WIDTH = 1.5; // 非可选运营区围栏描边宽度
@@ -517,7 +519,6 @@
return;
}
if (type !== 'end') return;
- state.setBounce(true);
const mapCtx = Taro.createMapContext('select-site-map');
// 获取真实的缩放等级并通过回调传递出去,彻底阻断与 mapScale 状态的绑定,避免触发原生组件抖动
@@ -543,6 +544,8 @@
const selectableMarkers = getSelectableMarkers(newMarkers, sites);
const { targetMarker, minDistance } = findNearestMarker(lat, lng, selectableMarkers);
if (targetMarker && minDistance < state.distanceConfig.adsorptionDistance) {
+ // 命中吸附:指针跳动 + 雷达确认动效(与地图硬切同一时刻触发),920ms 播完后才展示 tooltip,与首页保持一致
+ state.setBounce(true);
mapCtx.moveToLocation({
latitude: targetMarker.latitude,
longitude: targetMarker.longitude,
@@ -552,8 +555,13 @@
state.setLatitude(activeSite?.latitude || targetMarker.latitude);
state.setLongitude(activeSite?.longitude || targetMarker.longitude);
syncPinLocation(activeSite?.latitude || targetMarker.latitude, activeSite?.longitude || targetMarker.longitude);
- state.setShowToolTip(true);
+ setTimeout(() => {
+ if (currentRegionSeq !== state.regionSeqRef.current) return;
+ state.setBounce(false);
+ state.setShowToolTip(true);
+ }, POINTER_BOUNCE_SEQUENCE_DURATION_MS);
} else {
+ // 未命中吸附:不播放跳动/雷达动效,立即展示提示,避免无意义等待
resetStationSelection();
state.setSelectedSite({});
state.setRangeStatus(
@@ -574,10 +582,6 @@
}
});
}
- setTimeout(() => {
- state.setBounce(false);
- state.setShowToolTip(true);
- }, 600);
};
/**
@@ -591,6 +595,7 @@
const targetSite = findSiteByMarker(targetMarker, state.nearbySites) || targetMarker;
state.pickupConfirmTypeRef.current = 'click';
state.setShowToolTip(false);
+ // 点击 marker 是明确的跳转动作,指针跳动 + 雷达确认动效播完(920ms)后才展示 tooltip,与首页保持一致
state.setBounce(true);
state.setLatitude(targetMarker.latitude);
state.setLongitude(targetMarker.longitude);
@@ -610,7 +615,7 @@
state.setLongitude(activeSite?.longitude || targetMarker.longitude);
syncPinLocation(activeSite?.latitude || targetMarker.latitude, activeSite?.longitude || targetMarker.longitude);
state.setShowToolTip(true);
- }, 600);
+ }, POINTER_BOUNCE_SEQUENCE_DURATION_MS);
};
/**
diff --git a/src/robotaxi/pages/select-map-site/index.less b/src/robotaxi/pages/select-map-site/index.less
index 1f0de37..08fa69f 100644
--- a/src/robotaxi/pages/select-map-site/index.less
+++ b/src/robotaxi/pages/select-map-site/index.less
@@ -126,6 +126,8 @@
.toolTip {
position: relative;
z-index: 10;
+ animation: tooltip-fade-in 240ms ease-out;
+ will-change: transform, opacity;
.toolTip-bg {
position: relative;
@@ -215,6 +217,8 @@
width: 64px;
height: 86px;
margin-top: -54px;
+ opacity: 1;
+ transition: opacity 240ms ease-out;
&.bounce {
animation: pointer-bounce 600ms ease-in-out;
@@ -224,28 +228,32 @@
opacity: 0;
}
}
-}
-.waiting-radar {
- position: fixed;
- width: 700px;
- height: 350px;
- top: 48%;
- left: 50%;
- transform: translate(-50%, -48%);
- pointer-events: none;
- z-index: 1;
-}
+ // 雷达以站点位置(.pointer-box 底边,即 translate(-50%,-100%) 后与地图中心点对齐的边)为圆心展开,
+ // 用绝对定位直接锚定该边,不受内部 flex 子元素排列影响,与首页保持一致
+ .waiting-radar {
+ position: absolute;
+ width: 700px;
+ height: 350px;
+ bottom: 0;
+ left: 50%;
+ transform: translate(-50%, 50%);
+ pointer-events: none;
+ z-index: 1;
+ animation: radar-fade-in 200ms ease-out;
+ }
-.waiting-radar-img {
- position: fixed;
- width: 700px;
- height: 350px;
- top: 48%;
- left: 50%;
- transform: translate(-50%, -48%);
- pointer-events: none;
- z-index: 1;
+ .waiting-radar-img {
+ position: absolute;
+ width: 700px;
+ height: 350px;
+ bottom: 0;
+ left: 50%;
+ transform: translate(-50%, 50%);
+ pointer-events: none;
+ z-index: 1;
+ animation: radar-fade-in 200ms ease-out;
+ }
}
@keyframes pointer-bounce {
@@ -266,6 +274,28 @@
}
}
+@keyframes tooltip-fade-in {
+ 0% {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes radar-fade-in {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
.home-container {
display: flex;
diff --git a/src/robotaxi/pages/share-trip/components/share-trip-view.tsx b/src/robotaxi/pages/share-trip/components/share-trip-view.tsx
index edd8155..a748c37 100644
--- a/src/robotaxi/pages/share-trip/components/share-trip-view.tsx
+++ b/src/robotaxi/pages/share-trip/components/share-trip-view.tsx
@@ -2,6 +2,7 @@
import { safeParse } from '../../../../utils/Robotaxi-public-method';
import { formatRemainingDurationDisplay } from '../../../utils/format';
import { TripStatus } from '../../../constants/trip-status';
+import SiteRouteCard from '../../../components/site-route-card';
type Props = {
isExpired: boolean;
@@ -61,6 +62,9 @@
if (isExpired) {
return (
<View className='share-trip-expired'>
+ <View onClick={goHome} className='select-city'>
+ <Image className='select-city-icon' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/back-home.png' />
+ </View>
<View className='expired-content'>
<Image className='expired-img' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/share-contact.png' />
<View className='expired-text'>行程结束已超过24小时</View>
@@ -164,32 +168,25 @@
</View>
</View>
{/* 站点信息卡:参考 cancel-site-box 布局与背景卡片样式 */}
- <View className='share-site-box'>
- <View className='share-site-box-icon'>
- <View className='share-site-box-icon-start'></View>
- <Image src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/dotted-line.png' />
- <View className='share-site-box-icon-end'></View>
- </View>
- <View className='share-site-box-content'>
- <View className='share-site-row'>
- <View className={`share-site-name ${isStartSiteGray ? 'gray-text' : ''}`}>
- {tripData?.startPointName}
- </View>
- {showBoardingTime && (
+ <SiteRouteCard
+ className='share-site-box'
+ sites={[
+ {
+ name: tripData?.startPointName,
+ nameClassName: isStartSiteGray ? 'gray-text' : '',
+ extra: showBoardingTime && (
<View className='share-site-time'>{tripData.boardingTime} 上车</View>
- )}
- </View>
- <View className='share-site-box-divider'></View>
- <View className='share-site-row'>
- <View className={`share-site-name ${isEndSiteGray ? 'gray-text' : ''}`}>
- {tripData?.endPointName}
- </View>
- {showGetoffTime && (
+ )
+ },
+ {
+ name: tripData?.endPointName,
+ nameClassName: isEndSiteGray ? 'gray-text' : '',
+ extra: showGetoffTime && (
<View className='share-site-time'>{tripData.getoffTime} 到达</View>
- )}
- </View>
- </View>
- </View>
+ )
+ }
+ ]}
+ />
</View>
</View>
</View>
diff --git a/src/robotaxi/pages/share-trip/hooks/use-map-markers.ts b/src/robotaxi/pages/share-trip/hooks/use-map-markers.ts
index df1dc1e..fe6c871 100644
--- a/src/robotaxi/pages/share-trip/hooks/use-map-markers.ts
+++ b/src/robotaxi/pages/share-trip/hooks/use-map-markers.ts
@@ -4,7 +4,7 @@
// 接驾态(前往上车点)/到达终点态 上车点三角图标(与 begin-route 对齐)
const PICKUP_APPROACHING_ICON_URL =
- 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/up-triangle-20260722.png';
+ 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/up-triangle-20260812.png';
// 送驾态(前往目的地)/到达终点态 下车点三角图标(与 begin-route 对齐)
const DROPOFF_INSERVICE_ICON_URL =
'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/down-triangle-20260722.png';
diff --git a/src/robotaxi/pages/share-trip/hooks/use-view-delegates.tsx b/src/robotaxi/pages/share-trip/hooks/use-view-delegates.tsx
index 6788a41..be67ceb 100644
--- a/src/robotaxi/pages/share-trip/hooks/use-view-delegates.tsx
+++ b/src/robotaxi/pages/share-trip/hooks/use-view-delegates.tsx
@@ -48,9 +48,7 @@
<View className='car-time' style={{ alignItems: 'baseline', paddingBottom: 0 }}>
<View className='car-time-num'>{routeDistance?.value ?? 0}</View>
<View style={{ flexShrink: 0 }}>
- {isPickup
- ? (routeDistance?.unit === 'm' ? 'M' : 'KM')
- : (routeDistance?.unit === 'm' ? '米' : '公里')}
+ {routeDistance?.unit === 'm' ? '米' : '公里'}
</View>
{!isPickup && (
<>
@@ -151,7 +149,7 @@
>
<CoverView className='car-time'>
<CoverView className='car-time-num'>{deps.routeDistance?.value ?? 0}</CoverView>
- <CoverView>{deps.routeDistance?.unit === 'm' ? 'M' : 'KM'}</CoverView>
+ <CoverView>{deps.routeDistance?.unit === 'm' ? '米' : '公里'}</CoverView>
</CoverView>
</CoverView>
)}
@@ -162,7 +160,7 @@
className='site-status-car-icon'
src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/golden-car-icon-20260708.png'
/>
- <CoverView className='site-status-time'>限停{deps.startSite?.time}MIN</CoverView>
+ <CoverView className='site-status-time'>限停{deps.startSite?.time}分钟</CoverView>
</CoverView>
<CoverImage
className='callout-diamond-icon'
@@ -179,7 +177,7 @@
{(item.id === 2 && [TripStatus.InService, TripStatus.ArrivedDestination, TripStatus.TripFinished, TripStatus.CancelOnlyPickup].includes(deps.tripStatus) && !item.hideNameCallout) && (
<CoverImage
className='site-status-end-img'
- src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/end-site-20260722.png'
+ src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/end-site-20260812.png'
/>
)}
</>
diff --git a/src/robotaxi/pages/share-trip/index.less b/src/robotaxi/pages/share-trip/index.less
index c04d97d..d3d3d62 100644
--- a/src/robotaxi/pages/share-trip/index.less
+++ b/src/robotaxi/pages/share-trip/index.less
@@ -227,7 +227,7 @@
.calling-car-vehicle-pic {
position: absolute;
top: -4px;
- right: 116px;
+ right: 80px;
width: 342px;
height: 294px;
z-index: 10;
@@ -352,77 +352,26 @@
}
}
- // 站点信息卡:参考 cancel-site-box 的布局与背景卡片样式,独立于车辆信息卡
+ // 站点信息卡:参考 cancel-site-box 的布局与背景卡片样式,独立于车辆信息卡;
+ // 结构已抽离为 SiteRouteCard 公共组件,此处只保留容器级样式与业务特有的置灰/时间文案样式
.share-site-box {
margin-top: 32px;
- display: flex;
- gap: 16px;
border-radius: 40px;
background: rgba(204, 208, 215, 0.25);
padding: 48px 32px 42px 32px;
- .share-site-box-icon {
- padding: 16px 0;
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
-
- .share-site-box-icon-start {
- width: 16px;
- height: 16px;
- background: @gold-light;
- border-radius: 50%;
- }
-
- .share-site-box-icon-end {
- width: 16px;
- height: 16px;
- background: @black-text-main;
- border-radius: 50%;
- }
-
- image {
- flex: 1;
- width: 2px;
- margin: 7px 0;
- height: fit-content;
- }
+ .site-route-card-item-text.gray-text {
+ color: @black-text-04;
}
- .share-site-box-content {
- flex: 1;
-
- .share-site-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
-
- .share-site-name {
- font-size: 32px;
- line-height: 48px;
- color: @black-text-main;
-
- &.gray-text {
- color: @black-text-04;
- }
- }
-
- .share-site-time {
- flex-shrink: 0;
- min-width: 120px;
- margin-left: 12px;
- text-align: right;
- font-size: 24px;
- line-height: 40px;
- color: @black-text-04;
- }
-
- .share-site-box-divider {
- margin: 34px 32px 40px 0;
- border-top: 1px solid rgba(204, 208, 215, 0.4);
- }
+ .share-site-time {
+ flex-shrink: 0;
+ min-width: 120px;
+ margin-left: 12px;
+ text-align: right;
+ font-size: 24px;
+ line-height: 40px;
+ color: @black-text-04;
}
}
}
diff --git a/src/robotaxi/style/ui-variables.less b/src/robotaxi/style/ui-variables.less
index 4cac50d..82efe45 100644
--- a/src/robotaxi/style/ui-variables.less
+++ b/src/robotaxi/style/ui-variables.less
@@ -20,6 +20,7 @@
@gold-text: #A68042; // 文字金
@gold-light: #DFB369; // 主题金
+@dark-gold-text: #8F754A; // 黑金
@black-text-main: #141E27; // 重要文字
@black-text-04: rgba(20, 30, 39, 0.4); // 次要文字
@gary-theme: #F1F5FA; // 浅色面板
diff --git a/src/robotaxi/utils/platform-env.ts b/src/robotaxi/utils/platform-env.ts
new file mode 100644
index 0000000..27ee7aa
--- /dev/null
+++ b/src/robotaxi/utils/platform-env.ts
@@ -0,0 +1,67 @@
+import Taro from '@tarojs/taro';
+
+/**
+ * 鸿蒙环境探测。
+ *
+ * 背景:地图 includePoints 的 padding 数值在鸿蒙小程序容器下与安卓/iOS 的像素解释基准不一致
+ * (鸿蒙上偏小,导致同样的 padding 视觉留白不足,出现路线端点被推出可视区域的问题),
+ * 需要对鸿蒙环境的 padding 做补偿放大。
+ */
+
+let cachedIsHarmony: boolean | null = null;
+
+/** 判断当前是否运行在鸿蒙环境(HarmonyOS / HarmonyOS NEXT 元服务容器)。结果按次进程缓存。 */
+export function isHarmonyPlatform(): boolean {
+ if (cachedIsHarmony !== null) {
+ return cachedIsHarmony;
+ }
+ let result = false;
+ try {
+ const info: any = Taro.getSystemInfoSync();
+ // 不同宿主/基础库版本下,鸿蒙标识可能出现在 platform / system / host.env / appName 等不同字段,
+ // 这里做多字段文本匹配兜底判断。
+ const candidates = [
+ info?.platform,
+ info?.system,
+ info?.host?.env,
+ info?.hostName,
+ info?.appName,
+ info?.brand,
+ info?.model,
+ ]
+ .filter(Boolean)
+ .map((v) => String(v).toLowerCase());
+ result = candidates.some((v) => v.includes('harmony') || v.includes('ohos'));
+ } catch (error) {
+ result = false;
+ }
+ cachedIsHarmony = result;
+ return result;
+}
+
+/** 鸿蒙地图 padding 补偿系数:仅在 isHarmonyPlatform() 为 true 时生效。 */
+export const HARMONY_PADDING_COMPENSATION_RATIO_TOP = 3;
+export const HARMONY_PADDING_COMPENSATION_RATIO_BOTTOM = 2.5;
+export const HARMONY_PADDING_COMPENSATION_RATIO_SIDE = 3;
+
+
+/** 按平台对 padding 数值做补偿:鸿蒙环境乘补偿系数,其它平台原样返回。 */
+export function applyHarmonyPaddingCompensation(value: number, direction: 'top' | 'bottom' | 'side'): number {
+ if (!Number.isFinite(value)) {
+ return value;
+ }
+ if (!isHarmonyPlatform()) {
+ return value;
+ }
+ switch (direction) {
+ case 'top':
+ return Math.round(value * HARMONY_PADDING_COMPENSATION_RATIO_TOP);
+ case 'bottom':
+ return Math.round(value * HARMONY_PADDING_COMPENSATION_RATIO_BOTTOM);
+ case 'side':
+ return Math.round(value * HARMONY_PADDING_COMPENSATION_RATIO_SIDE);
+ default:
+ return Math.round(value * HARMONY_PADDING_COMPENSATION_RATIO_SIDE);
+ }
+}
+
diff --git a/src/robotaxi/utils/user-location.ts b/src/robotaxi/utils/user-location.ts
new file mode 100644
index 0000000..0d753d0
--- /dev/null
+++ b/src/robotaxi/utils/user-location.ts
@@ -0,0 +1,79 @@
+import Taro from '@tarojs/taro';
+import { getLocationByIP } from '../../utils/Robotaxi-public-method';
+
+/** 定位来源:gps 精确定位 / ip 粗定位 / fallback 兜底坐标。 */
+export type UserLocationSource = 'gps' | 'ip' | 'fallback';
+
+/** 统一的用户定位结果。 */
+export interface UserLocation {
+ latitude: number;
+ longitude: number;
+ /** 本次坐标的来源,便于上层按精度差异做不同处理。 */
+ source: UserLocationSource;
+ /** 仅 ip 来源可能带回的城市名。 */
+ city?: string;
+ /** 仅 ip 来源可能带回的行政区编码。 */
+ adcode?: string;
+}
+
+/** 默认兜底坐标(北京),与项目其它地方保持一致。 */
+export const DEFAULT_FALLBACK_LOCATION: UserLocation = {
+ latitude: 39.916527,
+ longitude: 116.397128,
+ source: 'fallback',
+};
+
+const isValidCoordinate = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isFinite(value);
+
+/**
+ * 获取用户定位(公共函数)。
+ *
+ * 优先级:
+ * 1. Taro.getLocation 精确 GPS 定位(source: 'gps')
+ * 2. 失败退回 getLocationByIP 的 IP 粗定位(source: 'ip')
+ * 3. 再失败退回默认兜底坐标(source: 'fallback')
+ *
+ * 该函数**永不 reject**,始终 resolve 出一个可用坐标,调用方可据 `source` 判断精度。
+ *
+ * @param options.highAccuracy 是否开启高精度 GPS(默认 false)。
+ */
+export const getUserLocation = async (
+ options?: { highAccuracy?: boolean }
+): Promise<UserLocation> => {
+ // 1) GPS 精确定位
+ try {
+ const res: any = await Taro.getLocation({
+ type: 'gcj02',
+ isHighAccuracy: options?.highAccuracy ?? false,
+ });
+ if (isValidCoordinate(res?.latitude) && isValidCoordinate(res?.longitude)) {
+ return {
+ latitude: res.latitude,
+ longitude: res.longitude,
+ source: 'gps',
+ };
+ }
+ } catch (error) {
+ // 忽略,进入 IP 兜底
+ }
+
+ // 2) IP 粗定位
+ try {
+ const res: any = await getLocationByIP();
+ if (isValidCoordinate(res?.latitude) && isValidCoordinate(res?.longitude)) {
+ return {
+ latitude: res.latitude,
+ longitude: res.longitude,
+ source: 'ip',
+ city: res.city || '',
+ adcode: res.adcode || '',
+ };
+ }
+ } catch (error) {
+ // 忽略,进入默认兜底
+ }
+
+ // 3) 默认兜底
+ return { ...DEFAULT_FALLBACK_LOCATION };
+};
diff --git a/src/robotaxi/utils/video-preload.ts b/src/robotaxi/utils/video-preload.ts
new file mode 100644
index 0000000..d1e134b
--- /dev/null
+++ b/src/robotaxi/utils/video-preload.ts
@@ -0,0 +1,69 @@
+/**
+ * 视频预下载缓存:用 wx.downloadFile 提前把转场 mp4 落地为本地临时文件,
+ * 转场时改用本地路径作 <video src>,消除首帧网络缓冲导致的黑帧。
+ *
+ * ## 背景
+ * wx.preloadAssets 仅支持 image / font,**不支持视频**。派单转场 mp4 直接用 CDN URL 作
+ * <video src> 时,首帧需先经网络请求 + 缓冲,「加载完成 → 首帧绘制」间会露出约一帧黑屏。
+ * 方案:进入下单流程(发布中/呼叫中)即 downloadFile 把 mp4 下到本地临时路径,转场时用本地
+ * 路径播放——本地文件无网络缓冲,首帧秒出。
+ *
+ * ## 使用
+ * - preloadVideo(url):幂等发起下载,成功后缓存 `CDN URL → 本地 tempFilePath`。
+ * - getPreloadedVideoSrc(url):取本地路径;未就绪(下载中/失败/非 weapp)则回退原 CDN URL,不阻塞。
+ *
+ * 参考:https://developers.weixin.qq.com/miniprogram/dev/api/network/download/wx.downloadFile.html
+ */
+
+/** 判断当前是否为微信小程序环境。 */
+const isWeapp = (): boolean => process.env.TARO_ENV === 'weapp';
+
+/** CDN URL → 本地临时文件路径(下载成功后写入)。 */
+const localPathCache: Record<string, string> = {};
+
+/** 已发起下载的 URL(下载中/已完成均登记,避免重复请求;失败时移除以允许重试)。 */
+const pendingSet = new Set<string>();
+
+/**
+ * 幂等预下载视频。已缓存或正在下载则直接返回;非 weapp / 低版本无 downloadFile 时静默跳过。
+ * 下载失败不报错、不缓存,转场时自动回退 CDN URL。
+ *
+ * @param url 视频 CDN 地址
+ */
+export const preloadVideo = (url: string): void => {
+ try {
+ if (!isWeapp() || !url) return;
+ if (localPathCache[url] || pendingSet.has(url)) return;
+ // @ts-ignore 微信小程序全局 API,低版本走 typeof 兜底跳过
+ if (typeof wx === 'undefined' || typeof wx.downloadFile !== 'function') return;
+
+ pendingSet.add(url);
+ // @ts-ignore
+ wx.downloadFile({
+ url,
+ success: (res: any) => {
+ if (res && res.statusCode === 200 && res.tempFilePath) {
+ localPathCache[url] = res.tempFilePath;
+ } else {
+ // 非 200:不缓存,允许后续重试
+ pendingSet.delete(url);
+ }
+ },
+ fail: () => {
+ // 下载失败:不缓存、允许后续重试,转场时回退 CDN URL(不影响功能)
+ pendingSet.delete(url);
+ },
+ });
+ } catch (e) {
+ console.error('[preloadVideo] 视频预下载异常:', e);
+ // 兜底:绝不抛出
+ }
+};
+
+/**
+ * 取预下载好的本地视频路径;未就绪则回退原 CDN URL。
+ *
+ * @param url 视频 CDN 地址
+ * @returns 本地临时路径(已就绪)或原 CDN URL
+ */
+export const getPreloadedVideoSrc = (url: string): string => localPathCache[url] || url;
diff --git a/src/utils/preload-assets.ts b/src/utils/preload-assets.ts
new file mode 100644
index 0000000..fbf8a99
--- /dev/null
+++ b/src/utils/preload-assets.ts
@@ -0,0 +1,54 @@
+/**
+ * 通用预加载素材描述。
+ *
+ * 仅支持 `image` 与 `font`——微信 `wx.preloadAssets` 目前只支持这两类,
+ * **不支持视频**(视频无法通过该 API 预热,转场视频仅能预热其封面图)。
+ */
+export type PreloadAsset = { type: 'image' | 'font'; src: string };
+
+/** 判断当前是否为微信小程序环境。 */
+const isWeapp = (): boolean => process.env.TARO_ENV === 'weapp';
+
+/**
+ * 通用预加载方法:图片、字体统一走 `wx.preloadAssets` 为视图层预热媒体资源。
+ *
+ * ## 特性
+ * - 仅在微信小程序环境执行;非 weapp / 低版本(无 `wx.preloadAssets`)静默跳过。
+ * - image / font 合批调用一次 `wx.preloadAssets`,组件仍用同一 CDN URL,命中缓存即秒开。
+ * - **预加载失败不报错**:整体 try/catch + `fail` 空回调兜底。
+ *
+ * 参考:https://developers.weixin.qq.com/miniprogram/dev/api/base/performance/wx.preloadAssets.html
+ * (基础库 2.22.1+ 支持,功能描述:为视图层预加载媒体资源文件,目前支持 font、image)
+ *
+ * @param assets 待预加载素材列表
+ */
+export const preloadAssets = (assets: PreloadAsset[]): void => {
+ try {
+ if (!isWeapp() || !Array.isArray(assets) || assets.length === 0) {
+ return;
+ }
+ // @ts-ignore 微信基础库 2.22.1+ 能力,低版本走 typeof 兜底跳过
+ if (typeof wx === 'undefined' || typeof wx.preloadAssets !== 'function') {
+ return;
+ }
+
+ const data = assets
+ .filter((asset) => asset && asset.src && (asset.type === 'image' || asset.type === 'font'))
+ .map((asset) => ({ type: asset.type, src: asset.src }));
+
+ if (data.length === 0) {
+ return;
+ }
+
+ // @ts-ignore
+ wx.preloadAssets({
+ data,
+ fail: () => { /* 预加载失败不报错,静默忽略 */ },
+ });
+ } catch (e) {
+ console.error('[preloadAssets] 预加载素材异常:', e);
+ // 兜底:绝不抛出
+ }
+};
+
+export default preloadAssets;
--
Gitblit v1.7.1