| | |
| | | <script setup lang="ts"> |
| | | import { computed, onBeforeUnmount, ref } from "vue"; |
| | | import { onPullDownRefresh, onShow } from "@dcloudio/uni-app"; |
| | | import { |
| | | clockIn, |
| | | clockOut, |
| | | getAttendanceMyStatus, |
| | | type AttendanceClockRequest, |
| | | type AttendanceClockResponse, |
| | | type AttendanceMyStatus, |
| | | type AttendanceRecord, |
| | | } from "../../../api/hrm"; |
| | | import { computed, ref } from "vue"; |
| | | import { onShow } from "@dcloudio/uni-app"; |
| | | import { clockIn, clockOut, type AttendanceRecord } from "../../../api/hrm"; |
| | | import { useAuth } from "../../../stores/auth"; |
| | | |
| | | const { state } = useAuth(); |
| | | const COOLDOWN_SECONDS = 15; |
| | | const status = ref<AttendanceMyStatus>(); |
| | | const loading = ref(false); |
| | | const punching = ref<"" | "in" | "out">(""); |
| | | const positioning = ref(false); |
| | | const locationStatus = ref<"idle" | "locating" | "inside" | "outside" | "denied" | "error" | "poor">("idle"); |
| | | const currentAccuracy = ref<number>(); |
| | | const estimatedDistance = ref<number>(); |
| | | const lastClockResult = ref<AttendanceClockResponse>(); |
| | | const error = ref(""); |
| | | const now = ref(new Date()); |
| | | let clockOffset = 0; |
| | | let timer: ReturnType<typeof setInterval> | undefined; |
| | | |
| | | function pad(value: number) { return String(value).padStart(2, "0"); } |
| | | function localDate(value = new Date()) { return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`; } |
| | | function parseDateTime(value?: string) { |
| | | if (!value) return undefined; |
| | | const parsed = new Date(String(value).replace(" ", "T")); |
| | | return Number.isNaN(parsed.getTime()) ? undefined : parsed; |
| | | } |
| | | function clockTime(value?: string) { |
| | | const parsed = parseDateTime(value); |
| | | return parsed ? `${pad(parsed.getHours())}:${pad(parsed.getMinutes())}` : "--:--"; |
| | | } |
| | | function fullClockTime(value?: string) { |
| | | const parsed = parseDateTime(value); |
| | | return parsed ? `${localDate(parsed)} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}` : "尚未打卡"; |
| | | } |
| | | function syncNow() { now.value = new Date(Date.now() + clockOffset); } |
| | | |
| | | const rule = computed(() => status.value?.rule || undefined); |
| | | const record = computed(() => status.value?.record || undefined); |
| | | const locationRequired = computed(() => rule.value?.locationRequired === true); |
| | | const targetLocationName = computed(() => rule.value?.locationName || "考勤地点"); |
| | | const locationStatusText = computed(() => { |
| | | if (!locationRequired.value) return "当前规则未启用位置限制"; |
| | | if (locationStatus.value === "locating") return "正在获取高精度定位..."; |
| | | if (locationStatus.value === "denied") return "未获得定位权限,请在系统设置中允许定位"; |
| | | if (locationStatus.value === "poor") return `定位精度 ${Math.round(currentAccuracy.value || 0)} 米,请移动到开阔处重试`; |
| | | if (locationStatus.value === "error") return "定位失败,请检查系统定位开关后重试"; |
| | | if (locationStatus.value === "inside") return `预计在范围内 · 距打卡点约 ${Math.round(estimatedDistance.value || 0)} 米`; |
| | | if (locationStatus.value === "outside") return `预计在范围外 · 距打卡点约 ${Math.round(estimatedDistance.value || 0)} 米`; |
| | | return "点击打卡后将获取当前位置"; |
| | | }); |
| | | |
| | | function calculateDistance(longitude: number, latitude: number) { |
| | | const targetLongitude = rule.value?.longitude; |
| | | const targetLatitude = rule.value?.latitude; |
| | | if (targetLongitude === undefined || targetLatitude === undefined) return undefined; |
| | | const radians = (value: number) => value * Math.PI / 180; |
| | | const latDelta = radians(latitude - targetLatitude); |
| | | const lonDelta = radians(longitude - targetLongitude); |
| | | const value = Math.sin(latDelta / 2) ** 2 |
| | | + Math.cos(radians(targetLatitude)) * Math.cos(radians(latitude)) * Math.sin(lonDelta / 2) ** 2; |
| | | const bounded = Math.min(1, Math.max(0, value)); |
| | | return 6371000 * 2 * Math.atan2(Math.sqrt(bounded), Math.sqrt(1 - bounded)); |
| | | } |
| | | |
| | | function getCurrentLocation(): Promise<AttendanceClockRequest> { |
| | | if (!locationRequired.value) return Promise.resolve({}); |
| | | if (positioning.value) return Promise.reject(new Error("正在定位,请稍候")); |
| | | positioning.value = true; |
| | | locationStatus.value = "locating"; |
| | | return new Promise((resolve, reject) => { |
| | | uni.getLocation({ |
| | | type: "wgs84", |
| | | isHighAccuracy: true, |
| | | highAccuracyExpireTime: 8000, |
| | | success: result => { |
| | | const accuracy = Number(result.accuracy || result.horizontalAccuracy || 0); |
| | | currentAccuracy.value = accuracy; |
| | | if (!Number.isFinite(accuracy) || accuracy <= 0) { |
| | | locationStatus.value = "error"; |
| | | reject(new Error("未获取到有效的定位精度,请重新定位")); |
| | | return; |
| | | } |
| | | if (accuracy > 100) { |
| | | locationStatus.value = "poor"; |
| | | reject(new Error(`当前定位精度为 ${Math.round(accuracy)} 米,请移动到开阔处重新定位`)); |
| | | return; |
| | | } |
| | | estimatedDistance.value = calculateDistance(result.longitude, result.latitude); |
| | | locationStatus.value = estimatedDistance.value !== undefined |
| | | && estimatedDistance.value > (rule.value?.radiusMeters || 200) ? "outside" : "inside"; |
| | | resolve({ |
| | | longitude: result.longitude, |
| | | latitude: result.latitude, |
| | | accuracy, |
| | | coordinateSystem: "WGS84", |
| | | }); |
| | | }, |
| | | fail: cause => { |
| | | const message = String(cause.errMsg || ""); |
| | | locationStatus.value = /auth|authorize|permission|deny/i.test(message) ? "denied" : "error"; |
| | | reject(new Error(locationStatus.value === "denied" ? "未获得定位权限,无法打卡" : "定位失败,请检查定位开关后重试")); |
| | | }, |
| | | complete: () => { positioning.value = false; }, |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | const dateText = computed(() => { |
| | | const weekdays = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; |
| | | return `${now.value.getMonth() + 1}月${now.value.getDate()}日 ${weekdays[now.value.getDay()]}`; |
| | | }); |
| | | const timeText = computed(() => `${pad(now.value.getHours())}:${pad(now.value.getMinutes())}:${pad(now.value.getSeconds())}`); |
| | | const userName = computed(() => status.value?.employeeName || state.user?.nickname || state.user?.username || "当前用户"); |
| | | const department = computed(() => status.value?.deptName || "未分配部门"); |
| | | const dateLabel = computed(() => { |
| | | const rest = status.value && status.value.workDay === false ? `(${status.value.restDayReason || "休息日"})` : ""; |
| | | return `${dateText.value}${rest} · ${rule.value?.name || "无适用班次"}`; |
| | | }); |
| | | const clockInLabel = computed(() => record.value?.clockInTypeName || (record.value?.clockInTime ? "已打卡" : "未打卡")); |
| | | const clockOutLabel = computed(() => record.value?.clockOutTypeName || (record.value?.clockOutTime ? "已打卡" : "未打卡")); |
| | | const workHourText = computed(() => { |
| | | const value = record.value?.workHours; |
| | | if (value === undefined || value === null) return ""; |
| | | const overtime = record.value?.overtimeHours; |
| | | const overtimeText = overtime ? ` · 加班 ${overtime} 小时` : ""; |
| | | return `工作时长 ${value} 小时${overtimeText}`; |
| | | }); |
| | | const cooldownLeft = computed(() => { |
| | | const clockInAt = parseDateTime(record.value?.clockInTime); |
| | | if (!clockInAt) return 0; |
| | | const left = COOLDOWN_SECONDS - Math.floor((now.value.getTime() - clockInAt.getTime()) / 1000); |
| | | return left > 0 ? left : 0; |
| | | }); |
| | | |
| | | type PunchState = { action: "in" | "out" | undefined; title: string; status: string; hint: string; tone: string }; |
| | | const punch = computed<PunchState>(() => { |
| | | if (!status.value && error.value) { |
| | | return { action: undefined, title: "无法打卡", status: "考勤信息加载失败", hint: "点击红色提示条重试", tone: "danger" }; |
| | | } |
| | | if (!status.value) { |
| | | return { action: undefined, title: "加载中", status: "正在读取考勤状态", hint: "请稍候", tone: "idle" }; |
| | | } |
| | | const info = status.value.clockable; |
| | | if (!info) { |
| | | return { action: undefined, title: "无法打卡", status: "未获取到打卡状态", hint: "下拉刷新重试", tone: "idle" }; |
| | | } |
| | | const message = info.message || ""; |
| | | const action = info.nextAction === "in" || info.nextAction === "out" ? info.nextAction : undefined; |
| | | if (!action) { |
| | | const rest = status.value.workDay === false; |
| | | return { |
| | | action: undefined, |
| | | title: rest ? "今日休息" : "今日已打卡", |
| | | status: message || (rest ? "今日休息,无需打卡" : "今日已完成打卡"), |
| | | hint: message || "今日无需打卡", |
| | | tone: rest ? "idle" : "done", |
| | | }; |
| | | } |
| | | if (action === "out" && cooldownLeft.value > 0) { |
| | | return { action: undefined, title: "上班已打卡", status: "打卡间隔", hint: `${cooldownLeft.value} 秒后可下班打卡`, tone: "cooldown" }; |
| | | } |
| | | return { |
| | | action, |
| | | title: action === "in" ? "上班打卡" : "下班打卡", |
| | | status: message, |
| | | hint: action === "in" ? "请点击完成到岗打卡" : "请点击完成离岗打卡", |
| | | tone: message.includes("超过") || message.includes("迟到") ? "warning" : "ready", |
| | | }; |
| | | }); |
| | | |
| | | async function load() { |
| | | if (!state.accessToken || loading.value) return; |
| | | loading.value = true; |
| | | error.value = ""; |
| | | try { |
| | | const data = await getAttendanceMyStatus(state.accessToken); |
| | | status.value = data; |
| | | const serverAt = parseDateTime(data.serverTime); |
| | | if (serverAt) { |
| | | const offset = serverAt.getTime() - Date.now(); |
| | | // 设备时间与服务器偏差超过 12 小时视为时区配置异常,不使用 |
| | | if (Math.abs(offset) < 12 * 3600 * 1000) clockOffset = offset; |
| | | } |
| | | syncNow(); |
| | | } catch (cause) { |
| | | error.value = cause instanceof Error ? cause.message : "今日考勤信息加载失败"; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function confirmSmartPunch() { |
| | | const kind = punch.value.action; |
| | | if (!kind) return; |
| | | let position: AttendanceClockRequest; |
| | | try { |
| | | position = await getCurrentLocation(); |
| | | } catch (cause) { |
| | | uni.showToast({ title: cause instanceof Error ? cause.message : "定位失败", icon: "none", duration: 3000 }); |
| | | return; |
| | | } |
| | | const action = kind === "in" ? "上班" : "下班"; |
| | | const locationText = locationRequired.value |
| | | ? `当前位置距“${targetLocationName.value}”约 ${Math.round(estimatedDistance.value || 0)} 米。` |
| | | : "当前规则未启用位置限制。"; |
| | | uni.showModal({ |
| | | title: `${action}打卡`, |
| | | content: `${locationText}确认进行${action}打卡吗?`, |
| | | success: result => { if (result.confirm) submitPunch(kind, position); }, |
| | | }); |
| | | } |
| | | |
| | | function applyPunch(kind: "in" | "out", next: AttendanceRecord) { |
| | | const current = status.value; |
| | | if (!current) return; |
| | | current.record = next; |
| | | current.clockable = kind === "in" |
| | | ? { clockIn: false, clockOut: true, nextAction: "out", message: "已上班打卡,可下班打卡" } |
| | | : { clockIn: false, clockOut: false, nextAction: "none", message: "今日已完成打卡" }; |
| | | } |
| | | |
| | | async function submitPunch(kind: "in" | "out", position: AttendanceClockRequest) { |
| | | if (!state.accessToken || punching.value) return; |
| | | punching.value = kind; |
| | | try { |
| | | const result = kind === "in" |
| | | ? await clockIn(state.accessToken, position) |
| | | : await clockOut(state.accessToken, position); |
| | | lastClockResult.value = result; |
| | | applyPunch(kind, result.record); |
| | | const distanceText = result.distanceMeters === undefined ? "" : `,距打卡点 ${Math.round(result.distanceMeters)} 米`; |
| | | uni.showToast({ title: `${kind === "in" ? "上班" : "下班"}打卡成功${distanceText}`, icon: "success", duration: 2600 }); |
| | | } catch (cause) { |
| | | uni.showToast({ title: cause instanceof Error ? cause.message : "打卡失败", icon: "none", duration: 2600 }); |
| | | await load(); |
| | | } finally { |
| | | punching.value = ""; |
| | | } |
| | | } |
| | | |
| | | onShow(() => { |
| | | syncNow(); |
| | | if (!timer) timer = setInterval(syncNow, 1000); |
| | | load(); |
| | | }); |
| | | onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh(); }); |
| | | onBeforeUnmount(() => { if (timer) { clearInterval(timer); timer = undefined; } }); |
| | | const { state } = useAuth(); const location = ref("公司前台"), loading = ref<"" | "in" | "out">(""), result = ref<AttendanceRecord>(); |
| | | const userName = computed(() => state.user?.nickname || state.user?.username || "当前用户"); |
| | | async function punch(type: "in" | "out") { const value = location.value.trim(); if (!value) return uni.showToast({ title: "请输入打卡地点", icon: "none" }); if (!state.accessToken || loading.value) return; const label = type === "in" ? "上班" : "下班"; loading.value = type; try { result.value = type === "in" ? await clockIn(state.accessToken, value) : await clockOut(state.accessToken, value); uni.showToast({ title: `${label}打卡成功`, icon: "success" }); } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : "打卡失败", icon: "none" }); } finally { loading.value = ""; } } |
| | | onShow(() => { location.value = "公司前台"; }); |
| | | </script> |
| | | |
| | | <template> |
| | | <view class="page"> |
| | | <view class="clock-head"> |
| | | <view class="identity"> |
| | | <view><text class="welcome">今日考勤</text><text class="user">{{ userName }}</text></view> |
| | | <text class="dept">{{ department }}</text> |
| | | </view> |
| | | <text class="date">{{ dateLabel }}</text> |
| | | </view> |
| | | |
| | | <view class="punch-stage"> |
| | | <text class="time">{{ timeText }}</text> |
| | | <text :class="['smart-status', `status-${punch.tone}`]">{{ punch.status }}</text> |
| | | <button :class="['punch-circle', `circle-${punch.tone}`]" :disabled="!punch.action || Boolean(punching) || positioning" @click="confirmSmartPunch"> |
| | | <text class="circle-title">{{ positioning ? "定位中..." : punching ? "打卡中..." : punch.title }}</text> |
| | | <text class="circle-time">{{ pad(now.getHours()) }}:{{ pad(now.getMinutes()) }}</text> |
| | | </button> |
| | | <text class="smart-hint">{{ punch.hint }}</text> |
| | | <text class="rule-time">班次 {{ rule?.workStartTime || "--:--" }} - {{ rule?.workEndTime || "--:--" }}</text> |
| | | </view> |
| | | |
| | | <view class="location-row"> |
| | | <view class="pin"><view /></view> |
| | | <view class="location-copy"> |
| | | <text>{{ locationRequired ? "规定打卡地点" : "位置限制" }}</text> |
| | | <strong>{{ locationRequired ? targetLocationName : "未启用" }}</strong> |
| | | <text v-if="locationRequired && rule?.locationAddress" class="location-address">{{ rule.locationAddress }}</text> |
| | | <text :class="['location-state', `location-${locationStatus}`]">{{ locationStatusText }}</text> |
| | | <text v-if="locationRequired" class="location-range">允许范围 {{ rule?.radiusMeters || 200 }} 米 · WGS84</text> |
| | | <text v-if="lastClockResult?.distanceMeters !== undefined" class="location-success">最近打卡距离 {{ Math.round(lastClockResult.distanceMeters) }} 米</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <view v-if="error" class="error" @click="load">{{ error }},点击重试</view> |
| | | |
| | | <view class="section-title"><text>今日考勤</text><text>{{ loading ? "更新中" : localDate() }}</text></view> |
| | | <view class="timeline"> |
| | | <view class="timeline-row"> |
| | | <view :class="['dot', { done: record?.clockInTime }]" /> |
| | | <view class="event"> |
| | | <view><text class="event-name">上班打卡</text><text :class="['state', { done: record?.clockInTime }]">{{ clockInLabel }}</text></view> |
| | | <text class="event-time">{{ fullClockTime(record?.clockInTime) }}</text> |
| | | </view> |
| | | </view> |
| | | <view class="line" /> |
| | | <view class="timeline-row"> |
| | | <view :class="['dot', { done: record?.clockOutTime }]" /> |
| | | <view class="event"> |
| | | <view><text class="event-name">下班打卡</text><text :class="['state', { done: record?.clockOutTime }]">{{ clockOutLabel }}</text></view> |
| | | <text class="event-time">{{ fullClockTime(record?.clockOutTime) }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <text v-if="workHourText" class="work-hours">{{ workHourText }}</text> |
| | | |
| | | <view class="section-title"><text>考勤规则</text><text>{{ rule?.name || "--" }}</text></view> |
| | | <view class="rule-panel"> |
| | | <view><text>上班时间</text><strong>{{ rule?.workStartTime || "--:--" }}</strong></view> |
| | | <view><text>下班时间</text><strong>{{ rule?.workEndTime || "--:--" }}</strong></view> |
| | | <view><text>迟到宽限</text><strong>{{ rule?.lateAllowMinutes ?? "--" }} 分钟</strong></view> |
| | | <view><text>早退宽限</text><strong>{{ rule?.earlyAllowMinutes ?? "--" }} 分钟</strong></view> |
| | | <view class="rule-wide"><text>适用部门</text><strong>{{ rule?.deptName || "全局规则" }}</strong></view> |
| | | <view class="rule-wide"><text>加班起算</text><strong>下班后 {{ rule?.overtimeThreshold ?? "--" }} 小时</strong></view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <style lang="scss" scoped> |
| | | .page { min-height: 100vh; box-sizing: border-box; padding: 24rpx; background: linear-gradient(155deg, #dcecff 0, #ebf7f3 34%, #f4f7fb 62%, #f4f7fb 100%); color: #10233f; } |
| | | .clock-head { padding: 12rpx 4rpx 0; } |
| | | .identity { display: flex; align-items: center; justify-content: space-between; } |
| | | .identity > view { display: flex; align-items: baseline; gap: 14rpx; } |
| | | .welcome { color: #61758f; font-size: 24rpx; } |
| | | .user { font-size: 34rpx; font-weight: 700; } |
| | | .dept { padding: 8rpx 14rpx; border-radius: 8rpx; background: #fff; color: #49617e; font-size: 23rpx; box-shadow: 0 4rpx 14rpx rgba(35, 75, 126, .08); } |
| | | .date { display: block; margin-top: 10rpx; color: #6f8198; font-size: 23rpx; } |
| | | .punch-stage { display: flex; flex-direction: column; align-items: center; padding: 38rpx 0 30rpx; } |
| | | .time { display: block; font-size: 66rpx; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: 0; } |
| | | .smart-status { margin-top: 8rpx; padding: 7rpx 18rpx; border-radius: 999rpx; font-size: 23rpx; } |
| | | .status-ready,.status-done { color: #08784c; background: #dcf6eb; } |
| | | .status-warning { color: #a65b00; background: #fff0d5; } |
| | | .status-danger { color: #c44040; background: #ffebeb; } |
| | | .status-overtime { color: #5e4ab5; background: #eeeaff; } |
| | | .status-cooldown { color: #3f668d; background: #e4edf6; } |
| | | .status-idle { color: #687789; background: #e7ebf0; } |
| | | .punch-circle { width: 286rpx; height: 286rpx; margin: 28rpx 0 20rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 14rpx solid rgba(255,255,255,.86); border-radius: 50%; color: #fff; line-height: 1.4; box-shadow: 0 18rpx 40rpx rgba(21, 105, 215, .26), 0 0 0 12rpx rgba(74, 145, 235, .11); } |
| | | .punch-circle::after { border: 0; } |
| | | .circle-ready { background: linear-gradient(150deg, #2d8af0, #1261cf); } |
| | | .circle-warning { background: linear-gradient(150deg, #f2a82d, #dc7815); box-shadow: 0 18rpx 40rpx rgba(211, 125, 21, .24), 0 0 0 12rpx rgba(242, 168, 45, .12); } |
| | | .circle-danger { background: linear-gradient(150deg, #ef7169, #cf3f45); box-shadow: 0 18rpx 40rpx rgba(199, 61, 66, .22), 0 0 0 12rpx rgba(239, 113, 105, .12); } |
| | | .circle-overtime { background: linear-gradient(150deg, #8774e7, #5944bd); box-shadow: 0 18rpx 40rpx rgba(89, 68, 189, .23), 0 0 0 12rpx rgba(135, 116, 231, .12); } |
| | | .circle-cooldown { background: linear-gradient(150deg, #8ca2b8, #607c98); box-shadow: 0 14rpx 34rpx rgba(76, 104, 132, .18), 0 0 0 12rpx rgba(140, 162, 184, .12); } |
| | | .circle-done,.circle-idle { background: #aab5c2; box-shadow: 0 12rpx 30rpx rgba(79, 95, 113, .16), 0 0 0 12rpx rgba(170, 181, 194, .12); } |
| | | .punch-circle[disabled] { opacity: 1; } |
| | | .circle-title { font-size: 36rpx; font-weight: 700; } |
| | | .circle-time { margin-top: 8rpx; font-size: 26rpx; opacity: .82; } |
| | | .smart-hint { color: #425a76; font-size: 25rpx; } |
| | | .rule-time { margin-top: 10rpx; color: #8492a5; font-size: 23rpx; } |
| | | .location-row { display: flex; align-items: center; gap: 20rpx; margin: 0 0 22rpx; padding: 20rpx 24rpx; border: 1rpx solid #dbe4ef; border-radius: 14rpx; background: #fff; } |
| | | .pin { width: 58rpx; height: 58rpx; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 50%; background: #e8f2ff; } |
| | | .pin view { width: 16rpx; height: 16rpx; border: 5rpx solid #1672e8; border-radius: 50%; } |
| | | .location-copy { min-width: 0; flex: 1; } |
| | | .location-copy > text { display: block; color: #73839a; font-size: 22rpx; } |
| | | .location-copy strong { display: block; margin-top: 4rpx; font-size: 28rpx; color: #10233f; } |
| | | .location-copy .location-address { margin-top: 5rpx; color: #667991; } |
| | | .location-copy .location-state { margin-top: 10rpx; color: #1672e8; } |
| | | .location-copy .location-outside,.location-copy .location-poor,.location-copy .location-denied,.location-copy .location-error { color: #c74a42; } |
| | | .location-copy .location-inside,.location-copy .location-success { color: #11865b; } |
| | | .location-copy .location-range { margin-top: 5rpx; color: #8a98aa; } |
| | | .error { margin-bottom: 20rpx; padding: 18rpx 22rpx; border-left: 6rpx solid #ef6a62; border-radius: 8rpx; background: #fff2f1; color: #bd3732; font-size: 24rpx; } |
| | | .section-title { display: flex; align-items: baseline; justify-content: space-between; margin: 34rpx 2rpx 16rpx; } |
| | | .section-title text:first-child { font-size: 30rpx; font-weight: 700; } |
| | | .section-title text:last-child { color: #7a899e; font-size: 23rpx; } |
| | | .timeline { position: relative; padding: 10rpx 24rpx; border: 1rpx solid #dbe4ef; border-radius: 14rpx; background: #fff; } |
| | | .timeline-row { position: relative; z-index: 1; display: flex; align-items: center; gap: 22rpx; padding: 24rpx 0; } |
| | | .dot { width: 22rpx; height: 22rpx; box-sizing: border-box; flex: 0 0 auto; border: 5rpx solid #c4ceda; border-radius: 50%; background: #fff; } |
| | | .dot.done { border-color: #17a56b; background: #17a56b; box-shadow: 0 0 0 8rpx #e2f6ee; } |
| | | .line { position: absolute; top: 64rpx; bottom: 64rpx; left: 34rpx; width: 2rpx; background: #dfe6ef; } |
| | | .event { min-width: 0; flex: 1; } |
| | | .event > view { display: flex; align-items: center; justify-content: space-between; } |
| | | .event-name { font-size: 27rpx; font-weight: 600; } |
| | | .state { color: #8795a7; font-size: 23rpx; } |
| | | .state.done { color: #159765; } |
| | | .event-time { display: block; margin-top: 8rpx; color: #708198; font-size: 23rpx; } |
| | | .work-hours { display: block; margin-top: 14rpx; padding: 0 6rpx; color: #5b7697; font-size: 23rpx; } |
| | | .rule-panel { display: grid; grid-template-columns: 1fr 1fr; border: 1rpx solid #dbe4ef; border-radius: 14rpx; background: #fff; overflow: hidden; } |
| | | .rule-panel view { padding: 24rpx; border-right: 1rpx solid #edf1f6; border-bottom: 1rpx solid #edf1f6; } |
| | | .rule-panel view:nth-child(2n) { border-right: 0; } |
| | | .rule-panel .rule-wide { grid-column: 1 / -1; border-right: 0; } |
| | | .rule-panel view:last-child { border-bottom: 0; } |
| | | .rule-panel text { display: block; color: #7b899d; font-size: 22rpx; } |
| | | .rule-panel strong { display: block; margin-top: 8rpx; font-size: 27rpx; font-weight: 650; } |
| | | </style> |
| | | <template><view class="page"><view class="card"><text class="title">考勤打卡</text><text class="sub">{{ userName }}</text><view class="field"><text>打卡地点</text><input v-model="location" maxlength="100" placeholder="请输入打卡地点" /></view><view class="actions"><button :loading="loading === 'in'" :disabled="Boolean(loading)" @click="punch('in')">上班打卡</button><button class="outline" :loading="loading === 'out'" :disabled="Boolean(loading)" @click="punch('out')">下班打卡</button></view></view><view v-if="result" class="result"><text>最近打卡地点:{{ result.location || location }}</text><text>上班时间:{{ result.clockInTime || '—' }}</text><text>下班时间:{{ result.clockOutTime || '—' }}</text></view></view></template> |
| | | <style scoped lang="scss">.page{min-height:100vh;padding:32rpx 30rpx;background:#f4f7fb}.card,.result{background:#fff;border-radius:18rpx;box-shadow:0 10rpx 28rpx rgba(36,59,91,.08)}.card{padding:34rpx 28rpx}.title{display:block;color:#1c304a;font-size:36rpx;font-weight:600}.sub{display:block;margin-top:10rpx;color:#77879b;font-size:25rpx}.field{margin-top:38rpx;padding:20rpx 0;border-bottom:1rpx solid #edf1f6}.field text{display:block;margin-bottom:14rpx;color:#40546e;font-size:27rpx}.field input{height:68rpx;padding:0 18rpx;color:#20364f;font-size:28rpx;background:#f7f9fc;border-radius:10rpx}.actions{display:flex;gap:20rpx;margin-top:34rpx}.actions button{flex:1;margin:0;color:#fff;font-size:28rpx;background:var(--color-primary,#1769d8);border-radius:12rpx}.actions button::after{border:0}.actions .outline{color:var(--color-primary,#1769d8);background:#f3f7ff;border:1rpx solid #9bbce9}.result{display:flex;flex-direction:column;gap:16rpx;padding:26rpx 28rpx;margin-top:24rpx;color:#52677f;font-size:25rpx}</style> |