2
0
Эх сурвалжийг харах

feat(statistics): 实现故事011.005数据统计功能

- 创建企业专用数据统计API客户端enterpriseStatisticsClient
- 实现完整的类型推导types.ts文件
- 更新Statistics页面组件,展示6个分布图表
- 基于原型设计实现CSS图表组件
- 集成React Query进行数据获取
- 遵循主页面布局规范(YongrenTabBarLayout + Navbar)
- 更新故事文档状态和开发记录

待完成:饼图/环形图可视化、统计卡片数据计算、时间筛选、集成测试

🤖 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 долоо хоног өмнө
parent
commit
922feb1565

+ 22 - 1
docs/stories/011.005.story.md

@@ -1,7 +1,7 @@
 # 故事 011.005:数据统计功能实现
 
 ## 状态
-Ready
+In Progress
 
 ## 故事
 **作为**企业用户,
@@ -371,5 +371,26 @@ const genderData = await genderResponse.json()  // TypeScript自动推断为Gend
 - 修正API客户端使用示例,移除所有companyId查询参数(企业ID强制从认证token获取)
 - 更新变更日志中的查询参数描述,反映实际实现(不支持任何查询参数)
 
+## 开发代理记录
+*此部分由开发代理在实施过程中填充*
+
+**实施记录 (2025-12-23)**:
+- 根据故事012.015安全修复完成状态,更新数据统计API客户端实现
+- 创建企业专用数据统计API客户端 `enterpriseStatisticsClient`,路径前缀 `/api/v1/yongren/statistics`
+- 实现完整的类型推导文件 `types.ts`,使用Hono的 `InferResponseType` 和 `InferRequestType`
+- 更新数据统计页面组件 `Statistics.tsx`,实现6个分布图表的展示
+- 使用React Query进行API数据获取,实现加载状态和错误处理
+- 基于原型设计实现CSS图表:残疾类型分布(柱状图)、性别分布(柱状图)、年龄分布(饼图占位)、户籍分布(进度条)、在职状态统计(环形图占位)、薪资分布(进度条)
+- 添加统计卡片展示(在职人数、平均薪资、在职率、新增人数)- 目前为静态数据
+- 集成Navbar组件,遵循主页面布局规范(YongrenTabBarLayout + Navbar,无返回按钮)
+- 添加时间筛选器占位符(2023年11月)
+
+**待完成项**:
+- 图表数据需要与实际API响应格式对齐
+- 饼图和环形图需要实现可视化(可考虑添加recharts或SVG实现)
+- 统计卡片数据需要从API数据计算或添加新的API端点
+- 时间筛选功能需要实现
+- 需要完整的集成测试
+
 ## QA结果
 *来自QA代理对已完成故事实施的QA审查结果*

+ 4 - 2
mini-ui-packages/yongren-statistics-ui/package.json

@@ -31,14 +31,15 @@
     "test:coverage": "jest --coverage"
   },
   "dependencies": {
+    "@d8d/allin-statistics-module": "workspace:*",
+    "@d8d/mini-enterprise-auth-ui": "workspace:*",
     "@d8d/mini-shared-ui-components": "workspace:*",
     "@d8d/yongren-shared-ui": "workspace:*",
-    "@d8d/mini-enterprise-auth-ui": "workspace:*",
+    "@tanstack/react-query": "^5.90.12",
     "@tarojs/components": "4.1.4",
     "@tarojs/plugin-platform-weapp": "4.1.4",
     "@tarojs/react": "4.1.4",
     "@tarojs/taro": "4.1.4",
-    "@tanstack/react-query": "^5.90.12",
     "react": "^18.0.0",
     "react-dom": "^18.0.0"
   },
@@ -50,6 +51,7 @@
     "@types/node": "^18",
     "@types/react": "^18.0.0",
     "@types/react-dom": "^18.0.0",
+    "hono": "4.8.5",
     "jest": "^30.2.0",
     "jest-environment-jsdom": "^29.7.0",
     "ts-jest": "^29.4.5",

