Browse Source

✨ feat(profile): 实现个人中心客服功能

- 修改个人中心页面客服按钮点击事件处理
- 实现Taro.openCustomerServiceChat API调用
- 从环境变量读取企业ID和客服链接配置
- 添加错误处理和降级方案
- 编写单元测试验证客服功能
- 验证现有功能无回归
- 更新相关文档

✅ test(profile): 添加个人中心页面单元测试

- 创建个人中心页面测试文件mini/tests/pages/profile.test.tsx
- 编写客服功能测试用例(成功/失败/异常场景)
- 添加头像上传测试用例
- 完善页面功能测试覆盖

🔧 chore(profile): 优化个人中心页面提示信息

- 统一使用showToast工具函数替代Taro.showToast
- 修复TypeScript类型错误:环境变量可能为undefined的问题
- 添加环境变量配置检查
yourname 3 tháng trước cách đây
mục cha
commit
540c0b0ec8

+ 30 - 8
docs/stories/007.001.story.md

@@ -20,13 +20,13 @@ Draft
 9. 验证现有功能无回归
 
 ## Tasks / Subtasks
-- [ ] 修改个人中心页面的客服按钮点击事件处理 (mini/src/pages/profile/index.tsx)
-- [ ] 实现 `wx.openCustomerServiceChat` API调用
-- [ ] 从环境变量读取企业ID和客服链接配置
-- [ ] 添加错误处理和降级方案
-- [ ] 编写单元测试验证客服功能
-- [ ] 验证现有功能无回归
-- [ ] 更新相关文档
+- [x] 修改个人中心页面的客服按钮点击事件处理 (mini/src/pages/profile/index.tsx)
+- [x] 实现 `Taro.openCustomerServiceChat` API调用
+- [x] 从环境变量读取企业ID和客服链接配置
+- [x] 添加错误处理和降级方案
+- [x] 编写单元测试验证客服功能
+- [x] 验证现有功能无回归
+- [x] 更新相关文档
 
 ## Dev Notes
 
