|
|
@@ -0,0 +1,346 @@
|
|
|
+---
|
|
|
+title: '企业端首页统计数据修复'
|
|
|
+slug: 'enterprise-dashboard-stats-fix'
|
|
|
+created: '2026-03-09T00:00:00.000Z'
|
|
|
+status: 'ready-for-dev'
|
|
|
+stepsCompleted: [1, 2, 3, 4]
|
|
|
+tech_stack: ['React 19', 'Taro 3.x', 'TypeScript 5.9', '@tanstack/react-query', 'Hono 4.x RPC Client']
|
|
|
+files_to_modify: ['mini/src/pages/yongren/dashboard/index.tsx']
|
|
|
+code_patterns: ['useQuery with queryKey and queryFn', 'RPC client type inference with InferResponseType', 'Loading state with isLoading flag']
|
|
|
+test_patterns: ['Jest for mini package', 'Test files in mini/tests/ directory', 'API client tests in yongren-api.test.ts']
|
|
|
+---
|
|
|
+
|
|
|
+# Tech-Spec: 企业端首页统计数据修复
|
|
|
+
|
|
|
+**Created:** 2026-03-09
|
|
|
+
|
|
|
+## Overview
|
|
|
+
|
|
|
+### Problem Statement
|
|
|
+
|
|
|
+企业端小程序首页(Dashboard)显示的统计数据与数据统计页面不一致:
|
|
|
+
|
|
|
+1. **在职率**: 首页显示硬编码的 `92%`,数据统计页面显示真实 API 数据 `100%`
|
|
|
+2. **平均薪资**: 首页从近期分配列表(5条记录)前端计算得出 `¥4,500`,数据统计页面使用 API 数据 `¥2,500`
|
|
|
+
|
|
|
+这种不一致会:
|
|
|
+- 损害数据可信度
|
|
|
+- 造成用户困惑
|
|
|
+- 影响业务决策
|
|
|
+
|
|
|
+### Solution
|
|
|
+
|
|
|
+修改首页 (`mini/src/pages/yongren/dashboard/index.tsx`),使用真实 API 数据替代硬编码和前端计算:
|
|
|
+
|
|
|
+1. **在职率**: 调用 `/api/v1/yongren/statistics/employment-rate` API,使用返回的 `rate` 值
|
|
|
+2. **平均薪资**: 调用 `/api/v1/yongren/statistics/average-salary` API,使用返回的 `average` 值
|
|
|
+
|
|
|
+### Scope
|
|
|
+
|
|
|
+**In Scope:**
|
|
|
+- 修改首页的在职率显示,使用真实 API 数据
|
|
|
+- 修改首页的平均薪资显示,使用真实 API 数据
|
|
|
+- 保持与数据统计页面相同的数据源
|
|
|
+
|
|
|
+**Out of Scope:**
|
|
|
+- 修改数据统计页面
|
|
|
+- 修改后端 API 逻辑
|
|
|
+- 其他统计指标的修改
|
|
|
+
|
|
|
+## Context for Development
|
|
|
+
|
|
|
+### Codebase Patterns
|
|
|
+
|
|
|
+**React Query 数据获取模式:**
|
|
|
+```typescript
|
|
|
+// 使用 @tanstack/react-query 的 useQuery 钩子
|
|
|
+const { data, isLoading } = useQuery({
|
|
|
+ queryKey: ['key'],
|
|
|
+ queryFn: async () => {
|
|
|
+ const response = await client.api.$get()
|
|
|
+ return await response.json()
|
|
|
+ },
|
|
|
+ refetchOnWindowFocus: false
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+**RPC 客户端类型推断:**
|
|
|
+```typescript
|
|
|
+import type { InferResponseType } from 'hono/client'
|
|
|
+import { enterpriseStatisticsClient } from '@/api/enterpriseStatisticsClient'
|
|
|
+
|
|
|
+// 获取响应类型
|
|
|
+type EmploymentRateResponse = InferResponseType<typeof enterpriseStatisticsClient['employment-rate']['$get'], 200>
|
|
|
+```
|
|
|
+
|
|
|
+**Taro 小程序开发规范:**
|
|
|
+- 使用 `View` 组件时添加 `flex flex-col` 实现纵向布局
|
|
|
+- 使用 Heroicons 图标类,不使用 emoji
|
|
|
+- 加载状态使用 `isLoading` 判断
|
|
|
+
|
|
|
+### Files to Reference
|
|
|
+
|
|
|
+| File | Purpose |
|
|
|
+| ---- | ------- |
|
|
|
+| `mini/src/pages/yongren/dashboard/index.tsx` | 首页组件,需要修改的目标文件 |
|
|
|
+| `mini/src/pages/yongren/statistics/index.tsx` | 数据统计页面,参考正确实现 |
|
|
|
+| `mini/src/api/enterpriseStatisticsClient.ts` | 统计 API 客户端定义 |
|
|
|
+| `mini/src/types/statisticsTypes.ts` | 统计数据类型定义 |
|
|
|
+
|
|
|
+### Technical Decisions
|
|
|
+
|
|
|
+1. **复用现有 API**: 使用与数据统计页面相同的 API 端点,确保数据一致性
|
|
|
+2. **独立查询**: 首页使用独立的 `useQuery` 调用,不依赖数据统计页面的缓存
|
|
|
+3. **加载状态**: 添加加载状态显示,改善用户体验
|
|
|
+
|
|
|
+## Implementation Plan
|
|
|
+
|
|
|
+### Tasks
|
|
|
+
|
|
|
+#### 任务 1: 添加类型导入和 API 客户端导入
|
|
|
+- [ ] **任务 1**: 导入所需的类型和 API 客户端
|
|
|
+ - **文件**: `mini/src/pages/yongren/dashboard/index.tsx`
|
|
|
+ - **操作**: 在文件顶部添加以下导入:
|
|
|
+ ```typescript
|
|
|
+ import { enterpriseStatisticsClient } from '@/api/enterpriseStatisticsClient'
|
|
|
+ import type {
|
|
|
+ EmploymentRateResponse,
|
|
|
+ AverageSalaryResponse
|
|
|
+ } from '@/types/statisticsTypes'
|
|
|
+ ```
|
|
|
+ - **说明**: 确保 `enterpriseStatisticsClient` 和类型定义可用
|
|
|
+
|
|
|
+#### 任务 2: 添加在职率 API 查询
|
|
|
+- [ ] **任务 2**: 添加在职率数据的 useQuery 钩子
|
|
|
+ - **文件**: `mini/src/pages/yongren/dashboard/index.tsx`
|
|
|
+ - **操作**: 在现有的 `useQuery` 调用后(约第 105 行后)添加:
|
|
|
+ ```typescript
|
|
|
+ // 获取在职率统计
|
|
|
+ const { data: employmentRateData, isLoading: isLoadingEmploymentRate } = useQuery({
|
|
|
+ queryKey: ['dashboard', 'employment-rate'],
|
|
|
+ queryFn: async () => {
|
|
|
+ const response = await enterpriseStatisticsClient['employment-rate'].$get()
|
|
|
+ return await response.json()
|
|
|
+ },
|
|
|
+ staleTime: 5 * 60 * 1000, // 5分钟缓存
|
|
|
+ gcTime: 10 * 60 * 1000,
|
|
|
+ refetchOnWindowFocus: false
|
|
|
+ })
|
|
|
+ ```
|
|
|
+ - **说明**: 使用独立的 queryKey 避免与数据统计页面缓存冲突
|
|
|
+
|
|
|
+#### 任务 3: 添加平均薪资 API 查询
|
|
|
+- [ ] **任务 3**: 添加平均薪资数据的 useQuery 钩子
|
|
|
+ - **文件**: `mini/src/pages/yongren/dashboard/index.tsx`
|
|
|
+ - **操作**: 在任务 2 的查询后添加:
|
|
|
+ ```typescript
|
|
|
+ // 获取平均薪资统计
|
|
|
+ const { data: averageSalaryData, isLoading: isLoadingAverageSalary } = useQuery({
|
|
|
+ queryKey: ['dashboard', 'average-salary'],
|
|
|
+ queryFn: async () => {
|
|
|
+ const response = await enterpriseStatisticsClient['average-salary'].$get()
|
|
|
+ return await response.json()
|
|
|
+ },
|
|
|
+ staleTime: 5 * 60 * 1000,
|
|
|
+ gcTime: 10 * 60 * 1000,
|
|
|
+ refetchOnWindowFocus: false
|
|
|
+ })
|
|
|
+ ```
|
|
|
+ - **说明**: 独立查询,与数据统计页面分离
|
|
|
+
|
|
|
+#### 任务 4: 添加类型守卫函数
|
|
|
+- [ ] **任务 4**: 添加 API 响应类型守卫
|
|
|
+ - **文件**: `mini/src/pages/yongren/dashboard/index.tsx`
|
|
|
+ - **操作**: 在组件内部、hooks 之前添加:
|
|
|
+ ```typescript
|
|
|
+ // 类型守卫:检查响应是否成功
|
|
|
+ const isEmploymentRateSuccess = (data: any): data is EmploymentRateResponse => {
|
|
|
+ return data && typeof data === 'object' && 'rate' in data
|
|
|
+ }
|
|
|
+
|
|
|
+ const isAverageSalarySuccess = (data: any): data is AverageSalaryResponse => {
|
|
|
+ return data && typeof data === 'object' && 'average' in data
|
|
|
+ }
|
|
|
+ ```
|
|
|
+ - **说明**: 参考数据统计页面的类型守卫实现
|
|
|
+
|
|
|
+#### 任务 5: 修改在职率显示逻辑
|
|
|
+- [ ] **任务 5**: 替换硬编码的在职率为真实 API 数据
|
|
|
+ - **文件**: `mini/src/pages/yongren/dashboard/index.tsx`
|
|
|
+ - **操作**: 找到第 337 行,替换:
|
|
|
+ ```tsx
|
|
|
+ // 原代码:
|
|
|
+ {overview?.totalEmployees ? '92%' : '--'}
|
|
|
+
|
|
|
+ // 替换为:
|
|
|
+ {isLoadingEmploymentRate ? (
|
|
|
+ <Text className="text-2xl font-bold text-gray-400">加载中...</Text>
|
|
|
+ ) : !isEmploymentRateSuccess(employmentRateData) ? (
|
|
|
+ <Text className="text-2xl font-bold text-gray-400">--</Text>
|
|
|
+ ) : (
|
|
|
+ <Text className="text-2xl font-bold text-gray-800">{employmentRateData.rate ?? 0}%</Text>
|
|
|
+ )}
|
|
|
+ ```
|
|
|
+ - **说明**: 添加加载状态和错误处理
|
|
|
+
|
|
|
+#### 任务 6: 修改平均薪资显示逻辑
|
|
|
+- [ ] **任务 6**: 替换前端计算的平均薪资为真实 API 数据
|
|
|
+ - **文件**: `mini/src/pages/yongren/dashboard/index.tsx`
|
|
|
+ - **操作**: 找到第 341-346 行,替换:
|
|
|
+ ```tsx
|
|
|
+ // 原代码:
|
|
|
+ <Text className="text-sm text-gray-600 mb-2">平均薪资</Text>
|
|
|
+ <Text className="text-2xl font-bold text-gray-800">
|
|
|
+ {allocations && allocations.length > 0
|
|
|
+ ? `¥${Math.round(allocations.reduce((sum: number, a: AllocationData) => sum + a.salary, 0) / allocations.length).toLocaleString()}`
|
|
|
+ : '¥0'}
|
|
|
+ </Text>
|
|
|
+
|
|
|
+ // 替换为:
|
|
|
+ <Text className="text-sm text-gray-600 mb-2">平均薪资</Text>
|
|
|
+ {isLoadingAverageSalary ? (
|
|
|
+ <Text className="text-2xl font-bold text-gray-400">加载中...</Text>
|
|
|
+ ) : !isAverageSalarySuccess(averageSalaryData) ? (
|
|
|
+ <Text className="text-2xl font-bold text-gray-400">--</Text>
|
|
|
+ ) : (
|
|
|
+ <Text className="text-2xl font-bold text-gray-800">¥{(averageSalaryData.average ?? 0).toLocaleString()}</Text>
|
|
|
+ )}
|
|
|
+ ```
|
|
|
+ - **说明**: 移除对 allocations 数据的依赖,使用 API 数据
|
|
|
+
|
|
|
+#### 任务 7: 更新下拉刷新逻辑
|
|
|
+- [ ] **任务 7**: 在下拉刷新中添加新查询的失效触发
|
|
|
+ - **文件**: `mini/src/pages/yongren/dashboard/index.tsx`
|
|
|
+ - **操作**: 找到 `usePullDownRefresh` 中的 `Promise.all`,添加新的查询失效:
|
|
|
+ ```typescript
|
|
|
+ await Promise.all([
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['enterpriseOverview'] }),
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['recentAllocations'] }),
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['dashboard', 'employment-rate'] }), // 新增
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['dashboard', 'average-salary'] }), // 新增
|
|
|
+ ])
|
|
|
+ ```
|
|
|
+ - **说明**: 确保下拉刷新时更新所有统计数据
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 提交策略(Bob 建议)
|
|
|
+
|
|
|
+为了逐步验证功能并降低风险,建议将 7 个任务拆分为 3 个提交点:
|
|
|
+
|
|
|
+**提交 1: 基础准备** (任务 1-4)
|
|
|
+- 添加导入语句
|
|
|
+- 添加 API 查询 hooks
|
|
|
+- 添加类型守卫函数
|
|
|
+- **验证点**: 代码无语法错误,类型检查通过
|
|
|
+
|
|
|
+**提交 2: 在职率修复** (任务 5)
|
|
|
+- 修改在职率显示逻辑
|
|
|
+- **验证点**: 首页在职率显示 API 数据,与统计页一致
|
|
|
+
|
|
|
+**提交 3: 完成修复** (任务 6-7)
|
|
|
+- 修改平均薪资显示逻辑
|
|
|
+- 更新下拉刷新
|
|
|
+- **验证点**: 两个指标都正确显示,下拉刷新有效
|
|
|
+
|
|
|
+### Acceptance Criteria
|
|
|
+
|
|
|
+#### AC 1: 在职率显示真实数据
|
|
|
+- [ ] **Given** 用户已登录企业端小程序,**When** 用户访问首页,**Then** 在职率应显示来自 API 的真实数据(例如:100%),而非硬编码的 92%
|
|
|
+
|
|
|
+#### AC 2: 平均薪资显示真实数据
|
|
|
+- [ ] **Given** 用户已登录企业端小程序,**When** 用户访问首页,**Then** 平均薪资应显示来自 API 的真实数据(例如:¥2,500),而非前端计算的 ¥4,500
|
|
|
+
|
|
|
+#### AC 3: 数据一致性(含缓存说明)
|
|
|
+- [ ] **Given** 用户已登录企业端小程序,**When** 用户在首页和数据统计页面之间快速切换(5分钟缓存期内),**Then** 在职率和平均薪资应显示相同的数值
|
|
|
+- [ ] **Note**: 由于首页使用 5 分钟缓存,数据统计页使用独立缓存,在缓存过期时间点可能出现短暂不一致,这是预期行为
|
|
|
+
|
|
|
+#### AC 4: 加载状态处理
|
|
|
+- [ ] **Given** API 请求进行中,**When** 用户访问首页,**Then** 应显示"加载中..."文本而非旧数据或空白
|
|
|
+
|
|
|
+#### AC 5: 错误处理
|
|
|
+- [ ] **Given** API 请求失败,**When** 用户访问首页,**Then** 应显示"--"而非崩溃或 undefined
|
|
|
+
|
|
|
+#### AC 6: 下拉刷新
|
|
|
+- [ ] **Given** 用户在首页,**When** 用户执行下拉刷新操作,**Then** 在职率和平均薪资数据应更新为最新值
|
|
|
+
|
|
|
+#### AC 7: 无数据状态
|
|
|
+- [ ] **Given** 企业没有员工数据,**When** 用户访问首页,**Then** 在职率应显示 0%,平均薪资应显示 ¥0
|
|
|
+
|
|
|
+#### AC 8: 边界值显示(Quinn 补充)
|
|
|
+- [ ] **Given** 在职率为 0% 或 100%,**When** 用户访问首页,**Then** 边界值应正确显示,无溢出或格式错误
|
|
|
+
|
|
|
+#### AC 9: 大数值格式化(Quinn 补充)
|
|
|
+- [ ] **Given** 平均薪资超过 10,000,**When** 用户访问首页,**Then** 应正确显示千分位分隔符(如:¥12,500)
|
|
|
+
|
|
|
+## Additional Context
|
|
|
+
|
|
|
+### Dependencies
|
|
|
+
|
|
|
+**外部依赖:**
|
|
|
+- `@tanstack/react-query` - 已安装,用于数据获取
|
|
|
+- `@hono/client` - 已安装,用于 RPC 类型推断
|
|
|
+- `enterpriseStatisticsClient` - 已存在的 API 客户端
|
|
|
+- 类型定义 - 已存在于 `mini/src/types/statisticsTypes.ts`
|
|
|
+
|
|
|
+**API 依赖:**
|
|
|
+- `/api/v1/yongren/statistics/employment-rate` - 已存在的后端 API
|
|
|
+- `/api/v1/yongren/statistics/average-salary` - 已存在的后端 API
|
|
|
+
|
|
|
+### Testing Strategy
|
|
|
+
|
|
|
+**单元测试 (可选):**
|
|
|
+- 验证类型守卫函数正确识别有效/无效响应
|
|
|
+- 验证加载状态和错误状态的渲染逻辑
|
|
|
+
|
|
|
+**集成测试:**
|
|
|
+- 使用 Playwright E2E 测试验证首页统计数据正确显示
|
|
|
+- **基础测试场景:**
|
|
|
+ 1. 登录后验证在职率和平均薪资显示
|
|
|
+ 2. 与数据统计页面对比验证数据一致性
|
|
|
+ 3. 下拉刷新后验证数据更新
|
|
|
+ 4. API 错误时验证"--"显示
|
|
|
+
|
|
|
+**补充测试场景(Quinn 建议):**
|
|
|
+ 5. **边界值测试**: 当在职率为 0% 或 100% 时验证正确显示
|
|
|
+ 6. **大数值测试**: 当平均薪资 ≥ 10,000 时验证千分位格式化(如:¥12,345)
|
|
|
+ 7. **网络延迟模拟**: 在慢速网络下验证"加载中..."状态持续显示
|
|
|
+
|
|
|
+**手动测试步骤:**
|
|
|
+1. 启动开发服务器: `pnpm dev`
|
|
|
+2. 访问企业端小程序: `http://localhost:8080/mini`
|
|
|
+3. 使用测试账号登录 (13800013800 / 123123)
|
|
|
+4. 验证首页在职率和平均薪资显示
|
|
|
+5. 切换到数据统计页面,对比数值
|
|
|
+6. 返回首页,执行下拉刷新
|
|
|
+7. 验证数据已更新
|
|
|
+
|
|
|
+### Notes
|
|
|
+
|
|
|
+**代码位置详细信息:**
|
|
|
+
|
|
|
+1. **在职率硬编码位置**: `dashboard/index.tsx:337`
|
|
|
+ ```tsx
|
|
|
+ {overview?.totalEmployees ? '92%' : '--'}
|
|
|
+ ```
|
|
|
+
|
|
|
+2. **平均薪资计算位置**: `dashboard/index.tsx:344`
|
|
|
+ ```tsx
|
|
|
+ {allocations && allocations.length > 0
|
|
|
+ ? `¥${Math.round(allocations.reduce((sum: number, a: AllocationData) => sum + a.salary, 0) / allocations.length).toLocaleString()}`
|
|
|
+ : '¥0'}
|
|
|
+ ```
|
|
|
+
|
|
|
+3. **参考实现 - 数据统计页面**:
|
|
|
+ - 在职率 API: `statistics/index.tsx:166-174`
|
|
|
+ - 平均薪资 API: `statistics/index.tsx:155-163`
|
|
|
+ - 使用示例: `statistics/index.tsx:300` (显示 `averageSalaryData.average`)
|
|
|
+ - 使用示例: `statistics/index.tsx:312` (显示 `employmentRateData.rate`)
|
|
|
+
|
|
|
+4. **API 响应结构**:
|
|
|
+ - `employment-rate` 返回: `{ rate: number }`
|
|
|
+ - `average-salary` 返回: `{ average: number }`
|
|
|
+
|
|
|
+5. **类型定义**: `mini/src/types/statisticsTypes.ts`
|
|
|
+ - `EmploymentRateResponse`
|
|
|
+ - `AverageSalaryResponse`
|