zhangwencui
5 天以前 40cd6c820e6641ee6eb4ce97ca5fb4719de7874d
fix(app): 对齐同仁堂普通打卡流程
已修改2个文件
403 ■■■■■ 文件已修改
src/api/hrm.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/attendance-clock/index.vue 398 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm.ts
@@ -96,8 +96,9 @@
export function getSalaryStructureList(token: string) { return request<SimpleOption[]>(`/hrm/salary/structure/list${query({ status: 0 })}`, { header: header(token) }); }
export function getAutoApprovalPolicy(token: string) { return request<boolean>("/bpm/process-auto-approve/ui-policy", { header: header(token) }); }
export function getAttendanceMyStatus(token: string, date?: string) { return request<AttendanceMyStatus>(`/hrm/attendance/record/my-status${query({ date })}`, { header: header(token) }); }
export function clockIn(token: string, data: AttendanceClockRequest) { return request<AttendanceClockResponse>("/hrm/attendance/record/clock-in", { method: "POST", data, header: header(token) }); }
export function clockOut(token: string, data: AttendanceClockRequest) { return request<AttendanceClockResponse>("/hrm/attendance/record/clock-out", { method: "POST", data, header: header(token) }); }
/** 同仁堂旧版考勤:后端以请求参数 location 接收打卡地点。 */
export function clockIn(token: string, location: string) { return request<AttendanceRecord>(`/hrm/attendance/record/clock-in${query({ location })}`, { method: "POST", header: header(token) }); }
export function clockOut(token: string, location: string) { return request<AttendanceRecord>(`/hrm/attendance/record/clock-out${query({ location })}`, { method: "POST", header: header(token) }); }
export function uploadHrmFile(token: string, filePath: string) {
  return new Promise<HrmAttachment>((resolve, reject) => {
src/pages/hrm/attendance-clock/index.vue
@@ -1,392 +1,12 @@
<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>