zhangwencui
2026-10-26 a3453abe4127c413158f8e607c452fa545a8e7ff
打卡功能升级
已添加3个文件
已修改4个文件
1387 ■■■■■ 文件已修改
.env 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/company.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/manifest.json 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/attendance-clock/BaiduAttendanceMap.vue 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/attendance-clock/index.vue 1204 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/workbench/index.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/baidu-map.ts 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
VITE_BAIDU_MAP_KEY=5YhFUgsoi8KMm6tafbSljgz9NNpOYP1Z
src/config/company.ts
@@ -62,6 +62,6 @@
};
/** â˜… æ‰“包开关:只改这一行 */
export const ACTIVE_COMPANY: CompanyCode = "main";
export const ACTIVE_COMPANY: CompanyCode = "xingao";
export const APP_COMPANY: AppCompanyConfig = COMPANY_PROFILES[ACTIVE_COMPANY];
src/manifest.json
@@ -1,7 +1,7 @@
{
    "name" : "中亿装配",
    "appid" : "__UNI__2D08DB0",
    "description" : "中亿装配",
    "name" : "新高电力",
    "appid" : "__UNI__4CEFF85",
    "description" : "新高电力",
    "versionName" : "1.0.0",
    "versionCode" : 100,
    "transformPx" : false,
@@ -92,6 +92,12 @@
    "quickapp" : {},
    "mp-weixin" : {
        "appid" : "wx0faf8fbc5a763dc5",
        "permission" : {
            "scope.userLocation" : {
                "desc" : "您的位置信息将用于考勤打卡范围校验"
            }
        },
        "requiredPrivateInfos" : [ "getLocation" ],
        "setting" : {
            "urlCheck" : false
        },
src/pages/hrm/attendance-clock/BaiduAttendanceMap.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { loadBaiduMap, wgs84ToBd09 } from "../../../utils/baidu-map";
const props = defineProps<{
  targetLongitude?: number;
  targetLatitude?: number;
  currentLongitude?: number;
  currentLatitude?: number;
  radiusMeters?: number;
}>();
const emit = defineEmits<{ error: [message: string] }>();
const mapContainer = ref<any>();
const mapKey = String((import.meta as any).env?.VITE_BAIDU_MAP_KEY || "").trim();
const configured = computed(() => Boolean(mapKey));
let BMapGL: any;
let map: any;
function valid(value?: number) { return Number.isFinite(value); }
function draw() {
  if (!map || !BMapGL) return;
  map.clearOverlays();
  const points: any[] = [];
  if (valid(props.targetLongitude) && valid(props.targetLatitude)) {
    const [longitude, latitude] = wgs84ToBd09(Number(props.targetLongitude), Number(props.targetLatitude));
    const point = new BMapGL.Point(longitude, latitude);
    const marker = new BMapGL.Marker(point);
    marker.setTitle("规定打卡点");
    map.addOverlay(marker);
    map.addOverlay(new BMapGL.Circle(point, props.radiusMeters || 10, {
      fillColor: "#1677ff", fillOpacity: 0.13,
      strokeColor: "#1677ff", strokeOpacity: 0.85, strokeWeight: 2,
    }));
    points.push(point);
  }
  if (valid(props.currentLongitude) && valid(props.currentLatitude)) {
    const [longitude, latitude] = wgs84ToBd09(Number(props.currentLongitude), Number(props.currentLatitude));
    const point = new BMapGL.Point(longitude, latitude);
    map.addOverlay(new BMapGL.Circle(point, 7, {
      fillColor: "#13a66a", fillOpacity: 1,
      strokeColor: "#ffffff", strokeOpacity: 1, strokeWeight: 3,
    }));
    points.push(point);
  }
  if (points.length > 1) map.setViewport(points, { margins: [45, 45, 45, 45], zoomFactor: -1 });
  else if (points.length === 1) map.centerAndZoom(points[0], 18);
}
async function initialize() {
  // #ifdef H5
  if (!configured.value) { emit("error", "未配置百度地图 AK"); return; }
  try {
    BMapGL = await loadBaiduMap(mapKey);
    await nextTick();
    const element = mapContainer.value?.$el || mapContainer.value;
    map = new BMapGL.Map(element);
    map.centerAndZoom(new BMapGL.Point(116.404, 39.915), 12);
    map.enableScrollWheelZoom(true);
    map.addControl(new BMapGL.ZoomControl());
    draw();
  } catch (error) {
    emit("error", error instanceof Error ? error.message : "百度地图初始化失败");
  }
  // #endif
}
watch(
  () => [props.targetLongitude, props.targetLatitude, props.currentLongitude, props.currentLatitude, props.radiusMeters],
  draw,
);
onMounted(initialize);
onBeforeUnmount(() => { map?.clearOverlays(); map = undefined; });
</script>
<template>
  <!-- #ifdef H5 -->
  <view v-if="configured" ref="mapContainer" class="map" />
  <!-- #endif -->
</template>
<style scoped>
.map{width:100%;height:310rpx;border-radius:14rpx;overflow:hidden}
</style>
src/pages/hrm/attendance-clock/index.vue
@@ -1,260 +1,465 @@
<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 { useAuth } from "../../../stores/auth";
  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 { useAuth } from "../../../stores/auth";
  import BaiduAttendanceMap from "./BaiduAttendanceMap.vue";
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;
  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"
  >("idle");
  const estimatedDistance = ref<number>();
  const currentLocation = ref<AttendanceClockRequest>();
  const lastClockResult = ref<AttendanceClockResponse>();
  const mapError = ref("");
  const mapVisible = ref(true);
  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); }
  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 hideUnavailableMap(message: string) {
    mapError.value = message;
    mapVisible.value = false;
  }
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 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 isInsecureH5 = computed(() => {
    // #ifdef H5
    return typeof window !== "undefined" && !window.isSecureContext;
  });
}
  const locationStatusText = computed(() => {
    if (!locationRequired.value) return "当前规则未启用位置限制";
    if (isInsecureH5.value) return "浏览器定位需要 HTTPS(localhost è°ƒè¯•除外)";
    if (locationStatus.value === "locating") return "正在获取当前位置...";
    if (locationStatus.value === "denied")
      return "未获得定位权限,请在系统设置中允许定位";
    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 "点击打卡后将获取当前位置";
  });
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;
});
  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));
  }
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;
  function getCurrentLocation(): Promise<AttendanceClockRequest> {
    if (!locationRequired.value) return Promise.resolve({});
    if (positioning.value) return Promise.reject(new Error("正在定位,请稍候"));
    if (isInsecureH5.value) {
      locationStatus.value = "error";
      return Promise.reject(
        new Error("当前网页不是安全环境,请使用 HTTPS åŽå®šä½æ‰“卡")
      );
    }
    syncNow();
  } catch (cause) {
    error.value = cause instanceof Error ? cause.message : "今日考勤信息加载失败";
  } finally {
    loading.value = false;
    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
          );
          estimatedDistance.value = calculateDistance(
            result.longitude,
            result.latitude
          );
          if (estimatedDistance.value === undefined) {
            locationStatus.value = "error";
            reject(new Error("考勤规则未配置有效的打卡点坐标"));
            return;
          }
          const allowedRadius = rule.value?.radiusMeters || 200;
          locationStatus.value =
            estimatedDistance.value > allowedRadius ? "outside" : "inside";
          const position: AttendanceClockRequest = {
            longitude: result.longitude,
            latitude: result.latitude,
            accuracy:
              Number.isFinite(accuracy) && accuracy > 0 ? accuracy : undefined,
            coordinateSystem: "WGS84",
          };
          currentLocation.value = position;
          if (locationStatus.value === "outside") {
            reject(
              new Error(
                `当前位置距打卡点约 ${Math.round(
                  estimatedDistance.value
                )} ç±³ï¼Œä¸åœ¨å…è®¸çš„ ${allowedRadius} ç±³èŒƒå›´å†…`
              )
            );
            return;
          }
          resolve(position);
        },
        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;
        },
      });
    });
  }
}
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;
  async function refreshPosition(showFailure = true) {
    try {
      await getCurrentLocation();
    } catch (cause) {
      if (showFailure) {
        uni.showToast({
          title: cause instanceof Error ? cause.message : "定位失败",
          icon: "none",
          duration: 3000,
        });
      }
    }
  }
  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); },
  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;
  });
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: "今日已完成打卡" };
}
  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 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 = "";
  async function load() {
    if (!state.accessToken || loading.value) return;
    loading.value = true;
    error.value = "";
    try {
      const data = await getAttendanceMyStatus(state.accessToken);
      status.value = data;
      if (
        data.rule?.locationRequired &&
        !currentLocation.value &&
        !isInsecureH5.value
      ) {
        void refreshPosition(false);
      }
      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;
    }
  }
}
onShow(() => {
  syncNow();
  if (!timer) timer = setInterval(syncNow, 1000);
  load();
});
onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh(); });
onBeforeUnmount(() => { if (timer) { clearInterval(timer); timer = undefined; } });
  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" ? "上班" : "下班"}打卡成功`,
        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;
    }
  });
