| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638 |
- <template>
- <view class="page-no-tab attendance-page">
- <view class="month-head"
- ><text class="month-arrow press" @click="changeMonth(-1)">‹</text
- ><text class="month">{{ monthLabel }}</text
- ><text class="month-arrow press" @click="changeMonth(1)">›</text></view
- >
- <view v-if="ruleStatus === 'empty'" class="rule-banner empty">
- <text class="rule-banner-title">未配置考勤规则</text>
- <text class="rule-banner-sub">请联系管理员在后台完成配置后再使用考勤功能</text>
- </view>
- <view v-else-if="ruleStatus === 'disabled'" class="rule-banner disabled">
- <text class="rule-banner-title">考勤规则{{ ruleBlockedReason || '不可用' }}</text>
- <text class="rule-banner-sub">请联系管理员处理后再使用打卡功能</text>
- </view>
- <view v-else-if="ruleStatus === 'error'" class="rule-banner error">
- <text class="rule-banner-title">考勤规则加载失败</text>
- <text class="rule-banner-sub">下拉刷新或重新进入页面重试</text>
- </view>
- <view class="summary card section">
- <view
- ><text>{{ summary.attendanceDays }}</text
- ><text>出勤天数</text></view
- ><view
- ><text :class="{ danger: summary.lateCount > 0 }">{{ summary.lateCount }}</text
- ><text>迟到</text></view
- ><view
- ><text :class="{ danger: summary.abnormalCount > 0 }">{{ summary.abnormalCount }}</text
- ><text>异常</text></view
- ><view
- ><text>{{ summary.attendanceRate }}</text
- ><text>出勤率</text></view
- ></view
- >
- <view class="calendar card section">
- <view class="week"
- ><text v-for="w in weeks" :key="w">{{ w }}</text></view
- >
- <view class="days"
- ><view
- v-for="(cell, idx) in grid"
- :key="idx"
- class="day"
- :class="cellClass(cell)"
- @click="cell.date && selectDate(cell.date)"
- >
- <text v-if="!cell.blank">{{ cell.day }}</text>
- <view v-if="cell.dot" :class="['day-dot', cell.dotCls]"></view>
- </view></view
- >
- <view class="legend">
- <text><i class="normal"></i>正常</text>
- <text><i class="abnormal"></i>异常</text>
- <text><i class="leave"></i>请假</text>
- <text><i class="rest"></i>休息</text>
- </view>
- </view>
- <view class="section-title"
- ><text>{{ selectedLabel }}打卡</text></view
- >
- <view class="record card section">
- <view class="record-row">
- <view class="record-key">班次</view>
- <view class="record-value">{{ detail.shiftText }}</view>
- </view>
- <view class="record-row">
- <view class="record-key">上班</view>
- <view class="record-value">
- <text class="record-time">{{ detail.inText }}</text>
- <text class="record-tag">{{ detail.inLabel }}</text>
- </view>
- </view>
- <view class="record-row">
- <view class="record-key">下班</view>
- <view class="record-value">
- <text class="record-time" :class="{ muted: !detail.actualOut }">{{
- detail.outText
- }}</text>
- <text class="record-tag">{{ detail.outLabel }}</text>
- </view>
- </view>
- <view v-if="detail.workText" class="record-row">
- <view class="record-key">工时</view>
- <view class="record-value muted">{{ detail.workText }}</view>
- </view>
- <view v-if="detail.statusLabel" class="record-row">
- <view class="record-key">状态</view>
- <view class="record-value">
- <text :class="['status-tag', statusTagCls(detail.statusLabel)]">{{
- detail.statusLabel
- }}</text>
- </view>
- </view>
- </view>
- <view class="fab-wrap safe-bottom">
- <button class="check-fab press" @click="onPunch">立即打卡</button>
- <text class="fab-extra press" @click="go('/pages/apply/form?type=check')"
- >发现异常?申请补卡 ›</text
- >
- </view>
- </view>
- </template>
- <script setup>
- import { computed, reactive, ref } from "vue";
- import { onShow } from "@dcloudio/uni-app";
- import { useAuthGuard } from "@/hooks/useAuthGuard";
- import { useCurrentUser } from "@/hooks/useCurrentUser";
- import { getCurrentUser } from "@/utils/storage";
- import { go } from "@/utils/router";
- import {
- queryDailyAttendance,
- queryMonthAttendance,
- submitPunch,
- formatMonth,
- formatDate,
- translateStatus,
- queryAttendanceRule,
- } from "@/api/attendance";
- import { getLocationWithFallback } from "@/utils/location";
- import { extractTime } from "@/utils/format";
- useAuthGuard();
- const currentUser = useCurrentUser();
- const weeks = ["日", "一", "二", "三", "四", "五", "六"];
- const currentMonth = ref(formatMonth(new Date()));
- const monthList = ref([]); // ESS 月度返回数组
- const selectedDate = ref(formatDate(new Date()));
- const dailyDetail = ref(null); // 管理端 daily 返回(带 actualIn/Out 时间)
- const monthLoading = ref(false);
- // 班次信息:ruleName + 上下班打卡时间(缓存住,detail 里用)
- const shiftInfo = reactive({ ruleName: '', startTime: '', endTime: '' });
- // 考勤规则加载状态:'loading' / 'ok' / 'empty'(未配置) / 'disabled'(不可打卡) / 'error'(接口失败)
- const ruleStatus = ref('loading');
- const ruleBlockedReason = ref(''); // 不可打卡的具体原因:草稿 / 自然到期 / 停用
- const ruleToastShown = ref(false);
- const monthLabel = computed(() => {
- const [y, m] = currentMonth.value.split("-");
- return `${y}年${Number(m)}月`;
- });
- const selectedLabel = computed(() => {
- if (selectedDate.value === formatDate(new Date())) return "今日";
- const [, m, d] = selectedDate.value.split("-");
- return `${Number(m)}月${Number(d)}日`;
- });
- const summary = computed(() => {
- const list = monthList.value || [];
- let workDays = 0;
- let attendanceDays = 0;
- let lateCount = 0;
- let abnormalCount = 0;
- for (const item of list) {
- const status = item.attendanceStatusList
- if (status === "REST") continue;
- workDays++
- if (status === "NORMAL") attendanceDays++
- if ((item.lateMinutes || 0) > 0) lateCount++
- if ((item.exceptionCount || 0) > 0) abnormalCount++
- }
- const rate = workDays > 0 ? `${((attendanceDays / workDays) * 100).toFixed(1)}%` : "0%"
- return { attendanceDays, lateCount, abnormalCount, attendanceRate: rate }
- });
- const grid = computed(() => {
- const [y, m] = currentMonth.value.split("-").map(Number);
- const firstDay = new Date(y, m - 1, 1).getDay();
- const totalDays = new Date(y, m, 0).getDate();
- const todayStr = formatDate(new Date());
- const byDate = new Map();
- for (const item of monthList.value || []) {
- const key = (item.attendanceDate || "").slice(0, 10);
- if (key) byDate.set(key, item);
- }
- const cells = [];
- for (let i = 0; i < firstDay; i++) cells.push({ blank: true });
- for (let d = 1; d <= totalDays; d++) {
- const ds = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
- const data = byDate.get(ds);
- cells.push({
- date: ds,
- day: d,
- data,
- isToday: ds === todayStr,
- dot: computeDot(data, ds === todayStr),
- dotCls: computeDotCls(data, ds === todayStr),
- });
- }
- while (cells.length % 7 !== 0) cells.push({ blank: true });
- return cells;
- });
- function computeDot(data, isToday) {
- if (!data) return isToday;
- const status = data.attendanceStatusList;
- if (isToday) return true;
- if ((data.exceptionCount || 0) > 0) return true;
- if (status === "NORMAL") return true;
- return false;
- }
- function computeDotCls(data, isToday) {
- if (!data) return isToday ? "today" : "";
- if (isToday) return "today";
- if ((data.exceptionCount || 0) > 0) return "abnormal";
- return "normal";
- }
- function cellClass(cell) {
- if (cell.blank) return "blank";
- const status = cell.data?.attendanceStatusList;
- const cls = [];
- if (cell.isToday) cls.push("today");
- if (status === "REST") cls.push("rest");
- else if (status === "LEAVE") cls.push("leave");
- else if ((cell.data?.exceptionCount || 0) > 0) cls.push("abnormal");
- return cls.join(" ");
- }
- const detail = computed(() => {
- const d = dailyDetail.value || {};
- const inTime = d.actualIn || null;
- const outTime = d.actualOut || null;
- const inText = inTime ? extractTime(inTime) : "--:--";
- const outText = outTime ? extractTime(outTime) : "--:--";
- const inLabel = inTime
- ? (d.lateMinutes > 0 ? `迟到 ${d.lateMinutes} 分钟` : "正常")
- : "未打卡";
- const outLabel = outTime
- ? (d.earlyMinutes > 0 ? `早退 ${d.earlyMinutes} 分钟` : "正常")
- : "未打卡";
- const start = d.scheduledStart || shiftInfo.startTime;
- const end = d.scheduledEnd || shiftInfo.endTime;
- const shiftText =
- ruleStatus.value === 'empty'
- ? "未配置考勤规则"
- : ruleStatus.value === 'disabled'
- ? `考勤规则${ruleBlockedReason.value || '不可用'}`
- : start && end
- ? `${shiftInfo.ruleName || "今日班次"} · ${extractTime(start)} - ${extractTime(end)}`
- : "休息日";
- let workText = "";
- if (d.actualWorkMinutes || d.requiredMinutes) {
- workText = `实际 ${d.actualWorkMinutes || 0} 分钟 / 应出勤 ${d.requiredMinutes || 0} 分钟`;
- if ((d.lateMinutes || 0) > 0) workText += ` · 迟到 ${d.lateMinutes} 分钟`;
- if ((d.earlyMinutes || 0) > 0) workText += ` · 早退 ${d.earlyMinutes} 分钟`;
- }
- const statusLabel = translateStatus(d.statusList);
- return { shiftText, inText, outText, inLabel, outLabel, workText, statusLabel, actualOut: d.actualOut };
- });
- function statusTagCls(label) {
- if (!label) return "";
- if (label.includes("正常")) return "status-approved";
- if (label.includes("迟到") || label.includes("早退") || label.includes("异常") || label.includes("缺勤"))
- return "status-rejected";
- return "status-pending";
- }
- async function loadMonth(month) {
- monthLoading.value = true;
- uni.showLoading({ title: "加载中..." });
- try {
- const list = await queryMonthAttendance(month);
- monthList.value = list;
- } catch (error) {
- uni.showToast({ title: error.message || "月历加载失败", icon: "none" });
- } finally {
- uni.hideLoading();
- monthLoading.value = false;
- }
- }
- async function selectDate(date) {
- selectedDate.value = date;
- try {
- const user = getCurrentUser() || currentUser || {};
- const userId = (user && user.raw && user.raw.userId) || (user && user.raw && user.raw.id) || (user && user.id) || "";
- dailyDetail.value = await queryDailyAttendance(userId, date);
- } catch (error) {
- uni.showToast({ title: error.message || "日期详情加载失败", icon: "none" });
- }
- }
- async function loadShiftInfo() {
- const result = await queryAttendanceRule();
- ruleStatus.value = result.status;
- ruleBlockedReason.value = result.blockedReason || '';
- // 空态:进入考勤页时弹一次 modal(每个 session 只弹一次)
- if (result.status === 'empty' && !ruleToastShown.value) {
- ruleToastShown.value = true;
- uni.showModal({
- title: "未配置考勤规则",
- content: "尚未为您配置考勤规则,请联系管理员在后台完成配置后再使用考勤功能。",
- showCancel: false,
- confirmText: "我知道了",
- });
- return;
- }
- // 不可打卡(exemptFromPunch / mobilePunchEnabled=false):弹 modal 告知
- if (result.status === 'disabled' && !ruleToastShown.value) {
- ruleToastShown.value = true;
- const reason = result.blockedReason || '不可用';
- uni.showModal({
- title: `考勤规则${reason}`,
- content: `您当前的考勤规则为「${reason}」状态,无法打卡。请联系管理员处理后再使用考勤功能。`,
- showCancel: false,
- confirmText: "我知道了",
- });
- return;
- }
- if (result.status === 'error') {
- if (!ruleToastShown.value) {
- ruleToastShown.value = true;
- uni.showToast({ title: "考勤规则加载失败", icon: "none" });
- }
- return;
- }
- const rule = result.rule;
- if (!rule) return;
- shiftInfo.ruleName = rule.ruleName || '';
- shiftInfo.startTime = rule.shiftStartTime || '';
- shiftInfo.endTime = rule.shiftEndTime || '';
- }
- async function refreshAll() {
- await loadMonth(currentMonth.value);
- await selectDate(selectedDate.value);
- }
- function changeMonth(step) {
- const [y, m] = currentMonth.value.split("-").map(Number);
- const next = new Date(y, m - 1 + step, 1);
- const target = formatMonth(next);
- const cur = currentMonth.value;
- // 边界:超过当前月(未来)就停在当前月
- if (target > formatMonth(new Date())) {
- uni.showToast({ title: "暂不支持查看未来月份", icon: "none" });
- return;
- }
- if (target === cur) return;
- currentMonth.value = target;
- loadMonth(target);
- }
- async function onPunch() {
- // 规则未配置 / 不可打卡:拦截
- if (ruleStatus.value === 'empty') {
- return uni.showToast({ title: "未配置考勤规则,请联系管理员", icon: "none" });
- }
- if (ruleStatus.value === 'disabled') {
- const reason = ruleBlockedReason.value || '不可用';
- return uni.showToast({ title: `考勤规则${reason},暂不能打卡`, icon: "none" });
- }
- uni.showLoading({ title: "定位中..." });
- try {
- const loc = await getLocationWithFallback();
- const time = new Date().toISOString();
- const id = await submitPunch({
- method: "GPS",
- time,
- lat: loc.latitude,
- lng: loc.longitude,
- accuracy: loc.accuracy,
- address: loc.address,
- });
- uni.hideLoading();
- uni.showToast({ title: `打卡成功`, icon: "success" });
- // 打卡成功后:选中今天并刷新月历 + 当日详情
- selectedDate.value = formatDate(new Date());
- await refreshAll();
- } catch (error) {
- uni.hideLoading();
- uni.showToast({ title: error.message || "打卡失败", icon: "none" });
- }
- }
- onShow(async () => {
- await loadShiftInfo();
- await loadMonth(currentMonth.value);
- await selectDate(selectedDate.value);
- });
- </script>
- <style scoped>
- .attendance-page {
- padding-bottom: 220rpx;
- }
- .month-head {
- height: 100rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 60rpx;
- background: #fff;
- }
- .rule-banner {
- margin: 16rpx 24rpx 0;
- padding: 22rpx 26rpx;
- border-radius: 14rpx;
- display: flex;
- flex-direction: column;
- gap: 6rpx;
- }
- .rule-banner.empty { background: #fff7e6; color: #b46500; }
- .rule-banner.disabled { background: #f5f5f5; color: #5f6b7a; border: 1rpx solid #d9dde3; }
- .rule-banner.error { background: #fff1f0; color: #c2362d; }
- .rule-banner-title { font-size: 27rpx; font-weight: 600; }
- .rule-banner-sub { font-size: 22rpx; opacity: .85; }
- .month {
- font-size: 31rpx;
- font-weight: 600;
- }
- .month-arrow {
- color: #8b939f;
- font-size: 43rpx;
- }
- .summary {
- display: flex;
- padding: 28rpx 8rpx;
- }
- .summary > view {
- flex: 1;
- text-align: center;
- border-right: 1rpx solid #edf0f2;
- }
- .summary > view:last-child {
- border: 0;
- }
- .summary text {
- display: block;
- font-size: 31rpx;
- font-weight: 600;
- }
- .summary text + text {
- margin-top: 9rpx;
- color: #8b939f;
- font-size: 21rpx;
- font-weight: 400;
- }
- .summary text.danger {
- color: #ee4d4d;
- }
- .calendar {
- padding: 20rpx;
- }
- .week,
- .days {
- display: grid;
- grid-template-columns: repeat(7, 1fr);
- }
- .week text {
- height: 55rpx;
- text-align: center;
- color: #8b939f;
- font-size: 22rpx;
- }
- .day {
- position: relative;
- height: 76rpx;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- border-radius: 10rpx;
- }
- .day.rest {
- color: #b2b7be;
- }
- .day.leave {
- color: #b07900;
- background: #fff7e0;
- }
- .day.abnormal {
- color: #ee4d4d;
- background: #fff1f1;
- }
- .day.today {
- color: #fff;
- background: #1677ff;
- }
- .day-dot {
- position: absolute;
- bottom: 7rpx;
- width: 7rpx;
- height: 7rpx;
- border-radius: 50%;
- background: #07c160;
- }
- .day-dot.abnormal {
- background: #ee4d4d;
- }
- .day-dot.today {
- background: #fff;
- }
- .legend {
- display: flex;
- justify-content: center;
- gap: 26rpx;
- padding: 22rpx 0 6rpx;
- border-top: 1rpx solid #edf0f2;
- color: #8b939f;
- font-size: 21rpx;
- flex-wrap: wrap;
- }
- .legend i {
- display: inline-block;
- width: 11rpx;
- height: 11rpx;
- margin-right: 8rpx;
- border-radius: 50%;
- }
- .normal {
- background: #07c160;
- }
- .abnormal {
- background: #ee4d4d;
- }
- .leave {
- background: #f6bd16;
- }
- .rest {
- background: #cfd4da;
- }
- .section-title {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 24rpx 24rpx 12rpx;
- font-size: 27rpx;
- font-weight: 600;
- color: #1f2329;
- }
- .record {
- margin: 0 24rpx;
- padding: 12rpx 28rpx;
- }
- .record-row {
- display: flex;
- align-items: center;
- min-height: 84rpx;
- border-bottom: 1rpx solid #f2f4f7;
- }
- .record-row:last-child {
- border-bottom: 0;
- }
- .record-key {
- width: 96rpx;
- color: #8b939f;
- font-size: 25rpx;
- }
- .record-value {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 16rpx;
- font-size: 27rpx;
- }
- .record-value.muted {
- color: #8b939f;
- font-size: 23rpx;
- }
- .record-time {
- font-size: 31rpx;
- font-weight: 600;
- }
- .record-time.muted {
- color: #c4c8ce;
- font-weight: 400;
- }
- .record-tag {
- font-size: 22rpx;
- color: #8b939f;
- }
- .status-tag {
- display: inline-block;
- height: 32rpx;
- padding: 0 14rpx;
- border-radius: 16rpx;
- font-size: 22rpx;
- line-height: 32rpx;
- }
- .status-tag.status-approved {
- color: #07a44d;
- background: #e8f8ef;
- }
- .status-tag.status-rejected {
- color: #ee4d4d;
- background: #ffebed;
- }
- .status-tag.status-pending {
- color: #b07900;
- background: #fff7e0;
- }
- .fab-wrap {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- padding: 16rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
- background: #fff;
- border-top: 1rpx solid #edf0f2;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 10rpx;
- }
- .check-fab {
- width: 100%;
- height: 88rpx;
- border-radius: 44rpx;
- background: #1677ff;
- color: #fff;
- font-size: 30rpx;
- font-weight: 600;
- display: flex;
- align-items: center;
- justify-content: center;
- border: 0;
- box-shadow: 0 10rpx 24rpx rgba(22, 119, 255, 0.24);
- }
- .check-fab::after {
- border: 0;
- }
- .fab-extra {
- font-size: 23rpx;
- color: #1677ff;
- }
- </style>
|