attendanceRecord.vue 9.76 KB
<template>
<view class="attendance-page">
  <!-- 顶部统计卡片 -->
  <view class="stat-card">
    <view class="stat-item">
      <view class="stat-label">本月打卡</view>
      <view class="stat-value">{{ statData.checkInDays }}</view>
    </view>
    <view class="stat-item">
      <view class="stat-label">正常天数</view>
      <view class="stat-value">{{ statData.normalDays }}</view>
    </view>
    <view class="stat-item">
      <view class="stat-label">异常天数</view>
      <view class="stat-value">{{ statData.errorDays }}</view>
    </view>
  </view>

  <!-- 手写内联日历,无弹窗 -->
  <view class="calendar-wrap">
    <view class="calendar-header">
      <!-- 左右横向排列:上一月 | 年月标题 | 下一月 -->
      <view class="calendar-btn" @click="prevMonth">上一月</view>
      <view class="calendar-title">{{ currentYear }}年{{ currentMonth }}月</view>
      <!-- 当前是最新月份时,隐藏下个月按钮 -->
      <view v-if="canNext" class="calendar-btn" @click="nextMonth">下一月</view>
      <view v-else class="calendar-btn empty-btn"></view>
    </view>
    <!-- 星期头部 -->
    <view class="week-row">
      <view class="week-item" v-for="w in weekList" :key="w">{{ w }}</view>
    </view>
    <!-- 日期网格 -->
    <view class="day-row" v-for="(row, rIdx) in dayRows" :key="rIdx">
      <view
        class="day-item"
        v-for="(day, dIdx) in row"
        :key="dIdx"
        :class="{
          'day-item--other': day.otherMonth,
          'day-item--active': day.dateStr === selectDate,
        }"
        @click="onDayClick(day)"
      >
        <view class="day-text">{{ day.day }}</view>
        <view v-if="day.dot" class="day-dot" :style="{backgroundColor: day.dotColor}"></view>
      </view>
    </view>
  </view>

  <!-- 打卡详情区域 -->
  <view class="detail-card">
    <view v-if="!currentAttendance">
      <view class="empty-text">暂无考勤记录</view>
    </view>
    <view v-else>
      <view class="detail-row">
        <view class="detail-title">上班打卡时间:</view>
        <view class="detail-text">{{ currentAttendance.clockInTime || '缺少打卡记录' }}</view>
      </view>
      <view class="detail-row">
        <view class="detail-title">上班打卡地点:</view>
        <view class="detail-text">{{ currentAttendance.clockInAddress || '-' }}</view>
      </view>
      <view class="divider"></view>
      <view class="detail-row">
        <view class="detail-title">下班打卡时间:</view>
        <view class="detail-text">{{ currentAttendance.clockOutTime || '缺少打卡记录' }}</view>
      </view>
      <view class="detail-row">
        <view class="detail-title">下班打卡地点:</view>
        <view class="detail-text">{{ currentAttendance.clockOutAddress || '-' }}</view>
      </view>
    </view>
  </view>
</view>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';

const weekList = ['日','一','二','三','四','五','六'];
const today = new Date();
const todayTs = today.getTime();
// 当前显示月份
const currentYear = ref(today.getFullYear());
const currentMonth = ref(today.getMonth() + 1);
const selectDate = ref('');
const dayRows = ref<any[]>([]);

// 是否允许点击下一月:判断当前展示月份是否小于本月
const canNext = computed(() => {
  const showMonthDate = new Date(currentYear.value, currentMonth.value -1, 1);
  const nowMonthDate = new Date(today.getFullYear(), today.getMonth(), 1);
  return showMonthDate < nowMonthDate;
});

// ========== 写死静态统计数据 ==========
const statData = ref({
  checkInDays: 28,
  normalDays: 25,
  errorDays: 3
});

// ========== 写死日历标记:红点error异常,灰点normal正常 ==========
const markMap = ref<Record<string, {type:'error'|'normal'}>>({
  '2026-10-02': { type: 'error' },
  '2026-10-10': { type: 'error' },
  '2026-10-16': { type: 'error' },
  '2026-10-05': { type: 'normal' },
  '2026-10-12': { type: 'normal' },
});

// ========== 写死每日考勤详情数据 ==========
const dayAttendanceMap = ref<Record<string, any>>({
  '2026-10-02': {
    clockInTime: '2026-10-02 08:02:10',
    clockInAddress: '月坛南街安华大厦',
    clockOutTime: '',
    clockOutAddress: ''
  },
  '2026-10-13': {
    clockInTime: '2026-10-13 07:58:25',
    clockInAddress: '月坛南街安华大厦',
    clockOutTime: '',
    clockOutAddress: '南礼士地铁站'
  },
  '2026-10-05': {
    clockInTime: '2026-10-05 08:00:00',
    clockInAddress: '月坛南街安华大厦',
    clockOutTime: '2026-10-05 18:00:00',
    clockOutAddress: '月坛南街安华大厦'
  },
  '2026-10-10': null,
  '2026-10-12': {
    clockInTime: '2026-10-12 07:55:10',
    clockInAddress: '月坛南街安华大厦',
    clockOutTime: '2026-10-12 17:50:30',
    clockOutAddress: '月坛南街安华大厦'
  },
  '2026-10-16': {
    clockInTime: '2026-10-16 07:59:22',
    clockInAddress: '月坛南街安华大厦',
    clockOutTime: '',
    clockOutAddress: ''
  }
});
const currentAttendance = ref<any>(null);

