Просмотр исходного кода

feat(stories): 完成故事017.004 - 实现人才小程序考勤记录功能

- 实现考勤日历视图,标记打卡状态(绿色已打卡/灰色未打卡)
- 实现月份选择器,支持上个月/下个月切换
- 实现考勤统计卡片(出勤率、正常出勤天数、异常统计)
- 实现打卡明细列表(按日期倒序,支持状态标签)
- 使用前端模拟数据,数据结构符合后续API接口规范
- 集成Navbar导航栏(TabBar页面无返回按钮)
- 编写36个测试用例,全部通过

🤖 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 недель назад
Родитель
Сommit
2c49283529

+ 33 - 5
docs/stories/017.004.story.md

@@ -3,7 +3,7 @@
 ## 元信息
 - **史诗**: 017 - 人才小程序功能实现
 - **优先级**: P1 - 核心功能
-- **状态**: Approved
+- **状态**: Ready for Review
 - **创建日期**: 2025-12-26
 - **负责人**: 开发团队
 
@@ -1212,19 +1212,47 @@ pnpm test:coverage
 
 ### 使用的代理模型
 
-待填写
+Claude Sonnet (claude-sonnet-4-20250514)
 
 ### 调试日志引用
 
-待填写
+无特殊调试需求
 
 ### 完成说明列表
 
-待填写
+1. **类型定义** (`src/types/attendance.ts`): 创建了AttendanceStats、AttendanceRecord接口和AttendanceStatus枚举
+2. **前端模拟数据** (`src/utils/mockAttendanceData.ts`): 实现数据工厂模式,生成符合后续API接口规范的模拟数据
+3. **月份选择器** (`src/components/MonthSelector.tsx`): 支持月份切换,使用Heroicons图标
+4. **考勤统计卡片** (`src/components/AttendanceStats.tsx`): 显示出勤率、正常出勤天数和异常统计
+5. **考勤日历** (`src/components/AttendanceCalendar.tsx`): 7列网格布局,标记已打卡日期(绿色)和未打卡日期(灰色)
+6. **打卡记录项** (`src/components/AttendanceRecordItem.tsx`): 显示单条打卡记录,支持状态颜色标签
+7. **打卡明细列表** (`src/components/AttendanceDetails.tsx`): 显示打卡记录列表,按日期倒序排列
+8. **主页面组件** (`src/pages/AttendancePage/AttendancePage.tsx`): 集成所有子组件,实现月份切换和数据加载
+9. **测试覆盖**: 编写36个测试用例,覆盖所有组件和页面功能
 
 ### 文件列表
 
-待填写
+**新增源代码文件**:
+- `mini-ui-packages/rencai-attendance-ui/src/types/attendance.ts` - 类型定义
+- `mini-ui-packages/rencai-attendance-ui/src/utils/mockAttendanceData.ts` - 前端模拟数据
+- `mini-ui-packages/rencai-attendance-ui/src/components/MonthSelector.tsx` - 月份选择器
+- `mini-ui-packages/rencai-attendance-ui/src/components/AttendanceStats.tsx` - 考勤统计卡片
+- `mini-ui-packages/rencai-attendance-ui/src/components/AttendanceCalendar.tsx` - 考勤日历
+- `mini-ui-packages/rencai-attendance-ui/src/components/AttendanceRecordItem.tsx` - 打卡记录项
+- `mini-ui-packages/rencai-attendance-ui/src/components/AttendanceDetails.tsx` - 打卡明细列表
+
+**修改源代码文件**:
+- `mini-ui-packages/rencai-attendance-ui/src/pages/AttendancePage/AttendancePage.tsx` - 更新主页面组件
+
+**新增测试文件**:
+- `tests/unit/components/MonthSelector.test.tsx` - 月份选择器测试
+- `tests/unit/components/AttendanceStats.test.tsx` - 考勤统计测试
+- `tests/unit/components/AttendanceCalendar.test.tsx` - 考勤日历测试
+- `tests/unit/components/AttendanceRecordItem.test.tsx` - 打卡记录项测试
+- `tests/unit/components/AttendanceDetails.test.tsx` - 打卡明细列表测试
+- `tests/pages/AttendancePage/AttendancePage.test.tsx` - 主页面测试
+
+**测试结果**: 36个测试全部通过
 
 ## QA结果
 

+ 110 - 0
mini-ui-packages/rencai-attendance-ui/src/components/AttendanceCalendar.tsx

