Merge branch 'feature_v1.0' of http://gitlab.xiaopeng.local:18080/robotaxi1/xp-robotaxi-miniprogram into feature_v1.0
| New file |
| | |
| | | const fs = require('fs-extra'); |
| | | const path = require('path'); |
| | | |
| | | // 需要在构建完成后拷贝到产物目录的文件列表 |
| | | // src: 相对于项目根目录的源文件路径 |
| | | // dest: 相对于产物目录(outputPath)的目标文件路径 |
| | | const filesToCopy = [ |
| | | // { |
| | | // src: 'src/otherPage/pages/carControl/bleMethod.wasm.br', |
| | | // dest: 'otherPage/pages/carControl/bleMethod.wasm.br', |
| | | // }, |
| | | { |
| | | src: 'src/robotaxi/utils/car-control/bleMethod.wasm.br', |
| | | dest: 'robotaxi/utils/car-control/bleMethod.wasm.br', |
| | | }, |
| | | ]; |
| | | |
| | | class BuildDonePlugin { |
| | | apply(compiler) { |
| | | compiler.hooks.done.tap('BuildDonePlugin', (stats) => { |
| | | if (process.env.TARO_ENV !== 'weapp') return; |
| | | |
| | | const outputPath = stats.compilation.outputOptions.path; |
| | | |
| | | filesToCopy.forEach(({ src, dest }) => { |
| | | const srcFile = path.resolve(src); |
| | | const destFile = path.resolve(outputPath, dest); |
| | | const destDir = path.dirname(destFile); |
| | | |
| | | if (!fs.existsSync(destDir)) { |
| | | fs.mkdirSync(destDir, { recursive: true }); |
| | | } |
| | | |
| | | try { |
| | | fs.copyFileSync(srcFile, destFile); |
| | | console.log(`✅ copy: ${src} -> ${dest}`); |
| | | } catch (error) { |
| | | console.error(`❌ copy: ${src} -> ${dest}: ${error.message}`); |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | } |
| | | |
| | | module.exports = BuildDonePlugin; |
| | |
| | | }, |
| | | ], |
| | | }, |
| | | buildDone: { |
| | | plugin: require('./BuildDonePlugin'), |
| | | }, |
| | | }, |
| | | }) |
| | | }, |
| | |
| | | ] |
| | | } |
| | | |
| | | class BuildDonePlugin { |
| | | apply(compiler) { |
| | | compiler.hooks.done.tap('Build Done Plugin', () => { |
| | | // if (process.env.TARO_ENV === 'alipay') { |
| | | // fs.copyFileSync('./mini.project.json', './dist/alipay/mini.project.json') |
| | | // } |
| | | }); |
| | | } |
| | | } |
| | | |
| | | const config = { |
| | | projectName: "xp-robotaxi-miniprogram", |
| | | date: "2020-2-11", |
| | |
| | | webpackChain(chain) { |
| | | chain.merge({ |
| | | plugin: { |
| | | install: { |
| | | plugin: BuildDonePlugin |
| | | } |
| | | buildDone: { |
| | | plugin: require('./BuildDonePlugin'), |
| | | }, |
| | | } |
| | | }); |
| | | } |
| | |
| | | const ADMIN_OSS_HOST = '"https://xps01.xiaopeng.com"'; // 运营后台上传的额文件的 oss 域名 |
| | | |
| | | const ROBOTAXI_HOST = '"https://rt-api.xiaopeng.com"'; |
| | | const ROBOTAXI_WS_HOST = '"wss://rt-api.xiaopeng.com/websocket"'; |
| | | const ROBOTAXI_WS_HOST = '"wss://rt-ws.xiaopeng.com/websocket"'; |
| | | const AMAP_KEY = '"8157fdfa13878d3f7bf69c560c8a846a"'; |
| | | |
| | | const XP_APPID_MAP = { |
| | |
| | | const GIO_CONFIG = { |
| | | accountId: '"84eafaa69d23412f"', // 埋点平台id |
| | | dataSourceId: { |
| | | weapp: '"b9aa24dc0aab9edb"', // 埋点数据源id |
| | | weapp: '"b63706df57a52ecf"', // 埋点数据源id |
| | | }, |
| | | host: '"https://collect.xiaopeng.com"', |
| | | } |
| | |
| | | }, true); |
| | | } |
| | | |
| | | // 修改目的地 |
| | | export const orderChange = async (payload) => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/itinerary/api/v1/mini-program/order/orderChange', |
| | | method: 'POST', |
| | | payload |
| | | }, true); |
| | | } |
| | | |
| | | // 查询订单费用明细 |
| | | export const queryFareBreakdown = async (payload) => { |
| | | return request({ |
| | |
| | | } |
| | | |
| | | export default function AnnouncementsPopup({ show = false, onClose }: Props) { |
| | | const [visible, setVisible] = useState(false); |
| | | const [animationData, setAnimationData] = useState<any>(); |
| | | const [doorImgError, setDoorImgError] = useState(false); |
| | | const [seatbeltImgError, setSeatbeltImgError] = useState(false); |
| | | const [visible, setVisible] = useState(false); // 弹窗显示状态 |
| | | const [animationData, setAnimationData] = useState<any>(); // 弹窗开合动画 |
| | | const [doorImgError, setDoorImgError] = useState(false); // 开门动画图加载失败状态 |
| | | const [seatbeltImgError, setSeatbeltImgError] = useState(false); // 系安全带动画图加载失败状态 |
| | | |
| | | const doorImage = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/door-anim.webp' |
| | | const seatbeltImage = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/seatbelt-anim.webp' |
| | | |
| | | // 控制弹窗开合动画并重置图片兜底状态 |
| | | useEffect(() => { |
| | | if (show && !visible) { |
| | | setVisible(true); |
| | |
| | | packUpIcon: string; |
| | | onSelectRouteClick: () => void; |
| | | isRouteSelectExpanded: boolean; |
| | | canToggleRouteSelect: boolean; |
| | | walkIcon: string; |
| | | renderTripCard: () => JSX.Element | null; |
| | | showCustomerService: boolean; |
| | |
| | | packUpIcon, |
| | | onSelectRouteClick, |
| | | isRouteSelectExpanded, |
| | | canToggleRouteSelect, |
| | | walkIcon, |
| | | renderTripCard, |
| | | showCustomerService, |
| | |
| | | handleCloseEvaluatePopup, |
| | | handleConfirmEvaluate |
| | | } = props; |
| | | |
| | | // 统一渲染地图与业务弹窗容器 |
| | | return ( |
| | | <View className='home-container'> |
| | | <Map |
| | |
| | | )} |
| | | <Image onClick={toOrigin} className='origin-icon' src={originIcon} /> |
| | | { |
| | | tripStatus == 1 && |
| | | canToggleRouteSelect && |
| | | <View> |
| | | { |
| | | isRouteSelectExpanded ? |
| | |
| | | handleOpenOverRoutePopup, |
| | | handleOpenAnnouncementsPopup |
| | | }: Props) { |
| | | const [trunkStatus, setTrunkStatus] = useState(orderTrunkStatus); |
| | | const [lockStatus, setLockStatus] = useState(false); |
| | | const trunkPollTimerRef = useRef<any>(null); |
| | | const hasShownAnnouncementsRef = useRef(false); |
| | | const [trunkStatus, setTrunkStatus] = useState(orderTrunkStatus); // 后备箱开关状态 |
| | | const [lockStatus, setLockStatus] = useState(false); // 车门解锁状态 |
| | | const trunkPollTimerRef = useRef<any>(null); // 后备箱状态轮询定时器 |
| | | const hasShownAnnouncementsRef = useRef(false); // 安全须知是否已展示 |
| | | |
| | | // 外部后备箱状态变化时同步本地状态 |
| | | useEffect(() => { |
| | | setTrunkStatus(orderTrunkStatus); |
| | | }, [orderTrunkStatus]); |
| | | |
| | | // 组件卸载时清理轮询定时器 |
| | | useEffect(() => { |
| | | return () => { |
| | | if (trunkPollTimerRef.current) { |
| | |
| | | }; |
| | | }, []); |
| | | |
| | | // 获取解锁请求所需定位参数,失败时回退到缓存上车点 |
| | | const getUnlockLocationPayload = async () => { |
| | | try { |
| | | Taro.showLoading({ title: '正在获取位置...', mask: true }); |
| | |
| | | return {}; |
| | | }; |
| | | |
| | | // 解锁车门/后备箱,后备箱场景会轮询确认结果 |
| | | const handleUnlock = async (doorType: 'door' | 'trunk', onSuccess?: () => void) => { |
| | | const targetOrderId = orderId || Taro.getStorageSync('orderId'); |
| | | if (!targetOrderId) return; |
| | | |
| | | const locationPayload = await getUnlockLocationPayload(); |
| | | const isCloseAction = doorType === 'trunk' ? !!trunkStatus : false; |
| | | const actionLoadingText = doorType === 'trunk' |
| | | ? (isCloseAction ? '正在关闭后备箱' : '正在开启后备箱') |
| | | : '正在解锁车门'; |
| | | Taro.showLoading({ title: actionLoadingText, mask: true }); |
| | | unlockVehicle({ |
| | | orderId: targetOrderId, |
| | | doorType, |
| | |
| | | }).then((res: any) => { |
| | | console.log('解锁结果:', res); |
| | | |
| | | if (!res) { |
| | | Taro.showToast({ title: '解锁失败,请重试或联系客服', icon: 'none' }); |
| | | if (res?.code !== 200 || !res?.data) { |
| | | Taro.hideLoading(); |
| | | Taro.showToast({ title: res?.msg || '解锁失败,请重试或联系客服', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | | if (doorType === 'trunk') { |
| | | const isCloseAction = !!trunkStatus; |
| | | const loadingText = isCloseAction ? '正在关闭后备箱' : '正在开启后备箱'; |
| | | Taro.showLoading({ title: loadingText, mask: true }); |
| | | |
| | | let pollCount = 0; |
| | | const maxPolls = 5; |
| | | const pollInterval = 2000; |
| | |
| | | } |
| | | |
| | | if (doorType === 'door') { |
| | | Taro.hideLoading(); |
| | | Taro.showToast({ title: '右侧车门已解锁', icon: 'none' }); |
| | | if (!hasShownAnnouncementsRef.current) { |
| | | handleOpenAnnouncementsPopup?.(); |
| | |
| | | } |
| | | } |
| | | }).catch(() => { |
| | | Taro.hideLoading(); |
| | | Taro.showToast({ title: '解锁失败,请重试或联系客服', icon: 'none' }); |
| | | }); |
| | | }; |
| | |
| | | evaluateSubmitVersion, |
| | | evaluateCloseVersion |
| | | }: Props) { |
| | | const [activeIndex, setActiveIndex] = useState(0); |
| | | const [isEvaluated, setIsEvaluated] = useState(false); |
| | | const skipEvaluateResetRef = useRef(false); |
| | | const [activeIndex, setActiveIndex] = useState(0); // 当前选中的满意度 |
| | | const [isEvaluated, setIsEvaluated] = useState(false); // 是否已完成评价 |
| | | const skipEvaluateResetRef = useRef(false); // 跳过关闭重置的一次性标记 |
| | | |
| | | // 外部提交成功后同步评价状态 |
| | | useEffect(() => { |
| | | if (typeof evaluateSubmitVersion === 'number' && evaluateSubmitVersion > 0) { |
| | | skipEvaluateResetRef.current = true; |
| | |
| | | } |
| | | }, [evaluateSubmitVersion]); |
| | | |
| | | // 关闭评价弹窗时重置高亮状态 |
| | | useEffect(() => { |
| | | if (typeof evaluateCloseVersion === 'number' && evaluateCloseVersion > 0) { |
| | | if (skipEvaluateResetRef.current) { |
| | |
| | | } |
| | | }, [evaluateCloseVersion]); |
| | | |
| | | // 点击评价项:不满意走弹窗,其余直接提交 |
| | | const changeEvaluate = (index: number) => { |
| | | if (isEvaluated) return; |
| | | setActiveIndex(index); |
| | |
| | | </View> |
| | | </View> |
| | | ); |
| | | } |
| | | } |
| | |
| | | onToggleDetailFade, |
| | | handlePay |
| | | }: Props) { |
| | | const [showDetail, setShowDetail] = useState(false); |
| | | const [showDetail, setShowDetail] = useState(false); // 费用明细展开状态 |
| | | |
| | | // 外部重置版本变化时收起明细 |
| | | useEffect(() => { |
| | | if (typeof resetDetailVersion === 'number') { |
| | | setShowDetail(false); |
| | | } |
| | | }, [resetDetailVersion]); |
| | | |
| | | // 跳转费用说明页 |
| | | const toMoneyDetail = () => { |
| | | Taro.navigateTo({ |
| | | url: `/robotaxi/pages/expense-description/index?orderId=${orderId}` |
| | | }); |
| | | }; |
| | | |
| | | // 切换明细面板并同步地图淡化状态 |
| | | const handleToggleDetail = () => { |
| | | const next = !showDetail; |
| | | setShowDetail(next); |
| | |
| | | } |
| | | </View> |
| | | ); |
| | | } |
| | | } |
| | |
| | | }; |
| | | |
| | | export default function TemperatureControl({ tripStatus, orderId }: Props) { |
| | | const [tempStatus, setTempStatus] = useState<number | null>(); |
| | | const tempTimerRef = useRef<any>(null); |
| | | const lastConfirmedTempRef = useRef<number>(); |
| | | const [tempStatus, setTempStatus] = useState<number | null>(); // 当前显示温度 |
| | | const tempTimerRef = useRef<any>(null); // 温度设置防抖定时器 |
| | | const lastConfirmedTempRef = useRef<number>(); // 最近一次设置成功温度 |
| | | |
| | | const TEMP_MIN = 22; |
| | | const TEMP_MAX = 28; |
| | | |
| | | // 状态 4 时拉取车辆当前温度 |
| | | useEffect(() => { |
| | | if (tripStatus === 4) { |
| | | const targetOrderId = orderId || Taro.getStorageSync('orderId'); |
| | |
| | | } |
| | | }, [tripStatus, orderId]); |
| | | |
| | | // 初始化确认温度并在卸载时清理定时器 |
| | | useEffect(() => { |
| | | lastConfirmedTempRef.current = tempStatus as number; |
| | | return () => { |
| | |
| | | }; |
| | | }, []); |
| | | |
| | | // 防抖提交目标温度到后端 |
| | | const scheduleTemperatureUpdate = (targetTemperature: number) => { |
| | | if (tempTimerRef.current) { |
| | | clearTimeout(tempTimerRef.current); |
| | |
| | | }, 5000); |
| | | }; |
| | | |
| | | // 处理温度增减并做边界校验 |
| | | const handleTempChange = (delta: number) => { |
| | | setTempStatus((prev) => { |
| | | if (typeof prev !== 'number') { |
| | |
| | | </View> |
| | | </View> |
| | | ); |
| | | } |
| | | } |
| | |
| | | }; |
| | | }, [tripStatus, orderId]); |
| | | |
| | | // 标题行文案映射 |
| | | const titleMap = { |
| | | 3: <>预计<View className="time">{waitMinutes}</View>分钟内有车应答</>, |
| | | 4: <>车辆还有<View className="time">{durationDisplay.value}</View>{durationDisplay.unit}到达</>, |
| | |
| | | 9: '支付成功' |
| | | } |
| | | |
| | | // 说明文案映射 |
| | | const descMap = { |
| | | 3: { text: '正全力为您叫车中,请耐心等待...', className: 'calling-car-content-desc' }, |
| | | 4: limitMinutes ? { text: `此处限停${limitMinutes}分钟,请尽快前往上车点`, className: isArriveSoon ? 'calling-car-content-desc-red' : 'calling-car-content-warning' } : null, |
| | |
| | | const pickupTimerRef = useRef<any>(null); |
| | | const finishTimerRef = useRef<any>(null); |
| | | const countdownFinishedRef = useRef(false); |
| | | const pickupTripStatusRef = useRef(tripStatus); |
| | | const pickupEndTimeRef = useRef<number | null>(null); |
| | | const finishEndTimeRef = useRef<number | null>(null); |
| | | const lastFareOrderIdRef = useRef<string | null>(null); |
| | |
| | | }, [tripStatus]); |
| | | |
| | | useEffect(() => { |
| | | pickupTripStatusRef.current = tripStatus; |
| | | }, [tripStatus]); |
| | | |
| | | useEffect(() => { |
| | | if (tripStatus !== 5) { |
| | | clearPickupTimer(); |
| | | return; |
| | | } |
| | | |
| | | const runPickupTick = () => { |
| | | // 防止状态已切换后,旧定时回调仍触发取消逻辑 |
| | | if (pickupTripStatusRef.current !== 5) { |
| | | clearPickupTimer(); |
| | | return; |
| | | } |
| | | const remaining = getRemainingSeconds(pickupEndTimeRef.current); |
| | | if (remaining !== null && remaining <= 0) { |
| | | if (!countdownFinishedRef.current) { |
| | |
| | | }; |
| | | |
| | | export default function VehicleInfoCard({ vehicleInfo }: Props) { |
| | | // 展示车辆基础信息与车辆图片 |
| | | return ( |
| | | <View className="car-card-detail"> |
| | | <View> |
| | |
| | | } |
| | | </View> |
| | | ); |
| | | } |
| | | } |
| | |
| | | orderTrunkStatus, |
| | | }: Props) { |
| | | |
| | | // 汇总行程中的倒计时与费用数据 |
| | | const { |
| | | waitMinutes, |
| | | limitMinutes, |
| | |
| | | /** 上车倒计时即将结束(<60秒) */ |
| | | const isPickupCountdownSoon = tripStatus === 5 && (typeof pickupCountdownSeconds === 'number' && pickupCountdownSeconds < 60); |
| | | |
| | | // 按状态组合渲染行程卡片模块 |
| | | return ( |
| | | <View className='calling-car'> |
| | | <Image className="calling-car-bg" src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/site-top-bg.png' /> |
| | |
| | | } |
| | | |
| | | export default function CancelPopup({ show = false, cancelType = 1, startSite, endSite, waitMinutes, tripStatus, routeDurationSeconds, discountAmount, onClose, onConfirm }: Props) { |
| | | const [visible, setVisible] = useState(false); |
| | | const [animationData, setAnimationData] = useState<any>(); |
| | | const [visible, setVisible] = useState(false); // 弹窗显示状态 |
| | | const [animationData, setAnimationData] = useState<any>(); // 弹窗开合动画 |
| | | const cachedStartSite = startSite || Taro.getStorageSync('startSite') || {}; |
| | | const cachedEndSite = endSite || Taro.getStorageSync('endSite') || {}; |
| | | const startSiteName = cachedStartSite?.name || cachedStartSite?.address || '-'; |
| | |
| | | const durationDisplay = formatDurationDisplay(routeDurationSeconds); |
| | | const discountAmountValue = typeof discountAmount === 'number' && !Number.isNaN(discountAmount) ? discountAmount : 0; |
| | | |
| | | // 不同取消阶段对应的文案配置 |
| | | const STATUS_MAP = { |
| | | 1: { |
| | | title: `预计${waitMinutesValue}分钟内有车,确定取消吗?`, |
| | |
| | | }, |
| | | } |
| | | |
| | | // 控制弹窗开合动画 |
| | | useEffect(() => { |
| | | if (show && !visible) { |
| | | setVisible(true); |
| | |
| | | } |
| | | }, [show]); |
| | | |
| | | // 关闭弹窗时按类型执行对应流程 |
| | | const closePopup = () => { |
| | | if (cancelType == 5) { |
| | | onConfirm?.(); |
| New file |
| | |
| | | .modify-destination-review-container { |
| | | position: relative; |
| | | width: 100vw; |
| | | } |
| | | |
| | | .modify-destination-review { |
| | | position: relative; |
| | | width: 100%; |
| | | border-radius: 40px 40px 0 0; |
| | | background: transparent; |
| | | z-index: 10; |
| | | overflow: hidden; |
| | | transition: height 0.3s ease; |
| | | |
| | | &-inner { |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | width: 100%; |
| | | } |
| | | |
| | | // &-route-panel { |
| | | // margin: 0 32px 16px 32px; |
| | | // } |
| | | |
| | | &-content-wrapper { |
| | | position: relative; |
| | | padding-bottom: 180px; |
| | | background: #f2f3f7 url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/site-top-bg.png') no-repeat top center; |
| | | background-size: 100% auto; |
| | | border-top: 2px solid #ffffff; |
| | | border-radius: 40px 40px 0 0; |
| | | } |
| | | |
| | | &-content { |
| | | position: relative; |
| | | padding: 40px 32px 40px; |
| | | z-index: 10; |
| | | } |
| | | |
| | | &-title { |
| | | position: relative; |
| | | z-index: 1; |
| | | font-size: 28px; |
| | | font-weight: 500; |
| | | margin-bottom: 32px; |
| | | } |
| | | |
| | | &-sites { |
| | | position: relative; |
| | | z-index: 1; |
| | | padding: 24px; |
| | | border-radius: 24px; |
| | | background: #ffffff; |
| | | box-sizing: border-box; |
| | | border: 2px solid #ffffff; |
| | | backdrop-filter: blur(20px); |
| | | box-shadow: 0 40px 60px 0 rgba(87, 99, 147, 0.1); |
| | | } |
| | | |
| | | &-site-item { |
| | | display: flex; |
| | | align-items: center; |
| | | min-height: 92px; |
| | | padding: 0 24px; |
| | | border-radius: 20px; |
| | | |
| | | & + & { |
| | | margin-top: 12px; |
| | | } |
| | | } |
| | | |
| | | &-site-item-active { |
| | | background: #F5F6FA; |
| | | } |
| | | |
| | | &-site-dot { |
| | | width: 12px; |
| | | height: 12px; |
| | | margin-right: 20px; |
| | | border-radius: 50%; |
| | | flex-shrink: 0; |
| | | |
| | | &-old { |
| | | background: #768aff; |
| | | } |
| | | |
| | | &-new { |
| | | background: #9cc14e; |
| | | } |
| | | } |
| | | |
| | | &-site-text { |
| | | flex: 1; |
| | | font-size: 30px; |
| | | line-height: 42px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | word-break: break-all; |
| | | } |
| | | |
| | | &-footer { |
| | | position: fixed; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | z-index: 11; |
| | | height: 180px; |
| | | box-sizing: border-box; |
| | | padding: 24px 38px 0 32px; |
| | | background: #fefeff; |
| | | display: flex; |
| | | // align-items: center; |
| | | justify-content: space-between; |
| | | gap: 24px; |
| | | } |
| | | |
| | | &-fare { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | &-fare-main { |
| | | display: flex; |
| | | align-items: flex-end; |
| | | font-size: 24px; |
| | | line-height: 36px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | } |
| | | |
| | | &-fare-num { |
| | | margin: 0 6px; |
| | | font-size: 56px; |
| | | line-height: 56px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | } |
| | | |
| | | &-fare-discount { |
| | | margin-top: 8px; |
| | | font-size: 24px; |
| | | line-height: 34px; |
| | | color: #f58900; |
| | | } |
| | | |
| | | &-fare-original { |
| | | margin-top: 6px; |
| | | font-size: 24px; |
| | | line-height: 34px; |
| | | color: rgba(0, 0, 0, 0.4); |
| | | text-decoration: line-through; |
| | | } |
| | | |
| | | &-confirm { |
| | | flex-shrink: 0; |
| | | width: 296px; |
| | | height: 94px; |
| | | border-radius: 20px; |
| | | background: linear-gradient(102deg, #8475ff 0%, #7dd6ff 102%); |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 28px; |
| | | line-height: 40px; |
| | | color: #ffffff; |
| | | } |
| | | } |
| New file |
| | |
| | | .route-select { |
| | | border-radius: 24px; |
| | | background: #FFFFFF; |
| | | box-sizing: border-box; |
| | | border: 1px solid #FFFFFF; |
| | | margin: 0 32px 16px 32px; |
| | | padding: 16px 24px 24px 24px; |
| | | |
| | | .route-select-type { |
| | | display: flex; |
| | | align-items: center; |
| | | |
| | | .route-select-title { |
| | | font-size: 24px; |
| | | font-weight: 500; |
| | | line-height: 44px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | } |
| | | |
| | | .route-select-preference { |
| | | display: flex; |
| | | align-items: center; |
| | | |
| | | .route-select-preference-text { |
| | | font-size: 24px; |
| | | font-weight: 500; |
| | | line-height: 40px; |
| | | color: #6D7FD9; |
| | | } |
| | | |
| | | image { |
| | | flex-shrink: 0; |
| | | margin-left: 9.25px; |
| | | height: 11.97px; |
| | | width: 17.5px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .route-select-content { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | gap: 10px; |
| | | margin-top: 16px; |
| | | |
| | | .route-select-content-item { |
| | | flex: 1; |
| | | padding: 16px 0 24px 24px; |
| | | border-radius: 16px; |
| | | background: #F5F6FA; |
| | | border: 1px solid #F2F3F7; |
| | | |
| | | .route-select-content-item-title { |
| | | font-size: 20px; |
| | | line-height: 20px; |
| | | color: rgba(0, 0, 0, 0.4); |
| | | } |
| | | |
| | | .route-select-content-item-time { |
| | | margin-top: 10px; |
| | | font-size: 28px; |
| | | font-weight: 500; |
| | | line-height: 40px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | } |
| | | |
| | | .route-select-content-item-distance { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12px; |
| | | margin-top: 12px; |
| | | font-size: 20px; |
| | | line-height: 20px; |
| | | color: rgba(0, 0, 0, 0.4); |
| | | |
| | | .traffic-signal-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 4px; |
| | | |
| | | .traffic-signal-icon { |
| | | flex-shrink: 0; |
| | | width: 20px; |
| | | height: 20px; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .active-item { |
| | | background: linear-gradient(97deg, rgba(132, 117, 255, 0.2) 0%, rgba(125, 214, 255, 0.2) 103%) !important; |
| | | box-sizing: border-box; |
| | | position: relative; |
| | | |
| | | .route-select-content-item-title { |
| | | color: #6D7FD9; |
| | | } |
| | | |
| | | &::after { |
| | | content: ""; |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | border-radius: 16px; |
| | | padding: 1px; |
| | | background: linear-gradient(180deg, #AAA8FF -10%, rgba(41, 159, 255, 0.0469) 108%, rgba(0, 0, 0, 0) 108%); |
| | | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); |
| | | -webkit-mask-composite: xor; |
| | | mask-composite: exclude; |
| | | pointer-events: none; |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | export default function EvaluatePopup({ show = false, onClose, onConfirm, orderId, orderRating, submitMode = 'comment' }: Props) { |
| | | const [visible, setVisible] = useState(false); |
| | | const [animationData, setAnimationData] = useState<any>(); |
| | | const [selectedTypeId, setSelectedTypeId] = useState<number | null>(null); |
| | | const [feedbackContent, setFeedbackContent] = useState(''); |
| | | const [typeOptions, setTypeOptions] = useState<any[]>([]); |
| | | const [isSubmitting, setIsSubmitting] = useState(false); |
| | | const [visible, setVisible] = useState(false); // 弹窗显示状态 |
| | | const [animationData, setAnimationData] = useState<any>(); // 弹窗进出场动画 |
| | | const [selectedTypeId, setSelectedTypeId] = useState<number | null>(null); // 当前选择的问题类型 |
| | | const [feedbackContent, setFeedbackContent] = useState(''); // 反馈文本内容 |
| | | const [typeOptions, setTypeOptions] = useState<any[]>([]); // 问题类型选项 |
| | | const [isSubmitting, setIsSubmitting] = useState(false); // 提交中状态 |
| | | const [pictures, setPictures] = useState<Array<{ |
| | | localUrl: string; |
| | | server?: { id: string; name: string; url: string } | null; |
| | | status: 'uploading' | 'success' | 'failed'; |
| | | }>>([]); |
| | | }>>([]); // 图片上传队列 |
| | | |
| | | // 控制弹窗开合动画 |
| | | useEffect(() => { |
| | | if (show && !visible) { |
| | | setVisible(true); |
| | |
| | | } |
| | | }, [show]); |
| | | |
| | | // 提交反馈(评价/投诉) |
| | | const submit = () => { |
| | | if (isSubmitting) { |
| | | return; |
| | |
| | | }); |
| | | } |
| | | |
| | | // 压缩图片到可上传大小 |
| | | const ensureSizeUnderLimit = async (path: string): Promise<string> => { |
| | | try { |
| | | const fs = Taro.getFileSystemManager(); |
| | |
| | | } |
| | | }; |
| | | |
| | | // 上传单张图片并写回状态 |
| | | const uploadSingle = async (index: number, filePath: string) => { |
| | | setPictures(prev => { |
| | | const next = [...prev]; |
| | |
| | | } |
| | | }; |
| | | |
| | | // 选择图片并触发上传 |
| | | const handleChooseImages = async () => { |
| | | const remain = Math.max(0, 3 - pictures.length); |
| | | if (remain <= 0) { |
| | |
| | | } |
| | | }; |
| | | |
| | | // 重试上传失败图片 |
| | | const handleRetry = async (index: number) => { |
| | | const item = pictures[index]; |
| | | if (!item) return; |
| | | uploadSingle(index, item.localUrl); |
| | | }; |
| | | |
| | | // 删除已选图片 |
| | | const handleDelete = (index: number) => { |
| | | setPictures(prev => { |
| | | const next = [...prev]; |
| | |
| | | }); |
| | | }; |
| | | |
| | | // 拉取问题类型并重置本次填写内容 |
| | | useEffect(() => { |
| | | if (!show) { |
| | | return; |
| New file |
| | |
| | | import { View, Text } from '@tarojs/components'; |
| | | import Taro from '@tarojs/taro'; |
| | | import { useEffect, useState, useRef } from 'react'; |
| | | import './css-folder/modify-destination-review.less'; |
| | | import RouteSelectionPanel from './route-selection-panel'; |
| | | |
| | | type Props = { |
| | | previousEndSite: any; |
| | | nextEndSite: any; |
| | | previousFareEstimate?: any; |
| | | fareEstimate: any; |
| | | routeOptions?: any[]; |
| | | selectedRouteIndex?: number; |
| | | isRouteSelectExpanded?: boolean; |
| | | onToggleRouteSelect?: (expanded: boolean) => void; |
| | | handleSelectRoute?: (index: number) => void; |
| | | onEditDestination?: () => void; |
| | | onConfirm: () => void; |
| | | }; |
| | | |
| | | export default function ModifyDestinationReview({ |
| | | previousEndSite, |
| | | nextEndSite, |
| | | previousFareEstimate, |
| | | fareEstimate, |
| | | routeOptions = [], |
| | | selectedRouteIndex = 0, |
| | | isRouteSelectExpanded = false, |
| | | onToggleRouteSelect, |
| | | handleSelectRoute, |
| | | onEditDestination, |
| | | onConfirm |
| | | }: Props) { |
| | | // 高度统一使用 rpx,'auto' 表示由内容撑开 |
| | | const [boxHeightRpx, setBoxHeightRpx] = useState<number | 'auto'>('auto'); |
| | | |
| | | const isDragging = useRef(false); |
| | | const startY = useRef(0); |
| | | const startHeight = useRef(0); |
| | | const windowWidth = useRef(375); |
| | | |
| | | useEffect(() => { |
| | | const sysInfo = Taro.getSystemInfoSync(); |
| | | windowWidth.current = sysInfo.windowWidth; |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | if (!isDragging.current) { |
| | | if (isRouteSelectExpanded) { |
| | | // 提取数字,兼容之前可能带有 'px' 的情况 |
| | | setBoxHeightRpx(378); // 展开时高度 |
| | | } else { |
| | | setBoxHeightRpx('auto'); // 正常高度自适应 |
| | | } |
| | | } |
| | | }, [isRouteSelectExpanded]); |
| | | |
| | | const pxToRpx = (px: number) => { |
| | | return (px * 750) / windowWidth.current; |
| | | }; |
| | | |
| | | const handleTouchStart = (e: any) => { |
| | | if (!isRouteSelectExpanded) return; |
| | | isDragging.current = true; |
| | | startY.current = e.touches[0].clientY; |
| | | // 如果是 auto,我们在拖拽开始时需要获取当前真实高度并转换为 rpx |
| | | // 但为了流畅,通常展开时已经是具体的 rpx 数字了 |
| | | startHeight.current = typeof boxHeightRpx === 'number' ? boxHeightRpx : 378; |
| | | }; |
| | | |
| | | const handleTouchMove = (e: any) => { |
| | | if (!isRouteSelectExpanded || !isDragging.current) return; |
| | | const deltaYPx = e.touches[0].clientY - startY.current; |
| | | const deltaYRpx = pxToRpx(deltaYPx); |
| | | |
| | | // 往下拉 deltaY > 0,高度变小;往上拉 deltaY < 0,高度变大 |
| | | let newHeightRpx = startHeight.current - deltaYRpx; |
| | | |
| | | // 限制拖拽范围 |
| | | if (newHeightRpx < 150) newHeightRpx = 150; |
| | | if (newHeightRpx > 800) newHeightRpx = 800; |
| | | |
| | | setBoxHeightRpx(newHeightRpx); |
| | | }; |
| | | |
| | | const handleTouchEnd = () => { |
| | | if (!isRouteSelectExpanded || !isDragging.current) return; |
| | | isDragging.current = false; |
| | | |
| | | // 不自动回弹:让高度停留在滑动结束的位置 |
| | | // 但如果滑得太低,当做收起 |
| | | if (typeof boxHeightRpx === 'number' && boxHeightRpx <= 200) { |
| | | onToggleRouteSelect && onToggleRouteSelect(false); |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <View className="modify-destination-review-container"> |
| | | {isRouteSelectExpanded && routeOptions.length > 0 && ( |
| | | <View className='modify-destination-review-route-panel'> |
| | | <RouteSelectionPanel |
| | | routeOptions={routeOptions} |
| | | selectedRouteIndex={selectedRouteIndex} |
| | | handleSelectRoute={handleSelectRoute} |
| | | /> |
| | | </View> |
| | | )} |
| | | |
| | | <View |
| | | className='modify-destination-review' |
| | | style={{ |
| | | height: boxHeightRpx === 'auto' ? 'auto' : Taro.pxTransform(boxHeightRpx as number), |
| | | transition: isDragging.current ? 'none' : 'height 0.3s ease' |
| | | }} |
| | | catchMove={isRouteSelectExpanded} |
| | | onTouchStart={(e) => { |
| | | if (!isRouteSelectExpanded) return; |
| | | e.stopPropagation(); |
| | | handleTouchStart(e); |
| | | }} |
| | | onTouchMove={(e) => { |
| | | if (!isRouteSelectExpanded) return; |
| | | e.stopPropagation(); |
| | | handleTouchMove(e); |
| | | }} |
| | | onTouchEnd={(e) => { |
| | | if (!isRouteSelectExpanded) return; |
| | | e.stopPropagation(); |
| | | handleTouchEnd(); |
| | | }} |
| | | > |
| | | <View className='modify-destination-review-content-wrapper'> |
| | | <View className='modify-destination-review-content'> |
| | | <View className='modify-destination-review-title'> |
| | | 由于目的地变更,本次行程费用已更新 |
| | | </View> |
| | | <View className='modify-destination-review-sites'> |
| | | <View className='modify-destination-review-site-item'> |
| | | <View className='modify-destination-review-site-dot modify-destination-review-site-dot-old' /> |
| | | <View className='modify-destination-review-site-text'>{previousEndSite?.name || '-'}</View> |
| | | </View> |
| | | <View |
| | | className='modify-destination-review-site-item modify-destination-review-site-item-active' |
| | | onClick={() => onEditDestination?.()} |
| | | > |
| | | <View className='modify-destination-review-site-dot modify-destination-review-site-dot-new' /> |
| | | <View className='modify-destination-review-site-text'>{nextEndSite?.name || '-'}</View> |
| | | </View> |
| | | </View> |
| | | </View> |
| | | |
| | | <View className='modify-destination-review-footer'> |
| | | <View className='modify-destination-review-fare'> |
| | | <View className='modify-destination-review-fare-main'> |
| | | 预估 |
| | | <Text className='modify-destination-review-fare-num'>{fareEstimate?.finalFareStr || '-'}</Text> |
| | | 元 |
| | | </View> |
| | | {Number(fareEstimate?.discountAmount) > 0 && ( |
| | | <View className='modify-destination-review-fare-discount'> |
| | | 已优惠{fareEstimate.discountAmount}元 |
| | | </View> |
| | | )} |
| | | {previousFareEstimate?.finalFareStr && previousFareEstimate?.finalFareStr !== fareEstimate?.finalFareStr && ( |
| | | <View className='modify-destination-review-fare-original'> |
| | | 原预估{previousFareEstimate.finalFareStr}元 |
| | | </View> |
| | | )} |
| | | </View> |
| | | <View className='modify-destination-review-confirm' onClick={onConfirm}> |
| | | 确认修改 |
| | | </View> |
| | | </View> |
| | | </View> |
| | | </View> |
| | | </View> |
| | | ); |
| | | } |
| | |
| | | } |
| | | |
| | | export default function OverRoute({ show = false, onClose, onConfirm }: Props) { |
| | | const [visible, setVisible] = useState(false); |
| | | const [animationData, setAnimationData] = useState<any>(); |
| | | const [finishMinutes, setFinishMinutes] = useState<number | null>(null); |
| | | const [isSubmitting, setIsSubmitting] = useState(false); |
| | | const [visible, setVisible] = useState(false); // 弹窗显示状态 |
| | | const [animationData, setAnimationData] = useState<any>(); // 弹窗动画数据 |
| | | const [finishMinutes, setFinishMinutes] = useState<number | null>(null); // 自动驶离倒计时(分钟) |
| | | const [isSubmitting, setIsSubmitting] = useState(false); // 结束行程提交状态 |
| | | |
| | | // 确认结束行程并调用结束接口 |
| | | const handleConfirm = () => { |
| | | if (isSubmitting) { |
| | | return; |
| | |
| | | }); |
| | | }; |
| | | |
| | | // 控制弹窗关闭动画 |
| | | useEffect(() => { |
| | | if (!show && visible) { |
| | | const animation = Taro.createAnimation({ |
| | |
| | | } |
| | | }, [show]); |
| | | |
| | | // 打开弹窗时先拉取结束后离车时间 |
| | | useEffect(() => { |
| | | if (!show) { |
| | | return; |
| | |
| | | } |
| | | |
| | | export default function PassengerNotice({ show = false, onClose, onCheckedChange }: Props) { |
| | | const [visible, setVisible] = useState(false); |
| | | const [animationData, setAnimationData] = useState<any>(); |
| | | const [checked, setChecked] = useState(true); |
| | | const [noticeContent, setNoticeContent] = useState(''); |
| | | const [thumbHeight, setThumbHeight] = useState(0); |
| | | const [thumbTop, setThumbTop] = useState(0); |
| | | const [containerHeight, setContainerHeight] = useState(0); |
| | | const [showScrollbar, setShowScrollbar] = useState(false); |
| | | const [visible, setVisible] = useState(false); // 弹窗显示状态 |
| | | const [animationData, setAnimationData] = useState<any>(); // 弹窗动画数据 |
| | | const [checked, setChecked] = useState(true); // 近三个月不再显示勾选状态 |
| | | const [noticeContent, setNoticeContent] = useState(''); // 须知富文本内容 |
| | | const [thumbHeight, setThumbHeight] = useState(0); // 自定义滚动条高度 |
| | | const [thumbTop, setThumbTop] = useState(0); // 自定义滚动条位置 |
| | | const [containerHeight, setContainerHeight] = useState(0); // 文本容器高度 |
| | | const [showScrollbar, setShowScrollbar] = useState(false); // 是否展示自定义滚动条 |
| | | |
| | | // 内容滚动时同步自定义滚动条位置 |
| | | const onScroll = (e) => { |
| | | const { scrollHeight, scrollTop } = e.detail; |
| | | if (containerHeight > 0 && scrollHeight > containerHeight) { |
| | |
| | | } |
| | | }; |
| | | |
| | | // 文案渲染完成后计算滚动条尺寸 |
| | | useEffect(() => { |
| | | if (noticeContent && visible) { |
| | | // Give it a little time to render |
| | |
| | | } |
| | | }, [noticeContent, visible]); |
| | | |
| | | // 控制弹窗开合动画 |
| | | useEffect(() => { |
| | | if (show && !visible) { |
| | | setVisible(true); |
| | |
| | | } |
| | | }, [show]); |
| | | |
| | | // 每次打开时拉取最新须知内容 |
| | | useEffect(() => { |
| | | if (!show) { |
| | | return; |
| | |
| | | } |
| | | |
| | | export default function ReleaseBeBeing({ handleOpenCancelPopup, onFinish }: Props) { |
| | | const [progress, setProgress] = useState(0); |
| | | const [completedCount, setCompletedCount] = useState(0); |
| | | const [progress, setProgress] = useState(0); // 发布进度条 |
| | | const [completedCount, setCompletedCount] = useState(0); // 已完成步骤数 |
| | | const startSite = Taro.getStorageSync('startSite') || {}; |
| | | const endSite = Taro.getStorageSync('endSite') || {}; |
| | | const startSiteName = startSite?.name || startSite?.address || '-'; |
| | | const endSiteName = endSite?.name || endSite?.address || '-'; |
| | | |
| | | // 模拟发布流程进度和状态推进 |
| | | useEffect(() => { |
| | | // 使用 setTimeout 触发 CSS transition 动画 |
| | | const timer = setTimeout(() => { |
| | |
| | | import './css-folder/release-route.less' |
| | | import tipBlueIcon from '../../../assets/tip-blue-icon.png' |
| | | import tipDefaultIcon from '../../../assets/tip-default-icon.png' |
| | | import selectLineIcon from '../../../assets/select-line.png' |
| | | import trafficSignalIcon from '../../../assets/traffic-signal.png' |
| | | import RoutePreferencePopup, { RoutePreferenceOption } from './route-preference-popup'; |
| | | import { saveRoutePreference, queryRoutePreferences } from '../../../api/begin-route'; |
| | | import { formatDurationDisplay, formatTripDistance } from '../../../../utils/Robotaxi-public-method'; |
| | | import RouteSelectionPanel from './route-selection-panel'; |
| | | |
| | | type Props = { |
| | | startSite: any; |
| | |
| | | const dragFrame = useRef<any>(null); |
| | | const pendingHeight = useRef<number | null>(null); |
| | | |
| | | const [showPreferencePopup, setShowPreferencePopup] = useState(false); |
| | | const [preference, setPreference] = useState<RoutePreferenceOption>({ label: '智能推荐', value: '智能推荐', code: '智能推荐' }); |
| | | const [preferenceOptions, setPreferenceOptions] = useState<RoutePreferenceOption[]>([]); |
| | | const openCityId = Number(Taro.getStorageSync('selectedCity')?.id || 0); |
| | | const selectedRoute = routeOptions?.[selectedRouteIndex]; |
| | | const isOperateFree = Number(selectedRoute?.operateFree) === 1; |
| | | |
| | | useEffect(() => { |
| | | let cancelled = false; |
| | | queryRoutePreferences({ openCityId }).then((res: any) => { |
| | | if (cancelled) return; |
| | | const list = Array.isArray(res) ? res : (Array.isArray(res?.list) ? res.list : (Array.isArray(res?.data) ? res.data : [])); |
| | | const normalized = list.map((item: any) => { |
| | | const label = item?.label || item?.name || item?.displayName || item?.preferenceName || item?.value; |
| | | const value = item?.value ?? item?.code ?? item?.preferenceCode ?? label; |
| | | const code = item?.code ?? item?.preferenceCode ?? item?.value ?? label; |
| | | if (!label || value === undefined || value === null) return null; |
| | | return { label: String(label), value: String(value), code: String(code) }; |
| | | }).filter(Boolean) as RoutePreferenceOption[]; |
| | | if (normalized.length > 0) { |
| | | setPreferenceOptions(normalized); |
| | | } |
| | | }).catch(() => {}); |
| | | return () => { cancelled = true; }; |
| | | }, [openCityId]); |
| | | |
| | | // Sync preference label when route selection changes externally (e.g. clicking map or route card) |
| | | useEffect(() => { |
| | | if (routeOptions && routeOptions[selectedRouteIndex]) { |
| | | const currentRoute = routeOptions[selectedRouteIndex]; |
| | | |
| | | // Match label based on preference from API response |
| | | let mappedLabel = '智能推荐'; |
| | | if (preferenceOptions.length > 0 && currentRoute.preference !== undefined && currentRoute.preference !== null) { |
| | | const matchedOption = preferenceOptions.find(opt => String(opt.code) === String(currentRoute.preference)); |
| | | if (matchedOption) { |
| | | mappedLabel = matchedOption.label; |
| | | } else if (currentRoute.routePlanName) { |
| | | // Fallback to routePlanName if preference matching fails |
| | | const name = currentRoute.routePlanName; |
| | | mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | } |
| | | } else if (currentRoute.routePlanName) { |
| | | // Fallback to routePlanName if preference matching fails |
| | | const name = currentRoute.routePlanName; |
| | | mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | } |
| | | |
| | | setPreference(prev => { |
| | | if (prev.label !== mappedLabel) { |
| | | const newCode = currentRoute.preference !== undefined && currentRoute.preference !== null ? String(currentRoute.preference) : mappedLabel; |
| | | return { |
| | | label: mappedLabel, |
| | | value: newCode, |
| | | code: newCode |
| | | }; |
| | | } |
| | | return prev; |
| | | }); |
| | | } |
| | | }, [selectedRouteIndex, routeOptions, preferenceOptions]); |
| | | |
| | | useEffect(() => { |
| | | if (!isRouteSelectExpanded && boxHeight !== null && minHeight.current > 0) { |
| | |
| | | } |
| | | }; |
| | | |
| | | // 跳转选点页(起点/终点) |
| | | const toSeleteSitePage = (type: string) => { |
| | | Taro.navigateTo({ |
| | | url: `/robotaxi/pages/select-map-site/index?pageType=${type}&skipSource=1`, |
| | | }) |
| | | } |
| | | // 跳转费用说明页 |
| | | const toPriceDescription = () => { |
| | | Taro.navigateTo({ |
| | | url: `/robotaxi/pages/expense-detail/index`, |
| | | }) |
| | | } |
| | | // 格式化步行时长文案 |
| | | const formatWalkingTime = (minutes: number) => { |
| | | const safeMinutes = Math.max(0, Math.floor(Number(minutes) || 0)); |
| | | if (safeMinutes >= 60) { |
| | |
| | | return remainMinutes > 0 ? `${hours}小时${remainMinutes}分钟` : `${hours}小时`; |
| | | } |
| | | return `${safeMinutes}分钟`; |
| | | } |
| | | const handleSelectPreference = async (item: RoutePreferenceOption) => { |
| | | setPreference(item); |
| | | |
| | | if (routeOptions && routeOptions.length > 0 && handleSelectRoute) { |
| | | const targetIndex = routeOptions.findIndex(route => { |
| | | // Exact match by preference code |
| | | if (route.preference !== undefined && route.preference !== null && String(route.preference) === String(item.code)) { |
| | | return true; |
| | | } |
| | | |
| | | // Fallback match by name |
| | | if (route.routePlanName) { |
| | | const name = route.routePlanName; |
| | | const mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | if (mappedLabel === item.label) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | return false; |
| | | }); |
| | | |
| | | if (targetIndex !== -1) { |
| | | handleSelectRoute(targetIndex); |
| | | } |
| | | } |
| | | |
| | | try { |
| | | await saveRoutePreference({ |
| | | code: item.code |
| | | }); |
| | | } catch (error) { |
| | | // keep local selection state to avoid interrupting the popup interaction |
| | | } |
| | | } |
| | | |
| | | return ( |
| | |
| | | > |
| | | <View className='release-route-content'> |
| | | {isRouteSelectExpanded && ( |
| | | <View className="route-select"> |
| | | <View className="route-select-type"> |
| | | <View className="route-select-title">路线选择:</View> |
| | | <View className="route-select-preference" onClick={() => setShowPreferencePopup(true)}> |
| | | <View className="route-select-preference-text">{preference.label}</View> |
| | | <Image src={selectLineIcon} /> |
| | | </View> |
| | | </View> |
| | | <View className="route-select-content"> |
| | | { |
| | | routeOptions.map((item, index) => { |
| | | const routeData = item.route || {}; |
| | | const time = formatDurationDisplay(routeData.duration || 0); |
| | | const dist = formatTripDistance(routeData.distance || 0); |
| | | const isSelected = index === selectedRouteIndex; |
| | | |
| | | return ( |
| | | <View |
| | | className={`route-select-content-item ${isSelected ? 'active-item' : ''}`} |
| | | key={index} |
| | | onClick={() => handleSelectRoute && handleSelectRoute(index)} |
| | | > |
| | | <View className="route-select-content-item-title">{item.label || (index === 0 ? '智能推荐' : `路线${index + 1}`)}</View> |
| | | <View className="route-select-content-item-time">{time.value}{time.unit}</View> |
| | | <View className="route-select-content-item-distance"> |
| | | <View>{dist}</View> |
| | | {typeof routeData.trafficLights !== 'undefined' && ( |
| | | <View className="traffic-signal-item"> |
| | | <Image className="traffic-signal-icon" src={trafficSignalIcon} /> |
| | | <Text>{routeData.trafficLights}个</Text> |
| | | </View> |
| | | )} |
| | | </View> |
| | | </View> |
| | | ); |
| | | }) |
| | | } |
| | | </View> |
| | | </View> |
| | | <RouteSelectionPanel |
| | | routeOptions={routeOptions} |
| | | selectedRouteIndex={selectedRouteIndex} |
| | | handleSelectRoute={handleSelectRoute} |
| | | /> |
| | | )} |
| | | <View className="select-site"> |
| | | <Image className="site-top-bg" src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/site-top-bg.png' /> |
| | |
| | | </View> |
| | | </View> |
| | | </View> |
| | | <RoutePreferencePopup |
| | | show={showPreferencePopup} |
| | | value={preference.value} |
| | | openCityId={openCityId} |
| | | onClose={() => setShowPreferencePopup(false)} |
| | | onSelect={handleSelectPreference} |
| | | /> |
| | | </View> |
| | | ) |
| | | } |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useEffect, useState } from 'react'; |
| | | import { View, Text, Image } from '@tarojs/components'; |
| | | import selectLineIcon from '../../../assets/select-line.png'; |
| | | import trafficSignalIcon from '../../../assets/traffic-signal.png'; |
| | | import RoutePreferencePopup, { RoutePreferenceOption } from './route-preference-popup'; |
| | | import { queryRoutePreferences, saveRoutePreference } from '../../../api/begin-route'; |
| | | import { formatDurationDisplay, formatTripDistance } from '../../../../utils/Robotaxi-public-method'; |
| | | import './css-folder/route-selection-panel.less'; |
| | | |
| | | type Props = { |
| | | routeOptions?: any[]; |
| | | selectedRouteIndex?: number; |
| | | handleSelectRoute?: (index: number) => void; |
| | | }; |
| | | |
| | | export default function RouteSelectionPanel({ |
| | | routeOptions = [], |
| | | selectedRouteIndex = 0, |
| | | handleSelectRoute |
| | | }: Props) { |
| | | const [showPreferencePopup, setShowPreferencePopup] = useState(false); |
| | | const [preference, setPreference] = useState<RoutePreferenceOption>({ |
| | | label: '智能推荐', |
| | | value: '智能推荐', |
| | | code: '智能推荐' |
| | | }); |
| | | const [preferenceOptions, setPreferenceOptions] = useState<RoutePreferenceOption[]>([]); |
| | | const openCityId = Number(Taro.getStorageSync('selectedCity')?.id || 0); |
| | | |
| | | useEffect(() => { |
| | | let cancelled = false; |
| | | queryRoutePreferences({ openCityId }).then((res: any) => { |
| | | if (cancelled) return; |
| | | const list = Array.isArray(res) ? res : (Array.isArray(res?.list) ? res.list : (Array.isArray(res?.data) ? res.data : [])); |
| | | const normalized = list.map((item: any) => { |
| | | const label = item?.label || item?.name || item?.displayName || item?.preferenceName || item?.value; |
| | | const value = item?.value ?? item?.code ?? item?.preferenceCode ?? label; |
| | | const code = item?.code ?? item?.preferenceCode ?? item?.value ?? label; |
| | | if (!label || value === undefined || value === null) return null; |
| | | return { label: String(label), value: String(value), code: String(code) }; |
| | | }).filter(Boolean) as RoutePreferenceOption[]; |
| | | if (normalized.length > 0) { |
| | | setPreferenceOptions(normalized); |
| | | } |
| | | }).catch(() => {}); |
| | | |
| | | return () => { |
| | | cancelled = true; |
| | | }; |
| | | }, [openCityId]); |
| | | |
| | | useEffect(() => { |
| | | if (!routeOptions || !routeOptions[selectedRouteIndex]) return; |
| | | const currentRoute = routeOptions[selectedRouteIndex]; |
| | | |
| | | let mappedLabel = '智能推荐'; |
| | | if (preferenceOptions.length > 0 && currentRoute.preference !== undefined && currentRoute.preference !== null) { |
| | | const matchedOption = preferenceOptions.find(opt => String(opt.code) === String(currentRoute.preference)); |
| | | if (matchedOption) { |
| | | mappedLabel = matchedOption.label; |
| | | } else if (currentRoute.routePlanName) { |
| | | const name = currentRoute.routePlanName; |
| | | mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | } |
| | | } else if (currentRoute.routePlanName) { |
| | | const name = currentRoute.routePlanName; |
| | | mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | } |
| | | |
| | | setPreference(prev => { |
| | | if (prev.label !== mappedLabel) { |
| | | const newCode = currentRoute.preference !== undefined && currentRoute.preference !== null ? String(currentRoute.preference) : mappedLabel; |
| | | return { |
| | | label: mappedLabel, |
| | | value: newCode, |
| | | code: newCode |
| | | }; |
| | | } |
| | | return prev; |
| | | }); |
| | | }, [selectedRouteIndex, routeOptions, preferenceOptions]); |
| | | |
| | | const handleSelectPreference = async (item: RoutePreferenceOption) => { |
| | | setPreference(item); |
| | | |
| | | if (routeOptions.length > 0 && handleSelectRoute) { |
| | | const targetIndex = routeOptions.findIndex(route => { |
| | | if (route.preference !== undefined && route.preference !== null && String(route.preference) === String(item.code)) { |
| | | return true; |
| | | } |
| | | |
| | | if (route.routePlanName) { |
| | | const name = route.routePlanName; |
| | | const mappedLabel = name.includes('-') ? name.split('-')[1] : name; |
| | | if (mappedLabel === item.label) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | return false; |
| | | }); |
| | | |
| | | if (targetIndex !== -1) { |
| | | handleSelectRoute(targetIndex); |
| | | } |
| | | } |
| | | |
| | | try { |
| | | await saveRoutePreference({ code: item.code }); |
| | | } catch (error) { |
| | | // Keep local state even if persistence fails. |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <View className='route-select'> |
| | | <View className='route-select-type'> |
| | | <View className='route-select-title'>路线选择:</View> |
| | | <View className='route-select-preference' onClick={() => setShowPreferencePopup(true)}> |
| | | <View className='route-select-preference-text'>{preference.label}</View> |
| | | <Image src={selectLineIcon} /> |
| | | </View> |
| | | </View> |
| | | <View className='route-select-content'> |
| | | {routeOptions.map((item, index) => { |
| | | const routeData = item.route || {}; |
| | | const time = formatDurationDisplay(routeData.duration || 0); |
| | | const dist = formatTripDistance(routeData.distance || 0); |
| | | const isSelected = index === selectedRouteIndex; |
| | | |
| | | return ( |
| | | <View |
| | | className={`route-select-content-item ${isSelected ? 'active-item' : ''}`} |
| | | key={index} |
| | | onClick={() => handleSelectRoute && handleSelectRoute(index)} |
| | | > |
| | | <View className='route-select-content-item-title'>{item.label || (index === 0 ? '智能推荐' : `路线${index + 1}`)}</View> |
| | | <View className='route-select-content-item-time'>{time.value}{time.unit}</View> |
| | | <View className='route-select-content-item-distance'> |
| | | <View>{dist}</View> |
| | | {typeof routeData.trafficLights !== 'undefined' && ( |
| | | <View className='traffic-signal-item'> |
| | | <Image className='traffic-signal-icon' src={trafficSignalIcon} /> |
| | | <Text>{routeData.trafficLights}个</Text> |
| | | </View> |
| | | )} |
| | | </View> |
| | | </View> |
| | | ); |
| | | })} |
| | | </View> |
| | | |
| | | <RoutePreferencePopup |
| | | show={showPreferencePopup} |
| | | value={preference.value} |
| | | openCityId={openCityId} |
| | | onClose={() => setShowPreferencePopup(false)} |
| | | onSelect={handleSelectPreference} |
| | | /> |
| | | </View> |
| | | ); |
| | | } |
| New file |
| | |
| | | export const MODIFY_DESTINATION_SESSION_KEY = 'modifyDestinationSession'; |
| | | export const MODIFY_DESTINATION_CANDIDATE_KEY = 'modifyDestinationCandidate'; |
| | | export const MODIFY_DESTINATION_OVERRIDE_KEY = 'modifyDestinationOverride'; |
| | | export const MODIFY_DESTINATION_ROUTE_POINTS_KEY = 'modifyDestinationRoutePoints'; |
| | |
| | | shouldShowWaitingRadar: boolean; |
| | | isAndroid: boolean; |
| | | tripStatus: number; |
| | | mapScale: number; |
| | | startSite: any; |
| | | endSite: any; |
| | | vehicleLocation: { latitude: number; longitude: number } | null; |
| | |
| | | updateArriveCalloutPosition(); |
| | | }, [deps.isAndroid, deps.tripStatus, deps.endSite?.latitude, deps.endSite?.longitude]); |
| | | |
| | | useEffect(() => { |
| | | if (!deps.isAndroid || deps.tripStatus !== 7) 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]); |
| | | |
| | | return { |
| | | waitingRadarStyle, |
| | | waitingRadarVisible, |
| | |
| | | tripStatus: number; |
| | | statusRule: any; |
| | | isAndroid: boolean; |
| | | showTrip1StyleEndpoint: boolean; |
| | | startSite: any; |
| | | endSite: any; |
| | | vehicleLocation: { latitude: number; longitude: number } | null; |
| | | vehicleDirection: number | null; |
| | | vehicleMarkerId: number; |
| | | arrivedMarkerReady: boolean; |
| | | arrivedTooltipReady: boolean; |
| | | }; |
| | | |
| | | const defaultDeps: Deps = { |
| | | tripStatus: 1, |
| | | statusRule: { showPickup: true, showDropoff: true, showCar: false }, |
| | | isAndroid: false, |
| | | showTrip1StyleEndpoint: false, |
| | | startSite: null, |
| | | endSite: null, |
| | | vehicleLocation: null, |
| | | vehicleDirection: null, |
| | | vehicleMarkerId: 3 |
| | | vehicleMarkerId: 3, |
| | | arrivedMarkerReady: false, |
| | | arrivedTooltipReady: false |
| | | }; |
| | | |
| | | export default function useMapMarkers(rawDeps?: Partial<Deps>) { |
| | |
| | | ...(useAndroidNativeTrip1Callout |
| | | ? { |
| | | callout: { |
| | | content: deps.tripStatus === 1 ? `${pickupSite?.name || '上车点'} >` : (pickupSite?.name || '上车点'), |
| | | content: (deps.tripStatus === 1 || deps.showTrip1StyleEndpoint) ? `${pickupSite?.name || '上车点'} >` : (pickupSite?.name || '上车点'), |
| | | color: '#000000', |
| | | fontSize: 12, |
| | | bgColor: '#FFFFFF', |
| | |
| | | ...(useAndroidNativeTrip1Callout || deps.tripStatus == 6 |
| | | ? { |
| | | callout: { |
| | | content: deps.tripStatus === 1 ? `${dropoffName} >` : dropoffName, |
| | | content: (deps.tripStatus === 1 || deps.showTrip1StyleEndpoint) ? `${dropoffName} >` : dropoffName, |
| | | color: '#000000', |
| | | fontSize: 12, |
| | | bgColor: '#FFFFFF', |
| | |
| | | useEffect(() => { |
| | | const pickupSite = deps.startSite?.latitude && deps.startSite?.longitude ? deps.startSite : Taro.getStorageSync('startSite'); |
| | | const dropoffSite = deps.endSite?.latitude && deps.endSite?.longitude ? deps.endSite : Taro.getStorageSync('endSite'); |
| | | const useAndroidNativeTrip1Callout = deps.isAndroid && [1, 5, 8, 9, 10].includes(deps.tripStatus); |
| | | const useAndroidNativeTrip1Callout = deps.isAndroid && ([1, 5, 8, 9, 10].includes(deps.tripStatus) || deps.showTrip1StyleEndpoint); |
| | | const pickupMarker = createPickupMarker(pickupSite, useAndroidNativeTrip1Callout); |
| | | const dropoffMarker = createDropoffMarker(dropoffSite, useAndroidNativeTrip1Callout); |
| | | const shouldWaitArrivedDirection = deps.tripStatus === 7 && !deps.arrivedMarkerReady; |
| | | const effectiveShowCar = deps.statusRule.showCar && !shouldWaitArrivedDirection; |
| | | const carMarker = createCarMarker(); |
| | | const nextMarkers: any[] = []; |
| | | if (deps.statusRule.showPickup && pickupMarker) nextMarkers.push(pickupMarker); |
| | | if (deps.statusRule.showCar && carMarker) nextMarkers.push(carMarker); |
| | | if (effectiveShowCar && carMarker) nextMarkers.push(carMarker); |
| | | if (deps.statusRule.showDropoff && dropoffMarker) nextMarkers.push(dropoffMarker); |
| | | const nextMarkersSignature = nextMarkers |
| | | .map((m: any) => [ |
| | |
| | | .join(';'); |
| | | const hasDropoffMarker = markersRef.current.some((m: any) => m.id === 2); |
| | | const shouldHoldMarkerByTranslate = [4, 6].includes(deps.tripStatus) && carMarkerInitialized.current && deps.statusRule.showCar && (!deps.statusRule.showDropoff || hasDropoffMarker); |
| | | const shouldHoldMarkerWhenArrived = deps.tripStatus === 7 && carMarkerInitialized.current && deps.statusRule.showCar && (!deps.statusRule.showDropoff || hasDropoffMarker); |
| | | const shouldSkipUpdate = (shouldHoldMarkerByTranslate && (deps.isAndroid || tripStatus6MarkerRefreshedRef.current)) || shouldHoldMarkerWhenArrived; |
| | | const shouldSkipUpdate = shouldHoldMarkerByTranslate && (deps.isAndroid || tripStatus6MarkerRefreshedRef.current); |
| | | applyMarkerState(nextMarkers, nextMarkersSignature, shouldSkipUpdate, carMarker); |
| | | }, [deps.tripStatus, deps.startSite?.name, deps.startSite?.latitude, deps.startSite?.longitude, deps.endSite?.name, deps.endSite?.latitude, deps.endSite?.longitude, deps.vehicleLocation, deps.vehicleDirection, deps.vehicleMarkerId, deps.isAndroid, deps.statusRule.showPickup, deps.statusRule.showCar, deps.statusRule.showDropoff]); |
| | | |
| | |
| | | |
| | | const mapRectRef = useRef<any>(null); |
| | | const viewportKeyRef = useRef<string>(''); |
| | | const orderDetailViewportLockedRef = useRef(false); |
| | | const tripStatus6ViewportLockedRef = useRef(false); |
| | | const orderDetailEnterSignatureRef = useRef(''); |
| | | const orderDetailRouteUpdatedAfterEnterRef = useRef(false); |
| | | |
| | | const isAndroid = useMemo(() => { |
| | | try { |
| | |
| | | return { points, keyParts }; |
| | | }; |
| | | |
| | | 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('|'); |
| | | }; |
| | | |
| | | const updateViewportByPayload = (viewportType: string, points: any[]) => { |
| | | const mapCtx = Taro.createMapContext('map'); |
| | | const bottomPadding = computeBottomPadding(points, viewportType); |
| | |
| | | const { points, keyParts } = buildViewportPayload(viewportType, pickupSite, dropoffSite); |
| | | if (points.length === 0) return; |
| | | const nextKey = keyParts.join('|'); |
| | | if (nextKey === viewportKeyRef.current) return; |
| | | 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]); |
| | | }, [deps.tripStatus, deps.statusRule?.viewport, deps.endSite?.latitude, deps.endSite?.longitude, mapScale]); |
| | | |
| | | useEffect(() => { |
| | | if (![8, 9].includes(deps.tripStatus)) { |
| | | orderDetailViewportLockedRef.current = false; |
| | | orderDetailRouteUpdatedAfterEnterRef.current = false; |
| | | orderDetailEnterSignatureRef.current = ''; |
| | | return; |
| | | } |
| | | const { pickupSite, dropoffSite } = resolveViewportSites(); |
| | | orderDetailEnterSignatureRef.current = buildOrderDetailRouteSignature(pickupSite, dropoffSite); |
| | | orderDetailRouteUpdatedAfterEnterRef.current = false; |
| | | orderDetailViewportLockedRef.current = false; |
| | | viewportKeyRef.current = ''; |
| | | setMapScale(14); |
| | | }, [deps.tripStatus]); |
| | | |
| | | useEffect(() => { |
| | | if (![8, 9].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; |
| | | orderDetailRouteUpdatedAfterEnterRef.current = true; |
| | | orderDetailViewportLockedRef.current = false; |
| | | viewportKeyRef.current = ''; |
| | | }, [deps.tripStatus, deps.routePoints, deps.startSite?.latitude, deps.startSite?.longitude, deps.endSite?.latitude, deps.endSite?.longitude]); |
| | | |
| | | useEffect(() => { |
| | | const { pickupSite, dropoffSite } = resolveViewportSites(); |
| | |
| | | if (viewportType === 'dropoff') return; |
| | | const { points, keyParts } = buildViewportPayload(viewportType, pickupSite, dropoffSite); |
| | | if (points.length === 0) return; |
| | | if ( |
| | | [8, 9].includes(deps.tripStatus) && |
| | | viewportType === 'order_detail' && |
| | | orderDetailViewportLockedRef.current && |
| | | orderDetailRouteUpdatedAfterEnterRef.current |
| | | ) { |
| | | return; |
| | | } |
| | | if (deps.tripStatus === 6 && viewportType === 'route' && tripStatus6ViewportLockedRef.current) return; |
| | | const nextKey = keyParts.join('|'); |
| | | if (nextKey === viewportKeyRef.current) return; |
| | |
| | | updateViewportByPayload(viewportType, points); |
| | | if (deps.tripStatus === 6 && viewportType === 'route') { |
| | | tripStatus6ViewportLockedRef.current = true; |
| | | } |
| | | if ([8, 9].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]); |
| | | |
| | |
| | | setShowLocationFlag, |
| | | mapRectRef, |
| | | viewportKeyRef, |
| | | orderDetailViewportLockedRef, |
| | | tripStatus6ViewportLockedRef, |
| | | isAndroid, |
| | | resolveSafeBottomInset, |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useEffect, useMemo, useState } from 'react'; |
| | | import { orderChange } from '../../../api/begin-route'; |
| | | import { getOrderActive } from '../../../api/home'; |
| | | import { |
| | | MODIFY_DESTINATION_CANDIDATE_KEY, |
| | | MODIFY_DESTINATION_OVERRIDE_KEY, |
| | | MODIFY_DESTINATION_ROUTE_POINTS_KEY, |
| | | MODIFY_DESTINATION_SESSION_KEY |
| | | } from '../constants/modify-destination'; |
| | | |
| | | type Deps = { |
| | | tripStatus: number; |
| | | orderId: string; |
| | | endSite: any; |
| | | setEndSite: (site: any) => void; |
| | | toSelectSite: (pageType: 'start' | 'end') => void; |
| | | onModifySuccess?: () => void; |
| | | }; |
| | | |
| | | const isFiniteCoordinate = (value: any) => Number.isFinite(Number(value)); |
| | | |
| | | const isValidSite = (site: any) => { |
| | | if (!site) return false; |
| | | return isFiniteCoordinate(site.latitude) && isFiniteCoordinate(site.longitude); |
| | | }; |
| | | |
| | | const isSameSite = (left: any, right: any) => { |
| | | if (!left || !right) return false; |
| | | if (left.id && right.id) { |
| | | return String(left.id) === String(right.id); |
| | | } |
| | | return String(left.name || '') === String(right.name || '') |
| | | && Number(left.latitude) === Number(right.latitude) |
| | | && Number(left.longitude) === Number(right.longitude); |
| | | }; |
| | | |
| | | const clearModifyDestinationSession = () => { |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_SESSION_KEY); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_CANDIDATE_KEY); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_ROUTE_POINTS_KEY); |
| | | }; |
| | | |
| | | export default function useModifyDestinationFlow(deps: Deps) { |
| | | const [previewEndSite, setPreviewEndSite] = useState<any>(null); |
| | | const [previousEndSite, setPreviousEndSite] = useState<any>(null); |
| | | const [previousFareEstimate, setPreviousFareEstimate] = useState<any>(null); |
| | | const [previousRoutePoints, setPreviousRoutePoints] = useState<any[]>([]); |
| | | const [overrideEndSite, setOverrideEndSite] = useState<any>(() => { |
| | | const cached = Taro.getStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | return isValidSite(cached) ? cached : null; |
| | | }); |
| | | |
| | | useEffect(() => { |
| | | if (!deps.orderId || ![4, 5, 6, 7, 8, 9].includes(deps.tripStatus)) { |
| | | setPreviewEndSite(null); |
| | | setPreviousEndSite(null); |
| | | setPreviousFareEstimate(null); |
| | | setPreviousRoutePoints([]); |
| | | if (![4, 5, 6, 7, 8, 9].includes(deps.tripStatus)) { |
| | | clearModifyDestinationSession(); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | setOverrideEndSite(null); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | const session = Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY); |
| | | const candidate = Taro.getStorageSync(MODIFY_DESTINATION_CANDIDATE_KEY); |
| | | const cachedOverride = Taro.getStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | const cachedRoutePoints = Taro.getStorageSync(MODIFY_DESTINATION_ROUTE_POINTS_KEY); |
| | | |
| | | if (isValidSite(cachedOverride)) { |
| | | setOverrideEndSite(cachedOverride); |
| | | } |
| | | |
| | | if (session?.active && isValidSite(candidate)) { |
| | | if (isSameSite(session.previousEndSite, candidate)) { |
| | | clearModifyDestinationSession(); |
| | | setPreviewEndSite(null); |
| | | return; |
| | | } |
| | | setPreviousEndSite(session.previousEndSite || deps.endSite || null); |
| | | setPreviousFareEstimate(session.previousFareEstimate || null); |
| | | setPreviousRoutePoints(Array.isArray(cachedRoutePoints) ? cachedRoutePoints : []); |
| | | setPreviewEndSite(candidate); |
| | | return; |
| | | } |
| | | |
| | | if (session?.active && !isValidSite(candidate)) { |
| | | clearModifyDestinationSession(); |
| | | } |
| | | }, [ |
| | | deps.tripStatus, |
| | | deps.orderId, |
| | | deps.endSite?.id, |
| | | deps.endSite?.name, |
| | | deps.endSite?.latitude, |
| | | deps.endSite?.longitude, |
| | | ]); |
| | | |
| | | useEffect(() => { |
| | | if (!isValidSite(overrideEndSite)) return; |
| | | if (isSameSite(overrideEndSite, deps.endSite)) return; |
| | | deps.setEndSite(overrideEndSite); |
| | | }, [ |
| | | overrideEndSite, |
| | | deps.endSite?.id, |
| | | deps.endSite?.name, |
| | | deps.endSite?.latitude, |
| | | deps.endSite?.longitude |
| | | ]); |
| | | |
| | | const handleStartModifyDestination = (fareEstimate?: any, routePoints?: any[]) => { |
| | | Taro.setStorageSync(MODIFY_DESTINATION_SESSION_KEY, { |
| | | active: true, |
| | | previousEndSite: deps.endSite || null, |
| | | previousFareEstimate: fareEstimate || null, |
| | | orderId: deps.orderId || '', |
| | | tripStatus: deps.tripStatus, |
| | | updatedAt: Date.now() |
| | | }); |
| | | Taro.setStorageSync(MODIFY_DESTINATION_ROUTE_POINTS_KEY, Array.isArray(routePoints) ? routePoints : []); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_CANDIDATE_KEY); |
| | | deps.toSelectSite('end'); |
| | | }; |
| | | |
| | | const clearLocalDestinationOverride = () => { |
| | | setOverrideEndSite(null); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | }; |
| | | |
| | | const handleConfirmModifyDestination = async (selectedRoute?: any) => { |
| | | if (!isValidSite(previewEndSite)) return; |
| | | |
| | | try { |
| | | Taro.showLoading({ title: '正在修改...', mask: true }); |
| | | |
| | | let targetOrderId: string | number = deps.orderId || ''; |
| | | const activeOrderRes: any = await getOrderActive(); |
| | | targetOrderId = activeOrderRes?.id ? activeOrderRes.id : targetOrderId; |
| | | |
| | | // 如果后端需要字符串格式可以显式转String,如果后端支持number直接传值,保留原始格式防止隐式转换截断 |
| | | // 如果原本因为 Number() 导致大整数丢失精度,去掉 Number() 即可 |
| | | await orderChange({ |
| | | endPointId: previewEndSite.id || 0, |
| | | orderId: targetOrderId, |
| | | selectedRouteId: selectedRoute?.route?.routeID || selectedRoute?.routeId || '', |
| | | selectedRoutePlanRule: selectedRoute?.amapStrategy || '' |
| | | }); |
| | | Taro.hideLoading(); |
| | | |
| | | // 清除本地修改状态,不再作为“本地覆盖”存在,因为订单已真正变更 |
| | | // 同步更新缓存中新的终点信息 |
| | | const newEndSite = { |
| | | ...deps.endSite, |
| | | ...previewEndSite, |
| | | name: previewEndSite.name, |
| | | latitude: previewEndSite.latitude, |
| | | longitude: previewEndSite.longitude, |
| | | id: previewEndSite.id |
| | | }; |
| | | |
| | | Taro.setStorageSync('endSite', newEndSite); |
| | | deps.setEndSite(newEndSite); |
| | | |
| | | setPreviewEndSite(null); |
| | | setPreviousEndSite(null); |
| | | setPreviousFareEstimate(null); |
| | | setPreviousRoutePoints([]); |
| | | clearModifyDestinationSession(); |
| | | clearLocalDestinationOverride(); |
| | | |
| | | Taro.showToast({ |
| | | title: '目的地已更新', |
| | | icon: 'none' |
| | | }); |
| | | |
| | | if (typeof deps.onModifySuccess === 'function') { |
| | | deps.onModifySuccess(); |
| | | } |
| | | } catch (e: any) { |
| | | Taro.hideLoading(); |
| | | Taro.showToast({ |
| | | title: e?.message || '修改失败,请重试', |
| | | icon: 'none' |
| | | }); |
| | | } |
| | | }; |
| | | |
| | | const isModifyDestinationReviewVisible = isValidSite(previewEndSite); |
| | | const hasLocalDestinationOverride = isValidSite(overrideEndSite); |
| | | const effectiveEndSite = useMemo(() => ( |
| | | previewEndSite || overrideEndSite || deps.endSite |
| | | ), [previewEndSite, overrideEndSite, deps.endSite]); |
| | | |
| | | return { |
| | | effectiveEndSite, |
| | | previewEndSite, |
| | | previousEndSite, |
| | | previousFareEstimate, |
| | | previousRoutePoints, |
| | | isModifyDestinationReviewVisible, |
| | | hasLocalDestinationOverride, |
| | | clearLocalDestinationOverride, |
| | | handleStartModifyDestination, |
| | | handleConfirmModifyDestination |
| | | }; |
| | | } |
| | |
| | | tripStatusRef: React.MutableRefObject<number>; |
| | | statusRule: any; |
| | | shouldShowRoute: boolean; |
| | | allowSelectableRoutes: boolean; |
| | | startSite: any; |
| | | endSite?: any; |
| | | setStartSite?: (site: any) => void; |
| | |
| | | tripStatusRef: { current: 1 }, |
| | | statusRule: { showWalking: false }, |
| | | shouldShowRoute: false, |
| | | allowSelectableRoutes: false, |
| | | startSite: null, |
| | | resolveSafeTopInset: () => 0, |
| | | computeBottomPadding: () => 280, |
| | |
| | | newEndLng = routeData.destination.longitude ?? newEndLng; |
| | | } |
| | | |
| | | if (Number.isFinite(newStartLat) && Number.isFinite(newStartLng) && typeof deps.setStartSite === 'function') { |
| | | if (deps.tripStatusRef.current === 1 && Number.isFinite(newStartLat) && Number.isFinite(newStartLng) && typeof deps.setStartSite === 'function') { |
| | | deps.setStartSite({ |
| | | ...deps.startSite, |
| | | latitude: newStartLat, |
| | |
| | | }); |
| | | } |
| | | |
| | | if (Number.isFinite(newEndLat) && Number.isFinite(newEndLng) && typeof deps.setEndSite === 'function') { |
| | | if (deps.tripStatusRef.current === 1 && Number.isFinite(newEndLat) && Number.isFinite(newEndLng) && typeof deps.setEndSite === 'function') { |
| | | deps.setEndSite({ |
| | | ...deps.endSite, |
| | | latitude: newEndLat, |
| | |
| | | |
| | | if (Array.isArray(points) && points.length > 0) { |
| | | applyRoutePoints(points); |
| | | if (deps.tripStatusRef.current === 1) { |
| | | // 在 tripStatus === 1 时,我们需要包含所有路线的点,以便自适应视野 |
| | | // 如果允许选择路线(当前业务下未发单及修改目的地确认态时 allowSelectableRoutes 为 true) |
| | | if (deps.allowSelectableRoutes) { |
| | | // 在 tripStatus === 1 或 修改目的地确认态 时,我们需要包含所有路线的点,以便自适应视野 |
| | | const allRoutesPoints: any[] = []; |
| | | allRoutePoints.forEach(pts => { |
| | | if (Array.isArray(pts)) { |
| | |
| | | }; |
| | | |
| | | const resolveRouteIndexByTap = (e: any) => { |
| | | // 确保当前处于发单状态(有路线选项),并且有路线可供选择 |
| | | if (deps.tripStatusRef.current !== 1 || allRoutePoints.length <= 1) return -1; |
| | | // 确保允许选择路线并且有多条路线可供选择 |
| | | if (!deps.allowSelectableRoutes || allRoutePoints.length <= 1) return -1; |
| | | |
| | | // 优先使用 polylineId;如果运行时未返回 id,则退化为用点击坐标匹配最近路线 |
| | | let targetIndex = -1; |
| | |
| | | const points = Array.isArray(route?.polyline) ? route.polyline : []; |
| | | if (Array.isArray(points) && points.length > 0) { |
| | | applyRoutePoints(points); |
| | | if (deps.tripStatusRef.current === 1) { |
| | | // 在 tripStatus === 1 时,我们需要包含所有路线的点,以便自适应视野 |
| | | if (deps.allowSelectableRoutes) { |
| | | const allRoutesPoints: any[] = []; |
| | | allPointsArr.forEach(pts => { |
| | | if (Array.isArray(pts)) { |
| | |
| | | const buildPolylineLines = (displayRoutePoints: any[]) => { |
| | | const lines: any[] = []; |
| | | |
| | | // 如果有多条路线且在未发单状态(tripStatus === 1),渲染所有路线 |
| | | if (deps.tripStatusRef.current === 1 && allRoutePoints.length > 1) { |
| | | // 如果有多条路线且在未发单状态或修改目的地状态(allowSelectableRoutes === true),渲染所有路线 |
| | | if (deps.allowSelectableRoutes && allRoutePoints.length > 1) { |
| | | allRoutePoints.forEach((points, index) => { |
| | | if (!points || points.length === 0) return; |
| | | |
| | |
| | | walkingTime, |
| | | setWalkingTime, |
| | | routeOptions, |
| | | setRouteOptions, |
| | | selectedRouteIndex, |
| | | handleSelectRoute, |
| | | handlePolylineTap, |
| | |
| | | useEffect(() => { |
| | | const wasHold = [4, 6].includes(deps.lastTripStatusRef.current); |
| | | const isHold = [4, 6].includes(deps.tripStatus); |
| | | if (deps.tripStatus != 4) Taro.removeStorageSync('pickupEndTime'); |
| | | if (deps.tripStatus != 5) Taro.removeStorageSync('pickupEndTime'); |
| | | if (deps.tripStatus != 7) Taro.removeStorageSync('finishEndTime'); |
| | | if (!wasHold && isHold) { |
| | | deps.orderRouteInitializedRef.current = false; |
| | |
| | | import ReleaseRoute from '../components/release-route'; |
| | | import ReleaseBeBeing from '../components/release-be-being'; |
| | | import CallingCar from '../components/calling-car'; |
| | | import ModifyDestinationReview from '../components/modify-destination-review'; |
| | | import { formatDurationDisplay } from '../../../../utils/Robotaxi-public-method'; |
| | | |
| | | type Deps = { |
| | |
| | | arriveCalloutVisible: boolean; |
| | | /** 到达气泡样式 */ |
| | | arriveCalloutStyle: any; |
| | | /** 到达态气泡渲染就绪(角度与缩放均就绪) */ |
| | | arrivedTooltipReady: boolean; |
| | | /** 路线距离 */ |
| | | routeDistance: { value: number; unit: 'km' | 'm' } | null; |
| | | /** 路线时长 */ |
| | | routeDuration: number | null; |
| | | /** 自定义气泡图标 */ |
| | | customCalloutIconSrc: string; |
| | | /** 是否使用下单前端点气泡样式 */ |
| | | showTrip1StyleEndpoint: boolean; |
| | | /** 上车点 */ |
| | | startSite: any; |
| | | /** 下车点 */ |
| | |
| | | selectedRouteIndex: number; |
| | | /** 切换路线回调 */ |
| | | handleSelectRoute: (index: number) => void; |
| | | /** 地图点击 */ |
| | | handleMapTap?: (e: any) => void; |
| | | /** 修改目的地确认态 */ |
| | | isModifyDestinationReviewVisible?: boolean; |
| | | /** 修改前目的地 */ |
| | | previousEndSite?: any; |
| | | /** 修改前费用 */ |
| | | previousFareEstimate?: any; |
| | | /** 修改后目的地 */ |
| | | previewEndSite?: any; |
| | | /** 确认修改目的地 */ |
| | | handleConfirmModifyDestination?: (selectedRoute?: any) => void; |
| | | }; |
| | | |
| | | /** |
| | |
| | | <CoverView className={`custom-callout-content ${deps.isAndroid && 'txt-center '}`} style={{ zIndex: 1 }}> |
| | | <CoverView style={{ lineHeight: '22px', display: 'flex', alignItems: 'center' }}>{item.name}</CoverView> |
| | | {deps.isAndroid && <CoverView style={{ width: '2px' }}></CoverView>} |
| | | {deps.tripStatus == 1 && <CoverImage className='custom-callout-icon' src={deps.customCalloutIconSrc} />} |
| | | {(deps.tripStatus == 1 || deps.showTrip1StyleEndpoint) && <CoverImage className='custom-callout-icon' src={deps.customCalloutIconSrc} />} |
| | | </CoverView> |
| | | )} |
| | | {(item.name === '车辆位置' && [4, 6].includes(deps.tripStatus) && !deps.isAndroid) && ( |
| | |
| | | </CoverView> |
| | | </CoverView> |
| | | )} |
| | | {(item.id === 2 && [7].includes(deps.tripStatus) && !deps.isAndroid && deps.mapScale >= 20) && ( |
| | | {(item.id === 2 && [7].includes(deps.tripStatus) && !deps.isAndroid && deps.arrivedTooltipReady) && ( |
| | | <CoverView className='custom-callout-content'> |
| | | 已到达 |
| | | </CoverView> |
| | |
| | | </View> |
| | | </View> |
| | | )} |
| | | {deps.isAndroid && deps.arriveCalloutVisible && ( |
| | | {deps.isAndroid && deps.arriveCalloutVisible && deps.arrivedTooltipReady && ( |
| | | <View className="car-callout-android" style={deps.arriveCalloutStyle}> |
| | | <View className="custom-callout-content"> |
| | | 已到达 |
| | |
| | | /> |
| | | ); |
| | | } |
| | | if (deps.isModifyDestinationReviewVisible) { |
| | | return ( |
| | | <ModifyDestinationReview |
| | | previousEndSite={deps.previousEndSite} |
| | | nextEndSite={deps.previewEndSite} |
| | | previousFareEstimate={deps.previousFareEstimate} |
| | | fareEstimate={deps.fareEstimate} |
| | | routeOptions={deps.routeOptions} |
| | | selectedRouteIndex={deps.selectedRouteIndex} |
| | | isRouteSelectExpanded={deps.isRouteSelectExpanded} |
| | | handleSelectRoute={deps.handleSelectRoute} |
| | | onEditDestination={deps.handleModifyDestination} |
| | | onConfirm={() => deps.handleConfirmModifyDestination?.(deps.routeOptions?.[deps.selectedRouteIndex || 0])} |
| | | /> |
| | | ); |
| | | } |
| | | if (deps.tripStatus == 2) { |
| | | return ( |
| | | <ReleaseBeBeing |
| | |
| | | const currentTripStatus = Number(deps.tripStatusRef.current); |
| | | if (currentTripStatus === 7) { |
| | | const lastPoint = deps.lastVehiclePointRef.current; |
| | | const lastUpdateAt = Number(deps.lastVehicleUpdateAtRef.current || 0); |
| | | const now = Date.now(); |
| | | const latDiff = Math.abs(Number(lastPoint?.latitude || 0) - targetPoint.latitude); |
| | | const lngDiff = Math.abs(Number(lastPoint?.longitude || 0) - targetPoint.longitude); |
| | | const almostSamePoint = latDiff < 0.000003 && lngDiff < 0.000003; |
| | | if (almostSamePoint || (now - lastUpdateAt < 1200)) { |
| | | if (almostSamePoint) { |
| | | return; |
| | | } |
| | | } |
| | |
| | | const forceFullRedraw = payload?.forceFullRedraw === true; |
| | | const currentTripStatus = Number(deps.tripStatusRef.current); |
| | | updateRouteSummary(payload, deps); |
| | | // 行程结束态仅更新概要信息,不再消费路线点,避免视口反复重算造成跳动 |
| | | if ([8, 9].includes(currentTripStatus)) { |
| | | return; |
| | | } |
| | | if ([4, 6].includes(currentTripStatus)) { |
| | | deps.setVehicleMoveAlongPoints(servedPoints); |
| | | } |
| | |
| | | import useOrderActions from './hooks/use-order-actions'; |
| | | // 视图委托渲染编排 |
| | | import useViewDelegates from './hooks/use-view-delegates'; |
| | | import useModifyDestinationFlow from './hooks/use-modify-destination-flow'; |
| | | |
| | | import backIcon from '../../../robotaxi/assets/back-icon.png' |
| | | import personalCenter from '../../../robotaxi/assets/personal-center.png' |
| | |
| | | 4: { showPickup: true, showDropoff: false, showCar: true, showRoute: true, showWalking: true, viewport: 'route', translateCar: true }, |
| | | 5: { showPickup: true, showDropoff: false, showCar: true, showRoute: false, showWalking: true, viewport: 'car_user', translateCar: true }, |
| | | 6: { showPickup: false, showDropoff: true, showCar: true, showRoute: true, showWalking: false, viewport: 'route', translateCar: true }, |
| | | 7: { showPickup: false, showDropoff: true, showCar: true, showRoute: false, showWalking: false, viewport: 'dropoff', translateCar: false }, |
| | | 7: { showPickup: false, showDropoff: true, showCar: true, showRoute: false, showWalking: false, viewport: 'car_user', translateCar: true }, |
| | | 8: { showPickup: true, showDropoff: true, showCar: false, showRoute: true, showWalking: false, viewport: 'order_detail', translateCar: false }, |
| | | 9: { showPickup: true, showDropoff: true, showCar: false, showRoute: true, showWalking: false, viewport: 'order_detail', translateCar: false }, |
| | | 10: { showPickup: true, showDropoff: false, showCar: false, showRoute: false, showWalking: false, viewport: 'pickup', translateCar: false }, |
| | | }; |
| | | const statusRule = useMemo(() => STATUS_RULES[tripStatus] || STATUS_RULES[1], [tripStatus]); // 当前行程状态对应的显示规则 |
| | | const shouldShowRoute = statusRule.showRoute; // 当前状态是否显示路线 |
| | | const orderDetailRouteRequestIdRef = useRef(0); // 订单详情路线请求ID |
| | | const isCheckedEnter = useRef(false); // 是否已检查页面进入状态 |
| | | const isAndroid = useMemo(() => { |
| | | try { |
| | | const info = Taro.getSystemInfoSync(); |
| | |
| | | }, []); |
| | | const shouldShowWaitingRadar = isAndroid && [2, 3].includes(tripStatus); // 是否显示等待雷达动画(Android且状态2/3) |
| | | const vehicleMarkerId = !isAndroid && tripStatus === 6 ? 30 : 3; // 车辆标记ID,iOS状态6使用不同ID |
| | | const { |
| | | openService, |
| | | toCoupon, |
| | | toOrder, |
| | | toBack, |
| | | handleCalloutTap, |
| | | toSelectSite, |
| | | guardPrivacyThenNavigate |
| | | } = usePageActions({ |
| | | tripStatus, |
| | | startSite, |
| | | getCheckedEnter: () => isCheckedEnter.current, |
| | | setCheckedEnter: (checked) => { |
| | | isCheckedEnter.current = checked; |
| | | } |
| | | }); // 页面动作编排 |
| | | const { |
| | | effectiveEndSite, |
| | | previewEndSite, |
| | | previousEndSite, |
| | | previousFareEstimate, |
| | | previousRoutePoints, |
| | | isModifyDestinationReviewVisible, |
| | | hasLocalDestinationOverride, |
| | | clearLocalDestinationOverride, |
| | | handleStartModifyDestination, |
| | | handleConfirmModifyDestination |
| | | } = useModifyDestinationFlow({ |
| | | tripStatus, |
| | | orderId, |
| | | endSite, |
| | | setEndSite, |
| | | toSelectSite, |
| | | onModifySuccess: () => { |
| | | // 成功修改后触发路线重新获取 |
| | | // 下一次渲染时 isModifyDestinationReviewVisible 变为 false |
| | | // 我们需要强制让选路状态退出,同时获取后端的新行程路线 |
| | | setIsRouteSelectExpanded(false); |
| | | setRouteOptions([]); |
| | | setRoutePoints([]); |
| | | setFareEstimate({}); |
| | | routeRequestOrderRoute(orderId, tripStatus); |
| | | } |
| | | }); |
| | | const statusRule = useMemo(() => { |
| | | const baseRule = STATUS_RULES[tripStatus] || STATUS_RULES[1]; |
| | | if (isModifyDestinationReviewVisible || hasLocalDestinationOverride) { |
| | | return { |
| | | ...baseRule, |
| | | showPickup: true, |
| | | showDropoff: true, |
| | | showCar: false, |
| | | showRoute: true, |
| | | showWalking: false, |
| | | viewport: 'route' |
| | | }; |
| | | } |
| | | return baseRule; |
| | | }, [tripStatus, isModifyDestinationReviewVisible, hasLocalDestinationOverride]); |
| | | const shouldShowRoute = statusRule.showRoute; // 当前状态是否显示路线 |
| | | const allowSelectableRoutes = tripStatus === 1 || isModifyDestinationReviewVisible || hasLocalDestinationOverride; |
| | | const showTrip1StyleEndpoint = isModifyDestinationReviewVisible || hasLocalDestinationOverride; |
| | | const vehicleState = useVehicleTracking({ |
| | | statusRule, |
| | | vehicleMarkerId |
| | |
| | | lastVehiclePointRef, |
| | | lastVehicleUpdateAtRef |
| | | } = vehicleState; // 车辆状态解构 |
| | | const markerState = useMapMarkers({ |
| | | tripStatus, |
| | | statusRule, |
| | | isAndroid, |
| | | startSite, |
| | | endSite, |
| | | vehicleLocation, |
| | | vehicleDirection, |
| | | vehicleMarkerId |
| | | }); // 地图标记状态容器 |
| | | const { |
| | | markers, |
| | | tripStatus6MarkerRefreshedRef |
| | | } = markerState; // 标记状态解构 |
| | | const { |
| | | waitingRadarStyle, |
| | | waitingRadarVisible, |
| | | carCalloutStyle, |
| | | carCalloutVisible, |
| | | arriveCalloutStyle, |
| | | arriveCalloutVisible, |
| | | handleRegionChange: handleAndroidOverlayRegionChange |
| | | } = useAndroidOverlays({ |
| | | shouldShowWaitingRadar, |
| | | isAndroid, |
| | | tripStatus, |
| | | startSite, |
| | | endSite, |
| | | vehicleLocation, |
| | | longitude, |
| | | latitude |
| | | }); // Android 覆盖物状态编排 |
| | | const routeState = useRouteManager({ |
| | | isAndroid, |
| | | tripStatus, |
| | | tripStatusRef, |
| | | statusRule, |
| | | shouldShowRoute, |
| | | allowSelectableRoutes, |
| | | startSite, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | setStartSite, |
| | | setEndSite, |
| | | updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled, |
| | |
| | | walkingTime, |
| | | setWalkingTime, |
| | | routeOptions, |
| | | setRouteOptions, |
| | | selectedRouteIndex, |
| | | handleSelectRoute, |
| | | handlePolylineTap, |
| | | handleMapTap, |
| | | fareEstimate, |
| | | setFareEstimate, |
| | | routeAndFareError, |
| | | routeDistance, |
| | | setRouteDistance, |
| | |
| | | requestOrderRoute: routeRequestOrderRoute, |
| | | updateWalkingRouteForStart: routeUpdateWalkingRouteForStart |
| | | } = routeState; // 路线状态解构 |
| | | const displayPolyline = useMemo(() => { |
| | | if (!(isModifyDestinationReviewVisible || hasLocalDestinationOverride) || !Array.isArray(previousRoutePoints) || previousRoutePoints.length === 0) { |
| | | return polyline; |
| | | } |
| | | return [ |
| | | { |
| | | id: 900001, |
| | | points: previousRoutePoints, |
| | | color: '#D7DDEF', |
| | | width: 8, |
| | | dottedLine: false, |
| | | arrowLine: true, |
| | | clickable: false |
| | | }, |
| | | ...polyline |
| | | ]; |
| | | }, [polyline, previousRoutePoints, isModifyDestinationReviewVisible, hasLocalDestinationOverride]); |
| | | const routeSummaryMarker = useMemo(() => { |
| | | if (tripStatus !== 1 || !routeDistance || routeDuration === null || !Array.isArray(routePoints) || routePoints.length === 0) { |
| | | if (!allowSelectableRoutes || !routeDistance || routeDuration === null || !Array.isArray(routePoints) || routePoints.length === 0) { |
| | | return null; |
| | | } |
| | | const midpoint = routePoints[Math.max(0, Math.floor(routePoints.length / 2))]; |
| | |
| | | isRouteSummaryBubble: true, |
| | | customCallout: { anchorY: 0, anchorX: 0, display: 'ALWAYS' } |
| | | }; |
| | | }, [tripStatus, routeDistance, routeDuration, routePoints]); |
| | | const markersWithRouteSummary = useMemo(() => { |
| | | if (!routeSummaryMarker) return markers; |
| | | return [...markers, routeSummaryMarker]; |
| | | }, [markers, routeSummaryMarker]); |
| | | }, [allowSelectableRoutes, routeDistance, routeDuration, routePoints]); |
| | | const viewportState = useMapViewport({ |
| | | tripStatus, |
| | | statusRule, |
| | | routePoints, |
| | | walkingPoints, |
| | | startSite, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | vehicleLocation, |
| | | longitude, |
| | | latitude, |
| | |
| | | toOrigin, |
| | | getLocationInfo |
| | | } = viewportState; // 视口状态解构 |
| | | const isCheckedEnter = useRef(false); // 是否已检查页面进入状态 |
| | | const arrivedMarkerReady = tripStatus !== 7 || typeof vehicleDirection === 'number'; |
| | | const arrivedTooltipReady = tripStatus !== 7 || (arrivedMarkerReady && mapScale >= 20); |
| | | const markerState = useMapMarkers({ |
| | | tripStatus, |
| | | statusRule, |
| | | isAndroid, |
| | | showTrip1StyleEndpoint, |
| | | startSite, |
| | | endSite: effectiveEndSite, |
| | | vehicleLocation, |
| | | vehicleDirection, |
| | | vehicleMarkerId, |
| | | arrivedMarkerReady, |
| | | arrivedTooltipReady |
| | | }); // 地图标记状态容器 |
| | | const { |
| | | markers, |
| | | tripStatus6MarkerRefreshedRef |
| | | } = markerState; // 标记状态解构 |
| | | const { |
| | | waitingRadarStyle, |
| | | waitingRadarVisible, |
| | | carCalloutStyle, |
| | | carCalloutVisible, |
| | | arriveCalloutStyle, |
| | | arriveCalloutVisible, |
| | | handleRegionChange: handleAndroidOverlayRegionChange |
| | | } = useAndroidOverlays({ |
| | | shouldShowWaitingRadar, |
| | | isAndroid, |
| | | tripStatus, |
| | | mapScale, |
| | | startSite, |
| | | endSite: effectiveEndSite, |
| | | vehicleLocation, |
| | | longitude, |
| | | latitude |
| | | }); // Android 覆盖物状态编排 |
| | | const markersWithRouteSummary = useMemo(() => { |
| | | if (!routeSummaryMarker) return markers; |
| | | return [...markers, routeSummaryMarker]; |
| | | }, [markers, routeSummaryMarker]); |
| | | const { checkDistanceAndCityReady } = useOrderDistanceConfig({ |
| | | endSite, |
| | | setTipType, |
| | |
| | | getCurrentLongitude: () => longitude, |
| | | getCurrentLatitude: () => latitude |
| | | }); // 页面恢复与初始化编排 |
| | | const { |
| | | openService, |
| | | toCoupon, |
| | | toOrder, |
| | | toBack, |
| | | handleCalloutTap, |
| | | toSelectSite |
| | | } = usePageActions({ |
| | | tripStatus, |
| | | startSite, |
| | | getCheckedEnter: () => isCheckedEnter.current, |
| | | setCheckedEnter: (checked) => { |
| | | isCheckedEnter.current = checked; |
| | | } |
| | | }); // 页面动作编排 |
| | | const router = Taro.useRouter(); |
| | | const tripRuntime = useTripRuntime({ |
| | | waitingTimerStartKey: WAITING_TIMER_START_KEY, |
| | |
| | | currentFullRouteRef, |
| | | orderDetailRouteRequestIdRef, |
| | | routePoints, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | longitude, |
| | | latitude, |
| | | elapsedSeconds, |
| | |
| | | const orderActions = useOrderActions({ |
| | | tripStatus, |
| | | orderId, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | startSite, |
| | | fareEstimate, |
| | | routeOptions, |
| | |
| | | handleFinishCountdownTimeout, |
| | | handlePay |
| | | } = orderActions; |
| | | const modifyPreviewRouteKeyRef = useRef(''); |
| | | |
| | | useEffect(() => { |
| | | const shouldPreviewModifiedRoute = isModifyDestinationReviewVisible || hasLocalDestinationOverride; |
| | | const targetEndSite = shouldPreviewModifiedRoute ? effectiveEndSite : null; |
| | | const destLat = Number(targetEndSite?.latitude); |
| | | const destLng = Number(targetEndSite?.longitude); |
| | | |
| | | if (![4, 5, 6].includes(tripStatus) || !Number.isFinite(destLat) || !Number.isFinite(destLng)) { |
| | | modifyPreviewRouteKeyRef.current = ''; |
| | | return; |
| | | } |
| | | |
| | | const cachedStartSite = Taro.getStorageSync('startSite') || {}; |
| | | let originLat = Number(startSite?.latitude ?? cachedStartSite?.latitude); |
| | | let originLng = Number(startSite?.longitude ?? cachedStartSite?.longitude); |
| | | |
| | | if ([4, 5].includes(tripStatus)) { |
| | | if (!Number.isFinite(originLat) || !Number.isFinite(originLng)) { |
| | | return; |
| | | } |
| | | } else if (tripStatus === 6 && Number.isFinite(Number(vehicleLocation?.latitude)) && Number.isFinite(Number(vehicleLocation?.longitude))) { |
| | | originLat = Number(vehicleLocation?.latitude); |
| | | originLng = Number(vehicleLocation?.longitude); |
| | | } else { |
| | | return; |
| | | } |
| | | |
| | | if (!Number.isFinite(originLat) || !Number.isFinite(originLng)) { |
| | | return; |
| | | } |
| | | |
| | | const nextKey = `${tripStatus}|${Number(targetEndSite?.id || 0)}|${originLng},${originLat}-${destLng},${destLat}`; |
| | | if (nextKey === modifyPreviewRouteKeyRef.current) { |
| | | return; |
| | | } |
| | | modifyPreviewRouteKeyRef.current = nextKey; |
| | | routeRequestRouteAndFare(originLat, originLng, destLat, destLng, true); |
| | | }, [ |
| | | tripStatus, |
| | | isModifyDestinationReviewVisible, |
| | | hasLocalDestinationOverride, |
| | | effectiveEndSite?.latitude, |
| | | effectiveEndSite?.longitude, |
| | | startSite?.latitude, |
| | | startSite?.longitude, |
| | | vehicleLocation?.latitude, |
| | | vehicleLocation?.longitude, |
| | | latitude, |
| | | longitude |
| | | ]); |
| | | const shareOrderId = orderId || Taro.getStorageSync('orderId') || ''; |
| | | const onShareAppMessage = () => ({ |
| | | title: '快来查看我的 Robotaxi 行程', |
| | |
| | | }, [isRouteSelectExpanded]); |
| | | const handleModifyDestination = () => { |
| | | isCheckedEnter.current = true; |
| | | toSelectSite('end'); |
| | | handleStartModifyDestination(fareEstimate, routePoints); |
| | | }; |
| | | const handleShareTrip = () => { |
| | | isCheckedEnter.current = true; |
| | |
| | | carCalloutStyle, |
| | | arriveCalloutVisible, |
| | | arriveCalloutStyle, |
| | | arrivedTooltipReady, |
| | | routeDistance, |
| | | routeDuration, |
| | | customCalloutIconSrc, |
| | | showTrip1StyleEndpoint, |
| | | startSite, |
| | | endSite, |
| | | endSite: effectiveEndSite, |
| | | callImmediately, |
| | | fareEstimate, |
| | | handleOpenPassengerNotice, |
| | |
| | | routeOptions, |
| | | selectedRouteIndex, |
| | | handleSelectRoute, |
| | | handleMapTap |
| | | handleMapTap, |
| | | isModifyDestinationReviewVisible, |
| | | previousEndSite, |
| | | previousFareEstimate, |
| | | previewEndSite, |
| | | handleConfirmModifyDestination |
| | | }); // 视图渲染委托 |
| | | const mapLongitude = longitude ?? 116.397128; |
| | | const mapLatitude = latitude ?? 39.916527; |
| | | const canToggleRouteSelect = allowSelectableRoutes && routeOptions.length > 1; |
| | | |
| | | const shouldShowOrigin = ![7, 8, 9].includes(tripStatus); |
| | | const handleServiceEntry = () => { |
| | |
| | | mapLatitude={mapLatitude} |
| | | mapScale={mapScale} |
| | | markers={markersWithRouteSummary} |
| | | polyline={polyline} |
| | | polyline={displayPolyline} |
| | | showLocation={!!longitude && showLocationFlag} |
| | | handleCalloutTap={handleCalloutTap} |
| | | handleRegionChange={handleRegionChange} |
| | |
| | | packUpIcon={packUpIcon} |
| | | onSelectRouteClick={() => setIsRouteSelectExpanded(!isRouteSelectExpanded)} |
| | | isRouteSelectExpanded={isRouteSelectExpanded} |
| | | canToggleRouteSelect={canToggleRouteSelect} |
| | | walkIcon={walkIcon} |
| | | originIcon={originIcon} |
| | | renderTripCard={renderTripCard} |
| | |
| | | showCancelPopup={showCancelPopup} |
| | | cancelType={cancelType} |
| | | startSite={startSite} |
| | | endSite={endSite} |
| | | endSite={effectiveEndSite} |
| | | fareEstimate={fareEstimate} |
| | | tripStatus={tripStatus} |
| | | routeDuration={routeDuration} |
| | |
| | | const [nearbySiteList, setNearbySiteList] = useState<any[]>([]); |
| | | // 页面类型(start/end) |
| | | const [pageType, setPageType] = useState(''); |
| | | // 当前关键词是否仍在搜索中(含高德补充结果) |
| | | const [isSearching, setIsSearching] = useState(false); |
| | | // 最近一次完成搜索的关键词 |
| | | const [lastCompletedKeyword, setLastCompletedKeyword] = useState(''); |
| | | const searchSeqRef = useRef(0); |
| | | |
| | | // 拉取附近站点 |
| | | const fetchNearbySites = async (lat: number, lon: number, electronicAreaId?: string) => { |
| | |
| | | |
| | | // 站点搜索请求与结果处理 |
| | | const searchSite = async (val?: string) => { |
| | | setNonOperationalSite([]); |
| | | const queryVal = val !== undefined ? val : deps.searchValue; |
| | | if (!queryVal) return; |
| | | const normalizedQuery = String(queryVal || '').trim(); |
| | | searchSeqRef.current += 1; |
| | | const requestSeq = searchSeqRef.current; |
| | | setNonOperationalSite([]); |
| | | if (!normalizedQuery) { |
| | | setIsSearching(false); |
| | | setLastCompletedKeyword(''); |
| | | return; |
| | | } |
| | | setIsSearching(true); |
| | | const { latitude, longitude, electronicAreaId } = deps.router.params; |
| | | const selectedCity = Taro.getStorageSync('selectedCity'); |
| | | const payload = { |
| | |
| | | electronicAreaId: electronicAreaId ? Number(electronicAreaId) : 0, |
| | | lat: Number(latitude), |
| | | lon: Number(longitude), |
| | | pointPoi: queryVal |
| | | pointPoi: normalizedQuery |
| | | }; |
| | | try { |
| | | const res = await queryPointByName(payload); |
| | | if (requestSeq !== searchSeqRef.current) return; |
| | | const { availablePoints } = res; |
| | | if (Array.isArray(availablePoints)) { |
| | | const list = availablePoints.map(normalizeSiteItem); |
| | | const sorted = sortSitesByNameMatchAndDistance(list, queryVal); |
| | | const sorted = sortSitesByNameMatchAndDistance(list, normalizedQuery); |
| | | setSiteList(sorted.slice(0, deps.maxExpandedCount)); |
| | | } else { |
| | | setSiteList([]); |
| | | } |
| | | const myAmapFun = new amapFile.AMapWX({ key: _AMAP_KEY }); |
| | | myAmapFun.getPoiAround({ |
| | | querykeywords: queryVal, |
| | | location: `${longitude},${latitude}`, |
| | | success: (data: any) => { |
| | | if (data && data.poisData) { |
| | | const list = data.poisData.map((item: any) => { |
| | | const locationArr = item.location.split(','); |
| | | return { |
| | | ...item, |
| | | longitude: Number(locationArr[0]), |
| | | latitude: Number(locationArr[1]), |
| | | ...normalizeSiteItem(item) |
| | | }; |
| | | }); |
| | | const sorted = sortSitesByNameMatchAndDistance(list, queryVal); |
| | | setNonOperationalSite(sorted.slice(0, deps.maxShowCount)); |
| | | await new Promise<void>((resolve) => { |
| | | myAmapFun.getPoiAround({ |
| | | querykeywords: normalizedQuery, |
| | | location: `${longitude},${latitude}`, |
| | | success: (data: any) => { |
| | | if (requestSeq !== searchSeqRef.current) { |
| | | resolve(); |
| | | return; |
| | | } |
| | | if (data && data.poisData) { |
| | | const list = data.poisData.map((item: any) => { |
| | | const locationArr = item.location.split(','); |
| | | return { |
| | | ...item, |
| | | longitude: Number(locationArr[0]), |
| | | latitude: Number(locationArr[1]), |
| | | ...normalizeSiteItem(item) |
| | | }; |
| | | }); |
| | | const sorted = sortSitesByNameMatchAndDistance(list, normalizedQuery); |
| | | setNonOperationalSite(sorted.slice(0, deps.maxShowCount)); |
| | | } else { |
| | | setNonOperationalSite([]); |
| | | } |
| | | resolve(); |
| | | }, |
| | | fail: () => { |
| | | if (requestSeq === searchSeqRef.current) { |
| | | setNonOperationalSite([]); |
| | | } |
| | | resolve(); |
| | | } |
| | | }, |
| | | fail: () => { } |
| | | }); |
| | | }); |
| | | } catch { |
| | | if (requestSeq !== searchSeqRef.current) return; |
| | | setSiteList([]); |
| | | setNonOperationalSite([]); |
| | | } finally { |
| | | if (requestSeq === searchSeqRef.current) { |
| | | setIsSearching(false); |
| | | setLastCompletedKeyword(normalizedQuery); |
| | | } |
| | | } |
| | | }; |
| | | |
| | |
| | | const clearSearchResults = () => { |
| | | setSiteList([]); |
| | | setNonOperationalSite([]); |
| | | setIsSearching(false); |
| | | setLastCompletedKeyword(''); |
| | | }; |
| | | |
| | | // 重新拉取初始化数据(历史记录和附近站点) |
| | |
| | | historySite, |
| | | nonOperationalSite, |
| | | nearbySiteList, |
| | | isSearching, |
| | | lastCompletedKeyword, |
| | | searchSite, |
| | | debouncedSearch, |
| | | clearSearchResults, |
| | |
| | | splitHighlightParts, |
| | | filterNearbyListByCurrentSite |
| | | } from './utils/site-search'; |
| | | import { |
| | | MODIFY_DESTINATION_CANDIDATE_KEY, |
| | | MODIFY_DESTINATION_SESSION_KEY |
| | | } from '../begin-route/constants/modify-destination'; |
| | | |
| | | |
| | | |
| | |
| | | historySite, |
| | | nonOperationalSite, |
| | | nearbySiteList, |
| | | isSearching, |
| | | lastCompletedKeyword, |
| | | searchSite, |
| | | debouncedSearch, |
| | | clearSearchResults, |
| | |
| | | } |
| | | } |
| | | } catch { } |
| | | Taro.setStorageSync('endSite', item) |
| | | const modifySession = Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY); |
| | | if (modifySession?.active) { |
| | | Taro.setStorageSync(MODIFY_DESTINATION_CANDIDATE_KEY, item); |
| | | } else { |
| | | Taro.setStorageSync('endSite', item); |
| | | } |
| | | Taro.reLaunch({ |
| | | url: Taro.getStorageSync('orderId') ? '/robotaxi/pages/begin-route/index?skipPageFlag=1' : '/robotaxi/pages/begin-route/index', |
| | | }) |
| | |
| | | const recommendList = searchValue ? sortSitesByNameMatchAndDistance(siteList, searchValue) : filteredNearbyList; |
| | | const visibleHistory = showAllHistory ? historySite.slice(0, MAX_EXPANDED_COUNT) : historySite.slice(0, MAX_SHOW_COUNT); |
| | | const visibleRecommend = showAllRecommend ? recommendList.slice(0, MAX_EXPANDED_COUNT) : recommendList.slice(0, MAX_SHOW_COUNT); |
| | | const canShowSearchFallback = !searchValue || (!isSearching && lastCompletedKeyword === searchValue.trim()); |
| | | |
| | | // 使用数据截断展示,不再通过高度裁剪内容 |
| | | |
| | |
| | | </View> |
| | | } |
| | | { |
| | | (!siteList.length && (!searchValue ? !nearbySiteList.length : true) && !nonOperationalSite.length && (!searchValue ? !historySite.length : true)) && |
| | | canShowSearchFallback && (!siteList.length && (!searchValue ? !nearbySiteList.length : true) && !nonOperationalSite.length && (!searchValue ? !historySite.length : true)) && |
| | | <View className='history-card'> |
| | | <View className='card-header'>暂无搜索结果,为您推荐附近站点</View> |
| | | <View className='card-content list-content'> |
| New file |
| | |
| | | // RtAuthBluetoothManager.js |
| | | // 精简版蓝牙管理器,只支持 RtAuth 鉴权模式(Robotaxi 场景) |
| | | import Taro from '@tarojs/taro'; |
| | | import { ab2hex, madAddressToLocalName, hexStringToArray } from '../../../utils/canControlTools'; |
| | | |
| | | // 引入日志和WASM模块 |
| | | var log = require('./log.js'); |
| | | const bleWasmModule = require('./bleMethod'); |
| | | |
| | | export default class RtAuthBluetoothManager { |
| | | constructor(options) { |
| | | // 回调函数 |
| | | this.onStatusChange = options.onStatusChange || (() => {}); // 连接状态回调 |
| | | this.onError = options.onError || (() => {}); // 错误回调 |
| | | this.onRtAuthResult = options.onRtAuthResult || (() => {}); // RtAuth 鉴权结果回调 |
| | | this.onRtNotification = options.onRtNotification || (() => {}); // RtNotification 通知回调 |
| | | |
| | | // 常量 |
| | | this.serviceId = '0000ED28-ABAB-406D-832D-7AD799B479F7'; |
| | | this.writeId = '0000ED28-ABAB-9C8C-2801-B1A015E46D6C'; |
| | | this.MAX_RETRY_TIMES = 1; |
| | | |
| | | // 内部状态 |
| | | this.state = { |
| | | deviceId: '', |
| | | macAddress: '', |
| | | certificate: '', // 鉴权凭证 |
| | | deviceModel: '', // 设备型号 |
| | | isIOS: false, |
| | | connected: false, |
| | | isScaning: false, |
| | | connectStatus: 0, // 0:断开, 1:连接中, 2:已连接, 3:已鉴权 |
| | | tryTimes: 0, |
| | | // 特征值 |
| | | readServiceId: '', |
| | | writeServiceId: '', |
| | | readChs: '', |
| | | writeChs: '', |
| | | indecateChs: '', |
| | | indecateServiceId: '' |
| | | }; |
| | | |
| | | this.initSystemInfo(); |
| | | } |
| | | |
| | | initSystemInfo() { |
| | | Taro.getSystemInfo({ |
| | | success: res => { |
| | | this.state.isIOS = res.system.toLowerCase().indexOf('ios') >= 0; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // 更新车辆配置(macAddress 必填,certificate 和 deviceModel 可选,用于自动鉴权) |
| | | updateCarConfig(config) { |
| | | this.state.macAddress = config.macAddress; |
| | | this.state.certificate = config.certificate || ''; |
| | | this.state.deviceModel = config.deviceModel || ''; |
| | | this.resetState(); |
| | | } |
| | | |
| | | resetState() { |
| | | this.state.connectStatus = 0; |
| | | this.state.connected = false; |
| | | this.state.tryTimes = 0; |
| | | this.notifyStatus(0); |
| | | } |
| | | |
| | | notifyStatus(status) { |
| | | this.state.connectStatus = status; |
| | | this.onStatusChange(status); |
| | | } |
| | | |
| | | // --- 蓝牙连接流程 --- |
| | | |
| | | openBluetoothAdapter() { |
| | | if (this.state.connectStatus === 2) return; |
| | | |
| | | log.info('RtAuth: 准备初始化蓝牙...'); |
| | | |
| | | Taro.getLocation({ |
| | | type: 'wgs84', |
| | | success: () => this._realOpenAdapter(), |
| | | fail: () => this._realOpenAdapter() |
| | | }); |
| | | } |
| | | |
| | | _realOpenAdapter() { |
| | | wx.openBluetoothAdapter({ |
| | | success: () => { |
| | | log.info('RtAuth: 打开蓝牙适配器成功'); |
| | | this.findAndConnect(); |
| | | }, |
| | | fail: (res) => { |
| | | log.info('RtAuth: 打开蓝牙适配器失败', res); |
| | | if (res.errMsg.includes('already opened')) { |
| | | wx.closeBluetoothAdapter({ |
| | | success: () => this._realOpenAdapter() |
| | | }); |
| | | } else { |
| | | this.onError({ type: 'ADAPTER_ERR', msg: '请打开蓝牙和定位功能' }); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | |
| | | findAndConnect() { |
| | | const { macAddress, isIOS } = this.state; |
| | | let key = madAddressToLocalName(macAddress); |
| | | if (isIOS) key = key + 'IOS'; |
| | | |
| | | const cacheDeviceId = Taro.getStorageSync(key); |
| | | if (cacheDeviceId) { |
| | | log.info('RtAuth: 使用缓存DeviceId连接', cacheDeviceId); |
| | | this.state.deviceId = cacheDeviceId; |
| | | this.connectDevice(); |
| | | } else { |
| | | this.startScan(); |
| | | } |
| | | } |
| | | |
| | | startScan() { |
| | | if (this.state.isScaning) return; |
| | | this.state.isScaning = true; |
| | | |
| | | wx.startBluetoothDevicesDiscovery({ |
| | | allowDuplicatesKey: true, |
| | | success: () => this.onBluetoothDeviceFound() |
| | | }); |
| | | } |
| | | |
| | | onBluetoothDeviceFound() { |
| | | const { macAddress, isIOS } = this.state; |
| | | const targetLocalName = madAddressToLocalName(macAddress); |
| | | |
| | | wx.onBluetoothDeviceFound((res) => { |
| | | res.devices.forEach(device => { |
| | | if (!device.name && !device.localName) return; |
| | | |
| | | if (device.localName === targetLocalName) { |
| | | log.info('RtAuth: 扫描到目标设备', device.deviceId); |
| | | |
| | | const keyS = device.localName + (isIOS ? 'IOS' : ''); |
| | | Taro.setStorage({ key: keyS, data: device.deviceId }); |
| | | |
| | | this.state.deviceId = device.deviceId; |
| | | this.stopScan(); |
| | | this.connectDevice(); |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | stopScan() { |
| | | wx.stopBluetoothDevicesDiscovery(); |
| | | this.state.isScaning = false; |
| | | } |
| | | |
| | | connectDevice() { |
| | | if (this.state.connectStatus === 2) return; |
| | | |
| | | this.notifyStatus(1); |
| | | log.info('RtAuth: 开始连接设备', this.state.deviceId); |
| | | |
| | | wx.createBLEConnection({ |
| | | deviceId: this.state.deviceId, |
| | | success: () => { |
| | | log.info('RtAuth: 连接设备成功'); |
| | | this.state.tryTimes = 0; |
| | | this.state.connected = true; |
| | | if (this.state.isScaning) this.stopScan(); |
| | | this.getServices(); |
| | | }, |
| | | fail: (err) => { |
| | | log.info('RtAuth: 连接失败', err); |
| | | |
| | | if (err.errno === 1509007 || err.errMsg.includes('already connected')) { |
| | | this.state.tryTimes = 0; |
| | | this.state.connected = true; |
| | | if (this.state.isScaning) this.stopScan(); |
| | | this.getServices(); |
| | | return; |
| | | } |
| | | |
| | | if (this.state.tryTimes < this.MAX_RETRY_TIMES) { |
| | | this.state.tryTimes++; |
| | | wx.closeBLEConnection({ deviceId: this.state.deviceId }); |
| | | setTimeout(() => this.openBluetoothAdapter(), 1500); |
| | | } else { |
| | | this.notifyStatus(0); |
| | | this.closeConnection(); |
| | | this.onError({ type: 'CONNECT_ERR', msg: '蓝牙连接失败,请靠近车辆重试' }); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | |
| | | getServices() { |
| | | wx.getBLEDeviceServices({ |
| | | deviceId: this.state.deviceId, |
| | | success: (res) => { |
| | | const targetService = res.services.find(s => |
| | | s.uuid === this.serviceId || s.uuid.toUpperCase() === this.serviceId |
| | | ); |
| | | if (targetService) { |
| | | this.getCharacteristics(targetService.uuid); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | |
| | | getCharacteristics(serviceId) { |
| | | wx.getBLEDeviceCharacteristics({ |
| | | deviceId: this.state.deviceId, |
| | | serviceId, |
| | | success: (res) => { |
| | | res.characteristics.forEach(item => { |
| | | if (item.properties.read) { |
| | | this.state.readChs = item.uuid; |
| | | this.state.readServiceId = serviceId; |
| | | } |
| | | if (item.properties.write && item.uuid === this.writeId) { |
| | | this.state.writeChs = item.uuid; |
| | | this.state.writeServiceId = serviceId; |
| | | } |
| | | if (item.properties.indicate) { |
| | | this.state.indecateChs = item.uuid; |
| | | this.state.indecateServiceId = serviceId; |
| | | this.setIndicateListener(); |
| | | } |
| | | }); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | setIndicateListener() { |
| | | wx.notifyBLECharacteristicValueChange({ |
| | | deviceId: this.state.deviceId, |
| | | serviceId: this.state.indecateServiceId, |
| | | characteristicId: this.state.indecateChs, |
| | | state: true, |
| | | success: () => { |
| | | log.info('RtAuth: 蓝牙连接就绪'); |
| | | this.notifyStatus(2); |
| | | |
| | | wx.onBLECharacteristicValueChange((characteristic) => { |
| | | this.handleReceivedData(characteristic.value); |
| | | }); |
| | | |
| | | // 如果已配置 certificate 和 deviceModel,自动发送鉴权 |
| | | if (this.state.certificate && this.state.deviceModel) { |
| | | log.info('RtAuth: 自动发送鉴权'); |
| | | this.sendRtAuthData(this.state.certificate, this.state.deviceModel); |
| | | } |
| | | } |
| | | }); |
| | | |
| | | this.listenConnectionState(); |
| | | } |
| | | |
| | | listenConnectionState() { |
| | | wx.onBLEConnectionStateChange((res) => { |
| | | if (!res.connected && this.state.connected) { |
| | | log.info('RtAuth: 蓝牙异常断开'); |
| | | this.closeConnection(); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | closeConnection() { |
| | | log.info('RtAuth: 断开连接'); |
| | | this.stopScan(); |
| | | if (this.state.connected) { |
| | | wx.closeBLEConnection({ deviceId: this.state.deviceId }); |
| | | } |
| | | this.resetState(); |
| | | } |
| | | |
| | | // --- 数据处理 --- |
| | | |
| | | handleReceivedData(value) { |
| | | const dataHex = ab2hex(value); |
| | | log.info('RtAuth: 收到数据', dataHex); |
| | | |
| | | // RtAuth 响应 (MsgId = 0x0B) |
| | | if (dataHex.startsWith('0b') || dataHex.startsWith('0B')) { |
| | | this.handleRtAuthResponse(dataHex); |
| | | return; |
| | | } |
| | | |
| | | // RtNotification (MsgId = 0x0C) |
| | | if (dataHex.startsWith('0c') || dataHex.startsWith('0C')) { |
| | | this.handleRtNotification(dataHex); |
| | | return; |
| | | } |
| | | |
| | | log.info('RtAuth: 忽略非 0x0B/0x0C 数据'); |
| | | } |
| | | |
| | | handleRtAuthResponse(dataHex) { |
| | | log.info('RtAuth: 解析 RtAuth 响应'); |
| | | |
| | | try { |
| | | const wasmData = bleWasmModule.allocateUTF8(dataHex); |
| | | const wasmResult = bleWasmModule._parseRtAuthResponse(wasmData, dataHex.length / 2); |
| | | const resultStr = bleWasmModule.UTF8ToString(wasmResult); |
| | | bleWasmModule._free(wasmData); |
| | | |
| | | log.info('RtAuth: 解析结果', resultStr); |
| | | const result = JSON.parse(resultStr); |
| | | |
| | | if (result.error) { |
| | | this.onRtAuthResult({ success: false, error: result.error }); |
| | | } else if (result.rtAuthResult) { |
| | | const isSuccess = result.rtAuthResult.status === 0; |
| | | |
| | | // 鉴权成功,更新状态为 3(已鉴权) |
| | | if (isSuccess) { |
| | | this.notifyStatus(3); |
| | | } |
| | | |
| | | this.onRtAuthResult({ |
| | | success: isSuccess, |
| | | data: result.rtAuthResult |
| | | }); |
| | | } |
| | | } catch (e) { |
| | | log.info('RtAuth: 解析异常', e); |
| | | this.onRtAuthResult({ success: false, error: e.message || e }); |
| | | } |
| | | } |
| | | |
| | | handleRtNotification(dataHex) { |
| | | log.info('RtAuth: 解析 RtNotification'); |
| | | |
| | | try { |
| | | const wasmData = bleWasmModule.allocateUTF8(dataHex); |
| | | const wasmResult = bleWasmModule._parseRtNotification(wasmData, dataHex.length / 2); |
| | | const resultStr = bleWasmModule.UTF8ToString(wasmResult); |
| | | bleWasmModule._free(wasmData); |
| | | |
| | | log.info('RtAuth: 解析结果', resultStr); |
| | | const result = JSON.parse(resultStr); |
| | | |
| | | if (result.error) { |
| | | this.onRtNotification({ type: 'error', error: result.error }); |
| | | } else if (result.rtNotification) { |
| | | this.onRtNotification({ |
| | | type: 'pollingUnlock', |
| | | success: result.rtNotification.result === 0, |
| | | data: result.rtNotification |
| | | }); |
| | | } |
| | | } catch (e) { |
| | | log.info('RtAuth: 解析异常', e); |
| | | this.onRtNotification({ type: 'error', error: e.message || e }); |
| | | } |
| | | } |
| | | |
| | | // --- 发送数据 --- |
| | | |
| | | writeBLEData(data) { |
| | | const buffer = Uint8Array.from(data).buffer; |
| | | |
| | | wx.writeBLECharacteristicValue({ |
| | | deviceId: this.state.deviceId, |
| | | serviceId: this.state.writeServiceId, |
| | | characteristicId: this.state.writeChs, |
| | | value: buffer, |
| | | success: () => log.info('RtAuth: 发送成功'), |
| | | fail: (err) => log.info('RtAuth: 发送失败', err) |
| | | }); |
| | | } |
| | | |
| | | // 发送 RtAuth 鉴权数据 |
| | | sendRtAuthData(certificate, deviceModel) { |
| | | log.info('RtAuth: 发送鉴权数据'); |
| | | |
| | | if (this.state.connectStatus !== 2) { |
| | | this.onError({ type: 'RT_AUTH_ERR', msg: '蓝牙未连接' }); |
| | | return false; |
| | | } |
| | | |
| | | if (!certificate || !deviceModel) { |
| | | this.onError({ type: 'RT_AUTH_ERR', msg: '缺少 certificate 或 deviceModel' }); |
| | | return false; |
| | | } |
| | | |
| | | try { |
| | | // certificate: hex 字符串,长度除以 2 得到字节数 |
| | | // deviceModel: 普通字符串,使用 lengthBytesUTF8 计算 UTF-8 编码后的字节长度 |
| | | const deviceModelLength = bleWasmModule.lengthBytesUTF8(deviceModel); |
| | | |
| | | const wasmCertificate = bleWasmModule.allocateUTF8(certificate); |
| | | const wasmDeviceModel = bleWasmModule.allocateUTF8(deviceModel); |
| | | |
| | | const wasmResult = bleWasmModule._generateRtAuthData( |
| | | wasmCertificate, certificate.length / 2, // certificate 是 hex,字节数 = 长度/2 |
| | | wasmDeviceModel, deviceModelLength // deviceModel 使用 UTF-8 字节长度 |
| | | ); |
| | | |
| | | const rtAuthHex = bleWasmModule.UTF8ToString(wasmResult); |
| | | |
| | | bleWasmModule._free(wasmCertificate); |
| | | bleWasmModule._free(wasmDeviceModel); |
| | | |
| | | log.info('RtAuth: 生成数据', rtAuthHex); |
| | | this.writeBLEData(hexStringToArray(rtAuthHex)); |
| | | |
| | | return true; |
| | | } catch (e) { |
| | | log.info('RtAuth: 发送失败', e); |
| | | this.onError({ type: 'RT_AUTH_ERR', msg: e.message || e }); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | // 重试连接 |
| | | retryConnection() { |
| | | log.info('RtAuth: 重连'); |
| | | this.closeConnection(); |
| | | this.state.tryTimes = 0; |
| | | |
| | | setTimeout(() => { |
| | | if (!this.state.macAddress) { |
| | | this.onError({ type: 'CONFIG_ERR', msg: '缺少 macAddress' }); |
| | | return; |
| | | } |
| | | this.openBluetoothAdapter(); |
| | | }, 500); |
| | | } |
| | | } |