Bläddra i källkod

✨ feat(ui): 增强人才详情页面功能并优化类型安全

- 新增工作视频管理区域,支持按分类(个税视频、工资视频、工作视频、其他)展示视频列表
- 新增历史工作内容时间线卡片,展示工作历史记录并支持排序
- 新增底部操作按钮区域,包含联系和编辑功能
- 使用 Hono 的 InferResponseType 自动推断 API 响应类型,提升类型安全性
- 移除冗余的手动类型定义,简化代码结构
- 为人才详情数据添加兼容字段映射,增强数据容错性

📦 build(deps): 添加 hono 依赖至人才管理 UI 包

- 在 `yongren-talent-management-ui` 的 package.json 中添加 hono@4.8.5 依赖
- 更新 pnpm-lock.yaml 文件

🐛 fix(api): 修复 Zod 路由参数类型定义

- 在 `person-extension.route.ts` 中,将 `z.coerce.number()` 更新为 `z.coerce.number<number>()` 以明确指定泛型类型
- 涉及多个路由端点:工作历史、薪资历史、征信信息、视频列表、企业人才详情
yourname 1 månad sedan
förälder
incheckning
4cc9b65379

+ 5 - 5
allin-packages/disability-module/src/routes/person-extension.route.ts

@@ -26,7 +26,7 @@ const getWorkHistoryRoute = createRoute({
   middleware: [enterpriseAuthMiddleware],
   request: {
     params: z.object({
-      id: z.coerce.number().int().positive().openapi({
+      id: z.coerce.number<number>().int().positive().openapi({
         param: { name: 'id', in: 'path' },
         example: 1,
         description: '残疾人ID'
@@ -72,7 +72,7 @@ const getSalaryHistoryRoute = createRoute({
   middleware: [enterpriseAuthMiddleware],
   request: {
     params: z.object({
-      id: z.coerce.number().int().positive().openapi({
+      id: z.coerce.number<number>().int().positive().openapi({
         param: { name: 'id', in: 'path' },
         example: 1,
         description: '残疾人ID'
@@ -118,7 +118,7 @@ const getCreditInfoRoute = createRoute({
   middleware: [enterpriseAuthMiddleware],
   request: {
     params: z.object({
-      id: z.coerce.number().int().positive().openapi({
+      id: z.coerce.number<number>().int().positive().openapi({
         param: { name: 'id', in: 'path' },
         example: 1,
         description: '残疾人ID'
@@ -164,7 +164,7 @@ const getPersonVideosRoute = createRoute({
   middleware: [enterpriseAuthMiddleware],
   request: {
     params: z.object({
-      id: z.coerce.number().int().positive().openapi({
+      id: z.coerce.number<number>().int().positive().openapi({
         param: { name: 'id', in: 'path' },
         example: 1,
         description: '残疾人ID'
@@ -246,7 +246,7 @@ const getCompanyPersonDetailRoute = createRoute({
   middleware: [enterpriseAuthMiddleware],
   request: {
     params: z.object({
-      id: z.coerce.number().int().positive().openapi({
+      id: z.coerce.number<number>().int().positive().openapi({
         param: { name: 'id', in: 'path' },
         example: 1,
         description: '残疾人ID'

+ 2 - 1
mini-ui-packages/yongren-talent-management-ui/package.json

@@ -54,7 +54,8 @@
     "@tarojs/taro": "4.1.4",
     "@tanstack/react-query": "^5.90.12",
     "react": "^18.0.0",
-    "react-dom": "^18.0.0"
+    "react-dom": "^18.0.0",
+    "hono": "4.8.5"
   },
   "devDependencies": {
     "@testing-library/jest-dom": "^6.8.0",

+ 231 - 82
mini-ui-packages/yongren-talent-management-ui/src/pages/TalentDetail/TalentDetail.tsx

@@ -6,76 +6,26 @@ import { YongrenTabBarLayout } from '@d8d/yongren-shared-ui/components/YongrenTa
 import { PageContainer } from '@d8d/mini-shared-ui-components/components/page-container'
 import { enterpriseDisabilityClient } from '../../api'
 import { useRequireAuth } from '@d8d/mini-enterprise-auth-ui/hooks'
+import type { InferResponseType } from 'hono/client'
 
 export interface TalentDetailProps {
   // 组件属性定义(目前为空)
 }
 
+// 从RPC客户端推断类型
+type TalentDetailResponse = InferResponseType<typeof enterpriseDisabilityClient[':id']['$get'], 200>
+type WorkHistoryResponse = InferResponseType<typeof enterpriseDisabilityClient[':id']['work-history']['$get'], 200>
+type SalaryHistoryResponse = InferResponseType<typeof enterpriseDisabilityClient[':id']['salary-history']['$get'], 200>
+type CreditInfoResponse = InferResponseType<typeof enterpriseDisabilityClient[':id']['credit-info']['$get'], 200>
+type VideoResponse = InferResponseType<typeof enterpriseDisabilityClient[':id']['videos']['$get'], 200>
 
+// 提取数组元素类型
+type WorkHistoryItem = WorkHistoryResponse['工作历史'][number]
+type SalaryHistoryItem = SalaryHistoryResponse['薪资历史'][number]
+type CreditInfoItem = CreditInfoResponse['征信信息'][number]
+type VideoItem = VideoResponse['视频列表'][number]
 
 
-interface SalaryData {
-  id: number
-  personId: number
-  amount?: number
-  paymentDate?: string
-  period?: string
-  type?: string
-  notes?: string
-  [key: string]: any
-}
-
-interface FileData {
-  id: string
-  name: string
-  url?: string
-  size?: number
-  type?: string
-  createdAt?: string
-  [key: string]: any
-}
-
-// 企业专用API响应类型
-interface WorkHistoryItem {
-  订单ID: number
-  订单名称: string | null
-  入职日期: string | null
-  实际入职日期: string | null
-  离职日期: string | null
-  工作状态: string
-  个人薪资: number
-}
-
-interface WorkHistoryResponse {
-  工作历史: WorkHistoryItem[]
-}
-
-interface SalaryHistoryItem {
-  月份: string | null
-  基本工资: number
-  补贴: number
-  扣款: number
-  实发工资: number
-}
-
-interface SalaryHistoryResponse {
-  薪资历史: SalaryHistoryItem[]
-}
-
-interface CreditInfoItem {
-  文件ID: string
-  文件URL: string | null
-  上传时间: string | null
-  文件类型: string | null
-  银行卡号: string | null
-  持卡人姓名: string | null
-  银行名称: number | null
-}
-
-interface CreditInfoResponse {
-  征信信息: CreditInfoItem[]
-}
-
 const TalentDetail: React.FC<TalentDetailProps> = () => {
   const { isLoggedIn } = useRequireAuth()
   const router = Taro.useRouter()
@@ -87,7 +37,7 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
     queryFn: async () => {
       if (!talentId) throw new Error('无效的人才ID')
       const response = await enterpriseDisabilityClient[':id'].$get({
-        param: { id: talentId.toString() }
+        param: { id: talentId }
       })
       if (response.status !== 200) {
         throw new Error('获取人才详情失败')
@@ -99,7 +49,13 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
         id: data.personId,  // 映射id字段
         status: data.jobStatus,  // 映射status字段
         // 计算年龄
-        age: data.birthDate ? Math.floor((Date.now() - new Date(data.birthDate).getTime()) / (1000 * 60 * 60 * 24 * 365.25)) : undefined
+        age: data.birthDate ? Math.floor((Date.now() - new Date(data.birthDate).getTime()) / (1000 * 60 * 60 * 24 * 365.25)) : undefined,
+        // 兼容字段映射
+        salary: data.salary || data.monthlySalary || data.currentSalary,
+        joinDate: data.joinDate || data.employmentDate || data.hireDate,
+        disabilityId: data.disabilityId || data.disabilityCardNumber,
+        idAddress: data.idAddress || data.address || data.residentialAddress,
+        phone: data.phone || data.mobile || data.contactPhone
       }
     },
     enabled: isLoggedIn && talentId > 0
@@ -112,13 +68,13 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
       if (!talentId) throw new Error('无效的人才ID')
       // 使用企业专用工作历史API:/api/v1/yongren/disability-person/{id}/work-history
       const response = await enterpriseDisabilityClient[':id']['work-history'].$get({
-        param: { id: talentId.toString() }
+        param: { id: talentId }
       })
       if (response.status !== 200) {
         // 可能没有工作信息,返回空对象
         return {}
       }
-      const data = await response.json() as WorkHistoryResponse
+      const data = await response.json()
       // 企业专用工作历史API返回的是工作历史列表,取最新的一条作为当前工作信息
       const workHistory = data?.工作历史 || []
       if (workHistory.length === 0) {
@@ -147,17 +103,17 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
       if (!talentId) throw new Error('无效的人才ID')
       // 使用企业专用工作历史API:/api/v1/yongren/disability-person/{id}/work-history
       const response = await enterpriseDisabilityClient[':id']['work-history'].$get({
-        param: { id: talentId.toString() }
+        param: { id: talentId }
       })
       if (response.status !== 200) {
         // 可能没有工作信息,返回空数组
         return [] as WorkHistoryItem[]
       }
-      const data = await response.json() as WorkHistoryResponse
+      const data = await response.json()
       // 企业专用工作历史API返回的是工作历史列表
       const workHistory = data?.工作历史 || []
       // 按入职日期降序排序(最新的在前)
-      return workHistory.sort((a, b) => {
+      return workHistory.sort((a: WorkHistoryItem, b: WorkHistoryItem) => {
         const dateA = a.入职日期 ? new Date(a.入职日期).getTime() : 0
         const dateB = b.入职日期 ? new Date(b.入职日期).getTime() : 0
         return dateB - dateA
@@ -173,17 +129,17 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
       if (!talentId) throw new Error('无效的人才ID')
       // 使用企业专用薪资历史API:/api/v1/yongren/disability-person/{id}/salary-history
       const response = await enterpriseDisabilityClient[':id']['salary-history'].$get({
-        param: { id: talentId.toString() }
+        param: { id: talentId }
       })
       if (response.status !== 200) {
         // 可能没有薪资信息,返回空对象
-        return {} as SalaryData
+        return {}
       }
-      const data = await response.json() as SalaryHistoryResponse
+      const data = await response.json()
       // 企业专用薪资历史API返回结构:{ 薪资历史: [...] }
       const salaryHistory = data?.薪资历史 || []
       if (salaryHistory.length === 0) {
-        return {} as SalaryData
+        return {}
       }
       // 取最新的一条薪资记录(按月份降序)
       const latestSalary = salaryHistory[0]
@@ -194,7 +150,7 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
         paymentDate: latestSalary.月份 || undefined,
         type: '月薪', // 默认类型
         period: '月度' // 默认周期
-      } as SalaryData
+      }
     },
     enabled: isLoggedIn && talentId > 0
   })
@@ -206,10 +162,10 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
       if (!talentId) throw new Error('无效的人才ID')
       // 使用企业专用薪资历史API:/api/v1/yongren/disability-person/{id}/salary-history
       const response = await enterpriseDisabilityClient[':id']['salary-history'].$get({
-        param: { id: talentId.toString() }
+        param: { id: talentId }
       })
       if (response.status !== 200) {
-        return [] as SalaryData[]
+        return []
       }
       const data = await response.json() as SalaryHistoryResponse
       // 企业专用薪资历史API返回结构:{ 薪资历史: [...] }
@@ -222,7 +178,7 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
         paymentDate: item.月份 || undefined,
         type: '月薪', // 默认类型
         period: '月度' // 默认周期
-      })) as SalaryData[]
+      }))
     },
     enabled: isLoggedIn && talentId > 0
   })
@@ -234,12 +190,12 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
       if (!talentId) throw new Error('无效的人才ID')
       // 使用企业专用征信信息API:/api/v1/yongren/disability-person/{id}/credit-info
       const response = await enterpriseDisabilityClient[':id']['credit-info'].$get({
-        param: { id: talentId.toString() }
+        param: { id: talentId }
       })
       if (response.status !== 200) {
-        return [] as FileData[]
+        return []
       }
-      const data = await response.json() as CreditInfoResponse
+      const data = await response.json()
       // 企业专用征信信息API返回结构:{ 征信信息: [...] }
       const creditInfoList = data?.征信信息 || []
       // 转换为FileData数组
@@ -250,7 +206,37 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
         size: undefined, // 征信信息API不返回文件大小
         type: item.文件类型 || undefined,
         createdAt: item.上传时间 || undefined
-      })) as FileData[]
+      }))
+    },
+    enabled: isLoggedIn && talentId > 0
+  })
+
+  // 获取工作视频 - 使用企业专用视频API
+  const { data: videos, isLoading: videosLoading } = useQuery({
+    queryKey: ['videos', talentId],
+    queryFn: async () => {
+      if (!talentId) throw new Error('无效的人才ID')
+      // 使用企业专用视频API:/api/v1/yongren/disability-person/{id}/videos
+      const response = await enterpriseDisabilityClient[':id']['videos'].$get({
+        param: { id: talentId }
+      })
+      if (response.status !== 200) {
+        // 可能没有视频,返回空数组
+        return []
+      }
+      const data = await response.json()
+      // 企业专用视频API返回结构:{ 视频列表: [...] }
+      const videoList = data?.视频列表 || []
+      // 转换为VideoData数组
+      return videoList.map((item) => ({
+        id: item.视频ID || '',
+        title: item.视频标题 || '未命名视频',
+        url: item.视频URL || undefined,
+        type: item.视频类型 || undefined,
+        uploadTime: item.上传时间 || undefined,
+        size: item.文件大小 || undefined,
+        category: (item.分类 as '个税视频' | '工资视频' | '工作视频' | '其他') || '其他'
+      }))
     },
     enabled: isLoggedIn && talentId > 0
   })
@@ -262,7 +248,7 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
     })
   }, [])
 
-  const isLoading = talentLoading || workLoading || salaryLoading || filesLoading || historyLoading || workHistoryLoading
+  const isLoading = talentLoading || workLoading || salaryLoading || filesLoading || historyLoading || workHistoryLoading || videosLoading
   const hasError = talentError
 
   // 获取头像颜色
@@ -439,6 +425,64 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
                   </View>
                 </View>
 
+                {/* 历史工作内容时间线卡片 - 原型第673-739行 */}
+                <View className="card bg-white p-4 mb-4">
+                  <Text className="font-semibold text-gray-700 mb-3">历史工作内容</Text>
+                  {workHistoryLoading ? (
+                    <View className="space-y-4">
+                      {[1, 2, 3].map((i) => (
+                        <View key={i} className="flex items-start animate-pulse">
+                          <View className="w-3 h-3 bg-gray-200 rounded-full mt-1 mr-3" />
+                          <View className="flex-1">
+                            <View className="h-4 bg-gray-200 rounded w-1/3 mb-2" />
+                            <View className="h-3 bg-gray-200 rounded w-2/3" />
+                          </View>
+                        </View>
+                      ))}
+                    </View>
+                  ) : workHistoryFull && workHistoryFull.length > 0 ? (
+                    <View className="space-y-6">
+                      {workHistoryFull.map((work: WorkHistoryItem, index: number) => {
+                        const isCurrent = work.工作状态 === '在职' || index === 0
+                        const startDate = work.入职日期 ? formatDate(work.入职日期) : '未指定'
+                        const endDate = work.离职日期 ? formatDate(work.离职日期) : '至今'
+                        const period = `${startDate} - ${endDate}`
+                        const salary = work.个人薪资 ? `¥${work.个人薪资.toLocaleString()}` : '未指定'
+
+                        return (
+                          <View key={work.订单ID || index} className="flex items-start">
+                            {/* 时间线圆点 */}
+                            <View className={`w-3 h-3 rounded-full mt-1 mr-3 ${isCurrent ? 'bg-blue-500' : 'bg-gray-300'}`} />
+                            <View className="flex-1">
+                              {/* 公司/订单名称 */}
+                              <Text className="font-medium text-gray-800 text-sm">
+                                {work.订单名称 || `订单 #${work.订单ID}` || '未命名工作'}
+                              </Text>
+                              {/* 岗位和薪资 */}
+                              <View className="flex flex-wrap items-center gap-2 mt-1">
+                                <Text className="text-xs text-gray-600">岗位: {work.工作状态 || '未指定'}</Text>
+                                <Text className="text-xs text-gray-600">薪资: {salary}</Text>
+                              </View>
+                              {/* 时间段 */}
+                              <Text className="text-xs text-gray-500 mt-1">{period}</Text>
+                              {/* 工作描述(如果有的话) */}
+                              {work.工作状态 && (
+                                <Text className="text-xs text-gray-500 mt-1">
+                                  工作状态: {work.工作状态}
+                                </Text>
+                              )}
+                            </View>
+                          </View>
+                        )
+                      })}
+                    </View>
+                  ) : (
+                    <View className="text-center py-4">
+                      <Text className="text-gray-400 text-sm">暂无工作历史记录</Text>
+                    </View>
+                  )}
+                </View>
+
                 {/* 薪资历史记录卡片 */}
                 <View className="card bg-white p-4 mb-4">
                   <Text className="font-semibold text-gray-700 mb-3">薪资历史记录</Text>
@@ -512,6 +556,69 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
                     </View>
                   )}
                 </View>
+
+                {/* 工作视频管理区域 - 原型第765-829行 */}
+                <View className="card bg-white p-4 mb-4">
+                  <View className="flex justify-between items-center mb-3">
+                    <Text className="font-semibold text-gray-700">工作视频</Text>
+                    <Text className="text-sm text-blue-500">查看更多</Text>
+                  </View>
+                  {videosLoading ? (
+                    <View className="space-y-3">
+                      {[1, 2, 3].map((i) => (
+                        <View key={i} className="flex items-center p-3 bg-gray-50 rounded-lg animate-pulse">
+                          <View className="w-10 h-10 bg-gray-200 rounded mr-3" />
+                          <View className="flex-1">
+                            <View className="h-4 bg-gray-200 rounded w-1/2 mb-1" />
+                            <View className="h-3 bg-gray-200 rounded w-1/3" />
+                          </View>
+                          <View className="flex space-x-2">
+                            <View className="w-8 h-8 bg-gray-200 rounded" />
+                            <View className="w-8 h-8 bg-gray-200 rounded" />
+                          </View>
+                        </View>
+                      ))}
+                    </View>
+                  ) : videos && videos.length > 0 ? (
+                    <View className="space-y-3">
+                      {/* 按分类分组显示 */}
+                      {['个税视频', '工资视频', '工作视频', '其他'].map((category) => {
+                        const categoryVideos = videos.filter(v => v.category === category)
+                        if (categoryVideos.length === 0) return null
+
+                        return (
+                          <View key={category} className="space-y-2">
+                            <Text className="text-sm font-medium text-gray-600 mb-1">{category}</Text>
+                            {categoryVideos.map((video) => (
+                              <View key={video.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
+                                <View className="flex items-center">
+                                  <View className="w-10 h-10 bg-blue-100 rounded flex items-center justify-center mr-3">
+                                    <Text className="i-heroicons-play-20-solid text-blue-500 text-lg" />
+                                  </View>
+                                  <View>
+                                    <Text className="text-sm text-gray-800 font-medium">{video.title}</Text>
+                                    <Text className="text-xs text-gray-500">
+                                      {video.uploadTime ? formatDate(video.uploadTime) : '未知时间'} ·
+                                      {video.size ? ` ${(video.size / 1024 / 1024).toFixed(2)} MB` : ' 大小未知'}
+                                    </Text>
+                                  </View>
+                                </View>
+                                <View className="flex space-x-2">
+                                  <Text className="i-heroicons-eye-20-solid text-blue-500 text-lg" />
+                                  <Text className="i-heroicons-arrow-down-tray-20-solid text-green-500 text-lg" />
+                                </View>
+                              </View>
+                            ))}
+                          </View>
+                        )
+                      })}
+                    </View>
+                  ) : (
+                    <View className="text-center py-4">
+                      <Text className="text-gray-400 text-sm">暂无工作视频</Text>
+                    </View>
+                  )}
+                </View>
               </View>
             </>
           ) : (
@@ -522,6 +629,48 @@ const TalentDetail: React.FC<TalentDetailProps> = () => {
             </View>
           )}
         </ScrollView>
+
+        {/* 底部操作按钮区域 - 原型第831-839行 */}
+        {talentDetail && !isLoading && !hasError && (
+          <View className="fixed bottom-0 left-0 right-0 p-4 bg-white border-t border-gray-200">
+            <View className="flex space-x-3">
+              {/* 联系按钮 */}
+              <View
+                className="flex-1 bg-blue-500 text-white py-3 rounded-lg font-medium flex items-center justify-center active:bg-blue-600"
+                onClick={() => {
+                  // 联系功能:拨打电话或跳转
+                  if (talentDetail.phone) {
+                    Taro.makePhoneCall({
+                      phoneNumber: talentDetail.phone
+                    })
+                  } else {
+                    Taro.showToast({
+                      title: '暂无联系电话',
+                      icon: 'none'
+                    })
+                  }
+                }}
+              >
+                <Text className="i-heroicons-phone-20-solid mr-2" />
+                <Text>联系</Text>
+              </View>
+
+              {/* 编辑按钮 */}
+              <View
+                className="flex-1 bg-gray-100 text-gray-800 py-3 rounded-lg font-medium flex items-center justify-center active:bg-gray-200"
+                onClick={() => {
+                  // 编辑功能:跳转编辑页面
+                  Taro.navigateTo({
+                    url: `/pages/yongren/talent/edit?id=${talentId}`
+                  })
+                }}
+              >
+                <Text className="i-heroicons-pencil-square-20-solid mr-2" />
+                <Text>编辑</Text>
+              </View>
+            </View>
+          </View>
+        )}
       </PageContainer>
     </YongrenTabBarLayout>
   )

+ 3 - 0
pnpm-lock.yaml

@@ -1821,6 +1821,9 @@ importers:
       '@tarojs/taro':
         specifier: 4.1.4
         version: 4.1.4(@tarojs/components@4.1.4(@tarojs/helper@4.1.4)(@types/react@18.3.26)(html-webpack-plugin@5.6.4(webpack@5.91.0))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.22(typescript@5.9.3))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.91.0))(webpack@5.91.0))(@tarojs/helper@4.1.4)(@tarojs/shared@4.1.4)(@types/react@18.3.26)(html-webpack-plugin@5.6.4(webpack@5.91.0))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.22(typescript@5.9.3))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.91.0))(webpack@5.91.0)
+      hono:
+        specifier: 4.8.5
+        version: 4.8.5
       react:
         specifier: ^18.0.0
         version: 18.3.1