卢春龙
2026-07-29 35439f56684898f5aec4023eb4233b396c07d53a
Merge branch 'feat-feedback' into 'feature_v2.4.0'

[ai-coding] auto commit for merge request (何宏文)

See merge request robotaxi1/xp-robotaxi-miniprogram!177
6个文件已添加
7个文件已修改
1207 ■■■■■ 已修改文件
src/app.config.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/custom-navbar/index.less 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/custom-navbar/index.tsx 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/api/begin-route.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/api/feedback.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/api/types/begin-route.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/api/types/feedback.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/evaluate-popup.tsx 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/feedback/hooks/use-media-upload.ts 269 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/feedback/index.config.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/feedback/index.less 382 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/feedback/index.tsx 450 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/personal-center/index.tsx 10 ●●●●● 补丁 | 查看 | 原始文档 | 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 (