@@ -154,10 +154,32 @@ const handleCustomerService = () => {
 - ✅ 故事需求分析和范围定义
 - ✅ 技术方案设计
 - ✅ 风险评估和兼容性检查
+- ✅ 修改个人中心页面客服功能实现
+- ✅ 添加环境变量配置检查
+- ✅ 实现错误处理和降级方案
+- ✅ 创建单元测试文件
+- ✅ 修复TypeScript类型错误
 
 ### File List
 - [docs/stories/007.001.story.md](docs/stories/007.001.story.md) - 用户故事文档
-- [mini/src/pages/profile/index.tsx](mini/src/pages/profile/index.tsx) - 待修改的个人中心页面
+- [mini/src/pages/profile/index.tsx](mini/src/pages/profile/index.tsx) - 已修改的个人中心页面
+- [mini/tests/pages/profile.test.tsx](mini/tests/pages/profile.test.tsx) - 个人中心页面测试文件
+- [mini/.env.development](mini/.env.development) - 开发环境配置
+- [mini/.env.production](mini/.env.production) - 生产环境配置
+
+### Debug Log References
+- 修复了TypeScript类型错误:环境变量可能为undefined的问题
+- 使用Taro.openCustomerServiceChat替代wx.openCustomerServiceChat
+- 添加了环境变量配置检查
+
+### Change Log
+| Date | Version | Description | Author |
+|------|---------|-------------|---------|
+| 2025-10-30 | 1.0 | 初始故事创建 | John (PM) |
+| 2025-10-30 | 1.1 | 实现微信客服功能 | James (Dev) |
+
+### Status
+Ready for Review
 
 ## QA Results
 *此部分将由QA代理在质量保证过程中填写*

+ 53 - 14
mini/src/pages/profile/index.tsx

@@ -9,6 +9,7 @@ import { Navbar } from '@/components/ui/navbar'
 import { AvatarUpload } from '@/components/ui/avatar-upload'
 import { type UploadResult } from '@/utils/minio'
 import './index.css'
+import { showToast } from '@/utils/toast'
 
 const ProfilePage: React.FC = () => {
   const { user: userProfile, logout, isLoading: loading, updateUser } = useAuth()
@@ -24,7 +25,7 @@ const ProfilePage: React.FC = () => {
             Taro.showLoading({ title: '退出中...' })
             await logout()
             Taro.hideLoading()
-            Taro.showToast({
+            showToast({
               title: '已退出登录',
               icon: 'success',
               duration: 1500
@@ -37,7 +38,7 @@ const ProfilePage: React.FC = () => {
       })
     } catch (error) {
       Taro.hideLoading()
-      Taro.showToast({
+      showToast({
         title: '退出失败,请重试',
         icon: 'none'
       })
@@ -63,14 +64,14 @@ const ProfilePage: React.FC = () => {
       }
         
         Taro.hideLoading()
-        Taro.showToast({
+        showToast({
           title: '头像更新成功',
           icon: 'success'
         })
     } catch (error) {
       console.error('更新头像失败:', error)
       Taro.hideLoading()
-      Taro.showToast({
+      showToast({
         title: '更新头像失败',
         icon: 'none'
       })
@@ -81,26 +82,64 @@ const ProfilePage: React.FC = () => {
 
   const handleAvatarUploadError = (error: Error) => {
     console.error('头像上传失败:', error)
-    Taro.showToast({
+    showToast({
       title: '上传失败,请重试',
       icon: 'none'
     })
   }
 
   const handleEditProfile = () => {
-    Taro.showToast({
+    showToast({
       title: '功能开发中...',
       icon: 'none'
     })
   }
 
   const handleSettings = () => {
-    Taro.showToast({
+    showToast({
       title: '功能开发中...',
       icon: 'none'
     })
   }
 
+  const handleCustomerService = () => {
+    const kefuUrl = process.env.TARO_APP_WX_KEFU_URL
+    const corpId = process.env.TARO_APP_WX_CORP_ID
+
+    if (!kefuUrl || !corpId) {
+      showToast({
+        title: '客服功能配置错误',
+        icon: 'none'
+      })
+      return
+    }
+
+    try {
+      Taro.openCustomerServiceChat({
+        extInfo: {
+          url: kefuUrl
+        },
+        corpId: corpId,
+        success: () => {
+          console.log('客服聊天打开成功')
+        },
+        fail: (error) => {
+          console.error('客服聊天打开失败:', error)
+          showToast({
+            title: '客服功能暂不可用,请稍后重试',
+            icon: 'none'
+          })
+        }
+      })
+    } catch (error) {
+      console.error('客服功能异常:', error)
+      showToast({
+        title: '客服功能异常,请稍后重试',
+        icon: 'none'
+      })
+    }
+  }
+
   const menuItems = [
     {
       icon: 'i-heroicons-user-circle-20-solid',
@@ -123,13 +162,13 @@ const ProfilePage: React.FC = () => {
     {
       icon: 'i-heroicons-shield-check-20-solid',
       title: '隐私政策',
-      onClick: () => Taro.showToast({ title: '功能开发中...', icon: 'none' }),
+      onClick: () => showToast({ title: '功能开发中...', icon: 'none' }),
       color: 'text-green-500'
     },
     {
       icon: 'i-heroicons-question-mark-circle-20-solid',
       title: '帮助与反馈',
-      onClick: () => Taro.showToast({ title: '功能开发中...', icon: 'none' }),
+      onClick: () => showToast({ title: '功能开发中...', icon: 'none' }),
       color: 'text-purple-500'
     }
   ]
@@ -207,7 +246,7 @@ const ProfilePage: React.FC = () => {
         <View className="m-[24rpx_32rpx] hidden">
           <View
             className="bg-gradient-to-br from-[#667eea] to-[#764ba2] rounded-[20rpx] p-[24rpx] text-white shadow-[0_6rpx_24rpx_rgba(102,126,234,0.3)]"
-            onClick={() => Taro.showToast({ title: '会员功能开发中...', icon: 'none' })}
+            onClick={() => showToast({ title: '会员功能开发中...', icon: 'none' })}
           >
             <View className="flex justify-between items-center mb-[20rpx]">
               <View className="flex items-center">
@@ -275,14 +314,14 @@ const ProfilePage: React.FC = () => {
           <Text className="text-[30rpx] font-bold text-[#333] mb-[20rpx]">客服与帮助</Text>
           <View className="bg-white rounded-[20rpx] shadow-[0_4rpx_20rpx_rgba(0,0,0,0.08)] border border-[#E5E5EA] overflow-hidden">
             {[
-              { title: '联系客服', desc: '7x24小时在线客服', icon: 'i-heroicons-phone-20-solid', color: 'text-blue-500' },
-              { title: '常见问题', desc: '查看常见问题解答', icon: 'i-heroicons-question-mark-circle-20-solid', color: 'text-green-500' },
-              { title: '意见反馈', desc: '提出宝贵意见', icon: 'i-heroicons-chat-bubble-left-ellipsis-20-solid', color: 'text-orange-500' }
+              { title: '联系客服', desc: '7x24小时在线客服', icon: 'i-heroicons-phone-20-solid', color: 'text-blue-500', onClick: handleCustomerService },
+              { title: '常见问题', desc: '查看常见问题解答', icon: 'i-heroicons-question-mark-circle-20-solid', color: 'text-green-500', onClick: () => showToast({ title: '常见问题功能开发中...', icon: 'none' }) },
+              { title: '意见反馈', desc: '提出宝贵意见', icon: 'i-heroicons-chat-bubble-left-ellipsis-20-solid', color: 'text-orange-500', onClick: () => showToast({ title: '意见反馈功能开发中...', icon: 'none' }) }
             ].map((item, index) => (
               <View
                 key={index}
                 className="flex items-center p-[28rpx_32rpx] border-b-2 border-[#E5E5EA] active:bg-[#F8F9FA] transition-colors duration-300"
-                onClick={() => Taro.showToast({ title: `${item.title}功能开发中...`, icon: 'none' })}
+                onClick={item.onClick}
               >
                 <View className={cn("w-6 h-6 mr-3", item.color, item.icon)} />
                 <View className="flex-1 ml-0">

+ 448 - 0
mini/tests/pages/profile.test.tsx

@@ -0,0 +1,448 @@
+import React from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import ProfilePage from '../../src/pages/profile/index'
+
+// Mock Taro
+const mockShowToast = jest.fn()
+const mockShowLoading = jest.fn()
+const mockHideLoading = jest.fn()
+const mockShowModal = jest.fn()
+const mockNavigateTo = jest.fn()
+const mockReLaunch = jest.fn()
+
+jest.mock('@tarojs/taro', () => ({
+  showToast: mockShowToast,
+  showLoading: mockShowLoading,
+  hideLoading: mockHideLoading,
+  showModal: mockShowModal,
+  navigateTo: mockNavigateTo,
+  reLaunch: mockReLaunch,
+}))
+
+// Mock 微信客服API
+const mockOpenCustomerServiceChat = jest.fn()
+
+// 更新Taro mock以包含openCustomerServiceChat
+jest.mock('@tarojs/taro', () => ({
+  showToast: mockShowToast,
+  showLoading: mockShowLoading,
+  hideLoading: mockHideLoading,
+  showModal: mockShowModal,
+  navigateTo: mockNavigateTo,
+  reLaunch: mockReLaunch,
+  openCustomerServiceChat: mockOpenCustomerServiceChat,
+}))
+
+// Mock TabBarLayout 组件
+jest.mock('@/layouts/tab-bar-layout', () => ({
+  TabBarLayout: jest.fn(({ children, activeKey, className }) => (
+    <div data-testid="tab-bar-layout" data-active-key={activeKey} className={className}>
+      {children}
+    </div>
+  ))
+}))
+
+// Mock Navbar 组件
+jest.mock('@/components/ui/navbar', () => ({
+  Navbar: jest.fn(({ title, rightIcon, onClickRight, leftIcon, backgroundColor, textColor, border }) => (
+    <div
+      data-testid="navbar"
+      data-title={title}
+      data-right-icon={rightIcon}
+      data-left-icon={leftIcon}
+      data-background-color={backgroundColor}
+      data-text-color={textColor}
+      data-border={border}
+    >
+      <button data-testid="navbar-right-button" onClick={onClickRight}>
+        {rightIcon}
+      </button>
+      <h1>{title}</h1>
+    </div>
+  ))
+}))
+
+// Mock AvatarUpload 组件
+jest.mock('@/components/ui/avatar-upload', () => ({
+  AvatarUpload: jest.fn(({ currentAvatar, onUploadSuccess, onUploadError, size, editable, className }) => (
+    <div
+      data-testid="avatar-upload"
+      data-current-avatar={currentAvatar}
+      data-size={size}
+      data-editable={editable}
+      className={className}
+    >
+      <button
+        data-testid="avatar-upload-button"
+        onClick={() => onUploadSuccess({ fileId: 'test-file-id', fullUrl: 'https://example.com/avatar.jpg' })}
+      >
+        上传头像
+      </button>
+      <button
+        data-testid="avatar-upload-error-button"
+        onClick={() => onUploadError(new Error('Upload failed'))}
+      >
+        上传失败
+      </button>
+    </div>
+  ))
+}))
+
+// Mock Button 组件
+jest.mock('@/components/ui/button', () => ({
+  Button: jest.fn(({ children, variant, size, onClick, className }) => (
+    <button
+      data-testid="button"
+      data-variant={variant}
+      data-size={size}
+      className={className}
+      onClick={onClick}
+    >
+      {children}
+    </button>
+  ))
+}))
+
+// Mock useAuth hook
+const mockUser = {
+  id: 1,
+  username: '测试用户',
+  avatarFile: {
+    fullUrl: 'https://example.com/avatar.jpg'
+  }
+}
+
+const mockLogout = jest.fn()
+const mockUpdateUser = jest.fn()
+
+jest.mock('@/utils/auth', () => ({
+  useAuth: jest.fn(() => ({
+    user: mockUser,
+    logout: mockLogout,
+    isLoading: false,
+    updateUser: mockUpdateUser
+  }))
+}))
+
+// Mock cn utility
+jest.mock('@/utils/cn', () => ({
+  cn: jest.fn((...args) => args.join(' '))
+}))
+
+// Mock CSS imports
+jest.mock('../../src/pages/profile/index.css', () => ({}))
+
+// 创建测试用的 QueryClient
+const createTestQueryClient = () => new QueryClient({
+  defaultOptions: {
+    queries: {
+      retry: false,
+    },
+  },
+})
+
+// 包装组件
+const Wrapper = ({ children }: { children: React.ReactNode }) => {
+  const queryClient = createTestQueryClient()
+  return (
+    <QueryClientProvider client={queryClient}>
+      {children}
+    </QueryClientProvider>
+  )
+}
+
+describe('个人中心页面测试', () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+
+    // 设置环境变量
+    process.env.TARO_APP_WX_CORP_ID = 'wwc6d7911e2d23b7fb'
+    process.env.TARO_APP_WX_KEFU_URL = 'https://work.weixin.qq.com/kfid/kfc5f4d729bc3c893d7'
+  })
+
+  test('应该正确渲染个人中心页面', () => {
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 检查页面标题
+    expect(screen.getByText('个人中心')).toBeInTheDocument()
+
+    // 检查用户信息
+    expect(screen.getByText('测试用户')).toBeInTheDocument()
+    expect(screen.getByText('ID: 0001')).toBeInTheDocument()
+
+    // 检查功能菜单
+    expect(screen.getByText('我的服务')).toBeInTheDocument()
+    expect(screen.getByText('编辑资料')).toBeInTheDocument()
+    expect(screen.getByText('乘车人管理')).toBeInTheDocument()
+    expect(screen.getByText('设置')).toBeInTheDocument()
+    expect(screen.getByText('隐私政策')).toBeInTheDocument()
+    expect(screen.getByText('帮助与反馈')).toBeInTheDocument()
+
+    // 检查客服与帮助区域
+    expect(screen.getByText('客服与帮助')).toBeInTheDocument()
+    expect(screen.getByText('联系客服')).toBeInTheDocument()
+    expect(screen.getByText('7x24小时在线客服')).toBeInTheDocument()
+    expect(screen.getByText('常见问题')).toBeInTheDocument()
+    expect(screen.getByText('意见反馈')).toBeInTheDocument()
+
+    // 检查版本信息
+    expect(screen.getByText('去看出行 v1.0.0')).toBeInTheDocument()
+  })
+
+  test('应该处理联系客服功能 - 成功场景', async () => {
+    mockOpenCustomerServiceChat.mockImplementation((options) => {
+      options.success()
+    })
+
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 点击联系客服按钮
+    const customerServiceButton = screen.getByText('联系客服')
+    fireEvent.click(customerServiceButton)
+
+    // 检查微信客服API被正确调用
+    await waitFor(() => {
+      expect(mockOpenCustomerServiceChat).toHaveBeenCalledWith({
+        extInfo: {
+          url: 'https://work.weixin.qq.com/kfid/kfc5f4d729bc3c893d7'
+        },
+        corpId: 'wwc6d7911e2d23b7fb',
+        success: expect.any(Function),
+        fail: expect.any(Function)
+      })
+    })
+  })
+
+  test('应该处理联系客服功能 - 失败场景', async () => {
+    mockOpenCustomerServiceChat.mockImplementation((options) => {
+      options.fail({ errMsg: '客服功能不可用' })
+    })
+
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 点击联系客服按钮
+    const customerServiceButton = screen.getByText('联系客服')
+    fireEvent.click(customerServiceButton)
+
+    // 检查错误提示显示
+    await waitFor(() => {
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '客服功能暂不可用,请稍后重试',
+        icon: 'none'
+      })
+    })
+  })
+
+  test('应该处理联系客服功能 - 异常场景', async () => {
+    mockOpenCustomerServiceChat.mockImplementation(() => {
+      throw new Error('API调用异常')
+    })
+
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 点击联系客服按钮
+    const customerServiceButton = screen.getByText('联系客服')
+    fireEvent.click(customerServiceButton)
+
+    // 检查异常处理
+    await waitFor(() => {
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '客服功能异常,请稍后重试',
+        icon: 'none'
+      })
+    })
+  })
+
+  test('应该处理其他功能按钮点击', () => {
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 点击编辑资料按钮
+    const editProfileButton = screen.getByText('编辑资料')
+    fireEvent.click(editProfileButton)
+    expect(mockShowToast).toHaveBeenCalledWith({
+      title: '功能开发中...',
+      icon: 'none'
+    })
+
+    // 点击乘车人管理按钮
+    const passengersButton = screen.getByText('乘车人管理')
+    fireEvent.click(passengersButton)
+    expect(mockNavigateTo).toHaveBeenCalledWith({
+      url: '/pages/passengers/passengers'
+    })
+
+    // 点击设置按钮
+    const settingsButton = screen.getByText('设置')
+    fireEvent.click(settingsButton)
+    expect(mockShowToast).toHaveBeenCalledWith({
+      title: '功能开发中...',
+      icon: 'none'
+    })
+
+    // 点击常见问题按钮
+    const faqButton = screen.getByText('常见问题')
+    fireEvent.click(faqButton)
+    expect(mockShowToast).toHaveBeenCalledWith({
+      title: '常见问题功能开发中...',
+      icon: 'none'
+    })
+
+    // 点击意见反馈按钮
+    const feedbackButton = screen.getByText('意见反馈')
+    fireEvent.click(feedbackButton)
+    expect(mockShowToast).toHaveBeenCalledWith({
+      title: '意见反馈功能开发中...',
+      icon: 'none'
+    })
+  })
+
+  test('应该处理头像上传功能', async () => {
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 点击头像上传成功按钮
+    const uploadButton = screen.getByTestId('avatar-upload-button')
+    fireEvent.click(uploadButton)
+
+    // 检查上传成功处理
+    await waitFor(() => {
+      expect(mockShowLoading).toHaveBeenCalledWith({ title: '更新头像...' })
+      expect(mockHideLoading).toHaveBeenCalled()
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '头像更新成功',
+        icon: 'success'
+      })
+      expect(mockUpdateUser).toHaveBeenCalledWith({
+        ...mockUser,
+        avatarFileId: 'test-file-id'
+      })
+    })
+  })
+
+  test('应该处理头像上传失败', async () => {
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 点击头像上传失败按钮
+    const uploadErrorButton = screen.getByTestId('avatar-upload-error-button')
+    fireEvent.click(uploadErrorButton)
+
+    // 检查上传失败处理
+    await waitFor(() => {
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '上传失败,请重试',
+        icon: 'none'
+      })
+    })
+  })
+
+  test('应该处理退出登录', async () => {
+    mockShowModal.mockImplementation((options) => {
+      options.success({ confirm: true })
+    })
+
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 点击退出登录按钮
+    const logoutButton = screen.getByText('退出登录')
+    fireEvent.click(logoutButton)
+
+    // 检查退出登录流程
+    await waitFor(() => {
+      expect(mockShowModal).toHaveBeenCalledWith({
+        title: '退出登录',
+        content: '确定要退出登录吗?',
+        success: expect.any(Function)
+      })
+      expect(mockShowLoading).toHaveBeenCalledWith({ title: '退出中...' })
+      expect(mockLogout).toHaveBeenCalled()
+      expect(mockHideLoading).toHaveBeenCalled()
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '已退出登录',
+        icon: 'success',
+        duration: 1500
+      })
+    })
+  })
+
+  test('应该处理退出登录取消', async () => {
+    mockShowModal.mockImplementation((options) => {
+      options.success({ confirm: false })
+    })
+
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 点击退出登录按钮
+    const logoutButton = screen.getByText('退出登录')
+    fireEvent.click(logoutButton)
+
+    // 检查取消退出登录
+    await waitFor(() => {
+      expect(mockShowModal).toHaveBeenCalled()
+      expect(mockLogout).not.toHaveBeenCalled()
+    })
+  })
+
+  test('应该正确使用TabBarLayout', () => {
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 检查TabBarLayout是否正确使用
+    const tabBarLayout = screen.getByTestId('tab-bar-layout')
+    expect(tabBarLayout).toHaveAttribute('data-active-key', 'profile')
+  })
+
+  test('应该正确使用Navbar', () => {
+    render(
+      <Wrapper>
+        <ProfilePage />
+      </Wrapper>
+    )
+
+    // 检查Navbar是否正确使用
+    const navbar = screen.getByTestId('navbar')
+    expect(navbar).toHaveAttribute('data-title', '个人中心')
+    expect(navbar).toHaveAttribute('data-right-icon', 'i-heroicons-cog-6-tooth-20-solid')
+    expect(navbar).toHaveAttribute('data-background-color', 'bg-primary')
+    expect(navbar).toHaveAttribute('data-text-color', 'text-white')
+    expect(navbar).toHaveAttribute('data-border', 'false')
+  })
+})