import Taro from "@tarojs/taro";
|
import { showToast } from '../../../../components/toast';
|
import { View, Image, Text, ScrollView } from "@tarojs/components";
|
import { useState, useEffect, useRef } from "react";
|
// TODO(仅用于测试): 上线前将此处改回从 '../../../../api/begin-route' 导入 './test-case/ac-mock'
|
import {
|
airConditionerUnlock,
|
queryTemperatureByOrderId,
|
queryVehicleAirConditionerSwitch,
|
setTemperature
|
} from '../../../../api/begin-route';
|
// TODO(仅用于测试): 上线前删除以下 import
|
import '../css-folder/calling-car.less';
|
import '../css-folder/temperature-control.less';
|
import { getOrderId } from '../../../../utils/order';
|
import { TripStatus } from '../../../../constants/trip-status';
|
import { MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY } from '../../../../constants/modify-destination';
|
import { getAcStorage, saveAcStorage } from './ac-storage';
|
import useMinimumSpinLoading from './use-minimum-spin-loading';
|
import useBottomPopupAnimation from '../hooks/use-bottom-popup-animation';
|
import SeatHvArea from './seat-hv-control';
|
import AcSwitch from './ac-switch';
|
|
// 入口卡片图标(复用 temperature-fragrance-control 的 v2 资源)
|
const AC_HEADER_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/air-conditioner-20260716.png';
|
const AC_ARROW_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/temperature-fragrance-arrow-20260706.png';
|
const PANEL_CLOSE_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/close-icon.png';
|
const AC_TOGGLE_FAIL_TEXT = '空调开关操作失败,请重试';
|
const AC_TEMP_FAIL_TEXT = '温度设置失败,请重试';
|
const AC_TEMP_MIN_TIP_TEXT = '已到空调最低设定温度';
|
const AC_TEMP_MAX_TIP_TEXT = '已到空调最高设定温度';
|
const TEMP_ITEM_WIDTH = 130; // 温度滑条单项宽度(所有项等宽)
|
const TEMP_SCROLL_SETTLE_MS = 120; // 滑动停止判定延时
|
|
type Props = {
|
tripStatus: TripStatus;
|
orderId?: string;
|
pageDidShowVersion?: number;
|
/** 后端推送:当前被指派车辆是否仍在执行上一单(连环单) */
|
hasPreviousOrder?: boolean;
|
};
|
|
export default function TemperatureControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props) {
|
const [tempStatus, setTempStatus] = useState<number | null>(null);
|
const [acSwitchOn, setAcSwitchOn] = useState(false);
|
const [acStatusLoaded, setAcStatusLoaded] = useState(false);
|
// loading 期间正在切换的目标状态(true=开启中,false=关闭中)。
|
// 独立于 acSwitchOn,避免最小 loading 时长未结束前 acSwitchOn 已更新导致文案闪动。
|
const [acTogglingTarget, setAcTogglingTarget] = useState(false);
|
const tempTimerRef = useRef<any>(null);
|
const lastConfirmedTempRef = useRef<number | null>(null);
|
const skipQueryOnThisCycleRef = useRef(false);
|
// 记录本次接驾态周期内已查询过空调状态的订单 ID,避免同一订单在初始化过程中因
|
// tripStatus/orderId/hasPreviousOrder 分批异步更新而重复触发查询。
|
const queriedOrderIdRef = useRef('');
|
const userActionSeqRef = useRef(0);
|
const calibrationSeqRef = useRef(0);
|
const { visibleLoadingKey: acLoadingKey, startLoading, stopLoading } = useMinimumSpinLoading<'ac'>();
|
const TEMP_MIN = 15.5;
|
const TEMP_MAX = 32.5;
|
const TEMP_UPDATE_DEBOUNCE_MS = 500;
|
const DEFAULT_FIRST_TEMP = 23.5;
|
const isTogglingAc = acLoadingKey === 'ac';
|
|
const canToggleSwitch = tripStatus === TripStatus.VehicleApproaching && !isTogglingAc;
|
const canAdjustTemperature = canToggleSwitch && acStatusLoaded && acSwitchOn && typeof tempStatus === 'number';
|
const shouldShowTemperatureUnit = typeof tempStatus === 'number' && tempStatus > TEMP_MIN && tempStatus < TEMP_MAX;
|
// 温度展示文案:到达最高温显示 HI,到达最低温显示 LO,其余显示具体温度值
|
const tempDisplayText = typeof tempStatus === 'number'
|
? (tempStatus >= TEMP_MAX ? 'HI' : (tempStatus <= TEMP_MIN ? 'LO' : tempStatus.toFixed(1)))
|
: '--';
|
const isAtMinTemperature = typeof tempStatus === 'number' && tempStatus <= TEMP_MIN;
|
const isAtMaxTemperature = typeof tempStatus === 'number' && tempStatus >= TEMP_MAX;
|
const decreaseButtonDisabled = !canAdjustTemperature || isAtMinTemperature;
|
const increaseButtonDisabled = !canAdjustTemperature || isAtMaxTemperature;
|
const decreaseButtonIconSuffix = decreaseButtonDisabled ? '-no' : '';
|
const increaseButtonIconSuffix = increaseButtonDisabled ? '-no' : '';
|
const tempTextDisabledClassName = acSwitchOn ? '' : 'color-000-25';
|
|
// ============ 温度调节面板(合并座椅通风加热) ============
|
const [panelVisible, setPanelVisible] = useState(false);
|
const { animationData, shouldRender } = useBottomPopupAnimation(panelVisible);
|
// 温度可选项:15.5 ~ 32.5,步进 0.5
|
const TEMP_OPTIONS: number[] = [];
|
for (let t = TEMP_MIN; t <= TEMP_MAX + 1e-6; t += 0.5) {
|
TEMP_OPTIONS.push(Number(t.toFixed(1)));
|
}
|
// 卡片主标题温度(保留一位小数,如 20.5),沿用现有 HI/LO 语义
|
const cardTempText = typeof tempStatus === 'number'
|
? (tempStatus >= TEMP_MAX ? 'HI' : (tempStatus <= TEMP_MIN ? 'LO' : tempStatus.toFixed(1)))
|
: '--';
|
|
// 温度滑条:滚动停止后吸附居中项并下发
|
const tempScrollSettleTimerRef = useRef<any>(null);
|
const [tempScrollLeft, setTempScrollLeft] = useState(0);
|
// 当前居中项索引(滚动过程中跟随,用于高亮)
|
const [centeredTempIndex, setCenteredTempIndex] = useState(0);
|
// 单项真实像素宽度:设计稿 120px(@designWidth=750)→ 设备真实 px
|
const itemPxRef = useRef(0);
|
if (itemPxRef.current === 0) {
|
try {
|
const sysWidth = Taro.getSystemInfoSync().windowWidth || 375;
|
itemPxRef.current = (TEMP_ITEM_WIDTH / 750) * (sysWidth);
|
} catch {
|
itemPxRef.current = TEMP_ITEM_WIDTH / 2; // 兜底
|
}
|
}
|
|
// 打开面板时,把当前温度滚动到居中
|
useEffect(() => {
|
if (!panelVisible) {
|
return;
|
}
|
const idx = typeof tempStatus === 'number'
|
? TEMP_OPTIONS.findIndex((v) => Math.abs(v - tempStatus) < 1e-6)
|
: -1;
|
const safeIdx = idx >= 0 ? idx : 0;
|
setCenteredTempIndex(safeIdx);
|
setTempScrollLeft(safeIdx * itemPxRef.current);
|
}, [panelVisible]);
|
|
const openPanel = () => setPanelVisible(true);
|
const closePanel = () => setPanelVisible(false);
|
|
// 滚动过程中实时更新居中高亮,停止后吸附并下发(空调关闭时也可拖动,仅更新本地值)
|
const handleTempScroll = (e: any) => {
|
const scrollLeft = e?.detail?.scrollLeft ?? 0;
|
const idx = Math.round(scrollLeft / itemPxRef.current);
|
const clampedIdx = Math.max(0, Math.min(TEMP_OPTIONS.length - 1, idx));
|
// 滚动中实时跟随居中项高亮
|
setCenteredTempIndex(clampedIdx);
|
if (tempScrollSettleTimerRef.current) {
|
clearTimeout(tempScrollSettleTimerRef.current);
|
}
|
tempScrollSettleTimerRef.current = setTimeout(() => {
|
const value = TEMP_OPTIONS[clampedIdx];
|
// 吸附居中
|
setTempScrollLeft(clampedIdx * itemPxRef.current);
|
if (typeof tempStatus !== 'number' || Math.abs(tempStatus - value) > 1e-6) {
|
handleSelectTemp(value);
|
}
|
}, TEMP_SCROLL_SETTLE_MS);
|
};
|
|
// 点击某温度项:滚动居中并选中(空调关闭时也可选,仅更新本地值)
|
const handleTempItemClick = (index: number, value: number) => {
|
setCenteredTempIndex(index);
|
setTempScrollLeft(index * itemPxRef.current);
|
handleSelectTemp(value);
|
};
|
|
// 面板内滑动/点击选择温度:更新本地值;空调开启时下发,关闭时仅暂存(开启开关时统一下发)
|
const handleSelectTemp = (value: number) => {
|
setTempStatus(value);
|
lastConfirmedTempRef.current = value;
|
if (acSwitchOn) {
|
scheduleTemperatureUpdate(value);
|
}
|
};
|
|
|
useEffect(() => {
|
const skipOnce = Number(Taro.getStorageSync(MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY)) === 1;
|
skipQueryOnThisCycleRef.current = skipOnce;
|
if (skipOnce) {
|
Taro.removeStorageSync(MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY);
|
}
|
}, [pageDidShowVersion, tripStatus, orderId]);
|
|
const parseAcSwitchState = (res: any) => {
|
const raw = res?.open ?? res?.isOpen ?? res?.switchStatus ?? res?.airConditionerSwitch ?? res;
|
if (typeof raw === 'boolean') {
|
return raw;
|
}
|
if (typeof raw === 'number') {
|
return raw === 1;
|
}
|
if (typeof raw === 'string') {
|
const normalized = raw.trim().toLowerCase();
|
return normalized === '1' || normalized === 'true' || normalized === 'on';
|
}
|
return false;
|
};
|
|
const syncStorageToBackend = async (targetOrderId: string, acOpen: boolean, temperature: number) => {
|
try {
|
await airConditionerUnlock({ orderId: targetOrderId, open: acOpen });
|
} catch {}
|
try {
|
await setTemperature({ orderId: targetOrderId, targetTemperature: temperature });
|
} catch {}
|
};
|
|
const handleFirstTimeInit = async (targetOrderId: string) => {
|
setAcSwitchOn(true);
|
setTempStatus(DEFAULT_FIRST_TEMP);
|
lastConfirmedTempRef.current = DEFAULT_FIRST_TEMP;
|
setAcStatusLoaded(true);
|
setAcTogglingTarget(true);
|
startLoading('ac');
|
|
let acSuccess = false;
|
let tempSuccess = false;
|
|
try {
|
await airConditionerUnlock({ orderId: targetOrderId, open: true });
|
acSuccess = true;
|
} catch {}
|
|
try {
|
const res = await setTemperature({ orderId: targetOrderId, targetTemperature: DEFAULT_FIRST_TEMP });
|
if (res) {
|
tempSuccess = true;
|
}
|
} catch {}
|
|
stopLoading('ac', () => {
|
if (!acSuccess) {
|
showToast({ title: AC_TOGGLE_FAIL_TEXT, icon: 'none' });
|
} else if (!tempSuccess) {
|
showToast({ title: AC_TEMP_FAIL_TEXT, icon: 'none' });
|
}
|
});
|
|
saveAcStorage(targetOrderId, { acOpen: true, temperature: DEFAULT_FIRST_TEMP });
|
};
|
|
const handleCalibration = async (targetOrderId: string, storageAcOpen: boolean, storageTemp: number) => {
|
const snapshotSeq = userActionSeqRef.current;
|
calibrationSeqRef.current = snapshotSeq;
|
|
const CALIBRATION_TIMEOUT_MS = 1500;
|
|
type CalibrationResult =
|
| { type: 'success'; acOpen: boolean; temperature: number }
|
| { type: 'timeout' }
|
| { type: 'error' };
|
|
const queryPromise: Promise<CalibrationResult> = Promise.all([
|
queryVehicleAirConditionerSwitch({ orderId: targetOrderId }),
|
queryTemperatureByOrderId({ orderId: targetOrderId })
|
]).then(([acRes, tempRes]): CalibrationResult => {
|
const acOpen = parseAcSwitchState(acRes);
|
const parsedTemp = Number(tempRes);
|
const temperature = Number.isNaN(parsedTemp) ? storageTemp : parsedTemp;
|
return { type: 'success', acOpen, temperature };
|
}).catch((): CalibrationResult => ({ type: 'error' }));
|
|
const timeoutPromise: Promise<CalibrationResult> = new Promise((resolve) =>
|
setTimeout(() => resolve({ type: 'timeout' }), CALIBRATION_TIMEOUT_MS)
|
);
|
|
const result = await Promise.race([queryPromise, timeoutPromise]);
|
|
if (userActionSeqRef.current > snapshotSeq) {
|
return;
|
}
|
|
if (result.type === 'timeout' || result.type === 'error') {
|
// 超时或出错:展示 storageData,并同步远程
|
setAcSwitchOn(storageAcOpen);
|
setTempStatus(storageTemp);
|
lastConfirmedTempRef.current = storageTemp;
|
setAcStatusLoaded(true);
|
|
if (storageAcOpen) {
|
// 空调开启 → 正常下发开关 + 温度
|
syncStorageToBackend(targetOrderId, storageAcOpen, storageTemp);
|
} else {
|
// 空调关闭 → 只下发关闭,不下发温度(下发温度会自动开空调)
|
try { await airConditionerUnlock({ orderId: targetOrderId, open: false }); } catch {}
|
}
|
return;
|
}
|
|
// 接口在限定时间内返回:以接口数据为准
|
setAcSwitchOn(result.acOpen);
|
setTempStatus(result.temperature);
|
lastConfirmedTempRef.current = result.temperature;
|
setAcStatusLoaded(true);
|
saveAcStorage(targetOrderId, { acOpen: result.acOpen, temperature: result.temperature });
|
};
|
|
useEffect(() => {
|
if (tripStatus !== TripStatus.VehicleApproaching) {
|
// 离开接驾态:重置去重标记,允许下次进入接驾态时重新查询一次。
|
queriedOrderIdRef.current = '';
|
return;
|
}
|
// 连环派单场景:后端明确告知车辆仍在执行上一单(hasPreviousOrder 为 true)时,
|
// 不应初始化/下发温度,否则会把车内温度强制改回默认 23.5°C,覆盖上一单乘客的设置。
|
// 该字段与 tripStatus 原子下发、首次即正确,无竞态窗口。
|
if (hasPreviousOrder) {
|
return;
|
}
|
if (skipQueryOnThisCycleRef.current) {
|
return;
|
}
|
const targetOrderId = getOrderId(orderId);
|
if (!targetOrderId) {
|
return;
|
}
|
// 去重:同一订单在本次接驾态周期内只查询一次。tripStatus/pageDidShowVersion/hasPreviousOrder
|
// 在页面初始化恢复流程中会分多次异步更新,但 orderId 不变时无需重复查询。
|
if (queriedOrderIdRef.current === targetOrderId) {
|
return;
|
}
|
queriedOrderIdRef.current = targetOrderId;
|
|
const storageData = getAcStorage(targetOrderId);
|
|
if (storageData) {
|
handleCalibration(targetOrderId, storageData.acOpen, storageData.temperature);
|
return;
|
}
|
|
handleFirstTimeInit(targetOrderId);
|
}, [tripStatus, orderId, pageDidShowVersion, hasPreviousOrder]);
|
|
useEffect(() => {
|
lastConfirmedTempRef.current = tempStatus;
|
return () => {
|
if (tempTimerRef.current) {
|
clearTimeout(tempTimerRef.current);
|
}
|
};
|
}, []);
|
|
const scheduleTemperatureUpdate = (targetTemperature: number) => {
|
if (tempTimerRef.current) {
|
clearTimeout(tempTimerRef.current);
|
}
|
tempTimerRef.current = setTimeout(() => {
|
const targetOrderId = getOrderId(orderId);
|
if (!targetOrderId) {
|
return;
|
}
|
setTemperature({
|
orderId: targetOrderId,
|
targetTemperature
|
}).then((res: any) => {
|
if (res) {
|
lastConfirmedTempRef.current = targetTemperature;
|
userActionSeqRef.current++;
|
saveAcStorage(targetOrderId, { acOpen: acSwitchOn, temperature: targetTemperature });
|
// Taro.showToast({ title: '设置成功', icon: 'none' });
|
} else {
|
setTempStatus(lastConfirmedTempRef.current ?? null);
|
showToast({ title: AC_TEMP_FAIL_TEXT, icon: 'none' });
|
}
|
}).catch(() => {
|
setTempStatus(lastConfirmedTempRef.current ?? null);
|
showToast({ title: AC_TEMP_FAIL_TEXT, icon: 'none' });
|
});
|
}, TEMP_UPDATE_DEBOUNCE_MS);
|
};
|
|
const handleTempChange = (delta: number) => {
|
if (!canAdjustTemperature) {
|
return;
|
}
|
setTempStatus((prev) => {
|
if (typeof prev !== 'number') {
|
return prev;
|
}
|
const next = prev + delta;
|
if (delta < 0 && next < TEMP_MIN) {
|
showToast({ title: '当前已是空调最低温度', icon: 'none' });
|
return prev;
|
}
|
if (delta > 0 && next > TEMP_MAX) {
|
showToast({ title: '当前已是空调最高温度', icon: 'none' });
|
return prev;
|
}
|
scheduleTemperatureUpdate(next);
|
return next;
|
});
|
};
|
|
const handleDecreaseTemperature = () => {
|
if (!canAdjustTemperature) {
|
return;
|
}
|
if (isAtMinTemperature) {
|
showToast({ title: AC_TEMP_MIN_TIP_TEXT, icon: 'none' });
|
return;
|
}
|
handleTempChange(-0.5);
|
};
|
|
const handleIncreaseTemperature = () => {
|
if (!canAdjustTemperature) {
|
return;
|
}
|
if (isAtMaxTemperature) {
|
showToast({ title: AC_TEMP_MAX_TIP_TEXT, icon: 'none' });
|
return;
|
}
|
handleTempChange(0.5);
|
};
|
|
const handleToggleAcSwitch = async () => {
|
if (!canToggleSwitch) {
|
return;
|
}
|
const targetOrderId = getOrderId(orderId);
|
if (!targetOrderId) {
|
return;
|
}
|
const targetOpenState = !acSwitchOn;
|
setAcTogglingTarget(targetOpenState);
|
startLoading('ac');
|
let toastText = '';
|
try {
|
// 200就认为是指令下发成功了
|
await airConditionerUnlock({
|
orderId: targetOrderId,
|
open: targetOpenState
|
});
|
// 开启空调时同时下发温度设置
|
if (targetOpenState) {
|
const currentTemp = typeof tempStatus === 'number' ? tempStatus : DEFAULT_FIRST_TEMP;
|
try {
|
await setTemperature({ orderId: targetOrderId, targetTemperature: currentTemp });
|
} catch {
|
console.error('设置空调温度失败');
|
}
|
}
|
setAcSwitchOn(targetOpenState);
|
setAcStatusLoaded(true);
|
userActionSeqRef.current++;
|
const currentTemp = typeof tempStatus === 'number' ? tempStatus : DEFAULT_FIRST_TEMP;
|
saveAcStorage(targetOrderId, { acOpen: targetOpenState, temperature: currentTemp });
|
toastText = targetOpenState ? '已开启空调' : '已关闭空调';
|
if (targetOpenState && typeof tempStatus !== 'number') {
|
setTempStatus(DEFAULT_FIRST_TEMP);
|
lastConfirmedTempRef.current = DEFAULT_FIRST_TEMP;
|
}
|
} catch (error) {
|
toastText = AC_TOGGLE_FAIL_TEXT;
|
} finally {
|
// 等 loading 动画真正结束后再弹 toast,避免异常提示已弹出但 loading 仍在转
|
stopLoading('ac', () => {
|
if (toastText) {
|
showToast({ title: toastText, icon: 'none' });
|
}
|
});
|
}
|
};
|
|
// 连环单(车还在执行上一单)时不展示空调卡片
|
if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
|
return null;
|
}
|
|
return (
|
<View className='ac-control'>
|
{/* 空调入口卡片(复用 tf-card 视觉):点击打开温度调节面板;右上开关直接切空调 */}
|
<View
|
className={`tf-card tf-card-ac ${acSwitchOn ? 'is-open' : 'is-close'}`}
|
onClick={openPanel}
|
>
|
<View className='tf-card-header'>
|
<View className='tf-card-header-left'>
|
<Image className='tf-card-header-icon' src={AC_HEADER_ICON} />
|
<Text className='tf-card-header-title'>空调</Text>
|
</View>
|
<AcSwitch checked={acSwitchOn} loading={isTogglingAc} onChange={handleToggleAcSwitch} />
|
</View>
|
<View className='tf-card-bottom'>
|
{acStatusLoaded && (
|
<View className='tf-card-ac-temp'>
|
<Text className='tf-card-ac-temp-value'>{cardTempText}</Text>
|
{shouldShowTemperatureUnit && <Text className='tf-card-ac-temp-unit'>°C</Text>}
|
</View>
|
)}
|
<View className='tf-card-footer'>
|
<Text className='tf-card-footer-text'>调节温度|通风加热</Text>
|
<Image className='tf-card-footer-arrow' src={AC_ARROW_ICON} />
|
</View>
|
</View>
|
</View>
|
|
{/* 温度调节面板(底部弹窗,fixed 定位覆盖全屏) */}
|
{shouldRender && (
|
<View className={`ac-popup ${panelVisible ? 'show' : ''}`} catchMove>
|
<View className='popup-mask' onClick={closePanel}></View>
|
<View className='popup-content' animation={animationData}>
|
{/* 座椅通风/加热区(顶部满宽,关闭按钮浮于右上角) */}
|
<View className='popup-seat'>
|
<Image className='popup-close' src={PANEL_CLOSE_ICON} onClick={closePanel} />
|
<SeatHvArea
|
tripStatus={tripStatus}
|
orderId={orderId}
|
pageDidShowVersion={pageDidShowVersion}
|
hasPreviousOrder={hasPreviousOrder}
|
active={panelVisible}
|
/>
|
</View>
|
|
{/* 空调温度滑动选择(底部白色圆角卡片) */}
|
<View className='popup-ac'>
|
<Text className='popup-ac-title'>温度调节</Text>
|
<View className='popup-temp-wrap'>
|
<ScrollView
|
className='popup-temp-scroll'
|
scrollX
|
showScrollbar={false}
|
scrollWithAnimation
|
scrollLeft={tempScrollLeft}
|
onScroll={handleTempScroll}
|
>
|
<View className='popup-temp-track'>
|
{TEMP_OPTIONS.map((value, index) => {
|
const selected = index === centeredTempIndex;
|
return (
|
<View
|
key={value}
|
className={`popup-temp-item ${selected ? 'active' : ''} ${selected && !acSwitchOn ? 'active-off' : ''}`}
|
onClick={() => handleTempItemClick(index, value)}
|
>
|
<Text className='popup-temp-item-value'>{value.toFixed(1)}</Text>
|
{selected && <Text className='popup-temp-item-unit'>℃</Text>}
|
</View>
|
);
|
})}
|
</View>
|
</ScrollView>
|
</View>
|
<View className='popup-ac-switch-row'>
|
<AcSwitch checked={acSwitchOn} loading={isTogglingAc} onChange={handleToggleAcSwitch} />
|
<Text className='popup-ac-switch-text'>
|
{isTogglingAc ? (acTogglingTarget ? '开启中' : '关闭中') : '空调开关'}
|
</Text>
|
</View>
|
</View>
|
</View>
|
</View>
|
)}
|
</View>
|
);
|
}
|