@@ -0,0 +1,110 @@
+/**
+ * 考勤日历组件
+ * 显示当前月份的日历视图,标记打卡状态
+ */
+import React from 'react'
+import { View, Text } from '@tarojs/components'
+import { AttendanceCalendarProps } from '../types/attendance'
+
+/**
+ * 获取指定月份的日期网格
+ */
+function getCalendarDates(year: number, month: number): (Date | null)[] {
+  const dates: (Date | null)[] = []
+  const firstDay = new Date(year, month - 1, 1)
+  const lastDay = new Date(year, month, 0)
+  const firstDayOfWeek = firstDay.getDay()
+  const daysInMonth = lastDay.getDate()
+
+  // 填充月初空白
+  for (let i = 0; i < firstDayOfWeek; i++) {
+    dates.push(null)
+  }
+
+  // 填充日期
+  for (let day = 1; day <= daysInMonth; day++) {
+    dates.push(new Date(year, month - 1, day))
+  }
+
+  return dates
+}
+
+/**
+ * 判断是否为周末
+ */
+function isWeekend(date: Date): boolean {
+  const day = date.getDay()
+  return day === 0 || day === 6
+}
+
+/**
+ * 格式化日期为 YYYY-MM-DD
+ */
+function formatDate(date: Date): string {
+  const year = date.getFullYear()
+  const month = String(date.getMonth() + 1).padStart(2, '0')
+  const day = String(date.getDate()).padStart(2, '0')
+  return `${year}-${month}-${day}`
+}
+
+/**
+ * 检查日期是否有打卡记录
+ */
+function hasAttendanceRecord(date: Date, records: any[]): boolean {
+  const dateStr = formatDate(date)
+  return records.some(r => r.date === dateStr && r.checkInTime !== '--:--')
+}
+
+export const AttendanceCalendar: React.FC<AttendanceCalendarProps> = ({
+  year,
+  month,
+  attendanceRecords
+}) => {
+  const dates = getCalendarDates(year, month)
+
+  return (
+    <View className="bg-white rounded-lg p-4 mb-4 mx-4">
+      <Text className="font-semibold text-gray-700 mb-3 text-base">考勤日历</Text>
+
+      {/* 星期标题 */}
+      <View className="grid grid-cols-7 gap-1 mb-2">
+        <View className="text-center text-xs text-gray-500"><Text>日</Text></View>
+        <View className="text-center text-xs text-gray-500"><Text>一</Text></View>
+        <View className="text-center text-xs text-gray-500"><Text>二</Text></View>
+        <View className="text-center text-xs text-gray-500"><Text>三</Text></View>
+        <View className="text-center text-xs text-gray-500"><Text>四</Text></View>
+        <View className="text-center text-xs text-gray-500"><Text>五</Text></View>
+        <View className="text-center text-xs text-gray-500"><Text>六</Text></View>
+      </View>
+
+      {/* 日期网格 */}
+      <View className="grid grid-cols-7 gap-1">
+        {dates.map((date, index) => {
+          if (!date) {
+            return <View key={`empty-${index}`} className="p-2" />
+          }
+
+          const attended = hasAttendanceRecord(date, attendanceRecords)
+          const weekend = isWeekend(date)
+
+          return (
+            <View
+              key={date.toISOString()}
+              className={`
+                text-center
+                p-2
+                rounded-full
+                ${attended ? 'bg-green-500' : 'bg-gray-200'}
+                ${weekend && !attended ? 'text-gray-400' : ''}
+              `}
+            >
+              <Text className={`text-sm ${attended ? 'text-white' : ''}`}>
+                {date.getDate()}
+              </Text>
+            </View>
+          )
+        })}
+      </View>
+    </View>
+  )
+}

+ 23 - 0
mini-ui-packages/rencai-attendance-ui/src/components/AttendanceDetails.tsx

@@ -0,0 +1,23 @@
+/**
+ * 打卡明细列表组件
+ * 显示打卡记录列表,按日期倒序排列
+ */
+import React from 'react'
+import { View, Text } from '@tarojs/components'
+import { AttendanceDetailsProps } from '../types/attendance'
+import { AttendanceRecordItem } from './AttendanceRecordItem'
+
+export const AttendanceDetails: React.FC<AttendanceDetailsProps> = ({ records }) => {
+  return (
+    <View className="bg-white rounded-lg p-4 mb-4 mx-4">
+      <Text className="font-semibold text-gray-700 mb-3 text-base">打卡明细</Text>
+
+      {/* 列表容器 - 垂直布局 */}
+      <View className="flex flex-col space-y-3">
+        {records.map((record) => (
+          <AttendanceRecordItem key={record.date} record={record} />
+        ))}
+      </View>
+    </View>
+  )
+}

+ 81 - 0
mini-ui-packages/rencai-attendance-ui/src/components/AttendanceRecordItem.tsx

@@ -0,0 +1,81 @@
+/**
+ * 打卡记录项组件
+ * 显示单条打卡记录的详细信息
+ */
+import React from 'react'
+import { View, Text } from '@tarojs/components'
+import { AttendanceRecordItemProps, AttendanceStatus } from '../types/attendance'
+
+/**
+ * 获取状态颜色
+ */
+function getStatusColor(status: AttendanceStatus): string {
+  switch (status) {
+    case AttendanceStatus.NORMAL:
+      return 'bg-green-100 text-green-800'
+    case AttendanceStatus.LATE:
+      return 'bg-yellow-100 text-yellow-800'
+    case AttendanceStatus.EARLY_LEAVE:
+      return 'bg-orange-100 text-orange-800'
+    case AttendanceStatus.ABSENT:
+      return 'bg-red-100 text-red-800'
+    default:
+      return 'bg-gray-100 text-gray-800'
+  }
+}
+
+/**
+ * 获取状态标签
+ */
+function getStatusLabel(status: AttendanceStatus): string {
+  switch (status) {
+    case AttendanceStatus.NORMAL:
+      return '正常'
+    case AttendanceStatus.LATE:
+      return '迟到'
+    case AttendanceStatus.EARLY_LEAVE:
+      return '早退'
+    case AttendanceStatus.ABSENT:
+      return '缺勤'
+    default:
+      return '未知'
+  }
+}
+
+export const AttendanceRecordItem: React.FC<AttendanceRecordItemProps> = ({ record }) => {
+  const statusColor = getStatusColor(record.status)
+  const statusLabel = getStatusLabel(record.status)
+
+  // 格式化日期显示
+  const dateDisplay = formatChineseDate(record.date)
+
+  return (
+    <View className="flex justify-between items-center p-3 border border-gray-100 rounded-lg">
+      {/* 左侧:日期和时间 */}
+      <View>
+        <Text className="text-sm font-medium text-gray-800">{dateDisplay} {record.weekday}</Text>
+        <View className="flex text-xs text-gray-500 mt-1">
+          <Text className="mr-3">上班: {record.checkInTime}</Text>
+          <Text>下班: {record.checkOutTime}</Text>
+        </View>
+      </View>
+
+      {/* 右侧:状态标签 */}
+      <View className={`text-xs px-2 py-1 rounded-full ${statusColor}`}>
+        <Text>{statusLabel}</Text>
+      </View>
+    </View>
+  )
+}
+
+/**
+ * 格式化日期为中文格式
+ * @param dateStr YYYY-MM-DD格式日期
+ * @returns 中文格式日期(如:11月25日)
+ */
+function formatChineseDate(dateStr: string): string {
+  const date = new Date(dateStr)
+  const month = date.getMonth() + 1
+  const day = date.getDate()
+  return `${month}月${day}日`
+}