</script>
<template>
@@ -266,32 +471,58 @@
      </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">
      <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>
    <!-- #ifdef H5 -->
    <view v-if="locationRequired && mapVisible"
          class="map-panel">
      <BaiduAttendanceMap :target-longitude="rule?.longitude"
                          :target-latitude="rule?.latitude"
                          :current-longitude="currentLocation?.longitude"
                          :current-latitude="currentLocation?.latitude"
                          :radius-meters="rule?.radiusMeters"
                          @error="hideUnavailableMap" />
      <view class="map-legend">
        <text><i class="legend-target" />规定打卡点及范围</text>
        <text><i class="legend-current" />我的位置</text>
      </view>
      <text v-if="mapError"
            class="map-error">{{ mapError }}</text>
    </view>
    <!-- #endif -->
    <view class="location-row">
      <view class="pin"><view /></view>
      <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 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>
        <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>
      <button v-if="locationRequired"
              class="relocate"
              :loading="positioning"
              @click="refreshPosition()">{{ positioning ? "定位中" : "重新定位" }}</button>
    </view>
    <view v-if="error" class="error" @click="load">{{ error }},点击重试</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">
@@ -310,8 +541,8 @@
        </view>
      </view>
    </view>
    <text v-if="workHourText" class="work-hours">{{ workHourText }}</text>
    <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>
