Browse Source

docs(story): 完成故事017.014-个人信息页样式对照原型调整

- 新增UserInfoHeader顶部用户信息区域组件(蓝色渐变背景)
- 调整银行卡卡片样式(蓝色背景、添加按钮、显示持卡人)
- 调整证件照片样式(添加边框、更新占位图标)
- 更新Navbar标题从"我的"改为"个人信息"
- 新增UserInfoHeader测试用例,更新相关测试
- 所有28个测试用例通过,类型检查通过

🤖 Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 3 tuần trước cách đây
mục cha
commit
1ef6dcc110

+ 21 - 4
docs/stories/017.014.story.md

@@ -3,7 +3,7 @@
 ## 元信息
 - **史诗**: 017 - 人才小程序功能实现
 - **优先级**: P1 - 用户体验改进
-- **状态**: Approved
+- **状态**: Ready for Review
 - **创建日期**: 2025-12-26
 - **负责人**: 开发团队
 
@@ -601,15 +601,32 @@ Claude Sonnet (claude-sonnet-4-20250514)
 
 ### 调试日志引用
 
-(待实施时填写)
+无重大调试问题。实施过程顺利。
 
 ### 完成说明列表
 
-(待实施时填写)
+1. ✅ 创建了 UserInfoHeader 组件,实现了渐变背景的顶部用户信息区域
+2. ✅ 更新了 BankCardInfo 和 BankCardItem 组件,采用蓝色背景样式和新的布局
+3. ✅ 更新了 DocumentPhotoItem 组件,添加边框样式和新的占位图标
+4. ✅ 更新了 PersonalInfoPage 页面,集成 UserInfoHeader 并调整 Navbar 标题为"个人信息"
+5. ✅ 更新了所有相关测试用例,新增 UserInfoHeader.test.tsx
+6. ✅ 所有 28 个测试用例通过
+7. ✅ TypeScript 类型检查通过
 
 ### 文件列表
 
-(待实施时填写)
+**新增文件:**
+- `mini-ui-packages/rencai-personal-info-ui/src/components/UserInfoHeader.tsx` - 顶部用户信息区域组件
+- `mini-ui-packages/rencai-personal-info-ui/tests/unit/components/UserInfoHeader.test.tsx` - UserInfoHeader 组件测试
+
+**修改文件:**
+- `mini-ui-packages/rencai-personal-info-ui/src/pages/PersonalInfoPage/PersonalInfoPage.tsx` - 集成 UserInfoHeader,更新 Navbar 标题
+- `mini-ui-packages/rencai-personal-info-ui/src/components/BankCardInfo.tsx` - 添加"添加"按钮
+- `mini-ui-packages/rencai-personal-info-ui/src/components/BankCardItem.tsx` - 蓝色背景样式,显示持卡人姓名
+- `mini-ui-packages/rencai-personal-info-ui/src/components/DocumentPhotoItem.tsx` - 添加边框样式,更新占位图标
+- `mini-ui-packages/rencai-personal-info-ui/tests/pages/PersonalInfoPage/PersonalInfoPage.test.tsx` - 更新测试用例
+- `mini-ui-packages/rencai-personal-info-ui/tests/unit/components/BankCardItem.test.tsx` - 更新测试用例
+- `mini-ui-packages/rencai-personal-info-ui/tests/unit/components/DocumentPhotoItem.test.tsx` - 更新测试用例
 
 ## QA结果
 

+ 8 - 1
mini-ui-packages/rencai-personal-info-ui/src/components/BankCardInfo.tsx