+ 7 - 0
mini-ui-packages/yongren-statistics-ui/src/api/enterpriseStatisticsClient.ts

@@ -0,0 +1,7 @@
+import type { statisticsRoutes } from '@d8d/allin-statistics-module';
+import { rpcClient } from '@d8d/mini-shared-ui-components/utils/rpc/rpc-client';
+
+// 注意:企业专用数据统计API通过enterpriseAuthMiddleware中间件保护,确保仅限企业用户访问
+// 重要安全要求:企业专用API强制从JWT token中的`companyId`字段获取企业ID,不接受查询参数,确保数据隔离安全
+// 路径前缀 /api/v1/yongren/statistics 在路由层配置
+export const enterpriseStatisticsClient = rpcClient<typeof statisticsRoutes>('/api/v1/yongren/statistics');

+ 21 - 3
mini-ui-packages/yongren-statistics-ui/src/api/index.ts

@@ -1,10 +1,28 @@
 // Statistics API 客户端
-// 注意:目前statistics功能尚未实现,此文件为占位符
+// 企业专用数据统计API客户端,通过enterpriseAuthMiddleware中间件保护
+// 重要:企业专用API强制从JWT token中的`companyId`字段获取企业ID,不接受查询参数,确保数据隔离安全
 
+export { enterpriseStatisticsClient } from './enterpriseStatisticsClient';
+export type {
+  DisabilityTypeDistributionResponse,
+  GenderDistributionResponse,
+  AgeDistributionResponse,
+  HouseholdDistributionResponse,
+  JobStatusDistributionResponse,
+  SalaryDistributionResponse,
+  DisabilityTypeDistributionParams,
+  GenderDistributionParams,
+  AgeDistributionParams,
+  HouseholdDistributionParams,
+  JobStatusDistributionParams,
+  SalaryDistributionParams
+} from './types';
+
+// 向后兼容的导出(已弃用,请使用上面的企业专用API客户端)
 export const statisticsClient = {
-  // 统计相关API将在后续实现
+  // 统计相关API将在后续实现 - 已弃用,请使用enterpriseStatisticsClient
 }
 
 export type StatisticsRoutes = {
-  // 路由类型定义将在后续实现
+  // 路由类型定义将在后续实现 - 已弃用
 }

+ 18 - 0
mini-ui-packages/yongren-statistics-ui/src/api/types.ts

@@ -0,0 +1,18 @@
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { enterpriseStatisticsClient } from './enterpriseStatisticsClient';
+
+// 使用Hono类型推导 - 注意正确的属性访问语法
+export type DisabilityTypeDistributionResponse = InferResponseType<typeof enterpriseStatisticsClient['disability-type-distribution']['$get'], 200>;
+export type GenderDistributionResponse = InferResponseType<typeof enterpriseStatisticsClient['gender-distribution']['$get'], 200>;
+export type AgeDistributionResponse = InferResponseType<typeof enterpriseStatisticsClient['age-distribution']['$get'], 200>;
+export type HouseholdDistributionResponse = InferResponseType<typeof enterpriseStatisticsClient['household-distribution']['$get'], 200>;
+export type JobStatusDistributionResponse = InferResponseType<typeof enterpriseStatisticsClient['job-status-distribution']['$get'], 200>;
+export type SalaryDistributionResponse = InferResponseType<typeof enterpriseStatisticsClient['salary-distribution']['$get'], 200>;
+
+// 查询参数类型推导(目前统计API不支持查询参数,企业ID强制从认证token获取)
+export type DisabilityTypeDistributionParams = InferRequestType<typeof enterpriseStatisticsClient['disability-type-distribution']['$get']>['query'];
+export type GenderDistributionParams = InferRequestType<typeof enterpriseStatisticsClient['gender-distribution']['$get']>['query'];
+export type AgeDistributionParams = InferRequestType<typeof enterpriseStatisticsClient['age-distribution']['$get']>['query'];
+export type HouseholdDistributionParams = InferRequestType<typeof enterpriseStatisticsClient['household-distribution']['$get']>['query'];
+export type JobStatusDistributionParams = InferRequestType<typeof enterpriseStatisticsClient['job-status-distribution']['$get']>['query'];
+export type SalaryDistributionParams = InferRequestType<typeof enterpriseStatisticsClient['salary-distribution']['$get']>['query'];