+ 32 - 0
mini-ui-packages/rencai-attendance-ui/src/components/AttendanceStats.tsx

@@ -0,0 +1,32 @@
+/**
+ * 考勤统计卡片组件
+ * 显示出勤率、正常出勤天数、异常统计
+ */
+import React from 'react'
+import { View, Text } from '@tarojs/components'
+import { AttendanceStatsProps } from '../types/attendance'
+
+export const AttendanceStats: React.FC<AttendanceStatsProps> = ({ stats }) => {
+  return (
+    <View className="bg-white rounded-lg p-4 mb-4 mx-4">
+      {/* 两列统计布局 */}
+      <View className="flex justify-between items-center mb-4">
+        <View>
+          <Text className="text-gray-700 text-sm">出勤率</Text>
+          <Text className="text-2xl font-bold text-gray-800">{stats.attendanceRate}%</Text>
+        </View>
+        <View className="text-right">
+          <Text className="text-gray-700 text-sm">正常出勤</Text>
+          <Text className="text-2xl font-bold text-gray-800">{stats.normalDays}天</Text>
+        </View>
+      </View>
+
+      {/* 异常统计 */}
+      <View className="flex justify-between text-xs text-gray-500">
+        <Text>迟到: {stats.lateCount}次</Text>
+        <Text>早退: {stats.earlyLeaveCount}次</Text>
+        <Text>缺勤: {stats.absentCount}次</Text>
+      </View>
+    </View>
+  )
+}

+ 32 - 0
mini-ui-packages/rencai-attendance-ui/src/components/MonthSelector.tsx

@@ -0,0 +1,32 @@
+/**
+ * 月份选择器组件
+ * 支持切换上个月/下个月
+ */
+import React from 'react'
+import { View, Text } from '@tarojs/components'
+import { MonthSelectorProps } from '../types/attendance'
+
+export const MonthSelector: React.FC<MonthSelectorProps> = ({
+  currentMonth,
+  onPreviousMonth,
+  onNextMonth
+}) => {
+  return (
+    <View className="flex justify-between items-center mb-4 px-4">
+      <Text className="font-semibold text-gray-700 text-base">考勤记录</Text>
+      <View className="flex items-center bg-gray-100 rounded-lg px-3 py-1">
+        {/* 左箭头图标 */}
+        <View
+          className="i-heroicons-chevron-left-20-solid w-5 h-5 text-gray-500 mr-2"
+          onClick={onPreviousMonth}
+        />
+        <Text className="text-sm text-gray-700 mr-2">{currentMonth}</Text>
+        {/* 右箭头图标 */}
+        <View
+          className="i-heroicons-chevron-right-20-solid w-5 h-5 text-gray-500"
+          onClick={onNextMonth}
+        />
+      </View>
+    </View>
+  )
+}

+ 90 - 11
mini-ui-packages/rencai-attendance-ui/src/pages/AttendancePage/AttendancePage.tsx

@@ -1,20 +1,81 @@
-import React from 'react'
-import { View, Text, ScrollView } from '@tarojs/components'
-import { RencaiTabBarLayout } from '@d8d/rencai-shared-ui/components/RencaiTabBarLayout'
-import { Navbar } from '@d8d/mini-shared-ui-components/components/navbar'
-import { useRequireAuth } from '@d8d/rencai-auth-ui/hooks'
-
 /**
  * 人才小程序考勤记录页
  * TabBar页面 - 无返回按钮
  * 原型参考: docs/小程序原型/rencai.html (行303-481)
  */