@@ -325,68 +556,409 @@
</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; }
  .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, 0.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, 0.86);
    border-radius: 50%;
    color: #fff;
    line-height: 1.4;
    box-shadow: 0 18rpx 40rpx rgba(21, 105, 215, 0.26),
      0 0 0 12rpx rgba(74, 145, 235, 0.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, 0.24),
      0 0 0 12rpx rgba(242, 168, 45, 0.12);
  }
  .circle-danger {
    background: linear-gradient(150deg, #ef7169, #cf3f45);
    box-shadow: 0 18rpx 40rpx rgba(199, 61, 66, 0.22),
      0 0 0 12rpx rgba(239, 113, 105, 0.12);
  }
  .circle-overtime {
    background: linear-gradient(150deg, #8774e7, #5944bd);
    box-shadow: 0 18rpx 40rpx rgba(89, 68, 189, 0.23),
      0 0 0 12rpx rgba(135, 116, 231, 0.12);
  }
  .circle-cooldown {
    background: linear-gradient(150deg, #8ca2b8, #607c98);
    box-shadow: 0 14rpx 34rpx rgba(76, 104, 132, 0.18),
      0 0 0 12rpx rgba(140, 162, 184, 0.12);
  }
  .circle-done,
  .circle-idle {
    background: #aab5c2;
    box-shadow: 0 12rpx 30rpx rgba(79, 95, 113, 0.16),
      0 0 0 12rpx rgba(170, 181, 194, 0.12);
  }
  .punch-circle[disabled] {
    opacity: 1;
  }
  .circle-title {
    font-size: 36rpx;
    font-weight: 700;
  }
  .circle-time {
    margin-top: 8rpx;
    font-size: 26rpx;
    opacity: 0.82;
  }
  .smart-hint {
    color: #425a76;
    font-size: 25rpx;
  }
  .rule-time {
    margin-top: 10rpx;
    color: #8492a5;
    font-size: 23rpx;
  }
  .map-panel {
    margin-bottom: 16rpx;
    padding: 12rpx;
    border: 1rpx solid #dbe4ef;
    border-radius: 18rpx;
    background: #fff;
    box-shadow: 0 8rpx 24rpx rgba(35, 75, 126, 0.07);
  }
  .map-legend {
    display: flex;
    align-items: center;
    gap: 28rpx;
    padding: 16rpx 8rpx 6rpx;
    color: #718198;
    font-size: 21rpx;
  }
  .map-legend text {
    display: flex;
    align-items: center;
    gap: 8rpx;
  }
  .map-legend i {
    width: 14rpx;
    height: 14rpx;
    display: inline-block;
    border-radius: 50%;
  }
  .legend-target {
    background: #1677ff;
  }
  .legend-current {
    background: #13a66a;
    border: 3rpx solid #fff;
    box-shadow: 0 0 0 1rpx #13a66a;
  }
  .map-error {
    display: block;
    padding: 8rpx;
    color: #c74a42;
    font-size: 21rpx;
  }
  .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-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;
  }
  .relocate {
    flex: none;
    min-width: 128rpx;
    min-height: 58rpx;
    padding: 0 16rpx;
    margin: 0;
    color: #1672e8;
    font-size: 22rpx;
    line-height: 56rpx;
    background: #eef6ff;
    border-radius: 9rpx;
  }
  .relocate::after {
    border: 0;
  }
  .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>
src/pages/workbench/index.vue
@@ -215,7 +215,8 @@
  const apps = computed(() => {
    const menuApps = flattenMenus(state.menus);
    const selfServiceApps = getSelfServiceApps(menuApps);
    // â€œæˆ‘的申请”模块暂时隐藏;恢复时取消下一行注释,并在返回值中重新合并 selfServiceApps。
    // const selfServiceApps = getSelfServiceApps(menuApps);
    const attendance =
      !isModuleEnabled("考勤规则") ||
      menuApps.some(app => app.component === "hrm/attendance/clock/index")
@@ -234,7 +235,7 @@
              mark: "mark-9",
            },
          ];
    return menuApps.concat(selfServiceApps, attendance);
    return menuApps.concat(attendance);
  });
  const groups = computed(() => [
    "全部",
src/utils/baidu-map.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,79 @@
const PI = Math.PI;
const X_PI = PI * 3000 / 180;
const EARTH_SEMI_MAJOR_AXIS = 6_378_245;
const EARTH_ECCENTRICITY_SQUARED = 0.006_693_421_622_965_943;
function outOfChina(longitude: number, latitude: number) {
  return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271;
}
function transformLatitude(longitude: number, latitude: number) {
  let result = -100 + 2 * longitude + 3 * latitude + 0.2 * latitude * latitude
    + 0.1 * longitude * latitude + 0.2 * Math.sqrt(Math.abs(longitude));
  result += (20 * Math.sin(6 * longitude * PI) + 20 * Math.sin(2 * longitude * PI)) * 2 / 3;
  result += (20 * Math.sin(latitude * PI) + 40 * Math.sin(latitude / 3 * PI)) * 2 / 3;
  result += (160 * Math.sin(latitude / 12 * PI) + 320 * Math.sin(latitude * PI / 30)) * 2 / 3;
  return result;
}
function transformLongitude(longitude: number, latitude: number) {
  let result = 300 + longitude + 2 * latitude + 0.1 * longitude * longitude
    + 0.1 * longitude * latitude + 0.1 * Math.sqrt(Math.abs(longitude));
  result += (20 * Math.sin(6 * longitude * PI) + 20 * Math.sin(2 * longitude * PI)) * 2 / 3;
  result += (20 * Math.sin(longitude * PI) + 40 * Math.sin(longitude / 3 * PI)) * 2 / 3;
  result += (150 * Math.sin(longitude / 12 * PI) + 300 * Math.sin(longitude / 30 * PI)) * 2 / 3;
  return result;
}
export function wgs84ToGcj02(longitude: number, latitude: number): [number, number] {
  if (outOfChina(longitude, latitude)) return [longitude, latitude];
  let deltaLatitude = transformLatitude(longitude - 105, latitude - 35);
  let deltaLongitude = transformLongitude(longitude - 105, latitude - 35);
  const radianLatitude = latitude / 180 * PI;
  let magic = Math.sin(radianLatitude);
  magic = 1 - EARTH_ECCENTRICITY_SQUARED * magic * magic;
  const sqrtMagic = Math.sqrt(magic);
  deltaLatitude = deltaLatitude * 180
    / ((EARTH_SEMI_MAJOR_AXIS * (1 - EARTH_ECCENTRICITY_SQUARED)) / (magic * sqrtMagic) * PI);
  deltaLongitude = deltaLongitude * 180
    / (EARTH_SEMI_MAJOR_AXIS / sqrtMagic * Math.cos(radianLatitude) * PI);
  return [longitude + deltaLongitude, latitude + deltaLatitude];
}
export function wgs84ToBd09(longitude: number, latitude: number): [number, number] {
  const [gcjLongitude, gcjLatitude] = wgs84ToGcj02(longitude, latitude);
  const z = Math.sqrt(gcjLongitude * gcjLongitude + gcjLatitude * gcjLatitude)
    + 0.00002 * Math.sin(gcjLatitude * X_PI);
  const theta = Math.atan2(gcjLatitude, gcjLongitude) + 0.000003 * Math.cos(gcjLongitude * X_PI);
  return [z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006];
}
const SCRIPT_ID = "mom-baidu-map-jsapi";
const CALLBACK = "__momBaiduMapReady";
export function loadBaiduMap(apiKey: string): Promise<any> {
  if (typeof window === "undefined" || typeof document === "undefined") {
    return Promise.reject(new Error("当前运行环境不支持百度 WebGL åœ°å›¾"));
  }
  const mapWindow = window as any;
  if (mapWindow.BMapGL) return Promise.resolve(mapWindow.BMapGL);
  if (mapWindow.__momBaiduMapPromise) return mapWindow.__momBaiduMapPromise;
  mapWindow.__momBaiduMapPromise = new Promise((resolve, reject) => {
    const timeout = window.setTimeout(() => reject(new Error("百度地图加载超时,请检查 AK、域名白名单和网络")), 15_000);
    mapWindow[CALLBACK] = () => {
      window.clearTimeout(timeout);
      resolve(mapWindow.BMapGL);
      delete mapWindow[CALLBACK];
    };
    if (document.getElementById(SCRIPT_ID)) return;
    const script = document.createElement("script");
    script.id = SCRIPT_ID;
    script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${encodeURIComponent(apiKey)}&callback=${CALLBACK}`;
    script.addEventListener("error", () => {
      window.clearTimeout(timeout);
      reject(new Error("百度地图加载失败,请检查 AK、域名白名单和网络"));
    }, { once: true });
    document.head.append(script);
  });
  return mapWindow.__momBaiduMapPromise;
}