From f12ad358230b9c3e5b9382c294288230347bca6e Mon Sep 17 00:00:00 2001
From: hejianhao <15708179461@qq.com>
Date: 星期一, 25 五月 2026 11:45:04 +0800
Subject: [PATCH] refactor(robotaxi): 提取通用图片上传钩子并优化地图视口逻辑
---
src/robotaxi/hooks/use-image-upload.ts | 225 ++++++++++++++++++++++
src/robotaxi/pages/begin-route/components/evaluate-popup.tsx | 158 ++-------------
src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts | 197 ++++++++++++++++---
3 files changed, 415 insertions(+), 165 deletions(-)
diff --git a/src/robotaxi/hooks/use-image-upload.ts b/src/robotaxi/hooks/use-image-upload.ts
new file mode 100644
index 0000000..e82b86d
--- /dev/null
+++ b/src/robotaxi/hooks/use-image-upload.ts
@@ -0,0 +1,225 @@
+import Taro from '@tarojs/taro';
+import { useCallback, useMemo, useState } from 'react';
+
+/**
+ * 上传成功后的服务端文件信息。
+ */
+export type UploadedServerFile = {
+ id: string;
+ name: string;
+ url: string;
+};
+
+/**
+ * 图片上传项状态。
+ */
+export type ImageUploadItem = {
+ id: string;
+ localUrl: string;
+ server: UploadedServerFile | null;
+ status: 'uploading' | 'success' | 'failed';
+};
+
+/**
+ * 图片上传 Hook 配置。
+ */
+type UseImageUploadOptions = {
+ maxCount: number;
+ uploadUrl: string;
+ compress?: boolean;
+ sizeLimitBytes?: number;
+};
+
+const DEFAULT_IMAGE_SIZE_LIMIT_BYTES = 5 * 1024 * 1024;
+const IMAGE_COMPRESS_QUALITIES = [80, 60, 40];
+
+/**
+ * 图片上传:封装选择、压缩、上传、重试、删除与重置。
+ */
+export default function useImageUpload(options: UseImageUploadOptions) {
+ const [images, setImages] = useState<ImageUploadItem[]>([]);
+
+ /**
+ * 更新指定图片项。
+ */
+ const updateImageById = useCallback((imageId: string, updater: (item: ImageUploadItem) => ImageUploadItem) => {
+ setImages((prev) => prev.map((item) => (item.id === imageId ? updater(item) : item)));
+ }, []);
+
+ /**
+ * 获取文件大小。
+ */
+ const getFileSize = useCallback(async (filePath: string) => {
+ const fs = Taro.getFileSystemManager();
+ const info: any = await new Promise((resolve, reject) => {
+ fs.getFileInfo({
+ filePath,
+ success: resolve,
+ fail: reject
+ });
+ });
+ return Number(info?.size || 0);
+ }, []);
+
+ /**
+ * 压缩图片到体积限制内。
+ */
+ const ensureSizeUnderLimit = useCallback(async (filePath: string) => {
+ if (options.compress === false) {
+ return filePath;
+ }
+ const sizeLimitBytes = options.sizeLimitBytes || DEFAULT_IMAGE_SIZE_LIMIT_BYTES;
+ try {
+ let currentFilePath = filePath;
+ const currentSize = await getFileSize(currentFilePath);
+ if (currentSize <= sizeLimitBytes) {
+ return currentFilePath;
+ }
+ for (const quality of IMAGE_COMPRESS_QUALITIES) {
+ try {
+ const compressResult: any = await Taro.compressImage({
+ src: currentFilePath,
+ quality
+ });
+ const compressedPath = compressResult?.tempFilePath || currentFilePath;
+ const compressedSize = await getFileSize(compressedPath);
+ currentFilePath = compressedPath;
+ if (compressedSize <= sizeLimitBytes) {
+ return currentFilePath;
+ }
+ } catch (error) {
+ // 单次压缩失败时继续尝试更低质量。
+ }
+ }
+ return currentFilePath;
+ } catch (error) {
+ return filePath;
+ }
+ }, [getFileSize, options.compress, options.sizeLimitBytes]);
+
+ /**
+ * 上传单张图片。
+ */
+ const uploadSingle = useCallback(async (imageId: string, filePath: string) => {
+ updateImageById(imageId, (item) => ({
+ ...item,
+ status: 'uploading'
+ }));
+ try {
+ const finalFilePath = await ensureSizeUnderLimit(filePath);
+ const uploadResult = await Taro.uploadFile({
+ url: options.uploadUrl,
+ filePath: finalFilePath,
+ name: 'file'
+ });
+ let responseData: any = uploadResult.data;
+ try {
+ if (typeof responseData === 'string') {
+ responseData = JSON.parse(responseData);
+ }
+ } catch (error) {
+ responseData = null;
+ }
+ const payload = responseData?.data;
+ const serverFile = {
+ id: String(payload?.id || ''),
+ name: String(payload?.name || ''),
+ url: String(payload?.url || '')
+ };
+ if (!serverFile.id || !serverFile.url) {
+ throw new Error('invalid upload response');
+ }
+ updateImageById(imageId, (item) => ({
+ ...item,
+ server: serverFile,
+ status: 'success'
+ }));
+ } catch (error) {
+ updateImageById(imageId, (item) => ({
+ ...item,
+ status: 'failed'
+ }));
+ }
+ }, [ensureSizeUnderLimit, options.uploadUrl, updateImageById]);
+
+ /**
+ * 生成本地图片项。
+ */
+ const createImageItem = useCallback((localUrl: string, index: number): ImageUploadItem => ({
+ id: `${Date.now()}-${index}-${localUrl}`,
+ localUrl,
+ server: null,
+ status: 'uploading'
+ }), []);
+
+ /**
+ * 选择图片并立即上传。
+ */
+ const chooseAndUpload = useCallback(async () => {
+ const remainCount = Math.max(0, options.maxCount - images.length);
+ if (remainCount <= 0) {
+ Taro.showToast({
+ title: `最多可上传${options.maxCount}张图片`,
+ icon: 'none'
+ });
+ return;
+ }
+ try {
+ const chooseResult = await Taro.chooseImage({
+ count: remainCount,
+ sizeType: ['original', 'compressed'],
+ sourceType: ['album', 'camera']
+ });
+ const selectedPaths: string[] = chooseResult?.tempFilePaths || chooseResult?.tempFiles?.map((file: any) => file.path) || [];
+ if (!selectedPaths.length) {
+ return;
+ }
+ const nextItems = selectedPaths.map((path, index) => createImageItem(path, index));
+ setImages((prev) => [...prev, ...nextItems].slice(0, options.maxCount));
+ nextItems.forEach((item) => {
+ void uploadSingle(item.id, item.localUrl);
+ });
+ } catch (error) {
+ console.log('拉起上传图片', error);
+ }
+ }, [createImageItem, images.length, options.maxCount, uploadSingle]);
+
+ /**
+ * 重试上传失败图片。
+ */
+ const retryUpload = useCallback((index: number) => {
+ const imageItem = images[index];
+ if (!imageItem) {
+ return;
+ }
+ void uploadSingle(imageItem.id, imageItem.localUrl);
+ }, [images, uploadSingle]);
+
+ /**
+ * 删除图片。
+ */
+ const removeImage = useCallback((index: number) => {
+ setImages((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
+ }, []);
+
+ /**
+ * 重置全部图片状态。
+ */
+ const resetImages = useCallback(() => {
+ setImages([]);
+ }, []);
+
+ /**
+ * 当前是否存在图片上传中。
+ */
+ const uploading = useMemo(() => images.some((item) => item.status === 'uploading'), [images]);
+
+ return {
+ images,
+ uploading,
+ chooseAndUpload,
+ retryUpload,
+ removeImage,
+ resetImages
+ };
+}
diff --git a/src/robotaxi/pages/begin-route/components/evaluate-popup.tsx b/src/robotaxi/pages/begin-route/components/evaluate-popup.tsx
index 82a0ee8..548163d 100644
--- a/src/robotaxi/pages/begin-route/components/evaluate-popup.tsx
+++ b/src/robotaxi/pages/begin-route/components/evaluate-popup.tsx
@@ -4,6 +4,7 @@
import './css-folder/evaluate-popup.less'
import { getOrderCommentType, submitComplaint, submitOrderComment } from '../../../api/begin-route';
import { _ROBOTAXI_HOST } from '../../../../constants/api';
+import useImageUpload from '../../../hooks/use-image-upload';
import { getOrderId } from '../../../utils/order';
type Props = {
@@ -23,11 +24,18 @@
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';
- }>>([]); // 图片上传队列
+ const {
+ images: pictures,
+ uploading,
+ chooseAndUpload,
+ retryUpload,
+ removeImage,
+ resetImages
+ } = useImageUpload({
+ maxCount: 3,
+ compress: true,
+ uploadUrl: _ROBOTAXI_HOST + '/operation/base/v1/oss/upload'
+ }); // 图片上传状态与操作
// 控制弹窗开合动画
useEffect(() => {
@@ -80,6 +88,13 @@
});
return;
}
+ if (uploading) {
+ Taro.showToast({
+ title: '图片上传中,请稍后提交',
+ icon: 'none'
+ });
+ return;
+ }
setIsSubmitting(true);
const submitter = submitMode === 'complaint' ? submitComplaint : submitOrderComment;
const picturePayload = JSON.stringify(
@@ -118,129 +133,6 @@
});
}
- // 压缩图片到可上传大小
- const ensureSizeUnderLimit = async (path: string): Promise<string> => {
- try {
- const fs = Taro.getFileSystemManager();
- const info: any = await new Promise((resolve, reject) => {
- fs.getFileInfo({
- filePath: path,
- success: resolve,
- fail: reject,
- });
- });
- const size = Number(info?.size || 0);
- const LIMIT = 5 * 1024 * 1024;
- if (size <= LIMIT) return path;
- const qualities = [80, 60, 40];
- for (const q of qualities) {
- try {
- const res: any = await Taro.compressImage({ src: path, quality: q });
- const newPath = res?.tempFilePath || path;
- const info2: any = await new Promise((resolve, reject) => {
- fs.getFileInfo({
- filePath: newPath,
- success: resolve,
- fail: reject,
- });
- });
- if (Number(info2?.size || 0) <= LIMIT) return newPath;
- path = newPath;
- } catch (e) { }
- }
- return path;
- } catch (e) {
- return path;
- }
- };
-
- // 上传单张图片并写回状态
- const uploadSingle = async (index: number, filePath: string) => {
- setPictures(prev => {
- const next = [...prev];
- if (next[index]) next[index].status = 'uploading';
- return next;
- });
- try {
- const finalPath = await ensureSizeUnderLimit(filePath);
- const res = await Taro.uploadFile({
- url: _ROBOTAXI_HOST + '/operation/base/v1/oss/upload',
- filePath: finalPath,
- name: 'file',
- });
- let data: any = res.data;
- try {
- if (typeof data === 'string') data = JSON.parse(data);
- } catch (e) { }
- const payload = data?.data;
- const serverObj = {
- id: String(payload?.id),
- name: String(payload?.name),
- url: String(payload?.url),
- };
- if (!serverObj.id || !serverObj.url) {
- throw new Error('invalid');
- }
- setPictures(prev => {
- const next = [...prev];
- if (next[index]) {
- next[index].server = serverObj;
- next[index].status = 'success';
- }
- return next;
- });
- } catch (e) {
- setPictures(prev => {
- const next = [...prev];
- if (next[index]) next[index].status = 'failed';
- return next;
- });
- }
- };
-
- // 选择图片并触发上传
- const handleChooseImages = async () => {
- const remain = Math.max(0, 3 - pictures.length);
- if (remain <= 0) {
- Taro.showToast({ title: '最多可上传3张图片', icon: 'none' });
- return;
- }
- try {
- const res = await Taro.chooseImage({
- count: remain,
- sizeType: ['original', 'compressed'],
- sourceType: ['album', 'camera'],
- });
- const paths: string[] = res?.tempFilePaths || res?.tempFiles?.map((f: any) => f.path) || [];
- if (!paths || paths.length === 0) return;
- const addItems = paths.map(p => ({ localUrl: p, server: null, status: 'uploading' as const }));
- setPictures(prev => [...prev, ...addItems].slice(0, 3));
- const baseIndex = pictures.length;
- paths.forEach((p, i) => {
- const idx = baseIndex + i;
- uploadSingle(idx, p);
- });
- } catch (e) {
- console.log('拉起上传图片', e);
- }
- };
-
- // 重试上传失败图片
- 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];
- next.splice(index, 1);
- return next;
- });
- };
-
// 拉取问题类型并重置本次填写内容
useEffect(() => {
if (!show) {
@@ -248,7 +140,7 @@
}
setSelectedTypeId(null);
setFeedbackContent('');
- setPictures([]);
+ resetImages();
getOrderCommentType().then((res: any) => {
const list = res;
if (Array.isArray(list)) {
@@ -259,7 +151,7 @@
}).catch(() => {
setTypeOptions([]);
});
- }, [show]);
+ }, [resetImages, show]);
if (!visible) return null;
@@ -307,7 +199,7 @@
}
{
p.status === 'failed' && (
- <View className='evaluate-problem-pic-box-mask failed' onClick={() => handleRetry(idx)}>
+ <View className='evaluate-problem-pic-box-mask failed' onClick={() => retryUpload(idx)}>
<Image className='failed-icon' src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/tip-default-icon.png" />
<Text className='mask-text'>请重试</Text>
</View>
@@ -315,7 +207,7 @@
}
{
p.status === 'success' && (
- <View className='evaluate-problem-pic-box-delete' onClick={() => handleDelete(idx)}>
+ <View className='evaluate-problem-pic-box-delete' onClick={() => removeImage(idx)}>
<Image className='delete-icon' src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/clear-icon.png" />
</View>
)
@@ -324,7 +216,7 @@
))}
{
pictures.length < 3 && (
- <View className='evaluate-problem-pic-box-upload' onClick={handleChooseImages}>
+ <View className='evaluate-problem-pic-box-upload' onClick={chooseAndUpload}>
<Image className='evaluate-problem-pic-box-upload-img' src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/upload-icon.png" />
<View className='evaluate-problem-pic-box-upload-count'>{pictures.length}/3</View>
</View>
diff --git a/src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts b/src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts
index c87a35f..69824e0 100644
--- a/src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts
+++ b/src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts
@@ -35,6 +35,29 @@
const DEFAULT_DYNAMIC_SCALE_HOLD_MS = 2500; // 一次性视口调整后,暂停速度缩放的默认冷却时长
const ACTIVE_TRIP_VIEWPORT_SYNC_MIN_DELTA = 0.00001; // 车辆中心跟随的最小经纬度变化,约 1 米,避免重复写入中心点状态
+type ViewportPoint = {
+ latitude: number;
+ longitude: number;
+};
+
+type ViewportPayload = {
+ points: ViewportPoint[];
+ keyParts: any[];
+};
+
+/**
+ * 将合法站点转换为 includePoints 需要的坐标点。
+ */
+const buildSiteViewportPoint = (site: any) => {
+ if (!site?.latitude || !site?.longitude) {
+ return null;
+ }
+ return {
+ latitude: site.latitude,
+ longitude: site.longitude
+ };
+};
+
export default function useMapViewport(rawDeps?: Partial<Deps>) {
const deps = { ...defaultDeps, ...(rawDeps || {}) } as Deps;
// 地图底部留白
@@ -157,52 +180,162 @@
return { pickupSite, dropoffSite };
};
+ /**
+ * 构建步行路线视口数据。
+ */
+ const buildRouteWalkingViewportPayload = useCallback((viewportType: string): ViewportPayload => {
+ const points: ViewportPoint[] = [];
+ if (deps.routePoints.length > 0) {
+ points.push(...deps.routePoints);
+ }
+ if (deps.walkingPoints.length > 0) {
+ points.push(...deps.walkingPoints);
+ }
+ return {
+ points,
+ keyParts: [
+ viewportType === 'order_detail' ? TripStatus.TripFinished : deps.tripStatus,
+ viewportType,
+ deps.routePoints.length,
+ deps.walkingPoints.length
+ ]
+ };
+ }, [deps.routePoints, deps.tripStatus, deps.walkingPoints]);
+
+ /**
+ * 构建上车点视口数据。
+ */
+ const buildPickupViewportPayload = useCallback((viewportType: string, pickupSite: any): ViewportPayload => {
+ const pickupPoint = buildSiteViewportPoint(pickupSite);
+ return {
+ points: pickupPoint ? [pickupPoint] : [],
+ keyParts: [
+ viewportType === 'order_detail' ? TripStatus.TripFinished : deps.tripStatus,
+ viewportType,
+ pickupSite?.latitude,
+ pickupSite?.longitude
+ ]
+ };
+ }, [deps.tripStatus]);
+
+ /**
+ * 构建主路线视口数据。
+ */
+ const buildRouteViewportPayload = useCallback((viewportType: string): ViewportPayload => {
+ const points: ViewportPoint[] = [];
+ if (deps.routePoints.length > 0) {
+ points.push(...deps.routePoints);
+ }
+ return {
+ points,
+ keyParts: [
+ viewportType === 'order_detail' ? TripStatus.TripFinished : deps.tripStatus,
+ viewportType,
+ deps.routePoints.length
+ ]
+ };
+ }, [deps.routePoints, deps.tripStatus]);
+
+ /**
+ * 构建车辆与用户双点视口数据。
+ */
+ const buildCarUserViewportPayload = useCallback((viewportType: string): ViewportPayload => {
+ const points: ViewportPoint[] = [];
+ if (Number.isFinite(deps.latitude) && Number.isFinite(deps.longitude)) {
+ points.push({
+ latitude: deps.latitude as number,
+ longitude: deps.longitude as number
+ });
+ }
+ if (deps.vehicleLocation?.latitude && deps.vehicleLocation?.longitude) {
+ points.push({
+ latitude: deps.vehicleLocation.latitude,
+ longitude: deps.vehicleLocation.longitude
+ });
+ }
+ return {
+ points,
+ keyParts: [
+ viewportType === 'order_detail' ? TripStatus.TripFinished : deps.tripStatus,
+ viewportType,
+ deps.latitude,
+ deps.longitude,
+ deps.vehicleLocation?.latitude,
+ deps.vehicleLocation?.longitude
+ ]
+ };
+ }, [deps.latitude, deps.longitude, deps.tripStatus, deps.vehicleLocation?.latitude, deps.vehicleLocation?.longitude]);
+
+ /**
+ * 构建终点视口数据。
+ */
+ const buildDropoffViewportPayload = useCallback((viewportType: string, dropoffSite: any): ViewportPayload => {
+ const dropoffPoint = buildSiteViewportPoint(dropoffSite);
+ return {
+ points: dropoffPoint ? [dropoffPoint] : [],
+ keyParts: [
+ viewportType === 'order_detail' ? TripStatus.TripFinished : deps.tripStatus,
+ viewportType,
+ dropoffSite?.latitude,
+ dropoffSite?.longitude
+ ]
+ };
+ }, [deps.tripStatus]);
+
+ /**
+ * 构建订单详情视口数据。
+ */
+ const buildOrderDetailViewportPayload = useCallback((viewportType: string, pickupSite: any, dropoffSite: any): ViewportPayload => {
+ const points: ViewportPoint[] = [];
+ const pickupPoint = buildSiteViewportPoint(pickupSite);
+ const dropoffPoint = buildSiteViewportPoint(dropoffSite);
+ if (deps.routePoints.length > 0) {
+ points.push(...deps.routePoints);
+ }
+ if (pickupPoint) {
+ points.push(pickupPoint);
+ }
+ if (dropoffPoint) {
+ points.push(dropoffPoint);
+ }
+ return {
+ points,
+ keyParts: [
+ viewportType === 'order_detail' ? TripStatus.TripFinished : deps.tripStatus,
+ viewportType,
+ deps.routePoints.length,
+ pickupSite?.latitude,
+ pickupSite?.longitude,
+ dropoffSite?.latitude,
+ dropoffSite?.longitude
+ ]
+ };
+ }, [deps.routePoints, deps.tripStatus]);
+
// 按视口类型构建 includePoints 入参
const buildViewportPayload = (viewportType: string, pickupSite: any, dropoffSite: any) => {
- const points: any[] = [];
- const keyParts: any[] = [viewportType === 'order_detail' ? TripStatus.TripFinished : deps.tripStatus, viewportType];
if (viewportType === 'route_walking') {
- if (deps.routePoints.length > 0) points.push(...deps.routePoints);
- if (deps.walkingPoints.length > 0) points.push(...deps.walkingPoints);
- keyParts.push(deps.routePoints.length, deps.walkingPoints.length);
- return { points, keyParts };
+ return buildRouteWalkingViewportPayload(viewportType);
}
if (viewportType === 'pickup') {
- if (pickupSite?.latitude && pickupSite?.longitude) {
- points.push({ latitude: pickupSite.latitude, longitude: pickupSite.longitude });
- }
- keyParts.push(pickupSite?.latitude, pickupSite?.longitude);
- return { points, keyParts };
+ return buildPickupViewportPayload(viewportType, pickupSite);
}
if (viewportType === 'route') {
- if (deps.routePoints.length > 0) points.push(...deps.routePoints);
- keyParts.push(deps.routePoints.length);
- return { points, keyParts };
+ return buildRouteViewportPayload(viewportType);
}
if (viewportType === 'car_user') {
- if (Number.isFinite(deps.latitude) && Number.isFinite(deps.longitude)) {
- points.push({ latitude: deps.latitude, longitude: deps.longitude });
- }
- if (deps.vehicleLocation?.latitude && deps.vehicleLocation?.longitude) {
- points.push({ latitude: deps.vehicleLocation.latitude, longitude: deps.vehicleLocation.longitude });
- }
- keyParts.push(deps.latitude, deps.longitude, deps.vehicleLocation?.latitude, deps.vehicleLocation?.longitude);
- return { points, keyParts };
+ return buildCarUserViewportPayload(viewportType);
}
if (viewportType === 'dropoff') {
- if (dropoffSite?.latitude && dropoffSite?.longitude) {
- points.push({ latitude: dropoffSite.latitude, longitude: dropoffSite.longitude });
- }
- keyParts.push(dropoffSite?.latitude, dropoffSite?.longitude);
- return { points, keyParts };
+ return buildDropoffViewportPayload(viewportType, dropoffSite);
}
if (viewportType === 'order_detail') {
- if (deps.routePoints.length > 0) points.push(...deps.routePoints);
- if (pickupSite?.latitude && pickupSite?.longitude) points.push({ latitude: pickupSite.latitude, longitude: pickupSite.longitude });
- if (dropoffSite?.latitude && dropoffSite?.longitude) points.push({ latitude: dropoffSite.latitude, longitude: dropoffSite.longitude });
- keyParts.push(deps.routePoints.length, pickupSite?.latitude, pickupSite?.longitude, dropoffSite?.latitude, dropoffSite?.longitude);
+ return buildOrderDetailViewportPayload(viewportType, pickupSite, dropoffSite);
}
- return { points, keyParts };
+ return {
+ points: [],
+ keyParts: [viewportType === 'order_detail' ? TripStatus.TripFinished : deps.tripStatus, viewportType]
+ };
};
const buildOrderDetailRouteSignature = (pickupSite: any, dropoffSite: any) => {
--
Gitblit v1.7.1