+import React, { useState } from 'react'
+import { View } from '@tarojs/components'
+import { RencaiTabBarLayout } from '@d8d/rencai-shared-ui/components/RencaiTabBarLayout'
+import { Navbar } from '@d8d/mini-shared-ui-components/components/navbar'
+import { useRequireAuth } from '@d8d/rencai-auth-ui/hooks'
+
+// 组件导入
+import { MonthSelector } from '../../components/MonthSelector'
+import { AttendanceStats } from '../../components/AttendanceStats'
+import { AttendanceCalendar } from '../../components/AttendanceCalendar'
+import { AttendanceDetails } from '../../components/AttendanceDetails'
+
+// 工具函数导入
+import {
+  generateMockAttendanceData,
+  formatMonth,
+  getCurrentYearMonth,
+  getPreviousMonth,
+  getNextMonth
+} from '../../utils/mockAttendanceData'
+
+// 类型导入
+import { AttendanceStats as AttendanceStatsType, AttendanceRecord } from '../../types/attendance'
+
 const AttendancePage: React.FC = () => {
   // 检查登录状态,未登录则重定向
   useRequireAuth()
+
+  // 状态管理
+  const [currentYear, setCurrentYear] = useState<number>(() => getCurrentYearMonth().year)
+  const [currentMonth, setCurrentMonth] = useState<number>(() => getCurrentYearMonth().month)
+  const [stats, setStats] = useState<AttendanceStatsType>(() => ({
+    attendanceRate: 100,
+    normalDays: 0,
+    lateCount: 0,
+    earlyLeaveCount: 0,
+    absentCount: 0
+  }))
+  const [records, setRecords] = useState<AttendanceRecord[]>([])
+
+  // 加载指定月份的考勤数据
+  const loadAttendanceData = (year: number, month: number) => {
+    const { stats: newStats, records: newRecords } = generateMockAttendanceData(year, month)
+    setStats(newStats)
+    setRecords(newRecords)
+  }
+
+  // 处理上个月点击
+  const handlePreviousMonth = () => {
+    const { year: prevYear, month: prevMonth } = getPreviousMonth(currentYear, currentMonth)
+    setCurrentYear(prevYear)
+    setCurrentMonth(prevMonth)
+    loadAttendanceData(prevYear, prevMonth)
+  }
+
+  // 处理下个月点击
+  const handleNextMonth = () => {
+    const { year: nextYear, month: nextMonth } = getNextMonth(currentYear, currentMonth)
+    setCurrentYear(nextYear)
+    setCurrentMonth(nextMonth)
+    loadAttendanceData(nextYear, nextMonth)
+  }
+
+  // 初始化加载当前月份数据
+  React.useEffect(() => {
+    loadAttendanceData(currentYear, currentMonth)
+  }, [])
+
+  const currentMonthDisplay = formatMonth(currentYear, currentMonth)
+
   return (
     <RencaiTabBarLayout activeKey="attendance">
-      <ScrollView className="h-[calc(100%-60px)] overflow-y-auto bg-gray-100" scrollY>
+      <View className="h-screen bg-gray-100 flex flex-col">
         {/* Navbar导航栏 - TabBar页面无返回按钮 */}
         <Navbar
           title="考勤记录"
@@ -27,11 +88,29 @@ const AttendancePage: React.FC = () => {
           placeholder={true}
         />
 
-        {/* 页面内容 - 待实现完整功能 */}
-        <View className="h-full flex items-center justify-center bg-gray-100">
-          <Text className="text-gray-600">考勤页面占位</Text>
+        {/* 页面内容 */}
+        <View className="flex-1 overflow-y-auto pb-16">
+          {/* 月份选择器 */}
+          <MonthSelector
+            currentMonth={currentMonthDisplay}
+            onPreviousMonth={handlePreviousMonth}
+            onNextMonth={handleNextMonth}
+          />
+
+          {/* 考勤统计卡片 */}
+          <AttendanceStats stats={stats} />
+
+          {/* 考勤日历 */}
+          <AttendanceCalendar
+            year={currentYear}
+            month={currentMonth}
+            attendanceRecords={records}
+          />
+
+          {/* 打卡明细列表 */}
+          <AttendanceDetails records={records} />
         </View>
-      </ScrollView>
+      </View>
     </RencaiTabBarLayout>
   )
 }

+ 75 - 0
mini-ui-packages/rencai-attendance-ui/src/types/attendance.ts

@@ -0,0 +1,75 @@
+/**
+ * 考勤相关类型定义
+ * 数据结构符合后续API接口规范
+ */
+
+/**
+ * 考勤状态枚举
+ */
+export enum AttendanceStatus {
+  NORMAL = 'normal',         // 正常
+  LATE = 'late',             // 迟到
+  EARLY_LEAVE = 'early',     // 早退
+  ABSENT = 'absent'          // 缺勤
+}
+
+/**
+ * 考勤统计数据
+ */
+export interface AttendanceStats {
+  attendanceRate: number    // 出勤率(如:100表示100%)
+  normalDays: number        // 正常出勤天数
+  lateCount: number         // 迟到次数
+  earlyLeaveCount: number   // 早退次数
+  absentCount: number       // 缺勤次数
+}
+
+/**
+ * 打卡记录
+ */
+export interface AttendanceRecord {
+  date: string              // 日期(如:2023-11-25)
+  weekday: string           // 星期(如:星期六)
+  checkInTime: string       // 上班打卡时间(如:08:30)
+  checkOutTime: string      // 下班打卡时间(如:17:30,未打卡为--:--)
+  status: AttendanceStatus  // 打卡状态
+}
+
+/**
+ * 月份选择器属性
+ */
+export interface MonthSelectorProps {
+  currentMonth: string      // 当前年月显示(如:2023年11月)
+  onPreviousMonth: () => void
+  onNextMonth: () => void
+}
+
+/**
+ * 考勤日历属性
+ */
+export interface AttendanceCalendarProps {
+  year: number
+  month: number
+  attendanceRecords: AttendanceRecord[]
+}
+
+/**
+ * 考勤统计卡片属性
+ */
+export interface AttendanceStatsProps {
+  stats: AttendanceStats
+}
+
+/**
+ * 打卡明细列表属性
+ */
+export interface AttendanceDetailsProps {
+  records: AttendanceRecord[]
+}
+
+/**
+ * 打卡记录项属性
+ */
+export interface AttendanceRecordItemProps {
+  record: AttendanceRecord
+}

+ 177 - 0
mini-ui-packages/rencai-attendance-ui/src/utils/mockAttendanceData.ts

@@ -0,0 +1,177 @@
+/**
+ * 前端模拟考勤数据
+ * 数据结构符合后续API接口规范,便于后续替换为真实API
+ */
+
+import { AttendanceStats, AttendanceRecord, AttendanceStatus } from '../types/attendance'
+
+/**
+ * 获取指定月份的所有日期
+ */
+function getDatesInMonth(year: number, month: number): Date[] {
+  const dates: Date[] = []
+  const lastDay = new Date(year, month, 0).getDate()
+
+  for (let day = 1; day <= lastDay; day++) {
+    dates.push(new Date(year, month - 1, day))
+  }
+
+  return dates
+}
+
+/**
+ * 格式化日期为 YYYY-MM-DD
+ */
+function formatDate(date: Date): string {
+  const year = date.getFullYear()
+  const month = String(date.getMonth() + 1).padStart(2, '0')
+  const day = String(date.getDate()).padStart(2, '0')
+  return `${year}-${month}-${day}`
+}
+
+/**
+ * 获取星期名称
+ */
+function getWeekday(date: Date): string {
+  const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
+  return weekdays[date.getDay()]
+}
+
+/**
+ * 判断是否为周末
+ */
+function isWeekend(date: Date): boolean {
+  const day = date.getDay()
+  return day === 0 || day === 6
+}
+
+/**
+ * 计算考勤统计数据
+ */
+function calculateAttendanceStats(records: AttendanceRecord[]): AttendanceStats {
+  // 筛选出工作日(排除周末)
+  const workDays = records.filter(r => {
+    const weekday = r.weekday
+    return weekday !== '星期六' && weekday !== '星期日'
+  })
+
+  const normalDays = records.filter(r => r.status === AttendanceStatus.NORMAL).length
+  const lateCount = records.filter(r => r.status === AttendanceStatus.LATE).length
+  const earlyLeaveCount = records.filter(r => r.status === AttendanceStatus.EARLY_LEAVE).length
+  const absentCount = records.filter(r => r.status === AttendanceStatus.ABSENT).length
+
+  // 出勤率 = 正常出勤天数 / 工作日总数 * 100
+  const attendanceRate = workDays.length > 0
+    ? Math.round((normalDays / workDays.length) * 100)
+    : 0
+
+  return {
+    attendanceRate,
+    normalDays,
+    lateCount,
+    earlyLeaveCount,
+    absentCount
+  }
+}
+
+/**
+ * 生成指定月份的模拟考勤数据
+ * @param year 年份
+ * @param month 月份 (1-12)
+ * @returns 考勤统计数据和打卡记录
+ */
+export function generateMockAttendanceData(year: number, month: number): {
+  stats: AttendanceStats
+  records: AttendanceRecord[]
+} {
+  const dates = getDatesInMonth(year, month)
+  const records: AttendanceRecord[] = []
+
+  // 从后向前生成记录(倒序)
+  for (let i = dates.length - 1; i >= 0; i--) {
+    const date = dates[i]
+    const weekday = getWeekday(date)
+    const weekend = isWeekend(date)
+
+    // 周末不打卡
+    if (weekend) {
+      records.push({
+        date: formatDate(date),
+        weekday,
+        checkInTime: '--:--',
+        checkOutTime: '--:--',
+        status: AttendanceStatus.NORMAL
+      })
+    } else {
+      // 工作日随机生成打卡记录
+      const random = Math.random()
+      let status = AttendanceStatus.NORMAL
+      let checkInTime = '08:30'
+      let checkOutTime = '17:30'
+
+      // 10%概率迟到
+      if (random < 0.1) {
+        status = AttendanceStatus.LATE
+        checkInTime = '09:15'
+      }
+      // 10%概率早退(排除迟到的情况)
+      else if (random >= 0.1 && random < 0.2) {
+        status = AttendanceStatus.EARLY_LEAVE
+        checkOutTime = '16:30'
+      }
+
+      records.push({
+        date: formatDate(date),
+        weekday,
+        checkInTime,
+        checkOutTime,
+        status
+      })
+    }
+  }
+
+  const stats = calculateAttendanceStats(records)
+
+  return { stats, records }
+}
+
+/**
+ * 格式化月份显示
+ * @param year 年份
+ * @param month 月份 (1-12)
+ * @returns 格式化的月份字符串(如:2023年11月)
+ */
+export function formatMonth(year: number, month: number): string {
+  return `${year}年${month}月`
+}
+
+/**
+ * 获取当前年月
+ */
+export function getCurrentYearMonth(): { year: number; month: number } {
+  const now = new Date()
+  return {
+    year: now.getFullYear(),
+    month: now.getMonth() + 1
+  }
+}
+
+/**
+ * 获取上个月
+ */
+export function getPreviousMonth(year: number, month: number): { year: number; month: number } {
+  if (month === 1) {
+    return { year: year - 1, month: 12 }
+  }
+  return { year, month: month - 1 }
+}
+
+/**
+ * 获取下个月
+ */
+export function getNextMonth(year: number, month: number): { year: number; month: number } {
+  if (month === 12) {
+    return { year: year + 1, month: 1 }
+  }
+  return { year, month: month + 1 }
+}

+ 131 - 0
mini-ui-packages/rencai-attendance-ui/tests/pages/AttendancePage/AttendancePage.test.tsx

@@ -0,0 +1,131 @@
+/**
+ * AttendancePage页面组件测试
+ */
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import AttendancePage from '@/pages/AttendancePage/AttendancePage'
+
+// Mock dependencies
+jest.mock('@d8d/rencai-shared-ui/components/RencaiTabBarLayout', () => ({
+  RencaiTabBarLayout: ({ children, activeKey }: any) => (
+    <div data-testid="tab-bar-layout" data-active-key={activeKey}>
+      {children}
+    </div>
+  )
+}))
+
+jest.mock('@d8d/mini-shared-ui-components/components/navbar', () => ({
+  Navbar: ({ title, leftIcon }: any) => (
+    <div data-testid="navbar" data-title={title} data-left-icon={leftIcon}>
+      {title}
+    </div>
+  )
+}))
+
+jest.mock('@d8d/rencai-auth-ui/hooks', () => ({
+  useRequireAuth: jest.fn()
+}))
+
+jest.mock('@/utils/mockAttendanceData', () => ({
+  generateMockAttendanceData: jest.fn(() => ({
+    stats: {
+      attendanceRate: 100,
+      normalDays: 28,
+      lateCount: 2,
+      earlyLeaveCount: 1,
+      absentCount: 0
+    },
+    records: [
+      {
+        date: '2023-11-25',
+        weekday: '星期六',
+        checkInTime: '08:30',
+        checkOutTime: '17:30',
+        status: 'normal'
+      },
+      {
+        date: '2023-11-24',
+        weekday: '星期五',
+        checkInTime: '09:15',
+        checkOutTime: '17:30',
+        status: 'late'
+      }
+    ]
+  })),
+  formatMonth: jest.fn((year: number, month: number) => `${year}年${month}月`),
+  getCurrentYearMonth: jest.fn(() => ({ year: 2023, month: 11 })),
+  getPreviousMonth: jest.fn(() => ({ year: 2023, month: 10 })),
+  getNextMonth: jest.fn(() => ({ year: 2023, month: 12 }))
+}))
+
+import { useRequireAuth } from '@d8d/rencai-auth-ui/hooks'
+import { generateMockAttendanceData } from '@/utils/mockAttendanceData'
+
+describe('AttendancePage页面组件', () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+    ;(useRequireAuth as jest.Mock).mockReturnValue(undefined)
+  })
+
+  test('应该渲染页面标题', () => {
+    render(<AttendancePage />)
+
+    // "考勤记录"出现在Navbar和MonthSelector中,使用getAllByText
+    expect(screen.getAllByText('考勤记录')).toHaveLength(2)
+  })
+
+  test('应该渲染考勤统计数据', () => {
+    render(<AttendancePage />)
+
+    expect(screen.getByText('100%')).toBeInTheDocument()
+    expect(screen.getByText('28天')).toBeInTheDocument()
+    expect(screen.getByText('迟到: 2次')).toBeInTheDocument()
+    expect(screen.getByText('早退: 1次')).toBeInTheDocument()
+    expect(screen.getByText('缺勤: 0次')).toBeInTheDocument()
+  })
+
+  test('应该渲染考勤日历', () => {
+    render(<AttendancePage />)
+
+    expect(screen.getByText('考勤日历')).toBeInTheDocument()
+  })
+
+  test('应该渲染打卡明细', () => {
+    render(<AttendancePage />)
+
+    expect(screen.getByText('打卡明细')).toBeInTheDocument()
+    // 日期和星期在同一元素中
+    expect(screen.getByText((content) => content.includes('11月25日'))).toBeInTheDocument()
+    expect(screen.getByText((content) => content.includes('11月24日'))).toBeInTheDocument()
+  })
+
+  test('应该渲染月份选择器', () => {
+    render(<AttendancePage />)
+
+    expect(screen.getByText('2023年11月')).toBeInTheDocument()
+  })
+
+  test('应该调用useRequireAuth检查登录状态', () => {
+    render(<AttendancePage />)
+
+    expect(useRequireAuth).toHaveBeenCalled()
+  })
+
+  test('应该使用TabBarLayout包装页面', () => {
+    const { container } = render(<AttendancePage />)
+
+    const tabBarLayout = container.querySelector('[data-testid="tab-bar-layout"]')
+    expect(tabBarLayout).toBeInTheDocument()
+    expect(tabBarLayout).toHaveAttribute('data-active-key', 'attendance')
+  })
+
+  test('应该使用Navbar导航栏', () => {
+    const { container } = render(<AttendancePage />)
+
+    const navbar = container.querySelector('[data-testid="navbar"]')
+    expect(navbar).toBeInTheDocument()
+    expect(navbar).toHaveAttribute('data-title', '考勤记录')
+    expect(navbar).toHaveAttribute('data-left-icon', '')
+  })
+})