// 生成月份日期网格
const renderMonth = () => {
  const year = currentYear.value;
  const month = currentMonth.value;
  // 当月第一天
  const firstDay = new Date(year, month - 1, 1);
  // 当月最后一天
  const lastDay = new Date(year, month, 0);
  // 上个月最后一天
  const prevLastDay = new Date(year, month - 1, 0).getDate();
  // 当月第一天星期 0周日~6周六
  const firstWeek = firstDay.getDay();

  const rows: any[] = [];
  let row: any[] = [];
  // 填充上月尾部
  for(let i = 0; i < firstWeek; i++){
    const dayNum = prevLastDay - firstWeek + i + 1;
    row.push({
      day: dayNum,
      otherMonth: true,
      dateStr: `${year}-${String(month-1).padStart(2,'0')}-${String(dayNum).padStart(2,'0')}`
    })
  }
  // 填充当月日期
  for(let d = 1; d <= lastDay.getDate(); d++){
    const dateStr = `${year}-${String(month).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
    const mark = markMap.value[dateStr];
    const item:any = {
      day: d,
      otherMonth: false,
      dateStr,
      dot: false,
      dotColor: ''
    };
    if(mark){
      item.dot = true;
      item.dotColor = mark.type === 'error' ? '#f56c6c' : '#cccccc';
    }
    row.push(item);
    if(row.length ===7){
      rows.push(row);
      row = [];
    }
  }
  // 填充下月开头
  let nextDay = 1;
  while(row.length <7){
    row.push({
      day: nextDay,
      otherMonth:true,
      dateStr: `${year}-${String(month+1).padStart(2,'0')}-${String(nextDay).padStart(2,'0')}`
    })
    nextDay++;
  }
  rows.push(row);
  dayRows.value = rows;
};

// 上一月
const prevMonth = () => {
  if(currentMonth.value === 1){
    currentYear.value--;
    currentMonth.value = 12;
  }else{
    currentMonth.value--;
  }
  renderMonth();
};
// 下一月
const nextMonth = () => {
  let nextY = currentYear.value;
  let nextM = currentMonth.value + 1;
  if(nextM > 12){
    nextY++;
    nextM =1;
  }
  currentYear.value = nextY;
  currentMonth.value = nextM;
  renderMonth();
};

// 点击日期
const onDayClick = (day:any) => {
  // 禁止点击其他月份
  if(day.otherMonth) return;
  const selectDay = new Date(day.dateStr);
  if(selectDay > today){
    currentAttendance.value = null;
    return;
  }
  selectDate.value = day.dateStr;
  const data = dayAttendanceMap.value[day.dateStr];
  currentAttendance.value = data ?? null;
};

onMounted(() => {
  const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
  selectDate.value = todayStr;
  fetchDayAttendance(todayStr);
  renderMonth();
});

const fetchDayAttendance = (date:string) => {
  const selectDay = new Date(date);
  if(selectDay > today){
    currentAttendance.value = null;
    return;
  }
  const data = dayAttendanceMap.value[date];
  currentAttendance.value = data ?? null;
};
</script>

<style scoped lang="scss">
.attendance-page{
  padding: 20rpx;
  background-color: #f5f5f7;
  min-height: 100vh;
}
.stat-card{
  display: flex;
  background-color: #3c9cff;
  border-radius: 16rpx;
  padding:30rpx 0;
  margin-bottom:20rpx;
  .stat-item{
    flex:1;
    text-align:center;
    color:#fff;
    .stat-label{
      font-size:28rpx;
      opacity:0.9;
    }
    .stat-value{
      font-size:36rpx;
      margin-top:10rpx;
      font-weight:bold;
    }
  }
}
.calendar-wrap{
  background:#fff;
  border-radius:12rpx;
  padding:20rpx;
  margin-bottom:20rpx;
}
.calendar-header{
  display:flex;
  justify-content: space-between;
  align-items:center;
  margin-bottom:20rpx;
}
.calendar-title{
  font-size:32rpx;
  font-weight:bold;
}
.calendar-btn{
  padding:8rpx 16rpx;
  border:1rpx solid #eee;
  border-radius:8rpx;
  font-size:26rpx;
}
.empty-btn{
  border-color:transparent;
  color:transparent;
}
.week-row{
  display:flex;
  .week-item{
    flex:1;
    text-align:center;
    font-size:26rpx;
    color:#666;
    padding:10rpx 0;
  }
}
.day-row{
  display:flex;
  .day-item{
    flex:1;
    min-height:70rpx;
    display:flex;
    flex-direction:column;
    align-items:center;
    justify-content:center;
    padding:8rpx 0;
    position:relative;
    .day-text{
      font-size:28rpx;
    }
    .day-dot{
      width:10rpx;
      height:10rpx;
      border-radius:50%;
      margin-top:4rpx;
    }
  }
  .day-item--other{
    color:#ccc;
  }
  .day-item--active{
    background:#3c9cff;
    color:#fff;
    border-radius:50%;
  }
}
.detail-card{
  background:#fff;
  border-radius:12rpx;
  padding:30rpx;
  .empty-text{
    text-align:center;
    color:#999;
    font-size:30rpx;
    padding:40rpx 0;
  }
  .detail-row{
    margin-bottom:20rpx;
    display:flex;
    .detail-title{
      font-size:30rpx;
      color:#333;
      width:220rpx;
    }
    .detail-text{
      flex:1;
      font-size:30rpx;
      color:#666;
    }
  }
  .divider{
    height:1px;
    background:#eee;
    margin:30rpx 0;
  }
}
</style>