ai-coding
2026-07-26 10c40d9fa153ec15268fd83aaf44b112947e4042
feat(feedback): 优化反馈页UI样式、增强上传鉴权及VIN解析逻辑

Co-Authored-By: 何宏文 <hehw@xiaopeng.com>
5个文件已修改
265 ■■■■■ 已修改文件
src/components/custom-navbar/index.less 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/custom-navbar/index.tsx 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/feedback/hooks/use-media-upload.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/feedback/index.less 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/feedback/index.tsx 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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>
                {pureBackArrow ? (
                  <View className='ic-left pure' />
                  ) : (
                    <Image className='ic-left' mode='heightFix' src={BackWhite2}></Image>
                  )
                ) : (
                  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/pages/feedback/hooks/use-media-upload.ts
@@ -1,5 +1,6 @@
import Taro from '@tarojs/taro';
import { useCallback, useMemo, useState } from 'react';
import { getStorage } from '../../../../utils/tools';
/**
 * 上传成功后的服务端文件信息。
@@ -111,10 +112,12 @@
    }));
    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'
        name: 'file',
        header: otpToken ? { 'Xp-Otp-Token': String(otpToken) } : undefined
      });
      let responseData: any = uploadResult.data;
      try {
@@ -124,12 +127,18 @@
      } 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');
      }
@@ -138,11 +147,16 @@
        server: serverFile,
        status: 'success'
      }));
    } catch (error) {
    } 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]);
src/robotaxi/pages/feedback/index.less
@@ -19,7 +19,11 @@
}
.feedback-content {
  padding: 0 32px 48px;
  min-height: calc(100vh - 176px);
  padding: 0 44px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
}
.feedback-type {
@@ -27,6 +31,7 @@
    font-size: 28px;
    line-height: 40px;
    font-weight: 500;
    margin-top: 50px;
    color: #141E27;
    .required-mark {
@@ -36,10 +41,10 @@
  }
  &-list {
    margin-top: 24px;
    margin-top: 28px;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 20px 24px;
    gap: 20px 20px;
  }
  &-item {
@@ -48,7 +53,7 @@
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 16px;
    border-radius: 20px;
    background: rgba(255, 255, 255, 0.6);
    box-sizing: border-box;
    min-width: 0;
@@ -61,7 +66,7 @@
      text-overflow: ellipsis;
      font-size: 26px;
      line-height: 36px;
      color: rgba(20, 30, 39, 0.6);
      color: rgba(20, 30, 39, 0.5);
    }
    &.active {
@@ -95,7 +100,7 @@
    height: 196px;
    padding: 24px;
    padding-bottom: 48px;
    border-radius: 24px;
    border-radius: 32px;
    background: rgba(255, 255, 255, 0.6);
    font-size: 28px;
    line-height: 40px;
@@ -128,7 +133,7 @@
    position: relative;
    width: 208px;
    height: 208px;
    border-radius: 16px;
    border-radius: 32px;
    overflow: hidden;
    background: rgba(0, 0, 0, 0.03);
  }
@@ -190,7 +195,7 @@
  &-upload {
    width: 208px;
    height: 208px;
    border-radius: 16px;
    border-radius: 32px;
    background: rgba(255, 255, 255, 0.6);
    display: flex;
    flex-direction: column;
@@ -199,9 +204,28 @@
    gap: 8px;
    &-icon {
      font-size: 40px;
      line-height: 40px;
      color: rgba(20, 30, 39, 0.4);
      position: relative;
      width: 40px;
      height: 40px;
      &-line {
        position: absolute;
        top: 50%;
        left: 50%;
        background: rgba(20, 30, 39, 0.3);
        border-radius: 2px;
        transform: translate(-50%, -50%);
        &.horizontal {
          width: 32px;
          height: 4px;
        }
        &.vertical {
          width: 4px;
          height: 32px;
        }
      }
    }
    &-text {
@@ -213,7 +237,6 @@
}
.feedback-tip {
  margin-top: 48px;
  text-align: center;
  font-size: 24px;
  line-height: 34px;
@@ -222,8 +245,8 @@
.feedback-submit-btn {
  margin-top: 24px;
  height: 96px;
  border-radius: 48px;
  height: 104px;
  border-radius: 1000px;
  display: flex;
  align-items: center;
  justify-content: center;
@@ -238,6 +261,75 @@
  }
}
.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: 120px 44px calc(48px + env(safe-area-inset-bottom));
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  &-main {
    display: flex;
    flex-direction: column;
    align-items: center;
  }
  &-icon {
    width: 96px;
    height: 96px;
    border-radius: 50%;
    background: #26323D;
    display: flex;
    align-items: center;
    justify-content: center;
    &-mark {
      font-size: 50px;
      line-height: 1;
      color: #F1CA85;
      margin-top: -4px;
    }
  }
  &-title {
    margin-top: 32px;
    font-size: 44px;
    line-height: 60px;
    font-weight: 500;
    color: #141E27;
  }
  &-desc {
    margin-top: 20px;
    display: flex;
    flex-direction: column;
    align-items: center;
    font-size: 30px;
    line-height: 44px;
    color: rgba(20, 30, 39, 0.7);
  }
  &-btn {
    width: 100%;
    height: 104px;
    border-radius: 1000px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 32px;
    font-weight: 500;
    color: #FFFFFF;
    background: #141E27;
  }
}
@keyframes feedback-media-spin {
  from {
    transform: rotate(0deg);
src/robotaxi/pages/feedback/index.tsx
@@ -26,12 +26,19 @@
};
/**
 * 从扫码场景值中解析车辆 VIN,兼容 "vin=xxx" 或 "vin=xxx&other=1" 格式。
 * 从扫码场景值中解析车辆 VIN,兼容 vin=xxx、VIN=xxx、querystring 以及纯 VIN 文本。
 */
const parseVinFromScene = (scene: unknown) => {
  const decodedScene = decodeURIComponent(String(scene || ''));
  const matched = decodedScene.match(/vin=([^&]+)/);
  return matched ? matched[1] : '';
  if (!decodedScene) {
    return '';
  }
  const matched = decodedScene.match(/(?:^|[?&])vin=([^&]+)/i);
  if (matched?.[1]) {
    return matched[1];
  }
  // 部分扫码场景会直接透传 VIN。
  return decodedScene.trim();
}
function FeedbackPage() {
@@ -43,6 +50,7 @@
  const [typeOptions, setTypeOptions] = useState<any[]>([]); // 问题类型选项
  const [selectedTypeIds, setSelectedTypeIds] = useState<string[]>([]); // 已选问题类型
  const [feedbackContent, setFeedbackContent] = useState(''); // 问题描述文案
  const [isSubmitSuccess, setIsSubmitSuccess] = useState(false); // 是否展示提交成功态
  const isSubmittingRef = useRef(false); // 同步提交锁,防止重复点击
  const {
@@ -58,7 +66,7 @@
  const hasFeedbackContent = !!feedbackContent.trim();
  const isVinMissing = isScanChannel && !vin;
  const canSubmit = selectedTypeIds.length > 0 && hasFeedbackContent && !isVinMissing;
  const canSubmit = selectedTypeIds.length > 0;
  /**
   * 切换问题类型多选状态。
@@ -100,38 +108,53 @@
    }
    isSubmittingRef.current = true;
    const mediaUrlList = mediaItems
      .filter((item) => item.status === 'success' && item.server?.url)
      .map((item) => item.server!.url);
    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(),
      feedbackPictures: mediaUrlList.join(','),
      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);
    submitComplaint(payload).then((res: any) => {
      const isFailure = res?.success === false || (typeof res?.code === 'number' && res.code !== 0);
      if (isFailure) {
        Taro.showToast({ title: res?.msg || '提交失败,请稍后重试', icon: 'none' });
        return;
      }
      Taro.showToast({ title: '提交成功', icon: 'none' });
      if (isScanChannel) {
        Taro.reLaunch({ url: '/robotaxi/pages/begin-route/index' });
      } else {
        Taro.navigateBack({
          fail: () => Taro.navigateTo({ url: '/robotaxi/pages/personal-center/index' })
        });
      }
      console.log('[feedback submit] success response:', res);
      setIsSubmitSuccess(true);
    }).catch((error: any) => {
      Taro.showToast({ title: error?.msg || '提交失败,请稍后重试', icon: 'none' });
      console.log('[feedback submit] error response:', error);
      const errorTitle = error?.msg || (error?.code ? `提交失败(${error.code})` : '提交失败,请稍后重试');
      Taro.showToast({ title: errorTitle, icon: 'none' });
    }).finally(() => {
      isSubmittingRef.current = false;
    });
  };
  /**
   * 成功态确认按钮,沿用原有提交成功后的跳转逻辑。
   */
  const handleSuccessConfirm = () => {
    if (isScanChannel) {
      Taro.reLaunch({ url: '/robotaxi/pages/begin-route/index' });
      return;
    }
    Taro.navigateBack({
      fail: () => Taro.navigateTo({ url: '/robotaxi/pages/personal-center/index' })
    });
  };
@@ -148,11 +171,27 @@
      <CustomNavbar
        title='意见反馈'
        colorTheme='#141E27'
        pureBackArrow
        opacity={0}
        showTitleColor
        seizeSeat
      />
      {isSubmitSuccess ? (
        <View className='feedback-success'>
          <View className='feedback-success-main'>
            <View className='feedback-success-icon'>
              <Text className='feedback-success-icon-mark'>✓</Text>
            </View>
            <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>
@@ -218,19 +257,25 @@
          ))}
          {mediaItems.length < FEEDBACK_MEDIA_MAX_COUNT && (
            <View className='feedback-media-upload' onClick={chooseAndUpload}>
              <Text className='feedback-media-upload-icon'>+</Text>
              <View className='feedback-media-upload-icon'>
                <View className='feedback-media-upload-icon-line horizontal' />
                <View className='feedback-media-upload-icon-line vertical' />
              </View>
              <Text className='feedback-media-upload-text'>上传图片/视频</Text>
            </View>
          )}
        </View>
        <View className='feedback-tip'>您的反馈将帮助我们持续性优化 ❣️</View>
        <View className='feedback-footer'>
          <View className='feedback-tip'>您的反馈将帮助我们持续性优化 🌹</View>
        <View className={`feedback-submit-btn ${canSubmit ? 'active' : ''}`} onClick={handleSubmit}>
          提交反馈
        </View>
      </View>
    </View>
      )}
    </View>
  );
}