+ 298 - 3
mini-ui-packages/yongren-statistics-ui/src/pages/Statistics/Statistics.tsx

@@ -1,13 +1,80 @@
 import React from 'react'
 import { View, Text, ScrollView } from '@tarojs/components'
+import { useQuery } from '@tanstack/react-query'
 import { YongrenTabBarLayout } from '@d8d/yongren-shared-ui/components/YongrenTabBarLayout'
 import { Navbar } from '@d8d/mini-shared-ui-components/components/navbar'
+import { enterpriseStatisticsClient } from '../api/enterpriseStatisticsClient'
+import type {
+  DisabilityTypeDistributionResponse,
+  GenderDistributionResponse,
+  AgeDistributionResponse,
+  HouseholdDistributionResponse,
+  JobStatusDistributionResponse,
+  SalaryDistributionResponse
+} from '../api/types'
 
 export interface StatisticsProps {
   // 组件属性定义(目前为空)
 }
 
 const Statistics: React.FC<StatisticsProps> = () => {
+  // 获取残疾类型分布数据
+  const { data: disabilityData, isLoading: isLoadingDisability } = useQuery({
+    queryKey: ['statistics', 'disability-type-distribution'],
+    queryFn: async () => {
+      const response = await enterpriseStatisticsClient['disability-type-distribution'].$get()
+      return await response.json()
+    }
+  })
+
+  // 获取性别分布数据
+  const { data: genderData, isLoading: isLoadingGender } = useQuery({
+    queryKey: ['statistics', 'gender-distribution'],
+    queryFn: async () => {
+      const response = await enterpriseStatisticsClient['gender-distribution'].$get()
+      return await response.json()
+    }
+  })
+
+  // 获取年龄分布数据
+  const { data: ageData, isLoading: isLoadingAge } = useQuery({
+    queryKey: ['statistics', 'age-distribution'],
+    queryFn: async () => {
+      const response = await enterpriseStatisticsClient['age-distribution'].$get()
+      return await response.json()
+    }
+  })
+
+  // 获取户籍分布数据
+  const { data: householdData, isLoading: isLoadingHousehold } = useQuery({
+    queryKey: ['statistics', 'household-distribution'],
+    queryFn: async () => {
+      const response = await enterpriseStatisticsClient['household-distribution'].$get()
+      return await response.json()
+    }
+  })
+
+  // 获取在职状态分布数据
+  const { data: jobStatusData, isLoading: isLoadingJobStatus } = useQuery({
+    queryKey: ['statistics', 'job-status-distribution'],
+    queryFn: async () => {
+      const response = await enterpriseStatisticsClient['job-status-distribution'].$get()
+      return await response.json()
+    }
+  })
+
+  // 获取薪资分布数据
+  const { data: salaryData, isLoading: isLoadingSalary } = useQuery({
+    queryKey: ['statistics', 'salary-distribution'],
+    queryFn: async () => {
+      const response = await enterpriseStatisticsClient['salary-distribution'].$get()
+      return await response.json()
+    }
+  })
+
+  const isLoading = isLoadingDisability || isLoadingGender || isLoadingAge ||
+                    isLoadingHousehold || isLoadingJobStatus || isLoadingSalary
+
   return (
     <YongrenTabBarLayout activeKey="statistics">
       <ScrollView
@@ -25,9 +92,237 @@ const Statistics: React.FC<StatisticsProps> = () => {
           fixed={true}
           placeholder={true}
         />
-        <View className="p-4">
-          <Text className="text-xl font-bold">数据统计</Text>
-          <Text className="text-gray-600 mt-2">企业数据统计页面(待实现)</Text>
+
+        {/* 时间筛选 */}
+        <View className="flex justify-between items-center mb-4 pt-4">
+          <Text className="font-semibold text-gray-700">数据统计</Text>
+          <View className="flex items-center bg-gray-100 rounded-lg px-3 py-1">
+            <Text className="text-sm text-gray-700 mr-2">2023年11月</Text>
+            <Text className="fas fa-chevron-down text-gray-500"></Text>
+          </View>
+        </View>
+
+        {/* 统计卡片 */}
+        <View className="grid grid-cols-2 gap-3 mb-4">
+          <View className="stat-card bg-white rounded-lg p-3 shadow-sm">
+            <Text className="text-sm text-gray-600 mb-2">在职人数</Text>
+            <Text className="text-2xl font-bold text-gray-800">24</Text>
+            <Text className="text-xs text-green-500 mt-1">↑ 比上月增加2人</Text>
+          </View>
+          <View className="stat-card bg-white rounded-lg p-3 shadow-sm">
+            <Text className="text-sm text-gray-600 mb-2">平均薪资</Text>
+            <Text className="text-2xl font-bold text-gray-800">¥4,650</Text>
+            <Text className="text-xs text-green-500 mt-1">↑ 比上月增加¥150</Text>
+          </View>
+          <View className="stat-card bg-white rounded-lg p-3 shadow-sm">
+            <Text className="text-sm text-gray-600 mb-2">在职率</Text>
+            <Text className="text-2xl font-bold text-gray-800">92%</Text>
+            <Text className="text-xs text-green-500 mt-1">↑ 比上月提升3%</Text>
+          </View>
+          <View className="stat-card bg-white rounded-lg p-3 shadow-sm">
+            <Text className="text-sm text-gray-600 mb-2">新增人数</Text>
+            <Text className="text-2xl font-bold text-gray-800">3</Text>
+            <Text className="text-xs text-red-500 mt-1">↓ 比上月减少1人</Text>
+          </View>
+        </View>
+
+        {/* 残疾类型分布 */}
+        <View className="card bg-white p-4 mb-4 rounded-lg shadow-sm">
+          <Text className="font-semibold text-gray-700 mb-3">残疾类型分布</Text>
+          {isLoadingDisability ? (
+            <Text className="text-gray-500 text-center py-4">加载中...</Text>
+          ) : disabilityData?.stats && disabilityData.stats.length > 0 ? (
+            <>
+              <View className="chart-container mb-2 h-40 relative">
+                {disabilityData.stats.map((item: DisabilityTypeDistributionResponse['stats'][0], index: number) => {
+                  const maxValue = Math.max(...disabilityData.stats.map((s: DisabilityTypeDistributionResponse['stats'][0]) => s.value || 0))
+                  const height = maxValue > 0 ? ((item.value || 0) / maxValue) * 160 : 0
+                  const left = 20 + index * 50
+                  return (
+                    <View
+                      key={item.key}
+                      className="chart-bar absolute bottom-0 bg-blue-500 rounded-t-lg"
+                      style={{ left: `${left}px`, height: `${height}px`, width: '30px' }}
+                    />
+                  )
+                })}
+              </View>
+              <View className="flex justify-between text-xs text-gray-500">
+                {disabilityData.stats.map((item: DisabilityTypeDistributionResponse['stats'][0]) => (
+                  <View key={item.key} className="flex flex-col items-center">
+                    <Text>{item.key}</Text>
+                    <Text className="text-xs text-gray-400">{item.percentage}%</Text>
+                  </View>
+                ))}
+              </View>
+            </>
+          ) : (
+            <Text className="text-gray-500 text-center py-4">暂无数据</Text>
+          )}
+        </View>
+
+        {/* 性别分布 */}
+        <View className="card bg-white p-4 mb-4 rounded-lg shadow-sm">
+          <Text className="font-semibold text-gray-700 mb-3">性别分布</Text>
+          {isLoadingGender ? (
+            <Text className="text-gray-500 text-center py-4">加载中...</Text>
+          ) : genderData?.stats && genderData.stats.length > 0 ? (
+            <View className="bar-chart flex items-end justify-center gap-10 h-32">
+              {genderData.stats.map((item: GenderDistributionResponse['stats'][0]) => {
+                const maxValue = Math.max(...genderData.stats.map((s: GenderDistributionResponse['stats'][0]) => s.value || 0))
+                const height = maxValue > 0 ? ((item.value || 0) / maxValue) * 100 : 0
+                const color = item.key === '男' ? '#3b82f6' : '#ec4899'
+                return (
+                  <View key={item.key} className="bar-container flex flex-col items-center">
+                    <Text className="bar-value text-sm font-semibold mb-1">{item.value}人</Text>
+                    <View
+                      className="bar rounded-t-lg"
+                      style={{ height: `${height}px`, width: '40px', backgroundColor: color }}
+                    />
+                    <Text className="bar-label text-xs text-gray-500 mt-2">
+                      {item.key} ({item.percentage}%)
+                    </Text>
+                  </View>
+                )
+              })}
+            </View>
+          ) : (
+            <Text className="text-gray-500 text-center py-4">暂无数据</Text>
+          )}
+        </View>
+
+        {/* 年龄分布 */}
+        <View className="card bg-white p-4 mb-4 rounded-lg shadow-sm">
+          <Text className="font-semibold text-gray-700 mb-3">年龄分布</Text>
+          {isLoadingAge ? (
+            <Text className="text-gray-500 text-center py-4">加载中...</Text>
+          ) : ageData?.stats && ageData.stats.length > 0 ? (
+            <>
+              <View className="pie-chart-container flex justify-center mb-4">
+                {/* 简单的饼图表示 - 实际项目可替换为SVG饼图 */}
+                <View className="w-32 h-32 rounded-full border-4 border-gray-300 flex items-center justify-center">
+                  <Text className="text-gray-500">饼图</Text>
+                </View>
+              </View>
+              <View className="pie-legend flex flex-wrap justify-center gap-3">
+                {ageData.stats.map((item: AgeDistributionResponse['stats'][0], index: number) => {
+                  const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#8b5cf6']
+                  const color = colors[index % colors.length]
+                  return (
+                    <View key={item.key} className="legend-item flex items-center">
+                      <View
+                        className="legend-color w-3 h-3 rounded-sm mr-2"
+                        style={{ backgroundColor: color }}
+                      />
+                      <Text className="text-xs text-gray-700">
+                        {item.key} ({item.percentage}%)
+                      </Text>
+                    </View>
+                  )
+                })}
+              </View>
+            </>
+          ) : (
+            <Text className="text-gray-500 text-center py-4">暂无数据</Text>
+          )}
+        </View>
+
+        {/* 户籍省份分布 */}
+        <View className="card bg-white p-4 mb-4 rounded-lg shadow-sm">
+          <Text className="font-semibold text-gray-700 mb-3">户籍省份分布</Text>
+          {isLoadingHousehold ? (
+            <Text className="text-gray-500 text-center py-4">加载中...</Text>
+          ) : householdData?.stats && householdData.stats.length > 0 ? (
+            <View className="space-y-3">
+              {householdData.stats.slice(0, 6).map((item: HouseholdDistributionResponse['stats'][0], index: number) => {
+                const maxValue = Math.max(...householdData.stats.map((s: HouseholdDistributionResponse['stats'][0]) => s.value || 0))
+                const width = maxValue > 0 ? ((item.value || 0) / maxValue) * 100 : 0
+                const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899']
+                const color = colors[index % colors.length]
+                return (
+                  <View key={item.key}>
+                    <View className="flex justify-between text-sm mb-1">
+                      <Text className="text-gray-700">{item.key}</Text>
+                      <Text className="text-gray-500">{item.value}人</Text>
+                    </View>
+                    <View className="progress-bar h-2 bg-gray-200 rounded-full overflow-hidden">
+                      <View
+                        className="progress-fill h-full rounded-full"
+                        style={{ width: `${width}%`, backgroundColor: color }}
+                      />
+                    </View>
+                  </View>
+                )
+              })}
+            </View>
+          ) : (
+            <Text className="text-gray-500 text-center py-4">暂无数据</Text>
+          )}
+        </View>
+
+        {/* 在职状态统计 */}
+        <View className="card bg-white p-4 mb-4 rounded-lg shadow-sm">
+          <Text className="font-semibold text-gray-700 mb-3">在职状态统计</Text>
+          {isLoadingJobStatus ? (
+            <Text className="text-gray-500 text-center py-4">加载中...</Text>
+          ) : jobStatusData?.stats && jobStatusData.stats.length > 0 ? (
+            <View className="flex items-center justify-center">
+              {/* 环形图占位 */}
+              <View className="w-32 h-32 rounded-full border-8 border-blue-500 border-t-transparent transform -rotate-45" />
+              <View className="ml-6">
+                {jobStatusData.stats.map((item: JobStatusDistributionResponse['stats'][0], index: number) => {
+                  const colors = ['#3b82f6', '#f59e0b', '#ef4444', '#10b981']
+                  const color = colors[index % colors.length]
+                  return (
+                    <View key={item.key} className="flex items-center mb-2">
+                      <View
+                        className="w-3 h-3 rounded-full mr-2"
+                        style={{ backgroundColor: color }}
+                      />
+                      <Text className="text-sm text-gray-700">
+                        {item.key}: {item.value}人 ({item.percentage}%)
+                      </Text>
+                    </View>
+                  )
+                })}
+              </View>
+            </View>
+          ) : (
+            <Text className="text-gray-500 text-center py-4">暂无数据</Text>
+          )}
+        </View>
+
+        {/* 薪资分布 */}
+        <View className="card bg-white p-4 rounded-lg shadow-sm">
+          <Text className="font-semibold text-gray-700 mb-3">薪资分布</Text>
+          {isLoadingSalary ? (
+            <Text className="text-gray-500 text-center py-4">加载中...</Text>
+          ) : salaryData?.stats && salaryData.stats.length > 0 ? (
+            <View className="space-y-3">
+              {salaryData.stats.map((item: SalaryDistributionResponse['stats'][0], index: number) => {
+                const maxValue = Math.max(...salaryData.stats.map((s: SalaryDistributionResponse['stats'][0]) => s.value || 0))
+                const width = maxValue > 0 ? ((item.value || 0) / maxValue) * 100 : 0
+                const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444']
+                const color = colors[index % colors.length]
+                return (
+                  <View key={item.key}>
+                    <View className="flex justify-between text-sm mb-1">
+                      <Text className="text-gray-700">{item.key}</Text>
+                      <Text className="text-gray-500">{item.value}人</Text>
+                    </View>
+                    <View className="progress-bar h-2 bg-gray-200 rounded-full overflow-hidden">
+                      <View
+                        className="progress-fill h-full rounded-full"
+                        style={{ width: `${width}%`, backgroundColor: color }}
+                      />
+                    </View>
+                  </View>
+                )
+              })}
+            </View>
+          ) : (
+            <Text className="text-gray-500 text-center py-4">暂无数据</Text>
+          )}
         </View>
       </ScrollView>
     </YongrenTabBarLayout>

+ 6 - 0
pnpm-lock.yaml

@@ -1788,6 +1788,9 @@ importers:
 
   mini-ui-packages/yongren-statistics-ui:
     dependencies:
+      '@d8d/allin-statistics-module':
+        specifier: workspace:*
+        version: link:../../allin-packages/statistics-module
       '@d8d/mini-enterprise-auth-ui':
         specifier: workspace:*
         version: link:../mini-enterprise-auth-ui
@@ -1840,6 +1843,9 @@ importers:
       '@types/react-dom':
         specifier: ^18.0.0
         version: 18.3.7(@types/react@18.3.26)
+      hono:
+        specifier: 4.8.5
+        version: 4.8.5
       jest:
         specifier: ^30.2.0
         version: 30.2.0(@types/node@18.19.130)