+ 132 - 0
mini-ui-packages/rencai-attendance-ui/tests/unit/components/AttendanceCalendar.test.tsx

@@ -0,0 +1,132 @@
+/**
+ * AttendanceCalendar组件测试
+ */
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { AttendanceCalendar } from '@/components/AttendanceCalendar'
+import { AttendanceRecord, AttendanceStatus } from '@/types/attendance'
+
+describe('AttendanceCalendar组件', () => {
+  const mockRecords: AttendanceRecord[] = [
+    {
+      date: '2023-11-01',
+      weekday: '星期三',
+      checkInTime: '08:30',
+      checkOutTime: '17:30',
+      status: AttendanceStatus.NORMAL
+    },
+    {
+      date: '2023-11-02',
+      weekday: '星期四',
+      checkInTime: '08:30',
+      checkOutTime: '17:30',
+      status: AttendanceStatus.NORMAL
+    },
+    {
+      date: '2023-11-15',
+      weekday: '星期三',
+      checkInTime: '09:15',
+      checkOutTime: '17:30',
+      status: AttendanceStatus.LATE
+    }
+  ]
+
+  test('应该渲染考勤日历标题', () => {
+    render(
+      <AttendanceCalendar
+        year={2023}
+        month={11}
+        attendanceRecords={mockRecords}
+      />
+    )
+
+    expect(screen.getByText('考勤日历')).toBeInTheDocument()
+  })
+
+  test('应该渲染星期标题', () => {
+    render(
+      <AttendanceCalendar
+        year={2023}
+        month={11}
+        attendanceRecords={mockRecords}
+      />
+    )
+
+    expect(screen.getByText('日')).toBeInTheDocument()
+    expect(screen.getByText('一')).toBeInTheDocument()
+    expect(screen.getByText('二')).toBeInTheDocument()
+    expect(screen.getByText('三')).toBeInTheDocument()
+    expect(screen.getByText('四')).toBeInTheDocument()
+    expect(screen.getByText('五')).toBeInTheDocument()
+    expect(screen.getByText('六')).toBeInTheDocument()
+  })
+
+  test('应该渲染日期网格', () => {
+    const { container } = render(
+      <AttendanceCalendar
+        year={2023}
+        month={11}
+        attendanceRecords={mockRecords}
+      />
+    )
+
+    // 检查是否有日期数字
+    expect(screen.getByText('1')).toBeInTheDocument()
+    expect(screen.getByText('15')).toBeInTheDocument()
+    expect(screen.getByText('30')).toBeInTheDocument()
+  })
+
+  test('应该正确标记已打卡日期', () => {
+    render(
+      <AttendanceCalendar
+        year={2023}
+        month={11}
+        attendanceRecords={mockRecords}
+      />
+    )
+
+    // 11月1日有打卡记录,应该有绿色背景
+    expect(screen.getByText('1')).toBeInTheDocument()
+  })
+
+  test('应该正确处理跨月情况', () => {
+    render(
+      <AttendanceCalendar
+        year={2023}
+        month={12}
+        attendanceRecords={[]}
+      />
+    )
+
+    // 12月应该有31天
+    expect(screen.getByText('31')).toBeInTheDocument()
+  })
+
+  test('应该正确处理闰年2月', () => {
+    render(
+      <AttendanceCalendar
+        year={2024}
+        month={2}
+        attendanceRecords={[]}
+      />
+    )
+
+    // 2024年是闰年,2月应该有29天
+    expect(screen.getByText('29')).toBeInTheDocument()
+  })
+
+  test('应该正确处理非闰年2月', () => {
+    render(
+      <AttendanceCalendar
+        year={2023}
+        month={2}
+        attendanceRecords={[]}
+      />
+    )
+
+    // 2023年不是闰年,2月应该有28天
+    expect(screen.getByText('28')).toBeInTheDocument()
+    expect(screen.queryByText('29')).not.toBeInTheDocument()
+  })
+})