@@ -39,7 +39,14 @@ const BankCardInfo: React.FC<BankCardInfoProps> = ({ bankCards, loading }) => {
 
   return (
     <View className="bg-white rounded-lg p-4 mb-3">
-      <Text className="text-base font-semibold text-gray-800 mb-4">银行卡信息</Text>
+      {/* 标题和添加按钮 */}
+      <View className="flex justify-between items-center mb-3">
+        <Text className="text-base font-semibold text-gray-700">银行卡信息</Text>
+        <View className="flex items-center">
+          <View className="i-heroicons-plus-20-solid w-4 h-4 text-blue-500 mr-1" />
+          <Text className="text-sm text-blue-500">添加</Text>
+        </View>
+      </View>
 
       <View>
         {bankCards.map((bankCard) => (

+ 21 - 16
mini-ui-packages/rencai-personal-info-ui/src/components/BankCardItem.tsx

@@ -26,33 +26,38 @@ interface BankCardItemProps {
  */
 const BankCardItem: React.FC<BankCardItemProps> = ({ bankCard }) => {
   return (
-    <View className="border-b border-gray-100 last:border-0 py-3">
-      <View className="flex flex-col space-y-2">
-        {/* 第一行:银行名称 */}
-        <View className="flex justify-between items-center">
-          <Text className="text-sm font-medium text-gray-800">
+    <View className="bg-blue-50 rounded-lg p-4 mb-3 last:mb-0">
+      <View className="flex flex-col">
+        {/* 第一行:银行名称和默认标签 */}
+        <View className="flex justify-between items-center mb-2">
+          <Text className="font-medium text-gray-800">
             {bankCard.bankName || bankCard.subBankName}
           </Text>
           {bankCard.isDefault === 1 && (
-            <View className="bg-blue-50 px-2 py-0.5 rounded">
-              <Text className="text-xs text-blue-600">默认</Text>
+            <View className="bg-blue-100 px-2 py-1 rounded-full">
+              <Text className="text-xs text-blue-800">默认</Text>
             </View>
           )}
         </View>
 
-        {/* 第二行:卡号 */}
-        <View>
-          <Text className="text-sm text-gray-800">
+        {/* 第二行:卡类型 */}
+        {bankCard.cardType && (
+          <View className="mb-1">
+            <Text className="text-sm text-gray-600">{bankCard.cardType}</Text>
+          </View>
+        )}
+
+        {/* 第三行:卡号 (大号加粗) */}
+        <View className="mb-1">
+          <Text className="text-lg font-bold text-gray-800">
             {maskCardNumber(bankCard.cardNumber)}
           </Text>
         </View>
 
-        {/* 第三行:卡类型 */}
-        {bankCard.cardType && (
-          <View>
-            <Text className="text-xs text-gray-500">{bankCard.cardType}</Text>
-          </View>
-        )}
+        {/* 第四行:持卡人姓名 */}
+        <View className="mt-2">
+          <Text className="text-sm text-gray-600">持卡人:{bankCard.cardholderName}</Text>
+        </View>
       </View>
     </View>
   )

+ 3 - 3
mini-ui-packages/rencai-personal-info-ui/src/components/DocumentPhotoItem.tsx

@@ -35,9 +35,9 @@ const DocumentPhotoItem: React.FC<DocumentPhotoItemProps> = ({ photo }) => {
   return (
     <View
       onClick={handlePreview}
-      className="flex flex-col items-center cursor-pointer"
+      className="border border-gray-200 rounded-lg p-3 text-center cursor-pointer"
     >
-      <View className="w-full aspect-square bg-gray-100 rounded-lg overflow-hidden mb-2">
+      <View className="w-full aspect-square bg-gray-100 rounded-lg overflow-hidden mx-auto mb-2">
         {photo.fileUrl ? (
           <Image
             src={photo.fileUrl}
@@ -47,7 +47,7 @@ const DocumentPhotoItem: React.FC<DocumentPhotoItemProps> = ({ photo }) => {
           />
         ) : (
           <View className="w-full h-full flex items-center justify-center">
-            <View className="i-heroicons-photo-20-solid text-gray-400 text-2xl" />
+            <View className="i-heroicons-document-20-solid text-gray-400 text-2xl" />
           </View>
         )}
       </View>

+ 83 - 0
mini-ui-packages/rencai-personal-info-ui/src/components/UserInfoHeader.tsx

@@ -0,0 +1,83 @@
+import React from 'react'
+import { View, Text } from '@tarojs/components'
+import { PersonalInfoResponse } from './PersonalBasicInfo'
+
+interface UserInfoHeaderProps {
+  personalInfo: PersonalInfoResponse | null
+  loading: boolean
+}
+
+/**
+ * 就业状态映射
+ */
+const jobStatusMap: Record<number, string> = {
+  0: '待业',
+  1: '在职'
+}
+
+/**
+ * 顶部用户信息区域组件
+ * 渐变背景,显示用户头像、姓名和状态
+ */
+const UserInfoHeader: React.FC<UserInfoHeaderProps> = ({ personalInfo, loading }) => {
+  if (loading) {
+    return (
+      <View className="bg-gradient-to-b from-blue-500 to-blue-700 p-5">
+        <View className="flex justify-between items-start">
+          <View className="flex items-center">
+            <View className="w-16 h-16 rounded-full border-2 border-white mr-4 bg-blue-400 flex items-center justify-center animate-pulse" />
+            <View>
+              <View className="h-6 bg-white/30 rounded w-20 mb-2" />
+              <View className="h-4 bg-white/20 rounded w-32" />
+            </View>
+          </View>
+        </View>
+      </View>
+    )
+  }
+
+  if (!personalInfo) {
+    return null
+  }
+
+  // 提取姓名首字作为头像占位符
+  const nameInitial = personalInfo.name ? personalInfo.name.charAt(0) : '?'
+
+  // 组合用户状态: "残疾类型 · 等级 · 就业状态"
+  const userStatus = [
+    personalInfo.disabilityType,
+    personalInfo.disabilityLevel,
+    jobStatusMap[personalInfo.jobStatus] || '未知'
+  ].filter(Boolean).join(' · ')
+
+  return (
+    <View className="bg-gradient-to-b from-blue-500 to-blue-700 p-5">
+      <View className="flex justify-between items-start">
+        {/* 左侧: 头像和用户信息 */}
+        <View className="flex items-center">
+          {/* 用户头像 - 圆形,带白色边框,使用姓名首字 */}
+          <View className="w-16 h-16 rounded-full border-2 border-white mr-4 bg-blue-600 flex items-center justify-center">
+            <Text className="text-white text-xl font-bold">{nameInitial}</Text>
+          </View>
+
+          {/* 用户信息 */}
+          <View>
+            <Text className="text-xl font-bold text-white block mb-1">
+              {personalInfo.name || '未知用户'}
+            </Text>
+            <Text className="text-sm text-white opacity-80">
+              {userStatus || '状态未知'}
+            </Text>
+          </View>
+        </View>
+
+        {/* 右上角: 相机按钮 (暂不实现点击功能) */}
+        <View className="bg-white/20 rounded-full p-2">
+          <View className="i-heroicons-camera-20-solid w-5 h-5 text-white" />
+        </View>
+      </View>
+    </View>
+  )
+}
+
+export default UserInfoHeader

+ 9 - 2
mini-ui-packages/rencai-personal-info-ui/src/pages/PersonalInfoPage/PersonalInfoPage.tsx

@@ -6,6 +6,7 @@ import { RencaiTabBarLayout } from '@d8d/rencai-shared-ui/components/RencaiTabBa
 import { Navbar } from '@d8d/mini-shared-ui-components/components/navbar'
 import { talentPersonalInfoClient } from '../../api'
 import { useRequireAuth } from '@d8d/rencai-auth-ui/hooks'
+import UserInfoHeader from '../../components/UserInfoHeader'
 import PersonalBasicInfo, { PersonalInfoResponse } from '../../components/PersonalBasicInfo'
 import BankCardInfo from '../../components/BankCardInfo'
 import { BankCardInfo as BankCardInfoType } from '../../components/BankCardItem'
@@ -67,7 +68,7 @@ const PersonalInfoPage: React.FC = () => {
   // 页面加载时设置标题
   useEffect(() => {
     Taro.setNavigationBarTitle({
-      title: '我的'
+      title: '个人信息'
     })
   }, [])
 
@@ -101,7 +102,7 @@ const PersonalInfoPage: React.FC = () => {
       >
         {/* Navbar导航栏 - TabBar页面无返回按钮 */}
         <Navbar
-          title="我的"
+          title="个人信息"
           leftIcon=""
           leftText=""
           onClickLeft={() => {}}
@@ -113,6 +114,12 @@ const PersonalInfoPage: React.FC = () => {
 
         {/* 页面内容 */}
         <View className="px-4 py-3">
+          {/* 顶部用户信息区域 */}
+          <UserInfoHeader
+            personalInfo={personalInfo || null}
+            loading={personalInfoLoading}
+          />
+
           {/* 个人基本信息卡片 */}
           <PersonalBasicInfo
             personalInfo={personalInfo || null}

+ 51 - 3
mini-ui-packages/rencai-personal-info-ui/tests/pages/PersonalInfoPage/PersonalInfoPage.test.tsx

@@ -94,8 +94,55 @@ describe('PersonalInfoPage', () => {
     render(<PersonalInfoPage />, { wrapper })
 
     // 检查导航栏是否渲染了正确的标题
-    expect(screen.getByTestId('navbar')).toHaveTextContent('我的')
-    expect(Taro.setNavigationBarTitle).toHaveBeenCalledWith({ title: '我的' })
+    expect(screen.getByTestId('navbar')).toHaveTextContent('个人信息')
+    expect(Taro.setNavigationBarTitle).toHaveBeenCalledWith({ title: '个人信息' })
+  })
+
+  it('应该渲染顶部用户信息区域', async () => {
+    // 符合RPC类型的个人信息数据
+    const mockPersonalInfo = {
+      name: '张三',
+      gender: '男',
+      idCard: '110101199001011234',
+      disabilityId: 'D12345678901',
+      disabilityType: '肢体残疾',
+      disabilityLevel: '三级',
+      phone: '13800138000',
+      province: '北京市',
+      city: '北京市',
+      district: '朝阳区',
+      detailedAddress: '某某街道123号',
+      birthDate: '1990-01-01',
+      idAddress: '北京市朝阳区某某街道123号',
+      idValidDate: '2030-01-01',
+      disabilityValidDate: '2025-12-31',
+      canDirectContact: 1,
+      isMarried: 0,
+      nation: '汉族',
+      jobStatus: 1,
+      specificDisability: '左腿小腿截肢'
+    }
+
+    ;(talentPersonalInfoClient.personal.info.$get as jest.Mock).mockResolvedValue(
+      createMockResponse(200, mockPersonalInfo)
+    )
+    ;(talentPersonalInfoClient.personal['bank-cards'].$get as jest.Mock).mockResolvedValue(
+      createMockResponse(200, { data: [], total: 0 })
+    )
+    ;(talentPersonalInfoClient.personal.photos.$get as jest.Mock).mockResolvedValue(
+      createMockResponse(200, { data: [], total: 0 })
+    )
+
+    const wrapper = createTestWrapper()
+    const { container } = render(<PersonalInfoPage />, { wrapper })
+
+    await waitFor(() => {
+      // 检查顶部用户信息区域 - 使用更精确的选择器
+      expect(screen.getByText('肢体残疾 · 三级 · 在职')).toBeInTheDocument()
+      // 检查渐变背景
+      const gradientElement = container.querySelector('.bg-gradient-to-b')
+      expect(gradientElement).toBeInTheDocument()
+    })
   })
 
   it('应该渲染带有正确RPC类型的个人基本信息', async () => {
@@ -137,7 +184,8 @@ describe('PersonalInfoPage', () => {
     render(<PersonalInfoPage />, { wrapper })
 
     await waitFor(() => {
-      expect(screen.getByText('张三')).toBeInTheDocument()
+      // 使用 getAllByText 因为"张三"出现在顶部用户信息区域和个人基本信息卡片中
+      expect(screen.getAllByText('张三')).toHaveLength(2)
       expect(screen.getByText('男')).toBeInTheDocument()
       expect(screen.getByText('肢体残疾')).toBeInTheDocument()
       expect(screen.getByText('一级')).toBeInTheDocument()

+ 6 - 1
mini-ui-packages/rencai-personal-info-ui/tests/unit/components/BankCardItem.test.tsx

@@ -20,11 +20,16 @@ describe('BankCardItem', () => {
   }
 
   it('应该正确渲染银行卡信息', () => {
-    render(<BankCardItem bankCard={mockBankCard} />)
+    const { container } = render(<BankCardItem bankCard={mockBankCard} />)
 
     expect(screen.getByText('中国工商银行')).toBeInTheDocument()
     expect(screen.getByText(maskCardNumber('6222020200001234567'))).toBeInTheDocument()
     expect(screen.getByText('一类卡')).toBeInTheDocument()
+    expect(screen.getByText('持卡人:张三')).toBeInTheDocument()
+
+    // 检查蓝色背景样式
+    const cardElement = container.querySelector('.bg-blue-50')
+    expect(cardElement).toBeInTheDocument()
   })
 
   it('应该为默认卡显示默认标识', () => {

+ 8 - 4
mini-ui-packages/rencai-personal-info-ui/tests/unit/components/DocumentPhotoItem.test.tsx

@@ -22,17 +22,21 @@ describe('DocumentPhotoItem', () => {
   })
 
   it('应该正确渲染照片信息', () => {
-    render(<DocumentPhotoItem photo={mockPhoto} />)
+    const { container } = render(<DocumentPhotoItem photo={mockPhoto} />)
 
     expect(screen.getByText('身份证')).toBeInTheDocument()
+
+    // 检查边框样式
+    const borderedElement = container.querySelector('.border-gray-200')
+    expect(borderedElement).toBeInTheDocument()
   })
 
   it('应该在fileUrl为空时显示占位符', () => {
     const photoWithoutUrl = { ...mockPhoto, fileUrl: null }
-    render(<DocumentPhotoItem photo={photoWithoutUrl} />)
+    const { container } = render(<DocumentPhotoItem photo={photoWithoutUrl} />)
 
-    // 应该显示占位图标
-    const placeholderIcon = document.querySelector('.i-heroicons-photo-20-solid')
+    // 应该显示占位图标 (使用 document 图标)
+    const placeholderIcon = container.querySelector('.i-heroicons-document-20-solid')
     expect(placeholderIcon).toBeInTheDocument()
   })
 

+ 107 - 0
mini-ui-packages/rencai-personal-info-ui/tests/unit/components/UserInfoHeader.test.tsx

@@ -0,0 +1,107 @@
+/**
+ * UserInfoHeader 组件测试
+ */
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import UserInfoHeader from '../../../src/components/UserInfoHeader'
+import { PersonalInfoResponse } from '../../../src/components/PersonalBasicInfo'
+
+describe('UserInfoHeader', () => {
+  const mockPersonalInfo: PersonalInfoResponse = {
+    name: '张三',
+    gender: '男',
+    idCard: '110101199001011234',
+    disabilityId: 'D12345678901',
+    disabilityType: '肢体残疾',
+    disabilityLevel: '三级',
+    phone: '13800138000',
+    province: '北京市',
+    city: '北京市',
+    district: '朝阳区',
+    detailedAddress: '某某街道123号',
+    birthDate: '1990-01-01',
+    idAddress: '北京市朝阳区某某街道123号',
+    idValidDate: '2030-01-01',
+    disabilityValidDate: '2025-12-31',
+    canDirectContact: 1,
+    isMarried: 0,
+    nation: '汉族',
+    jobStatus: 1,
+    specificDisability: '左腿小腿截肢'
+  }
+
+  it('应该正确渲染用户信息区域', () => {
+    const { container } = render(
+      <UserInfoHeader personalInfo={mockPersonalInfo} loading={false} />
+    )
+
+    // 检查用户名
+    expect(screen.getByText('张三')).toBeInTheDocument()
+
+    // 检查用户状态
+    expect(screen.getByText('肢体残疾 · 三级 · 在职')).toBeInTheDocument()
+
+    // 检查渐变背景
+    const gradientElement = container.querySelector('.bg-gradient-to-b')
+    expect(gradientElement).toBeInTheDocument()
+
+    // 检查头像 (姓名首字)
+    expect(screen.getByText('张')).toBeInTheDocument()
+  })
+
+  it('应该显示loading状态', () => {
+    const { container } = render(
+      <UserInfoHeader personalInfo={null} loading={true} />
+    )
+
+    // 检查loading动画类
+    const pulseElement = container.querySelector('.animate-pulse')
+    expect(pulseElement).toBeInTheDocument()
+  })
+
+  it('应该在personalInfo为null时不渲染', () => {
+    const { container } = render(
+      <UserInfoHeader personalInfo={null} loading={false} />
+    )
+
+    // 应该返回null,不渲染任何内容
+    expect(container.firstChild).toBeNull()
+  })
+
+  it('应该正确提取姓名首字作为头像', () => {
+    render(<UserInfoHeader personalInfo={mockPersonalInfo} loading={false} />)
+
+    // 姓名首字 "张"
+    expect(screen.getByText('张')).toBeInTheDocument()
+  })
+
+  it('应该正确处理待业状态', () => {
+    const unemployedInfo = { ...mockPersonalInfo, jobStatus: 0 }
+    render(<UserInfoHeader personalInfo={unemployedInfo} loading={false} />)
+
+    expect(screen.getByText('肢体残疾 · 三级 · 待业')).toBeInTheDocument()
+  })
+
+  it('应该在缺少某些字段时正确显示', () => {
+    const incompleteInfo = {
+      ...mockPersonalInfo,
+      disabilityType: '',
+      disabilityLevel: ''
+    }
+    render(<UserInfoHeader personalInfo={incompleteInfo} loading={false} />)
+
+    // 只显示就业状态
+    expect(screen.getByText('在职')).toBeInTheDocument()
+  })
+
+  it('应该显示相机图标按钮', () => {
+    const { container } = render(
+      <UserInfoHeader personalInfo={mockPersonalInfo} loading={false} />
+    )
+
+    // 检查相机图标
+    const cameraIcon = container.querySelector('.i-heroicons-camera-20-solid')
+    expect(cameraIcon).toBeInTheDocument()
+  })
+})