| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466 |
- // 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<yyyy-MM-dd, HolidayInfo> */
- 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
- }
|