+ 82 - 0
mini-ui-packages/rencai-attendance-ui/tests/unit/components/AttendanceDetails.test.tsx

@@ -0,0 +1,82 @@
+/**
+ * AttendanceDetails组件测试
+ */
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { AttendanceDetails } from '@/components/AttendanceDetails'
+import { AttendanceRecord, AttendanceStatus } from '@/types/attendance'
+
+describe('AttendanceDetails组件', () => {
+  const mockRecords: AttendanceRecord[] = [
+    {
+      date: '2023-11-25',
+      weekday: '星期六',
+      checkInTime: '08:30',
+      checkOutTime: '17:30',
+      status: AttendanceStatus.NORMAL
+    },
+    {
+      date: '2023-11-24',
+      weekday: '星期五',
+      checkInTime: '09:15',
+      checkOutTime: '17:30',
+      status: AttendanceStatus.LATE
+    },
+    {
+      date: '2023-11-23',
+      weekday: '星期四',
+      checkInTime: '08:30',
+      checkOutTime: '16:30',
+      status: AttendanceStatus.EARLY_LEAVE
+    }
+  ]
+
+  test('应该渲染打卡明细标题', () => {
+    render(<AttendanceDetails records={mockRecords} />)
+
+    expect(screen.getByText('打卡明细')).toBeInTheDocument()
+  })
+
+  test('应该渲染所有打卡记录', () => {
+    render(<AttendanceDetails records={mockRecords} />)
+
+    // 日期和星期在同一元素中
+    expect(screen.getByText((content) => content.includes('11月25日'))).toBeInTheDocument()
+    expect(screen.getByText((content) => content.includes('11月24日'))).toBeInTheDocument()
+    expect(screen.getByText((content) => content.includes('11月23日'))).toBeInTheDocument()
+  })
+
+  test('应该渲染打卡状态标签', () => {
+    render(<AttendanceDetails records={mockRecords} />)
+
+    expect(screen.getByText('正常')).toBeInTheDocument()
+    expect(screen.getByText('迟到')).toBeInTheDocument()
+    expect(screen.getByText('早退')).toBeInTheDocument()
+  })
+
+  test('应该渲染打卡时间信息', () => {
+    render(<AttendanceDetails records={mockRecords} />)
+
+    // 多个记录中有相同的打卡时间
+    expect(screen.getAllByText('上班: 08:30').length).toBeGreaterThan(0)
+    expect(screen.getAllByText('下班: 17:30').length).toBeGreaterThan(0)
+    expect(screen.getByText('上班: 09:15')).toBeInTheDocument()
+  })
+
+  test('应该渲染空列表', () => {
+    render(<AttendanceDetails records={[]} />)
+
+    expect(screen.getByText('打卡明细')).toBeInTheDocument()
+  })
+
+  test('应该按日期倒序渲染记录', () => {
+    const { container } = render(<AttendanceDetails records={mockRecords} />)
+
+    const records = container.querySelectorAll('.border-gray-100')
+    expect(records).toHaveLength(3)
+
+    // 检查第一条记录包含11月25日
+    expect(screen.getByText((content) => content.includes('11月25日'))).toBeInTheDocument()
+  })
+})

