| src/app.config.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/components/custom-navbar/index.less | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/components/custom-navbar/index.tsx | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/robotaxi/api/begin-route.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/robotaxi/api/feedback.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/robotaxi/api/types/begin-route.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/robotaxi/api/types/feedback.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/robotaxi/pages/begin-route/components/evaluate-popup.tsx | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/robotaxi/pages/feedback/hooks/use-media-upload.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/robotaxi/pages/feedback/index.config.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/robotaxi/pages/feedback/index.less | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/robotaxi/pages/feedback/index.tsx | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/robotaxi/pages/personal-center/index.tsx | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
src/app.config.ts
@@ -40,6 +40,7 @@ 'pages/operating-area/index', 'pages/share-trip/index', 'pages/personal-center/index', 'pages/feedback/index', 'pages/favorite-sites/index', 'pages/payment-setting/index', 'pages/payment-setting-detail/index', src/components/custom-navbar/index.less
@@ -21,6 +21,16 @@ display: block; width: 64px; height: 64px; &.pure { width: 24px; height: 24px; box-sizing: border-box; border-left: 4px solid #141E27; border-bottom: 4px solid #141E27; transform: rotate(45deg); margin-left: 10px; } } } .btn-app-wrap { src/components/custom-navbar/index.tsx
@@ -26,6 +26,8 @@ showAppBtn: false, // 是否显示返回app按钮 appBtnText: '返回APP', // 返回app按钮文案 showTitleColor: false, // 显示页面标题设置颜色 backIcon: '', // 自定义返回图标,未传时使用默认图标 pureBackArrow: false, // 使用纯样式返回箭头,不使用图片 }; state = { @@ -82,7 +84,7 @@ render() { // 这里需要注意:当设置 opacity 时,不是整个导航栏都透明,而是要保留左边区域的操作,比如后退 const { isIOS } = this.state; const { fixedtop, seizeSeat, background, opacity, showBack, colorTheme, title, showAppBtn, appBtnText, showTitleColor } = this.props; const { fixedtop, seizeSeat, background, opacity, showBack, colorTheme, title, showAppBtn, appBtnText, showTitleColor, backIcon, pureBackArrow } = this.props; const appInaverStyle = { 'backgroundColor': hexToRgbA(background, opacity), height: this.getAppInaverHeight() + 'Px', @@ -97,18 +99,16 @@ <View className='left'> {showBack && !showAppBtn && ( <View className='back-wrap' onClick={this.handlergoback.bind(this)}> {opacity === 0 ? ( colorTheme === 'black' ? ( <Image className='ic-left' mode='heightFix' src={BackBlack2}></Image> ) : ( <Image className='ic-left' mode='heightFix' src={BackWhite2}></Image> ) {pureBackArrow ? ( <View className='ic-left pure' /> ) : ( colorTheme === 'black' ? ( <Image className='ic-left' mode='heightFix' src={BackBlack}></Image> ) : ( <Image className='ic-left' mode='heightFix' src={BackWhite}></Image> ) <Image className='ic-left' mode='heightFix' src={backIcon || (opacity === 0 ? (colorTheme === 'black' ? BackBlack2 : BackWhite2) : (colorTheme === 'black' ? BackBlack : BackWhite))} ></Image> )} </View> )} src/robotaxi/api/begin-route.ts
@@ -34,7 +34,6 @@ ShareQueryOrderInfoPayload, ShareQueryOrderInfoResponse, ShareQueryOrderRouteResponse, SubmitComplaintPayload, SubmitFeedbackResponse, SubmitOrderCommentPayload, UnlockVehiclePayload, @@ -269,17 +268,6 @@ ): Promise<SubmitFeedbackResponse> => { return request({ url: _ROBOTAXI_HOST + `/order/api/v1/mini-program/order/comment`, method: 'POST', payload }, true); } // 提交订单投诉 export const submitComplaint = async ( payload: SubmitComplaintPayload ): Promise<SubmitFeedbackResponse> => { return request({ url: _ROBOTAXI_HOST + `/operation/base/v1/mini-program/complaint/submit`, method: 'POST', payload }, true); src/robotaxi/api/feedback.ts
New file @@ -0,0 +1,23 @@ import request from '../../utils/request'; import { _ROBOTAXI_HOST } from '../../constants/api'; import type { SubmitComplaintPayload, SubmitFeedbackResponse } from './types/begin-route'; import type { FeedbackTypeItem } from './types/feedback'; // 提交订单投诉 export const submitComplaint = async ( payload: SubmitComplaintPayload ): Promise<SubmitFeedbackResponse> => { return request({ url: _ROBOTAXI_HOST + `/operation/base/v1/mini-program/complaint/submit`, method: 'POST', payload }, true); } // 获取意见反馈启用中的问题类型列表 export const getFeedbackTypeList = async (): Promise<FeedbackTypeItem[]> => { return request({ url: _ROBOTAXI_HOST + `/operation/base/v1/mini-program/feedback-type/getList`, method: 'GET' }, true); } src/robotaxi/api/types/begin-route.ts
@@ -384,15 +384,19 @@ /** 投诉提交请求。 */ export interface SubmitComplaintPayload { /** 评价星级。 */ orderRating: number; orderRating?: number; /** 反馈类型 ID,多个值使用英文逗号分隔。 */ feedbackTypeId?: string; /** 反馈文案。 */ feedbackContent?: string; /** 反馈图片 JSON。 */ /** 反馈图片/视频 URL,多个值使用英文逗号分隔。 */ feedbackPictures?: string; /** 订单 ID。 */ orderId: OrderId; orderId?: OrderId; /** 反馈渠道:1=车内二维码,2=小程序"我的"入口。 */ feedbackChannel?: number; /** 车辆 VIN,渠道 1(车内二维码)时必填。 */ vin?: string; } /** 评价/投诉提交响应。 */ src/robotaxi/api/types/feedback.ts
New file @@ -0,0 +1,9 @@ import type { ExtendableRecord } from './begin-route'; /** 意见反馈问题类型项。 */ export interface FeedbackTypeItem extends ExtendableRecord { /** 类型 ID。 */ id?: number | string; /** 类型名称。 */ feedbackType?: string; } src/robotaxi/pages/begin-route/components/evaluate-popup.tsx
@@ -3,7 +3,8 @@ import { View, Text, Image, Textarea } from '@tarojs/components'; import { useEffect, useRef, useState } from 'react'; import './css-folder/evaluate-popup.less' import { getOrderCommentType, submitComplaint, submitOrderComment } from '../../../api/begin-route'; import { getOrderCommentType, submitOrderComment } from '../../../api/begin-route'; import { submitComplaint } from '../../../api/feedback'; import { _ROBOTAXI_HOST } from '../../../../constants/api'; import useImageUpload from '../../../hooks/use-image-upload'; import { getOrderId } from '../../../utils/order'; src/robotaxi/pages/feedback/hooks/use-media-upload.ts
New file @@ -0,0 +1,269 @@ import Taro from '@tarojs/taro'; import { useCallback, useMemo, useState } from 'react'; import { getStorage } from '../../../../utils/tools'; /** * 上传成功后的服务端文件信息。 */ export type UploadedServerFile = { id: string; name: string; url: string; }; /** * 媒体类型:图片或视频。 */ export type MediaType = 'image' | 'video'; /** * 媒体上传项状态。 */ export type MediaUploadItem = { id: string; localUrl: string; mediaType: MediaType; server: UploadedServerFile | null; status: 'uploading' | 'success' | 'failed'; }; /** * 图片/视频混合上传 Hook 配置。 */ type UseMediaUploadOptions = { maxCount: number; uploadUrl: string; imageSizeLimitBytes?: number; videoSizeLimitBytes?: number; }; const DEFAULT_IMAGE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024; // 图片 10MB const DEFAULT_VIDEO_SIZE_LIMIT_BYTES = 20 * 1024 * 1024; // 视频 20MB const IMAGE_COMPRESS_QUALITIES = [80, 60, 40]; /** * 图片/视频混合上传:封装选择、图片压缩、视频大小校验、上传、重试、删除与重置。 */ export default function useMediaUpload(options: UseMediaUploadOptions) { const [items, setItems] = useState<MediaUploadItem[]>([]); /** * 更新指定媒体项。 */ const updateItemById = useCallback((itemId: string, updater: (item: MediaUploadItem) => MediaUploadItem) => { setItems((prev) => prev.map((item) => (item.id === itemId ? 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 ensureImageSizeUnderLimit = useCallback(async (filePath: string) => { // iOS 端 Taro.compressImage 仅支持 JPG/JPEG,其他格式直接使用原图上传。 if (!/\.(?:jpe?g)(?:[?#].*)?$/i.test(filePath)) { return filePath; } const sizeLimitBytes = options.imageSizeLimitBytes || DEFAULT_IMAGE_SIZE_LIMIT_BYTES; try { const originalFilePath = filePath; let lastCompressedFilePath = filePath; const currentSize = await getFileSize(originalFilePath); if (currentSize <= sizeLimitBytes) { return originalFilePath; } for (const quality of IMAGE_COMPRESS_QUALITIES) { try { // 每次都基于原图压缩,避免 80 -> 60 -> 40 的代际压缩导致画质累积劣化。 const compressResult: any = await Taro.compressImage({ src: originalFilePath, quality }); const compressedPath = compressResult?.tempFilePath; if (!compressedPath) { continue; } lastCompressedFilePath = compressedPath; const compressedSize = await getFileSize(compressedPath); if (compressedSize <= sizeLimitBytes) { return compressedPath; } } catch (error) { // 单次压缩失败时继续基于原图尝试更低质量。 } } return lastCompressedFilePath; } catch (error) { return filePath; } }, [getFileSize, options.imageSizeLimitBytes]); /** * 上传单个媒体文件。 */ const uploadSingle = useCallback(async (itemId: string, filePath: string, mediaType: MediaType) => { updateItemById(itemId, (item) => ({ ...item, status: 'uploading' })); try { const finalFilePath = mediaType === 'image' ? await ensureImageSizeUnderLimit(filePath) : filePath; const otpToken = await getStorage('otp').catch(() => ''); const uploadResult = await Taro.uploadFile({ url: options.uploadUrl, filePath: finalFilePath, name: 'file', header: otpToken ? { 'Xp-Otp-Token': String(otpToken) } : undefined }); let responseData: any = uploadResult.data; try { if (typeof responseData === 'string') { responseData = JSON.parse(responseData); } } catch (error) { responseData = null; } console.log('[feedback upload] raw response:', uploadResult); console.log('[feedback upload] parsed response data:', responseData); if (uploadResult.statusCode !== 200 || responseData?.code !== 200 || !responseData?.data) { throw new Error(responseData?.msg || '上传失败'); } const payload = responseData?.data; const serverFile = { id: String(payload?.id || ''), name: String(payload?.name || ''), url: String(payload?.url || '') }; console.log('[feedback upload] normalized server file:', serverFile); if (!serverFile.id || !serverFile.url) { throw new Error('invalid upload response'); } updateItemById(itemId, (item) => ({ ...item, server: serverFile, status: 'success' })); } catch (error: any) { console.log('[feedback upload] upload failed:', error); updateItemById(itemId, (item) => ({ ...item, status: 'failed' })); Taro.showToast({ title: error?.message || '上传失败,请重试', icon: 'none' }); } }, [ensureImageSizeUnderLimit, options.uploadUrl, updateItemById]); /** * 生成本地媒体项。 */ const createMediaItem = useCallback((localUrl: string, mediaType: MediaType, index: number): MediaUploadItem => ({ id: `${Date.now()}-${index}-${localUrl}`, localUrl, mediaType, server: null, status: 'uploading' }), []); /** * 选择图片/视频并立即上传,选择前校验数量与视频体积上限。 */ const chooseAndUpload = useCallback(async () => { const remainCount = Math.max(0, options.maxCount - items.length); if (remainCount <= 0) { Taro.showToast({ title: `最多可上传${options.maxCount}个图片或视频`, icon: 'none' }); return; } try { const chooseResult: any = await Taro.chooseMedia({ count: remainCount, mediaType: ['image', 'video'], sourceType: ['album', 'camera'] }); const files: any[] = chooseResult?.tempFiles || []; if (!files.length) { return; } const videoSizeLimitBytes = options.videoSizeLimitBytes || DEFAULT_VIDEO_SIZE_LIMIT_BYTES; const acceptedFiles: { path: string; mediaType: MediaType }[] = []; for (const file of files) { const mediaType: MediaType = file.fileType === 'video' ? 'video' : 'image'; if (mediaType === 'video' && Number(file.size || 0) > videoSizeLimitBytes) { Taro.showToast({ title: '视频不能超过20MB', icon: 'none' }); continue; } acceptedFiles.push({ path: file.tempFilePath, mediaType }); } if (!acceptedFiles.length) { return; } const nextItems = acceptedFiles.map((file, index) => createMediaItem(file.path, file.mediaType, index)); setItems((prev) => [...prev, ...nextItems].slice(0, options.maxCount)); nextItems.forEach((item) => { void uploadSingle(item.id, item.localUrl, item.mediaType); }); } catch (error) { console.log('拉起上传图片/视频', error); } }, [createMediaItem, items.length, options.maxCount, options.videoSizeLimitBytes, uploadSingle]); /** * 重试上传失败媒体。 */ const retryUpload = useCallback((index: number) => { const mediaItem = items[index]; if (!mediaItem) { return; } void uploadSingle(mediaItem.id, mediaItem.localUrl, mediaItem.mediaType); }, [items, uploadSingle]); /** * 删除媒体。 */ const removeItem = useCallback((index: number) => { setItems((prev) => prev.filter((_, currentIndex) => currentIndex !== index)); }, []); /** * 重置全部媒体状态。 */ const resetItems = useCallback(() => { setItems([]); }, []); /** * 当前是否存在媒体上传中。 */ const uploading = useMemo(() => items.some((item) => item.status === 'uploading'), [items]); return { items, uploading, chooseAndUpload, retryUpload, removeItem, resetItems }; } src/robotaxi/pages/feedback/index.config.ts
New file @@ -0,0 +1,4 @@ export default { navigationBarTitleText: '意见反馈', navigationStyle: 'custom' }; src/robotaxi/pages/feedback/index.less
New file @@ -0,0 +1,382 @@ @import "../../style/ui-variables.less"; .feedback-page { position: relative; min-height: 100vh; box-sizing: border-box; background: #FFFFFF; .custom-navbar .app-inaver .left { width: 88px; justify-content: flex-start; .back-wrap { padding-right: 0; padding-left: 32px; display: flex; align-items: center; .ic-left.pure { margin-left: 0; margin-top: -2px; } } } .custom-navbar .app-inaver .center { font-family: MiSans; font-size: 32px; font-weight: 500; line-height: 48px; letter-spacing: normal; color: #141E27; &.ioscenter { margin-left: 114px; } } } .feedback-toast { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 9999; height: 112px; display: flex; align-items: center; padding: 32px 64px; gap: 16px; border-radius: 64px; background: #2A2D30; box-shadow: 0px 16px 32px 0px rgba(23, 31, 38, 0.1); max-width: calc(100vw - 48px); &-icon { width: 40px; height: 40px; flex-shrink: 0; } &-text { font-family: PingFang SC; font-size: 32px; font-weight: normal; line-height: normal; letter-spacing: normal; color: #FFFFFF; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } } .feedback-content { min-height: calc(100vh - 176px); padding: 0 44px; box-sizing: border-box; display: flex; flex-direction: column; } .feedback-type { &-title { font-size: 28px; line-height: 40px; font-weight: 500; margin-top: 50px; color: #141E27; .required-mark { color: #F53F3F; margin-left: 4px; } } &-list { margin-top: 28px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px 20px; } &-item { height: 72px; padding: 0 16px; display: flex; align-items: center; justify-content: center; border-radius: 20px; background: #F1F5FA; box-sizing: border-box; min-width: 0; &-text { display: block; max-width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 26px; line-height: 36px; color: rgba(20, 30, 39, 0.5); } &.active { background: rgba(223, 179, 105, 0.2); .feedback-type-item-text { color: #B38F53; } } } } .feedback-desc { margin-top: 48px; &-title { font-size: 28px; line-height: 40px; font-weight: 500; color: #141E27; .required-mark { color: #F53F3F; margin-left: 4px; } } &-box { position: relative; margin-top: 24px; } &-textarea { width: 100%; box-sizing: border-box; height: 196px; padding: 24px; padding-bottom: 48px; border-radius: 32px; background: #F1F5FA; font-size: 28px; line-height: 40px; color: #141E27; } &-placeholder { font-size: 28px; line-height: 40px; color: rgba(20, 30, 39, 0.3); } &-count { position: absolute; right: 24px; bottom: 16px; font-size: 22px; line-height: 32px; color: rgba(20, 30, 39, 0.3); } } .feedback-media { margin-top: 24px; display: flex; flex-wrap: wrap; gap: 16px; &-item { position: relative; width: 208px; height: 208px; border-radius: 32px; overflow: hidden; background: #F1F5FA; } &-thumb { width: 208px; height: 208px; object-fit: cover; } &-mask { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; background: rgba(0, 0, 0, 0.4); color: #FFFFFF; &-text { font-size: 22px; color: #FFFFFF; } } &-spinner { width: 32px; height: 32px; border-radius: 50%; border: 3px solid rgba(255, 255, 255, 0.4); border-top-color: #FFFFFF; animation: feedback-media-spin 700ms linear infinite; } &-delete { position: absolute; top: 4px; right: 4px; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: rgba(0, 0, 0, 0.4); &-text { font-size: 24px; line-height: 24px; color: #FFFFFF; } } &-upload { width: 208px; height: 208px; border-radius: 32px; background: #F1F5FA; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; &-icon { width: 48px; height: 48px; } &-text { font-size: 22px; line-height: 32px; color: rgba(20, 30, 39, 0.4); } } } .feedback-tip { text-align: center; font-size: 24px; line-height: 34px; color: rgba(20, 30, 39, 0.4); } .feedback-submit-btn { margin-top: 24px; height: 104px; border-radius: 1000px; display: flex; align-items: center; justify-content: center; font-size: 32px; font-weight: 500; color: rgba(255, 255, 255, 0.8); background: rgba(20, 30, 39, 0.2); &.active { background: #141E27; color: #FFFFFF; } } .feedback-footer { margin-top: auto; padding-top: 48px; padding-bottom: calc(48px + env(safe-area-inset-bottom)); } .feedback-success { min-height: calc(100vh - 176px); padding: 237px 44px calc(48px + env(safe-area-inset-bottom)); box-sizing: border-box; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; &-main { display: flex; flex-direction: column; align-items: center; } &-icon { width: 114px; height: 114px; } &-title { margin-top: 32px; font-size: 44px; line-height: 60px; font-weight: 500; color: #141E27; } &-desc { margin-top: 8px; display: flex; flex-direction: column; align-items: center; font-family: PingFang SC; font-size: 28px; font-weight: normal; line-height: 44px; text-align: center; letter-spacing: normal; color: rgba(20, 30, 39, 0.5); } &-btn { width: 460px; height: 98px; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 25px 8px; z-index: 1; border-radius: 200px; box-sizing: border-box; border: 4px solid #141E27; background: transparent; font-family: PingFang SC; font-size: 32px; font-weight: 500; line-height: 48px; text-align: center; letter-spacing: normal; color: #141E27; margin-top: 112px; } } @keyframes feedback-media-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } src/robotaxi/pages/feedback/index.tsx
New file @@ -0,0 +1,450 @@ import Taro from '@tarojs/taro'; import { View, Text, Image, Textarea, Video } from '@tarojs/components'; import { useEffect, useMemo, useRef, useState } from 'react'; import CustomNavbar from '../../../components/custom-navbar'; import { withRobotaxiWhitelist } from '../../../utils/whitelist'; import { getFeedbackTypeList, submitComplaint } from '../../api/feedback'; import { _ROBOTAXI_HOST } from '../../../constants/api'; import useMediaUpload from './hooks/use-media-upload'; import './index.less'; const FEEDBACK_CONTENT_MAX_LENGTH = 500; // 问题描述最大字数 const FEEDBACK_MEDIA_MAX_COUNT = 3; // 图片/视频合计最多数量 const FEEDBACK_TOAST_DURATION = 2000; const FEEDBACK_TOAST_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/error-circle-filled-20260727.png'; const FEEDBACK_TOAST_ICON_CACHE_KEY = 'robotaxiFeedbackToastIconPath'; /** 反馈渠道:1=车内二维码,2=小程序"我的"入口。 */ const FEEDBACK_CHANNEL_SCAN = 1; const FEEDBACK_CHANNEL_MINE = 2; /** * 归一化问题类型 ID,过滤空值。 */ const normalizeFeedbackTypeId = (typeId: unknown) => { if (typeId === null || typeId === undefined || typeId === '') { return ''; } return String(typeId); }; /** * 从扫码场景值中解析车辆 VIN,兼容 vin=xxx、VIN=xxx、querystring 以及纯 VIN 文本。 */ const parseVinFromScene = (scene: unknown) => { const decodedScene = decodeURIComponent(String(scene || '')); if (!decodedScene) { return ''; } const matched = decodedScene.match(/(?:^|[?&])vin=([^&]+)/i); if (matched?.[1]) { return matched[1]; } // 部分扫码场景会直接透传 VIN。 return decodedScene.trim(); } function FeedbackPage() { const router = Taro.useRouter(); const isScanChannel = String(router.params?.source || '') !== 'mine'; // 除“我的”入口外均按扫码渠道处理 const vin = useMemo(() => parseVinFromScene(router.params?.scene), [router.params?.scene]); const feedbackChannel = isScanChannel ? FEEDBACK_CHANNEL_SCAN : FEEDBACK_CHANNEL_MINE; const [typeOptions, setTypeOptions] = useState<any[]>([]); // 问题类型选项 const [selectedTypeIds, setSelectedTypeIds] = useState<string[]>([]); // 已选问题类型 const [feedbackContent, setFeedbackContent] = useState(''); // 问题描述文案 const [isSubmitSuccess, setIsSubmitSuccess] = useState(false); // 是否展示提交成功态 const [toastMessage, setToastMessage] = useState(''); const [feedbackToastIconSrc, setFeedbackToastIconSrc] = useState(() => { const cachedIconPath = Taro.getStorageSync(FEEDBACK_TOAST_ICON_CACHE_KEY); return typeof cachedIconPath === 'string' && cachedIconPath ? cachedIconPath : FEEDBACK_TOAST_ICON_URL; }); const isSubmittingRef = useRef(false); // 同步提交锁,防止重复点击 const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const { items: mediaItems, uploading, chooseAndUpload, retryUpload, removeItem } = useMediaUpload({ maxCount: FEEDBACK_MEDIA_MAX_COUNT, uploadUrl: _ROBOTAXI_HOST + '/operation/base/v1/mini-program/oss/upload' }); // 图片/视频上传状态与操作 const hasFeedbackContent = !!feedbackContent.trim(); const isVinMissing = isScanChannel && !vin; const canSubmit = selectedTypeIds.length > 0; const hasUnsavedFeedback = selectedTypeIds.length > 0 || hasFeedbackContent || mediaItems.length > 0; const showFeedbackToast = (message: string) => { if (!message) { return; } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } setToastMessage(message); toastTimerRef.current = setTimeout(() => { setToastMessage(''); toastTimerRef.current = null; }, FEEDBACK_TOAST_DURATION); }; const handlePreviewImage = (currentUrl: string) => { if (!currentUrl) { return; } const imageUrls = mediaItems .filter((item) => item.mediaType !== 'video') .map((item) => item.server?.url || item.localUrl) .filter((url): url is string => !!url); if (imageUrls.length === 0) { return; } Taro.previewImage({ current: currentUrl, urls: imageUrls }); }; /** * 切换问题类型多选状态。 */ const toggleFeedbackType = (typeId: unknown) => { const normalizedTypeId = normalizeFeedbackTypeId(typeId); if (!normalizedTypeId) { return; } setSelectedTypeIds((prev) => ( prev.includes(normalizedTypeId) ? prev.filter((id) => id !== normalizedTypeId) : [...prev, normalizedTypeId] )); }; /** * 提交意见反馈。 */ const handleSubmit = async () => { if (isSubmittingRef.current) { return; } if (selectedTypeIds.length === 0) { showFeedbackToast('请选择问题类型'); return; } if (!hasFeedbackContent) { showFeedbackToast('请输入问题描述'); return; } if (isVinMissing) { showFeedbackToast('二维码信息无效,请重新扫码'); return; } if (uploading) { showFeedbackToast('图片/视频上传中,请稍后提交'); return; } isSubmittingRef.current = true; Taro.showLoading({ title: '提交中...', mask: true }); try { const networkTypeRes = await Taro.getNetworkType(); if (networkTypeRes.networkType === 'none') { showFeedbackToast('网络不可用'); return; } const uploadedMediaItems = mediaItems.filter((item) => item.status === 'success' && item.server?.id && item.server?.url); const feedbackPictures = JSON.stringify( uploadedMediaItems.map((item) => ({ id: String(item.server?.id || ''), name: String(item.server?.name || ''), url: String(item.server?.url || '') })) ); const payload: any = { feedbackTypeId: selectedTypeIds.join(','), feedbackContent: feedbackContent.trim(), feedbackChannel }; if (uploadedMediaItems.length > 0) { payload.feedbackPictures = feedbackPictures; } if (isScanChannel) { payload.vin = vin; } console.log('[feedback submit] media items:', mediaItems); console.log('[feedback submit] feedbackPictures:', feedbackPictures); console.log('[feedback submit] payload:', payload); const res = await submitComplaint(payload); console.log('[feedback submit] success response:', res); setIsSubmitSuccess(true); } catch (error: any) { console.log('[feedback submit] error response:', error); const errorTitle = error?.msg || (error?.code ? `提交失败(${error.code})` : '提交失败,请稍后重试'); showFeedbackToast(errorTitle); } finally { isSubmittingRef.current = false; Taro.hideLoading(); } }; /** * 成功态确认按钮,沿用原有提交成功后的跳转逻辑。 */ const handleSuccessConfirm = () => { if (isScanChannel) { Taro.reLaunch({ url: '/robotaxi/pages/begin-route/index' }); return; } Taro.navigateBack({ fail: () => Taro.navigateTo({ url: '/robotaxi/pages/personal-center/index' }) }); }; const navigateBack = () => { const pages = Taro.getCurrentPages(); if (isScanChannel && pages.length <= 1) { Taro.reLaunch({ url: '/robotaxi/pages/begin-route/index' }); return; } Taro.navigateBack({ fail: () => Taro.reLaunch({ url: '/robotaxi/pages/begin-route/index' }) }); }; /** * 导航栏返回按钮点击。存在未提交内容时先二次确认,避免误操作丢失反馈内容。 */ const handleGoBack = async () => { if (isSubmittingRef.current) { return; } if (isSubmitSuccess || !hasUnsavedFeedback) { navigateBack(); return; } const modalResult = await Taro.showModal({ title: '', content: '当前反馈尚未提交,确定退出吗?', cancelText: '取消', confirmText: '确定' }); if (modalResult.confirm) { navigateBack(); } }; useEffect(() => { getFeedbackTypeList().then((res: any) => { setTypeOptions(Array.isArray(res) ? res : []); }).catch((error) => { console.error('[feedback] failed to load feedback types:', error); setTypeOptions([]); showFeedbackToast('问题类型加载失败,请稍后重试'); }); }, []); useEffect(() => { let cancelled = false; const loadFeedbackToastIcon = async () => { const cachedIconPath = Taro.getStorageSync(FEEDBACK_TOAST_ICON_CACHE_KEY); if (typeof cachedIconPath === 'string' && cachedIconPath) { try { await Taro.getFileInfo({ filePath: cachedIconPath }); if (!cancelled) { setFeedbackToastIconSrc(cachedIconPath); } return; } catch (error) { Taro.removeStorageSync(FEEDBACK_TOAST_ICON_CACHE_KEY); } } if (!cancelled) { setFeedbackToastIconSrc(FEEDBACK_TOAST_ICON_URL); } try { const downloadResult = await Taro.downloadFile({ url: FEEDBACK_TOAST_ICON_URL }); if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) { return; } const saveResult = await Taro.saveFile({ tempFilePath: downloadResult.tempFilePath }); if (!saveResult.savedFilePath) { return; } Taro.setStorageSync(FEEDBACK_TOAST_ICON_CACHE_KEY, saveResult.savedFilePath); if (!cancelled) { setFeedbackToastIconSrc(saveResult.savedFilePath); } } catch (error) { console.warn('[feedback toast] icon cache failed:', error); } }; loadFeedbackToastIcon(); return () => { cancelled = true; }; }, []); useEffect(() => { return () => { if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } }; }, []); return ( <View className='feedback-page'> <CustomNavbar title='意见反馈' colorTheme='#141E27' pureBackArrow opacity={0} showTitleColor seizeSeat toBack={false} onGoBackClick={handleGoBack} /> {isSubmitSuccess ? ( <View className='feedback-success'> <View className='feedback-success-main'> <Image className='feedback-success-icon' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/selected-golden-20260729.png' mode='aspectFit' /> <View className='feedback-success-title'>反馈成功</View> <View className='feedback-success-desc'> <Text>我们将会尽快修复和完善相关功能,</Text> <Text>感谢您的关注与支持!</Text> </View> </View> <View className='feedback-success-btn' onClick={handleSuccessConfirm}>我知道了</View> </View> ) : ( <View className='feedback-content'> <View className='feedback-type'> <View className='feedback-type-title'>问题类型<Text className='required-mark'>*</Text></View> <View className='feedback-type-list'> {typeOptions.map((item) => ( <View key={item?.id} className={`feedback-type-item ${selectedTypeIds.includes(normalizeFeedbackTypeId(item?.id)) ? 'active' : ''}`} onClick={() => toggleFeedbackType(item?.id)} > <Text className='feedback-type-item-text'>{item?.feedbackType}</Text> </View> ))} </View> </View> <View className='feedback-desc'> <View className='feedback-desc-title'>问题描述<Text className='required-mark'>*</Text></View> <View className='feedback-desc-box'> <Textarea className='feedback-desc-textarea' maxlength={FEEDBACK_CONTENT_MAX_LENGTH} placeholderClass='feedback-desc-placeholder' placeholder='请描述您的建议或问题(可添加相关图片或视频)' value={feedbackContent} onInput={(e) => setFeedbackContent(e.detail.value)} /> <View className='feedback-desc-count'>{feedbackContent.length}/{FEEDBACK_CONTENT_MAX_LENGTH}</View> </View> </View> <View className='feedback-media'> {mediaItems.map((item, index) => ( <View key={item.id} className='feedback-media-item'> {item.mediaType === 'video' ? ( <Video className='feedback-media-thumb' src={item.server?.url || item.localUrl} showFullscreenBtn={false} showPlayBtn controls={false} /> ) : ( <Image className='feedback-media-thumb' src={item.server?.url || item.localUrl} mode='aspectFill' onClick={() => handlePreviewImage(item.server?.url || item.localUrl)} /> )} {item.status === 'uploading' && ( <View className='feedback-media-mask'> <View className='feedback-media-spinner' /> <Text className='feedback-media-mask-text'>上传中...</Text> </View> )} {item.status === 'failed' && ( <View className='feedback-media-mask failed' onClick={() => retryUpload(index)}> <Text className='feedback-media-mask-text'>请重试</Text> <View className='feedback-media-delete' onClick={(event) => { event.stopPropagation(); removeItem(index); }} > <Text className='feedback-media-delete-text'>×</Text> </View> </View> )} {item.status === 'success' && ( <View className='feedback-media-delete' onClick={() => removeItem(index)}> <Text className='feedback-media-delete-text'>×</Text> </View> )} </View> ))} {mediaItems.length < FEEDBACK_MEDIA_MAX_COUNT && ( <View className='feedback-media-upload' onClick={chooseAndUpload}> <Image className='feedback-media-upload-icon' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/v2/add-feedback-20260727.png' mode='aspectFit' /> <Text className='feedback-media-upload-text'>上传图片/视频</Text> </View> )} </View> <View className='feedback-footer'> <View className='feedback-tip'>您的反馈将帮助我们持续性优化</View> <View className={`feedback-submit-btn ${canSubmit ? 'active' : ''}`} onClick={handleSubmit}> 提交反馈 </View> </View> </View> )} {!!toastMessage && ( <View className='feedback-toast'> <Image className='feedback-toast-icon' src={feedbackToastIconSrc} mode='aspectFit' /> <Text className='feedback-toast-text'>{toastMessage}</Text> </View> )} </View> ); } export default withRobotaxiWhitelist(FeedbackPage); src/robotaxi/pages/personal-center/index.tsx
@@ -35,7 +35,8 @@ { key: "service", label: "联系客服" }, { key: "invoice", label: "开发票" }, { key: "payment", label: "支付设置" }, { key: "favorite", label: "收藏站点" } { key: "favorite", label: "收藏站点" }, { key: "feedback", label: "意见反馈" } ]; import { withRobotaxiWhitelist } from '../../../utils/whitelist'; @@ -139,7 +140,12 @@ return; } showToast({ title: "功能建设中", icon: "none" }); if (key === "feedback") { Taro.navigateTo({ url: "/robotaxi/pages/feedback/index?source=mine" }); return; } Taro.showToast({ title: "功能建设中", icon: "none" }); }; return (