|
|
@@ -1,79 +1,412 @@
|
|
|
<template>
|
|
|
- <view class="page-no-tab"
|
|
|
- ><view class="month-head"
|
|
|
+ <view class="page-no-tab attendance-page">
|
|
|
+ <view class="month-head"
|
|
|
><text class="month-arrow press" @click="changeMonth(-1)">‹</text
|
|
|
- ><text class="month">2026年{{ month }}月</text
|
|
|
+ ><text class="month">{{ monthLabel }}</text
|
|
|
><text class="month-arrow press" @click="changeMonth(1)">›</text></view
|
|
|
- ><view class="summary card section"
|
|
|
- ><view
|
|
|
- ><text>{{ month === 8 ? 4 : 22 }}</text
|
|
|
+ >
|
|
|
+
|
|
|
+ <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>0</text><text>迟到</text></view
|
|
|
><view
|
|
|
- ><text class="danger">{{ month === 8 ? 1 : 0 }}</text
|
|
|
+ ><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>{{ month === 8 ? "96.8%" : "100%" }}</text
|
|
|
+ ><text>{{ summary.attendanceRate }}</text
|
|
|
><text>出勤率</text></view
|
|
|
></view
|
|
|
- ><view class="calendar card section"
|
|
|
- ><view class="week"
|
|
|
+ >
|
|
|
+
|
|
|
+ <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="d in 35" :key="d" class="day" :class="dayClass(d)"
|
|
|
- ><text v-if="d > 4">{{ d - 4 }}</text
|
|
|
- ><view v-if="d > 4 && d < 10" class="day-dot"></view></view></view
|
|
|
- ><view class="legend"
|
|
|
- ><text><i class="normal"></i>正常</text
|
|
|
- ><text><i class="abnormal"></i>异常</text
|
|
|
- ><text><i class="rest"></i>休息</text></view
|
|
|
- ></view
|
|
|
- ><view class="section-title"
|
|
|
- ><text>{{ month === 8 ? "今日" : "月度" }}打卡</text></view
|
|
|
- ><view class="record card section"
|
|
|
- ><view class="record-item"
|
|
|
- ><view class="timeline-dot"></view
|
|
|
- ><view
|
|
|
- ><text class="record-time">08:22</text
|
|
|
- ><text class="record-label">上班打卡 · 正常</text
|
|
|
- ><text class="record-place">中盈产业园 A座 · Wi-Fi打卡</text></view
|
|
|
- ></view
|
|
|
- ><view class="record-line"></view
|
|
|
- ><view class="record-item"
|
|
|
- ><view class="timeline-dot gray"></view
|
|
|
+ >
|
|
|
+ <view class="days"
|
|
|
><view
|
|
|
- ><text class="record-time muted">--:--</text
|
|
|
- ><text class="record-label">下班打卡 · 未打卡</text></view
|
|
|
- ></view
|
|
|
- ></view
|
|
|
- ><button class="check-btn" @click="go('/pages/apply/form?type=check')">
|
|
|
- 发现异常?发起补卡申请
|
|
|
- </button></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 { ref } from "vue";
|
|
|
+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";
|
|
|
-const month = ref(8);
|
|
|
+import {
|
|
|
+ queryDailyAttendance,
|
|
|
+ queryMonthAttendance,
|
|
|
+ submitPunch,
|
|
|
+ formatMonth,
|
|
|
+ formatDate,
|
|
|
+ translateStatus,
|
|
|
+ queryAttendanceRule,
|
|
|
+ queryShiftDetail,
|
|
|
+ extractShiftDisplayRange,
|
|
|
+} from "@/api/attendance";
|
|
|
+import { getLocationWithFallback } from "@/utils/location";
|
|
|
+
|
|
|
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 || "--:--";
|
|
|
+ const outText = 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 || "今日班次"} · ${start} - ${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.id) || "";
|
|
|
+ dailyDetail.value = await queryDailyAttendance(userId, date);
|
|
|
+ } catch (error) {
|
|
|
+ uni.showToast({ title: error.message || "日期详情加载失败", icon: "none" });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function loadShiftInfo() {
|
|
|
+ const user = getCurrentUser() || currentUser || {};
|
|
|
+ const userId = (user && user.raw && user.raw.userId) || (user && user.id) || "";
|
|
|
+ const result = await queryAttendanceRule(userId);
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ // 不可打卡(DRAFT/EXPIRED/DISABLED):弹 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;
|
|
|
+ const firstPlan = (rule.shiftPlans || [])[0];
|
|
|
+ if (!firstPlan) return;
|
|
|
+ try {
|
|
|
+ const shift = await queryShiftDetail(firstPlan.shiftId);
|
|
|
+ if (!shift) return;
|
|
|
+ const range = extractShiftDisplayRange(shift);
|
|
|
+ shiftInfo.ruleName = rule.ruleName || shift.shiftName || '';
|
|
|
+ shiftInfo.startTime = (range && range.start) || shift.startTime || '';
|
|
|
+ shiftInfo.endTime = (range && range.end) || shift.endTime || '';
|
|
|
+ } catch (_) {
|
|
|
+ // shift 拉取失败不影响 ruleStatus
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshAll() {
|
|
|
+ await loadMonth(currentMonth.value);
|
|
|
+ await selectDate(selectedDate.value);
|
|
|
+}
|
|
|
+
|
|
|
function changeMonth(step) {
|
|
|
- const next = month.value + step;
|
|
|
- if (next < 1 || next > 12) return;
|
|
|
- month.value = next;
|
|
|
- uni.showToast({ title: `已切换至${month.value}月`, icon: "none" });
|
|
|
-}
|
|
|
-function dayClass(d) {
|
|
|
- const date = d - 4;
|
|
|
- if (d <= 4) return "blank";
|
|
|
- if (month.value === 8 && date === 5) return "today";
|
|
|
- if (month.value === 8 && date === 4) return "abnormal";
|
|
|
- if ([1, 2, 8, 9].includes(date)) return "rest";
|
|
|
- return "";
|
|
|
-}
|
|
|
+ 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: `打卡成功 #${id}`, 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;
|
|
|
@@ -82,6 +415,19 @@ function dayClass(d) {
|
|
|
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;
|
|
|
@@ -113,6 +459,9 @@ function dayClass(d) {
|
|
|
font-size: 21rpx;
|
|
|
font-weight: 400;
|
|
|
}
|
|
|
+.summary text.danger {
|
|
|
+ color: #ee4d4d;
|
|
|
+}
|
|
|
.calendar {
|
|
|
padding: 20rpx;
|
|
|
}
|
|
|
@@ -139,6 +488,10 @@ function dayClass(d) {
|
|
|
.day.rest {
|
|
|
color: #b2b7be;
|
|
|
}
|
|
|
+.day.leave {
|
|
|
+ color: #b07900;
|
|
|
+ background: #fff7e0;
|
|
|
+}
|
|
|
.day.abnormal {
|
|
|
color: #ee4d4d;
|
|
|
background: #fff1f1;
|
|
|
@@ -155,20 +508,21 @@ function dayClass(d) {
|
|
|
border-radius: 50%;
|
|
|
background: #07c160;
|
|
|
}
|
|
|
-.day.abnormal .day-dot {
|
|
|
+.day-dot.abnormal {
|
|
|
background: #ee4d4d;
|
|
|
}
|
|
|
-.day.today .day-dot {
|
|
|
+.day-dot.today {
|
|
|
background: #fff;
|
|
|
}
|
|
|
.legend {
|
|
|
display: flex;
|
|
|
justify-content: center;
|
|
|
- gap: 38rpx;
|
|
|
+ gap: 26rpx;
|
|
|
padding: 22rpx 0 6rpx;
|
|
|
border-top: 1rpx solid #edf0f2;
|
|
|
color: #8b939f;
|
|
|
font-size: 21rpx;
|
|
|
+ flex-wrap: wrap;
|
|
|
}
|
|
|
.legend i {
|
|
|
display: inline-block;
|
|
|
@@ -183,56 +537,114 @@ function dayClass(d) {
|
|
|
.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 {
|
|
|
- padding: 28rpx;
|
|
|
+ margin: 0 24rpx;
|
|
|
+ padding: 12rpx 28rpx;
|
|
|
}
|
|
|
-.record-item {
|
|
|
+.record-row {
|
|
|
display: flex;
|
|
|
+ align-items: center;
|
|
|
+ min-height: 84rpx;
|
|
|
+ border-bottom: 1rpx solid #f2f4f7;
|
|
|
}
|
|
|
-.timeline-dot {
|
|
|
- width: 20rpx;
|
|
|
- height: 20rpx;
|
|
|
- margin: 8rpx 24rpx 0 4rpx;
|
|
|
- border: 5rpx solid #cfe1ff;
|
|
|
- border-radius: 50%;
|
|
|
- background: #1677ff;
|
|
|
+.record-row:last-child {
|
|
|
+ border-bottom: 0;
|
|
|
}
|
|
|
-.timeline-dot.gray {
|
|
|
- border-color: #e6e8eb;
|
|
|
- background: #b2b7be;
|
|
|
+.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-label {
|
|
|
- margin-left: 18rpx;
|
|
|
- font-size: 25rpx;
|
|
|
+.record-time.muted {
|
|
|
+ color: #c4c8ce;
|
|
|
+ font-weight: 400;
|
|
|
}
|
|
|
-.record-place {
|
|
|
- display: block;
|
|
|
- margin-top: 12rpx;
|
|
|
+.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;
|
|
|
}
|
|
|
-.record-line {
|
|
|
- width: 2rpx;
|
|
|
- height: 48rpx;
|
|
|
- margin: 3rpx 0 3rpx 13rpx;
|
|
|
- background: #dfe3e8;
|
|
|
-}
|
|
|
-.check-btn {
|
|
|
- height: 84rpx;
|
|
|
- margin: 24rpx;
|
|
|
- border-radius: 12rpx;
|
|
|
- background: #eaf3ff;
|
|
|
- color: #1677ff;
|
|
|
+.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;
|
|
|
- font-size: 27rpx;
|
|
|
+ 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>
|