+ 86 - 0
mini-ui-packages/rencai-attendance-ui/tests/unit/components/AttendanceRecordItem.test.tsx

@@ -0,0 +1,86 @@
+/**
+ * AttendanceRecordItem组件测试
+ */
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { AttendanceRecordItem } from '@/components/AttendanceRecordItem'
+import { AttendanceRecord, AttendanceStatus } from '@/types/attendance'
+
+describe('AttendanceRecordItem组件', () => {
+  const mockRecord: AttendanceRecord = {
+    date: '2023-11-25',
+    weekday: '星期六',
+    checkInTime: '08:30',
+    checkOutTime: '17:30',
+    status: AttendanceStatus.NORMAL
+  }
+
+  test('应该渲染打卡记录信息', () => {
+    render(<AttendanceRecordItem record={mockRecord} />)
+
+    // 日期和星期在同一元素中
+    expect(screen.getByText((content) => content.includes('11月25日'))).toBeInTheDocument()
+    expect(screen.getByText((content) => content.includes('星期六'))).toBeInTheDocument()
+    expect(screen.getByText('上班: 08:30')).toBeInTheDocument()
+    expect(screen.getByText('下班: 17:30')).toBeInTheDocument()
+  })
+
+  test('应该渲染正常状态标签', () => {
+    render(<AttendanceRecordItem record={mockRecord} />)
+
+    expect(screen.getByText('正常')).toBeInTheDocument()
+  })
+
+  test('应该渲染迟到状态标签', () => {
+    const lateRecord: AttendanceRecord = {
+      ...mockRecord,
+      status: AttendanceStatus.LATE,
+      checkInTime: '09:15'
+    }
+
+    render(<AttendanceRecordItem record={lateRecord} />)
+
+    expect(screen.getByText('迟到')).toBeInTheDocument()
+    expect(screen.getByText('上班: 09:15')).toBeInTheDocument()
+  })
+
+  test('应该渲染早退状态标签', () => {
+    const earlyRecord: AttendanceRecord = {
+      ...mockRecord,
+      status: AttendanceStatus.EARLY_LEAVE,
+      checkOutTime: '16:30'
+    }
+
+    render(<AttendanceRecordItem record={earlyRecord} />)
+
+    expect(screen.getByText('早退')).toBeInTheDocument()
+    expect(screen.getByText('下班: 16:30')).toBeInTheDocument()
+  })
+
+  test('应该渲染缺勤状态标签', () => {
+    const absentRecord: AttendanceRecord = {
+      ...mockRecord,
+      status: AttendanceStatus.ABSENT,
+      checkInTime: '--:--',
+      checkOutTime: '--:--'
+    }
+
+    render(<AttendanceRecordItem record={absentRecord} />)
+
+    expect(screen.getByText('缺勤')).toBeInTheDocument()
+    expect(screen.getByText('上班: --:--')).toBeInTheDocument()
+    expect(screen.getByText('下班: --:--')).toBeInTheDocument()
+  })
+
+  test('应该正确处理未打卡情况', () => {
+    const noCheckOutRecord: AttendanceRecord = {
+      ...mockRecord,
+      checkOutTime: '--:--'
+    }
+
+    render(<AttendanceRecordItem record={noCheckOutRecord} />)
+
+    expect(screen.getByText('下班: --:--')).toBeInTheDocument()
+  })
+})

