Jelajahi Sumber

docs: 添加企业端首页统计数据修复技术规格

- 创建快速流技术规格 (tech-spec-enterprise-dashboard-stats-fix.md)
- 目标:修复首页硬编码的在职率 (92%) 和前端计算的平均薪资
- 方案:使用真实 API 数据替代硬编码和前端计算
- 7 个实现任务,3 个提交点策略,9 个验收标准
- 经过派对模式讨论和对抗性审查

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 1 hari lalu
induk
melakukan
0f8a841bf1

+ 346 - 0
_bmad-output/implementation-artifacts/tech-spec-enterprise-dashboard-stats-fix.md

@@ -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`