attendanceRecord.vue 10.5 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.onDutyTime || '缺少打卡记录' }}</view>
        </view>
        <view class="detail-row">
          <view class="detail-title">上班打卡地点:</view>
          <view class="detail-text">{{ currentAttendance.onDutyAddress || '-' }}</view>
        </view>
        <view class="divider"></view>
        <view class="detail-row">
          <view class="detail-title">下班打卡时间:</view>
          <view class="detail-text">{{ currentAttendance.offDutyTime || '缺少打卡记录' }}</view>
        </view>
        <view class="detail-row">
          <view class="detail-title">下班打卡地点:</view>
          <view class="detail-text">{{ currentAttendance.offDutyAddress || '-' }}</view>
        </view>
        <!-- 可选:展示异常原因 -->
        <view v-if="currentAttendance.abnormalReason" class="detail-row">
          <view class="detail-title">异常原因:</view>
          <view class="detail-text" style="color:#f56c6c">{{ currentAttendance.abnormalReason }}</view>
        </view>
      </view>
    </view>
    </view>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue';
import { attendanceDaily, dayDetail } from '@/api/user'
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: 0,
  normalDays: 0,
  errorDays: 0
});
// 当月考勤打点字典 {dateStr: {dot:string, abnormalReason:string}}
const monthDayMap = ref<Record<string, { dot: string | null, abnormalReason: string | null }>>({});

// 选中日期详情
const currentAttendance = ref<any>(null);

// 拉取月度考勤汇总(stat + 当月日期打点)
const getMonthAttendance = async () => {
  try {
    const month = `${currentYear.value}-${String(currentMonth.value).padStart(2, '0')}`;
    const res = await attendanceDaily({ month });
    // 赋值顶部统计
    statData.value.checkInDays = res.stat.clockDays;
    statData.value.normalDays = res.stat.normalDays;
    statData.value.errorDays = res.stat.abnormalDays;
    // 构建日期映射字典
    const map: Record<string, { dot: string | null, abnormalReason: string | null }> = {};
    res.days.forEach((item: any) => {
      map[item.date] = {
        dot: item.dot,
        abnormalReason: item.abnormalReason
      };
    })
    monthDayMap.value = map;
    // 重新渲染日历
    renderMonth();
  } catch (err) {
    console.error('获取月度考勤失败', err);
    uni.showToast({ title: '加载月度考勤失败', icon: 'none' });
  }
};

// 生成月份日期网格
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 = monthDayMap.value[dateStr];
    const item: any = {
      day: d,
      otherMonth: false,
      dateStr,
      dot: false,
      dotColor: ''
    };
    if (mark?.dot) {
      item.dot = true;
      if (mark.dot === 'abnormal') {
        item.dotColor = '#f56c6c';
      } else if (mark.dot === 'partial') {
        item.dotColor = '#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--;
  }
  getMonthAttendance();
};
// 下一月
const nextMonth = () => {
  let nextY = currentYear.value;
  let nextM = currentMonth.value + 1;
  if (nextM > 12) {
    nextY++;
    nextM = 1;
  }
  currentYear.value = nextY;
  currentMonth.value = nextM;
  getMonthAttendance();
};

// 点击日期:获取当日详情
const onDayClick = async (day: any) => {
  // 禁止点击其他月份
  if (day.otherMonth) return;
  const selectDay = new Date(day.dateStr);
  if (selectDay > today) {
    currentAttendance.value = null;
    selectDate.value = day.dateStr;
    return;
  }
  selectDate.value = day.dateStr;
  try {
    const res = await dayDetail({ date: day.dateStr });
    // 取第一条记录,大多数场景单条记录
    const record = res.records?.[0] ?? null;
    if (record) {
      currentAttendance.value = {
        ...record,
        abnormalReason: res.abnormalReason
      };
    } else {
      currentAttendance.value = null;
    }
  } catch (err) {
    console.error('获取当日考勤详情失败', err);
    uni.showToast({ title: '加载当日考勤失败', icon: 'none' });
    currentAttendance.value = null;
  }
};

// 页面初始化
onMounted(async () => {
  const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
  selectDate.value = todayStr;
  await getMonthAttendance();
  // 默认选中今天,加载当日详情
  await onDayClick({ dateStr: todayStr, otherMonth: false });
});
</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 {
      position: absolute;
      bottom: 8rpx;
      width: 10rpx;
      height: 10rpx;
      border-radius: 50%;
    }
  }

  .day-item--other {
    color: #ccc;
  }

  .day-item--active {
    color: #f00;
  }
}

.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>