// HR 考勤工具集 // 由 kd-aiot-frontend-hr-H5 的 src/utils/attendance-time.ts + src/utils/location.ts // 以及 src/api/attendance.ts 中的纯函数部分迁移而来(Vue2 / JS 写法) // ============ 枚举 ============ export const HOLIDAY_DAY_TYPE = { LEGAL_HOLIDAY: "LEGAL_HOLIDAY", COMPENSATORY_WORKDAY: "COMPENSATORY_WORKDAY", COMPENSATORY_RESTDAY: "COMPENSATORY_RESTDAY", }; export const HOLIDAY_SOURCE_TYPE = { SYSTEM: "SYSTEM", EXTERNAL: "EXTERNAL", MANUAL: "MANUAL", }; const WEEKDAY_KEYWORDS = { 周一: 1, 周二: 2, 周三: 3, 周四: 4, 周五: 5, 周六: 6, 周日: 7, 星期一: 1, 星期二: 2, 星期三: 3, 星期四: 4, 星期五: 5, 星期六: 6, 星期日: 7, Monday: 1, Tuesday: 2, Wednesday: 3, Thursday: 4, Friday: 5, Saturday: 6, Sunday: 7, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7, }; const WEEKDAY_LABELS = { 1: "周一", 2: "周二", 3: "周三", 4: "周四", 5: "周五", 6: "周六", 7: "周日", }; const pad2 = (n) => String(n).padStart(2, "0"); // ============ 日期格式化 ============ /** yyyy-MM */ export function formatMonth(date) { const d = date instanceof Date ? date : new Date(date); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`; } /** * 健壮的 yyyy-MM-dd。 * 兼容 Date / 时间戳 / "yyyy-MM-dd" / "yyyy-MM-dd HH:mm:ss" / ISO 串, * 非法值返回空字符串(调用方据此跳过该条数据)。 */ export function formatDateKey(value) { if (value === null || value === undefined || value === "") return ""; let d; if (value instanceof Date) { d = value; } else if (typeof value === "number") { d = new Date(value); } else { const raw = String(value); if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw; d = new Date(/[T ]/.test(raw) ? raw : raw.replace(/-/g, "/")); } if (!d || isNaN(d.getTime())) return ""; return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } /** 解析打卡时间:支持 Date / 时间戳 / "HH:mm" / "HH:mm:ss" / 日期串 */ export function parsePunchTime(value) { if (!value) return null; if (value instanceof Date) return isNaN(value.getTime()) ? null : value; if (typeof value === "number") { const d = new Date(value); return isNaN(d.getTime()) ? null : d; } const raw = String(value); if (/[T ]/.test(raw)) { const d = new Date(raw); return isNaN(d.getTime()) ? null : d; } const m = raw.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (m) { const now = new Date(); return new Date( now.getFullYear(), now.getMonth(), now.getDate(), Number(m[1]), Number(m[2]), Number(m[3] || 0) ); } const d = new Date(raw.replace(/-/g, "/")); return isNaN(d.getTime()) ? null : d; } /** 解析成 yyyy-MM-dd 键;失败返回 null */ export function punchDateKey(value) { const d = parsePunchTime(value); if (!d) return null; return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } /** end - start 分钟数,负数截断为 0 */ export function minutesBetween(start, end) { const s = parsePunchTime(start); const e = parsePunchTime(end); if (!s || !e) return 0; const diff = Math.floor((e.getTime() - s.getTime()) / 60000); return diff > 0 ? diff : 0; } /** * 本地补偿迟到 / 早退 / 工时(后端 daily 接口常不下发达这仨事实字段)。 * 跨日补卡(actualOut 日期 ≠ 当天)按当天 23:59 截断工时,且不算早退。 */ export function computeDailyMinutes(params) { const { actualIn, actualOut, scheduledStart, scheduledEnd, dateStr } = params || {}; let lateMinutes = 0; let earlyMinutes = 0; let actualWorkMinutes = 0; let isCrossDayRepair = false; const outDateKey = punchDateKey(actualOut); if (actualOut && dateStr && outDateKey && outDateKey !== dateStr) { isCrossDayRepair = true; } if (actualIn && scheduledStart) { const inDate = parsePunchTime(actualIn); if (inDate) { const parts = String(scheduledStart).split(":"); const scheduled = new Date( inDate.getFullYear(), inDate.getMonth(), inDate.getDate(), Number(parts[0]), Number(parts[1]) ); const diff = Math.floor((inDate.getTime() - scheduled.getTime()) / 60000); if (diff > 0) lateMinutes = diff; } } if (actualOut && scheduledEnd && !isCrossDayRepair) { const outDate = parsePunchTime(actualOut); if (outDate) { const parts = String(scheduledEnd).split(":"); const scheduled = new Date( outDate.getFullYear(), outDate.getMonth(), outDate.getDate(), Number(parts[0]), Number(parts[1]) ); const diff = Math.floor((scheduled.getTime() - outDate.getTime()) / 60000); if (isFinite(diff) && diff > 0) earlyMinutes = diff; } } if (actualIn && actualOut) { const s = parsePunchTime(actualIn); const e = parsePunchTime(actualOut); if (s && e && e > s) { if (isCrossDayRepair) { const dayEnd = new Date(s.getFullYear(), s.getMonth(), s.getDate(), 23, 59, 59); actualWorkMinutes = Math.floor((dayEnd.getTime() - s.getTime()) / 60000); } else { actualWorkMinutes = Math.floor((e.getTime() - s.getTime()) / 60000); } } } return { lateMinutes, earlyMinutes, actualWorkMinutes, isCrossDayRepair }; } /** 跨日补卡的下班时间显示成 "次日 HH:mm" */ export function formatPunchTimeWithDate(value, baseDateStr) { if (!value) return ""; const t = punchDateKey(value); const base = baseDateStr || ""; const m = String(value).match(/(\d{1,2}:\d{2})/); const hm = m ? m[1] : String(value); if (!t || !base || t === base) return hm; return `次日 ${hm}`; } // ============ 打卡类型(上班卡 / 下班卡)判定 ============ /** * 时间串 → 当天分钟数(0~1439)。 * 兼容 "HH:mm" / "HH:mm:ss" / "yyyy-MM-dd HH:mm:ss" / ISO(取第一段 HH:mm); * 非法值返回 null。 */ export function minutesOfDay(value) { if (!value) return null; const m = String(value).match(/(\d{1,2}):(\d{2})/); if (!m) return null; const h = Number(m[1]); const mi = Number(m[2]); if (!isFinite(h) || !isFinite(mi) || h > 23 || mi > 59) return null; return h * 60 + mi; } /** 分钟数 → "HH:mm" */ export function formatMinutesOfDay(minutes) { if (minutes === null || minutes === undefined || !isFinite(minutes)) return ""; const m = ((Math.round(minutes) % 1440) + 1440) % 1440; return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; } /** 拿不到班次时间时的默认「上午结束」分界 */ export const DEFAULT_HALF_DAY_END_MINUTES = 12 * 60; /** * 「上班卡」的有效截止分界(分钟数)。 * 过了这个点还没打上班卡 → 视为忘打卡,之后点击打卡按「下班卡」处理(上班卡需走补卡流程)。 * 分界规则(2026-10-23 用户口径):规则里配置了上午下班时间用配置值,否则固定 12:00。 * shiftStart / shiftEnd 参数保留兼容旧调用,不再参与计算。 */ export function resolvePunchInDeadline(shiftStart, shiftEnd, halfDayEnd) { const explicit = minutesOfDay(halfDayEnd); if (explicit !== null) return explicit; return DEFAULT_HALF_DAY_END_MINUTES; } /** * 判定本次该打「上班卡」还是「下班卡」。 * * - 上下班都打过 → "DONE" * - 已打上班未打下班 → "OUT" * - 都没打:已过上班卡截止分界(规则配置的上午下班时间,未配置则 12:00)→ "OUT",否则 "IN" * (2026-10-23 用户口径:12 点之前是上班打卡,之后是下班打卡,不再按班次中点算) * * @param {object} params * @param {string} [params.actualIn] 已记录的上班打卡时间 * @param {string} [params.actualOut] 已记录的下班打卡时间 * @param {string} [params.shiftStart] 班次上班时间(保留兼容,不参与分界计算) * @param {string} [params.shiftEnd] 班次下班时间(保留兼容,不参与分界计算) * @param {string} [params.halfDayEnd] 规则里配置的上午下班时间,优先级最高 * @param {Date} [params.now] 当前时间,默认 new Date() * @returns {"IN"|"OUT"|"DONE"} */ export function resolvePunchType(params = {}) { const { actualIn, actualOut } = params; if (actualIn && actualOut) return "DONE"; if (actualIn) return "OUT"; const now = params.now instanceof Date ? params.now : new Date(); const nowMinutes = now.getHours() * 60 + now.getMinutes(); const explicit = minutesOfDay(params.halfDayEnd); return nowMinutes >= (explicit !== null ? explicit : DEFAULT_HALF_DAY_END_MINUTES) ? "OUT" : "IN"; } /** * 判断当前是否属于「提前打下班卡」(已打上班卡、未打下班卡、还没到班次下班时间)。 * 这种情况不拦截打卡,但要提示「记为早退」并二次确认。 * * @param {object} params * @param {string} [params.actualIn] 已记录的上班打卡时间(必须已打上班卡才可能早退) * @param {string} [params.actualOut] 已记录的下班打卡时间 * @param {string} [params.shiftEnd] 班次下班时间(HH:mm 或含时间的任意串) * @param {Date} [params.now] 当前时间,默认 new Date() * @returns {{early: boolean, endTime: string}} early 是否提前打下班卡;endTime 班次下班时间(HH:mm) */ export function resolveEarlyOut(params = {}) { const { actualIn, actualOut, shiftEnd } = params; if (!actualIn || actualOut) return { early: false, endTime: "" }; const endMinutes = minutesOfDay(shiftEnd); if (endMinutes === null) return { early: false, endTime: "" }; const d = params.now instanceof Date ? params.now : new Date(); const nowMinutes = d.getHours() * 60 + d.getMinutes(); return { early: nowMinutes < endMinutes, endTime: formatMinutesOfDay(endMinutes), }; } // ============ 周班次表解析 ============ const SHIFT_RANGE_RE = /(?:(.+?)\s*)?[((]\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*[-~到至]\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*[))]/; const TIME_RANGE_ONLY_RE = /(\d{1,2}:\d{2}(?::\d{2})?)\s*[-~到至]\s*(\d{1,2}:\d{2}(?::\d{2})?)/; function buildRestStub(i) { return { weekday: i, weekdayLabel: WEEKDAY_LABELS[i], isRestDay: true, shiftName: "", segments: [], }; } /** * 解析后端 shiftTimeSummary(如 "周一:行政班(08:30-17:30);周六:休息") * 为 7 项(周一→周日)班次数组。 */ export function parseShiftWeeklySummary(summary) { if (!summary || typeof summary !== "string") { return [1, 2, 3, 4, 5, 6, 7].map(buildRestStub); } const byWeekday = {}; summary .split(/[;;]/) .map((s) => s.trim()) .filter(Boolean) .forEach((part) => { const m = part.match(/^([^::]+)[::]\s*(.*)$/); if (!m) return; const weekday = WEEKDAY_KEYWORDS[m[1].trim()]; if (!weekday) return; const content = m[2].trim(); if (/^休(息|息日)?$/.test(content)) { byWeekday[weekday] = buildRestStub(weekday); return; } const withName = content.match(SHIFT_RANGE_RE); if (withName) { byWeekday[weekday] = { weekday, weekdayLabel: WEEKDAY_LABELS[weekday], isRestDay: false, shiftName: (withName[1] || "").trim(), segments: [{ start: withName[2].slice(0, 5), end: withName[3].slice(0, 5), raw: content }], }; return; } const bare = content.match(TIME_RANGE_ONLY_RE); if (bare) { byWeekday[weekday] = { weekday, weekdayLabel: WEEKDAY_LABELS[weekday], isRestDay: false, shiftName: "", segments: [{ start: bare[1].slice(0, 5), end: bare[2].slice(0, 5), raw: content }], }; } }); const result = []; for (let i = 1; i <= 7; i++) result.push(byWeekday[i] || buildRestStub(i)); return result; } /** 按日期取周班次表中的那一天(周日 → 索引 6) */ export function getShiftForDate(dateStr, schedule) { if (!dateStr || !Array.isArray(schedule) || schedule.length === 0) return null; const d = new Date(String(dateStr).replace(/-/g, "/")); if (isNaN(d.getTime())) return null; const iso = d.getDay() === 0 ? 7 : d.getDay(); return schedule[iso - 1] || null; } /** 后端 holidayDates → Map */ export function buildHolidayMap(holidayDates) { const map = new Map(); if (!Array.isArray(holidayDates)) return map; holidayDates.forEach((item) => { if (!item || item.calendarDate === null || item.calendarDate === undefined) return; const dateStr = formatDateKey(item.calendarDate); if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return; map.set(dateStr, { calendarDate: dateStr, changeReason: item.changeReason || "", dayType: item.dayType || "", sourceType: item.sourceType || "", }); }); return map; } export function getHolidayInfo(dateStr, holidayMap) { if (!dateStr || !holidayMap || typeof holidayMap.get !== "function") return null; return holidayMap.get(dateStr) || null; } /** 综合节假日 / 调休 + 周班次表判断是否休息日 */ export function isRestDay(dateStr, schedule, holidayMap) { if (dateStr && holidayMap && typeof holidayMap.get === "function") { const holiday = holidayMap.get(dateStr); if (holiday) { if (holiday.dayType === HOLIDAY_DAY_TYPE.COMPENSATORY_WORKDAY) return false; if ( holiday.dayType === HOLIDAY_DAY_TYPE.LEGAL_HOLIDAY || holiday.dayType === HOLIDAY_DAY_TYPE.COMPENSATORY_RESTDAY ) { return true; } } } const info = getShiftForDate(dateStr, schedule); return info ? Boolean(info.isRestDay) : false; } function parseShiftSummary(summary) { if (!summary || typeof summary !== "string") return []; const segments = []; summary .split(/[;;]/) .map((s) => s.trim()) .filter(Boolean) .forEach((part) => { let m = part.match(/\((\d{1,2}:\d{2}(?::\d{2})?)\s*[-~]\s*(\d{1,2}:\d{2}(?::\d{2})?)\)/); if (!m) m = part.match(/(\d{1,2}:\d{2}(?::\d{2})?)\s*[-~到至]\s*(\d{1,2}:\d{2}(?::\d{2})?)/); if (m) segments.push({ start: m[1].slice(0, 5), end: m[2].slice(0, 5), raw: part }); }); return segments; } /** * 归一化打卡规则:补出 shiftSegments / weeklySchedule / shiftStartTime / * shiftEndTime / holidayMap,供页面直接使用。 */ export function normalizeAttendanceRule(rule) { if (!rule) return null; const segments = parseShiftSummary(rule.shiftTimeSummary); const weeklySchedule = parseShiftWeeklySummary(rule.shiftTimeSummary); const holidayMap = buildHolidayMap(rule.holidayDates); const workDay = weeklySchedule.find((d) => !d.isRestDay && d.segments.length > 0); let shiftStartTime = ""; let shiftEndTime = ""; if (workDay) { const segs = workDay.segments; shiftStartTime = segs[0].start; shiftEndTime = segs[segs.length - 1].end; } else if (segments.length) { shiftStartTime = segments[0].start; shiftEndTime = segments[segments.length - 1].end; } return Object.assign({}, rule, { shiftSegments: segments, weeklySchedule, shiftStartTime, shiftEndTime, holidayMap, }); } // ============ 定位 ============ // 后端「打卡范围 / 围栏」用的是 GCJ02(高德/腾讯地图上选的点), // 因此各端最终提交的坐标都统一到 GCJ02。 const GCJ02_A = 6378245.0; // 克拉索夫斯基椭球长半轴 const GCJ02_EE = 0.00669342162296594323; // 偏心率平方 const PI = Math.PI; function outOfChina(lat, lng) { return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271; } function transformLat(x, y) { let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)); ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0; ret += ((20.0 * Math.sin(y * PI) + 40.0 * Math.sin((y / 3.0) * PI)) * 2.0) / 3.0; ret += ((160.0 * Math.sin((y / 12.0) * PI) + 320 * Math.sin((y * PI) / 30.0)) * 2.0) / 3.0; return ret; } function transformLng(x, y) { let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0; ret += ((20.0 * Math.sin(x * PI) + 40.0 * Math.sin((x / 3.0) * PI)) * 2.0) / 3.0; ret += ((150.0 * Math.sin((x / 12.0) * PI) + 300.0 * Math.sin((x / 30.0) * PI)) * 2.0) / 3.0; return ret; } /** * WGS84(gps 原始坐标)→ GCJ02(国测局坐标) * App 端只集成系统定位时只能拿到 wgs84(系统定位仅支持 wgs84), * 由前端转换成 gcj02,保证与后端围栏、后台地图是同一坐标系。 * 境外坐标不做转换。 */ export function wgs84ToGcj02(latitude, longitude) { const lat = Number(latitude); const lng = Number(longitude); if (!isFinite(lat) || !isFinite(lng)) return null; if (outOfChina(lat, lng)) return { latitude: lat, longitude: lng }; let dLat = transformLat(lng - 105.0, lat - 35.0); let dLng = transformLng(lng - 105.0, lat - 35.0); const radLat = (lat / 180.0) * PI; let magic = Math.sin(radLat); magic = 1 - GCJ02_EE * magic * magic; const sqrtMagic = Math.sqrt(magic); dLat = (dLat * 180.0) / (((GCJ02_A * (1 - GCJ02_EE)) / (magic * sqrtMagic)) * PI); dLng = (dLng * 180.0) / ((GCJ02_A / sqrtMagic) * Math.cos(radLat) * PI); return { latitude: lat + dLat, longitude: lng + dLng }; } /** uni.getLocation 包装,统一返回 { latitude, longitude, accuracy, address } */ function requestUniLocation(type) { return new Promise((resolve, reject) => { uni.getLocation({ type, geocode: false, success: (res) => resolve({ latitude: res.latitude, longitude: res.longitude, accuracy: res.accuracy, address: res.address, }), fail: (err) => reject(err), }); }); } /** 把平台原始 errMsg 翻译成人话 */ function formatLocationError(err) { const raw = String((err && (err.errMsg || err.message)) || ""); if (/not support gcj02/i.test(raw)) return "当前安装包不支持 gcj02 定位,请更新后重试"; if (/auth deny|auth denied|authorize|permission denied/i.test(raw)) return "未获得定位权限,请在手机设置中允许定位后重试"; if (/timeout/i.test(raw)) return "定位超时,请到开阔区域后重试"; if (/location switch off|system permission|ERROR_NOCELL/i.test(raw)) return "手机定位服务未开启,请打开系统定位后重试"; console.warn("[考勤] 定位失败:", raw); return "获取位置失败,请稍后重试"; } /** 是否「当前安装包没有 gcj02 能力」:App 未集成三方定位 SDK / 小程序未认证备案 */ function isGcj02Unsupported(err) { const raw = String((err && (err.errMsg || err.message)) || ""); const code = err && (err.errCode != null ? err.errCode : err.code); return /not support gcj02/i.test(raw) || Number(code) === 18; } /** * App / 小程序端定位: * 1. 优先取 gcj02(微信小程序原生支持;App 端需要集成高德/腾讯定位模块, * 正式包若只勾了「系统定位」会返回 getLocation:fail not support gcj02); * 2. 当前包不支持 gcj02 时,回退取 wgs84 原始坐标,再在前端转成 gcj02 提交。 */ async function getNativeLocation() { try { return await requestUniLocation("gcj02"); } catch (err) { if (!isGcj02Unsupported(err)) throw new Error(formatLocationError(err)); let raw = null; try { raw = await requestUniLocation("wgs84"); } catch (fallbackErr) { throw new Error(formatLocationError(fallbackErr)); } const converted = wgs84ToGcj02(raw.latitude, raw.longitude); console.warn("[考勤] 当前安装包不支持 gcj02,已回退 wgs84 并转换为 gcj02"); if (!converted) return raw; return { latitude: converted.latitude, longitude: converted.longitude, accuracy: raw.accuracy, address: raw.address, }; } } /** H5 端:浏览器定位给的是 wgs84,转成 gcj02 与后端围栏保持一致 */ function getBrowserLocation(timeout) { if (typeof navigator !== "undefined" && navigator.geolocation) { return new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition( (pos) => { const converted = wgs84ToGcj02(pos.coords.latitude, pos.coords.longitude); resolve({ latitude: converted ? converted.latitude : pos.coords.latitude, longitude: converted ? converted.longitude : pos.coords.longitude, accuracy: pos.coords.accuracy, }); }, (err) => { const msg = err && err.code === 1 ? "定位权限被拒绝,请在浏览器中允许定位后重试" : "获取位置失败,请稍后重试"; reject(new Error(msg)); }, { enableHighAccuracy: true, timeout, maximumAge: 0 } ); }); } return Promise.reject(new Error("当前环境不支持定位")); } export function getLocation(options) { const timeout = (options && options.timeout) || 10000; // #ifdef H5 return getBrowserLocation(timeout); // #endif // #ifndef H5 return getNativeLocation(); // #endif }