// 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, }); } // ============ 定位 ============ export function getLocation(options) { const timeout = (options && options.timeout) || 10000; // #ifdef H5 if (typeof navigator !== "undefined" && navigator.geolocation) { return new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition( (pos) => resolve({ latitude: pos.coords.latitude, 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("当前环境不支持定位")); // #endif // #ifdef MP-WEIXIN || APP-PLUS return new Promise((resolve, reject) => { uni.getLocation({ type: "gcj02", geocode: false, success: (res) => resolve({ latitude: res.latitude, longitude: res.longitude, accuracy: res.accuracy, }), fail: (err) => reject(new Error((err && err.errMsg) || "获取位置失败")), }); }); // #endif }