+ 63 - 0
mini-ui-packages/rencai-attendance-ui/tests/unit/components/AttendanceStats.test.tsx

@@ -0,0 +1,63 @@
+/**
+ * AttendanceStats组件测试
+ */
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { AttendanceStats } from '@/components/AttendanceStats'
+import { AttendanceStats as AttendanceStatsType } from '@/types/attendance'
+
+describe('AttendanceStats组件', () => {
+  const mockStats: AttendanceStatsType = {
+    attendanceRate: 100,
+    normalDays: 28,
+    lateCount: 2,
+    earlyLeaveCount: 1,
+    absentCount: 0
+  }
+
+  test('应该渲染考勤统计数据', () => {
+    render(<AttendanceStats stats={mockStats} />)
+
+    expect(screen.getByText('100%')).toBeInTheDocument()
+    expect(screen.getByText('28天')).toBeInTheDocument()
+  })
+
+  test('应该渲染出勤率标签', () => {
+    render(<AttendanceStats stats={mockStats} />)
+
+    expect(screen.getByText('出勤率')).toBeInTheDocument()
+  })
+
+  test('应该渲染正常出勤标签', () => {
+    render(<AttendanceStats stats={mockStats} />)
+
+    expect(screen.getByText('正常出勤')).toBeInTheDocument()
+  })
+
+  test('应该渲染异常统计数据', () => {
+    render(<AttendanceStats stats={mockStats} />)
+
+    expect(screen.getByText('迟到: 2次')).toBeInTheDocument()
+    expect(screen.getByText('早退: 1次')).toBeInTheDocument()
+    expect(screen.getByText('缺勤: 0次')).toBeInTheDocument()
+  })
+
+  test('应该正确渲染零统计数据', () => {
+    const zeroStats: AttendanceStatsType = {
+      attendanceRate: 0,
+      normalDays: 0,
+      lateCount: 0,
+      earlyLeaveCount: 0,
+      absentCount: 0
+    }
+
+    render(<AttendanceStats stats={zeroStats} />)
+
+    expect(screen.getByText('0%')).toBeInTheDocument()
+    expect(screen.getByText('0天')).toBeInTheDocument()
+    expect(screen.getByText('迟到: 0次')).toBeInTheDocument()
+    expect(screen.getByText('早退: 0次')).toBeInTheDocument()
+    expect(screen.getByText('缺勤: 0次')).toBeInTheDocument()
+  })
+})

+ 80 - 0
mini-ui-packages/rencai-attendance-ui/tests/unit/components/MonthSelector.test.tsx

@@ -0,0 +1,80 @@
+/**
+ * MonthSelector组件测试
+ */
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { MonthSelector } from '@/components/MonthSelector'
+
+describe('MonthSelector组件', () => {
+  const mockOnPrevious = jest.fn()
+  const mockOnNext = jest.fn()
+
+  beforeEach(() => {
+    jest.clearAllMocks()
+  })
+
+  test('应该渲染当前月份显示', () => {
+    render(
+      <MonthSelector
+        currentMonth="2023年11月"
+        onPreviousMonth={mockOnPrevious}
+        onNextMonth={mockOnNext}
+      />
+    )
+
+    expect(screen.getByText('考勤记录')).toBeInTheDocument()
+    expect(screen.getByText('2023年11月')).toBeInTheDocument()
+  })
+
+  test('应该渲染左右箭头按钮', () => {
+    const { container } = render(
+      <MonthSelector
+        currentMonth="2023年11月"
+        onPreviousMonth={mockOnPrevious}
+        onNextMonth={mockOnNext}
+      />
+    )
+
+    // 检查左右箭头图标是否存在
+    const leftArrow = container.querySelector('.i-heroicons-chevron-left-20-solid')
+    const rightArrow = container.querySelector('.i-heroicons-chevron-right-20-solid')
+
+    expect(leftArrow).toBeInTheDocument()
+    expect(rightArrow).toBeInTheDocument()
+  })
+
+  test('应该调用上个月处理函数', () => {
+    const { container } = render(
+      <MonthSelector
+        currentMonth="2023年11月"
+        onPreviousMonth={mockOnPrevious}
+        onNextMonth={mockOnNext}
+      />
+    )
+
+    const leftArrow = container.querySelector('.i-heroicons-chevron-left-20-solid')
+    if (leftArrow) {
+      leftArrow.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+    }
+
+    expect(mockOnPrevious).toHaveBeenCalledTimes(1)
+  })
+
+  test('应该调用下个月处理函数', () => {
+    const { container } = render(
+      <MonthSelector
+        currentMonth="2023年11月"
+        onPreviousMonth={mockOnPrevious}
+        onNextMonth={mockOnNext}
+      />
+    )
+
+    const rightArrow = container.querySelector('.i-heroicons-chevron-right-20-solid')
+    if (rightArrow) {
+      rightArrow.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+    }
+
+    expect(mockOnNext).toHaveBeenCalledTimes(1)
+  })
+})