Procházet zdrojové kódy

feat(attendance): 添加周班次解析功能,优化考勤日历显示逻辑

xieyong před 4 dny
rodič
revize
d082ae261c
2 změnil soubory, kde provedl 204 přidání a 27 odebrání
  1. 147 13
      src/api/attendance.js
  2. 57 14
      src/pages/attendance/index.vue

+ 147 - 13
src/api/attendance.js

@@ -540,12 +540,133 @@ const shiftCache = new Map()                    // shiftId -> { shift, fetchedAt
 const RULE_TTL = 30 * 60 * 1000        // 30 分钟(规则可能调整)
 const SHIFT_TTL = 24 * 60 * 60 * 1000  // 24 小时(班次很少变)
 
-// 把 shiftTimeSummary 拆成结构化的班次段数组。
+// ============ 周班次(按"周X:班次/休息"格式)============
 // 后端实际下发形态:
-//   "谢用规则班次1(09:00:00-18:00:00);谢用规则班次1(09:00:00-18:00:00);…"
+//   "周一:谢用规则班次1(09:00:00-18:00:00);周二:谢用规则班次1(09:00:00-18:00:00);…
+//    周六:休息;周日:休息"
+//   段间分隔:; / ;
+//   段内分隔:"周X:" + 内容;内容为字面量"休息"表示休息日,否则视作
+//   "班次名(HH:mm-HH:mm)" / "(HH:mm-HH:mm)" / "HH:mm-HH:mm"。
+
+// ISO 周一=1..周日=7;中文/英文/缩写都接住
+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: '周日',
+}
+
+// 抓 "(前缀)(HH:mm-HH:mm)" / "(HH:mm-HH:mm)" / "HH:mm-HH:mm"
+// 前缀可缺省;时间部分接受 HH:mm 或 HH:mm:ss;连接符接受 - ~ 到 至
+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})?)/
+
+// 把 shiftTimeSummary 解析成 7 项 ISO 周一→周日 数组。
+// 缺的日期默认填休息,避免下游下标错位。
+// 解析失败/空串 → 全休息的 7 项数组(不要被当成"无规则"误判)。
+export function parseShiftWeeklySummary(summary) {
+  const restStub = (i) => ({
+    weekday: i,
+    weekdayLabel: WEEKDAY_LABELS[i],
+    isRestDay: true,
+    shiftName: '',
+    segments: [],
+  })
+  if (!summary || typeof summary !== 'string') {
+    return [1, 2, 3, 4, 5, 6, 7].map(restStub)
+  }
+
+  const byWeekday = {}
+  for (const part of summary.split(/[;;]/).map((s) => s.trim()).filter(Boolean)) {
+    // "<周X>:<内容>",全/半角冒号都接
+    const m = part.match(/^([^::]+)[::]\s*(.*)$/)
+    if (!m) continue
+    const weekday = WEEKDAY_KEYWORDS[m[1].trim()]
+    if (!weekday) continue
+    const content = m[2].trim()
+
+    // 休息日:字面量 "休息" / "休" / "休息日"
+    if (/^休(息|息日)?$/.test(content)) {
+      byWeekday[weekday] = {
+        weekday,
+        weekdayLabel: WEEKDAY_LABELS[weekday],
+        isRestDay: true,
+        shiftName: '',
+        segments: [],
+      }
+      continue
+    }
+
+    // 工作日:先抓 "前缀(time-time)";抓不到再尝试纯 time-time
+    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,
+        }],
+      }
+      continue
+    }
+    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] || restStub(i))
+  return result
+}
+
+// JS getDay() 0=Sun..6=Sat → ISO 1=Mon..7=Sun
+function jsDayToIso(jsDay) {
+  return jsDay === 0 ? 7 : jsDay
+}
+
+// 给定日期 + 周班次数组,返回当天的班次信息(含 isRestDay)。
+// schedule 缺失/空 → null(让上游按"未配置"处理,而不是误报休息日)
+export function getShiftForDate(dateStr, schedule) {
+  if (!dateStr || !Array.isArray(schedule) || schedule.length === 0) return null
+  const d = new Date(dateStr)
+  if (Number.isNaN(d.getTime())) return null
+  return schedule[jsDayToIso(d.getDay()) - 1] || null
+}
+
+// 给定日期 + 周班次数组,是否为休息日。
+// 找不到 schedule → false(宁可选成工作日也别误报休息日)
+export function isRestDay(dateStr, schedule) {
+  const info = getShiftForDate(dateStr, schedule)
+  return info ? Boolean(info.isRestDay) : false
+}
+
+// 把 shiftTimeSummary 拆成结构化的班次段数组(仅时间区间)。
+// 后端实际下发形态:
+//   "周一:谢用规则班次1(09:00:00-18:00:00);周二:谢用规则班次1(09:00:00-18:00:00);…"
 //   段间用 ";" 或 ";" 分隔,每段形如 "前缀(HH:mm:ss-HH:mm:ss)"。
 //   也兼容纯 "HH:mm-HH:mm" / "HH:mm - HH:mm"。
 // 返回 [{ start, end, raw }, …];解析失败返回 []。
+// 注:rest 段(如"周六:休息")不含时间区间,自然不会出现在结果里。
 function parseShiftSummary(summary) {
   if (!summary || typeof summary !== 'string') return []
   const segments = []
@@ -567,6 +688,7 @@ function parseShiftSummary(summary) {
 }
 
 // demo fallback:行政班 + GPS 打卡 + 中盈产业园(匹配 punch-rule 新 schema)
+// shiftTimeSummary 用周班次形态,与服务端下发一致
 const DEMO_RULE = {
   ruleId: 1,
   ruleCode: 'ADMIN_DAY',
@@ -583,7 +705,8 @@ const DEMO_RULE = {
     { locationName: '中盈产业园 A座', radius: 200, outsidePolicy: 'ALLOW' },
   ],
   wifiDisplayNames: [],
-  shiftTimeSummary: '08:30 - 17:30',
+  shiftTimeSummary:
+    '周一:行政班(08:30-17:30);周二:行政班(08:30-17:30);周三:行政班(08:30-17:30);周四:行政班(08:30-17:30);周五:行政班(08:30-17:30);周六:休息;周日:休息',
   message: '',
   timezone: 'Asia/Shanghai',
   version: 1,
@@ -630,12 +753,7 @@ const DEMO_SHIFT = {
 export async function queryAttendanceRule() {
   if (!isServerMode()) {
     return {
-      rule: {
-        ...DEMO_RULE,
-        shiftSegments: [{ start: '08:30', end: '17:30', raw: DEMO_RULE.shiftTimeSummary }],
-        shiftStartTime: '08:30',
-        shiftEndTime: '17:30',
-      },
+      rule: normalize(DEMO_RULE),
       status: 'ok',
       loaded: true,
     }
@@ -653,16 +771,32 @@ export async function queryAttendanceRule() {
   }
 
   // 把 shiftTimeSummary 拆成结构化字段,方便 UI 直接拿 startTime/endTime。
-  // shiftSegments 是全部班次段(多段三班倒用得到),shiftStartTime/End 始终取第一段作"主班次"。
+  // - shiftSegments 全部时间区间(按"段"线性拉平,跨段三班倒用得到)
+  // - weeklySchedule ISO 周一→周日 7 项数组,每项含 isRestDay + segments
+  // - shiftStartTime/End 主班次的上下班(取第一个工作日的首/末段;取不到再退到 shiftSegments)
   function normalize(rule) {
     if (!rule) return null
     const segments = parseShiftSummary(rule.shiftTimeSummary)
-    const first = segments[0] || {}
+    const weeklySchedule = parseShiftWeeklySummary(rule.shiftTimeSummary)
+    const workDay = Array.isArray(weeklySchedule)
+      ? weeklySchedule.find((d) => !d.isRestDay && d.segments.length > 0)
+      : null
+    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 {
       ...rule,
       shiftSegments: segments,
-      shiftStartTime: first.start || '',
-      shiftEndTime: first.end || '',
+      weeklySchedule,
+      shiftStartTime,
+      shiftEndTime,
     }
   }
 

+ 57 - 14
src/pages/attendance/index.vue

@@ -121,6 +121,8 @@ import {
   formatDate,
   translateStatus,
   queryAttendanceRule,
+  getShiftForDate,
+  isRestDay,
 } from "@/api/attendance";
 import { getLocationWithFallback } from "@/utils/location";
 import { extractTime } from "@/utils/format";
@@ -137,6 +139,10 @@ const dailyDetail = ref(null); // 管理端 daily 返回(带 actualIn/Out 时
 const monthLoading = ref(false);
 // 班次信息:ruleName + 上下班打卡时间(缓存住,detail 里用)
 const shiftInfo = reactive({ ruleName: '', startTime: '', endTime: '' });
+// 周班次表:7 项 ISO 周一→周日,每项含 isRestDay / shiftName / segments。
+// 用于日历格在月度接口还没回来时也能正确判断哪些天是休息日,
+// 也用于详情卡在 daily 接口没数据时也能展示班次名 + 时间范围。
+const weeklySchedule = ref([]);
 // 考勤规则加载状态:'loading' / 'ok' / 'empty'(未配置) / 'disabled'(不可打卡) / 'error'(接口失败)
 const ruleStatus = ref('loading');
 const ruleBlockedReason = ref(''); // 不可打卡的具体原因:草稿 / 自然到期 / 停用
@@ -159,9 +165,12 @@ const summary = computed(() => {
   let attendanceDays = 0;
   let lateCount = 0;
   let abnormalCount = 0;
+  const schedule = weeklySchedule.value;
   for (const item of list) {
     const status = item.attendanceStatusList
-    if (status === "REST") continue;
+    // 休息日判定:API 标 REST 或 周班次表里是休息日
+    // 后者兜底,避免后端漏标(比如月底补算没及时回写)让工作日误算成"出勤"
+    if (status === "REST" || isRestDay(item.attendanceDate, schedule)) continue;
     workDays++
     if (status === "NORMAL") attendanceDays++
     if ((item.lateMinutes || 0) > 0) lateCount++
@@ -184,20 +193,24 @@ const grid = computed(() => {
     const key = (item.attendanceDate || "").slice(0, 10);
     if (key) byDate.set(key, item);
   }
+  const schedule = weeklySchedule.value;
   const cells = [];
   for (let i = 0; i < firstDay; i++) cells.push({ blank: true });
   for (let d = 1; d <= totalDays; d++) {
     const ds = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
     const data = byDate.get(ds);
+    // 从周班次表查当天的班次信息(用于月度接口未加载/未标 REST 时的兜底着色)
+    const scheduleInfo = getShiftForDate(ds, schedule);
     cells.push({
       date: ds,
       day: d,
       data,
+      scheduleInfo,
       isToday: ds === todayStr,
       // 选中态跟原来 today 走同一套蓝色样式,这样点击其他日期时蓝色高亮会跟着移动,
       // 跟 record 卡片的数据切换形成视觉上的对应
       isSelected: ds === selectedDate.value,
-      dot: computeDot(data, ds === selectedDate.value),
+      dot: computeDot(data, ds === selectedDate.value, scheduleInfo),
       dotCls: computeDotCls(data, ds === selectedDate.value),
     });
   }
@@ -205,7 +218,9 @@ const grid = computed(() => {
   return cells;
 });
 
-function computeDot(data, isSelected) {
+function computeDot(data, isSelected, scheduleInfo) {
+  // 休息日且无打卡数据:不显示绿点(避免周末出现误导性的"已打卡"圆点)
+  if (scheduleInfo?.isRestDay && !data) return isSelected;
   if (!data) return isSelected;
   const status = data.attendanceStatusList;
   // 选中态:始终显示圆点(蓝底配白点)
@@ -226,12 +241,15 @@ function computeDotCls(data, isSelected) {
 function cellClass(cell) {
   if (cell.blank) return "blank";
   const status = cell.data?.attendanceStatusList;
+  // 休息日兜底:API 标 REST 或 周班次表里是休息日 都按休息日样式
+  // (后端漏标或接口延迟时,日历格依然能正确显示为灰色)
+  const isRest = status === "REST" || cell.scheduleInfo?.isRestDay;
   const cls = [];
   // 选中态优先:被选中的日期复用原本 today 的蓝色背景(白字 + 白点)
   if (cell.isSelected) cls.push("today");
   // 今天的标记:仅在今天不是当前选中时显示,避免选中其他日期后"今天"完全消失
   if (cell.isToday && !cell.isSelected) cls.push("is-today-mark");
-  if (status === "REST") cls.push("rest");
+  if (isRest) cls.push("rest");
   else if (status === "LEAVE") cls.push("leave");
   else if ((cell.data?.exceptionCount || 0) > 0) cls.push("abnormal");
   return cls.join(" ");
@@ -239,6 +257,10 @@ function cellClass(cell) {
 
 const detail = computed(() => {
   const d = dailyDetail.value || {};
+  // 选中日期的周班次(用于 daily 接口没返回数据时的兜底显示)
+  const scheduleInfo = getShiftForDate(selectedDate.value, weeklySchedule.value);
+  const isRest = Boolean(scheduleInfo?.isRestDay);
+
   const inTime = d.actualIn || null;
   const outTime = d.actualOut || null;
   const inText = inTime ? extractTime(inTime) : "--:--";
@@ -249,16 +271,32 @@ const detail = computed(() => {
   const outLabel = outTime
     ? (d.earlyMinutes > 0 ? `早退 ${d.earlyMinutes} 分钟` : "正常")
     : "未打卡";
-  const start = d.scheduledStart || shiftInfo.startTime;
-  const end = d.scheduledEnd || shiftInfo.endTime;
-  const shiftText =
-    ruleStatus.value === 'empty'
-      ? "未配置考勤规则"
-      : ruleStatus.value === 'disabled'
-        ? `考勤规则${ruleBlockedReason.value || '不可用'}`
-        : start && end
-          ? `${shiftInfo.ruleName || "今日班次"} · ${extractTime(start)} - ${extractTime(end)}`
-          : "休息日";
+  // 上下班时间:daily 返回 > 周班次表(当天非休息日时)> rule.shiftStartTime/End
+  let start = d.scheduledStart || '';
+  let end = d.scheduledEnd || '';
+  if ((!start || !end) && scheduleInfo && !scheduleInfo.isRestDay && scheduleInfo.segments.length) {
+    start = start || scheduleInfo.segments[0].start
+    end = end || scheduleInfo.segments[scheduleInfo.segments.length - 1].end
+  }
+  if (!start) start = shiftInfo.startTime
+  if (!end) end = shiftInfo.endTime
+
+  let shiftText;
+  if (ruleStatus.value === 'empty') {
+    shiftText = "未配置考勤规则";
+  } else if (ruleStatus.value === 'disabled') {
+    shiftText = `考勤规则${ruleBlockedReason.value || '不可用'}`;
+  } else if (isRest && !d.actualIn && !d.actualOut) {
+    // 休息日且当天无打卡:显示「周X 休息日」(去掉冗余的"今日班次 · --:-- - --:--")
+    shiftText = `${scheduleInfo.weekdayLabel || ''} 休息日`.trim();
+  } else if (start && end) {
+    const name = (scheduleInfo && !scheduleInfo.isRestDay && scheduleInfo.shiftName)
+      || shiftInfo.ruleName
+      || "今日班次";
+    shiftText = `${name} · ${extractTime(start)} - ${extractTime(end)}`;
+  } else {
+    shiftText = "休息日";
+  }
   let workText = "";
   if (d.actualWorkMinutes || d.requiredMinutes) {
     workText = `实际 ${d.actualWorkMinutes || 0} 分钟 / 应出勤 ${d.requiredMinutes || 0} 分钟`;
@@ -272,6 +310,10 @@ const detail = computed(() => {
   const augmentedList = [...baseList]
   if ((d.lateMinutes || 0) > 0 && !augmentedList.includes('LATE')) augmentedList.push('LATE')
   if ((d.earlyMinutes || 0) > 0 && !augmentedList.includes('EARLY')) augmentedList.push('EARLY')
+  // 休息日(按周班次表判定)补一个 REST,避免详情卡在无 daily 数据时漏掉"休息日"标签
+  if (isRest && !d.actualIn && !d.actualOut && !augmentedList.includes('REST')) {
+    augmentedList.push('REST')
+  }
   const statusLabel = augmentedList.length > 0 ? translateStatus(augmentedList) : ''
   return { shiftText, inText, outText, inLabel, outLabel, workText, statusLabel, actualOut: d.actualOut };
 });
@@ -355,6 +397,7 @@ async function loadShiftInfo() {
   shiftInfo.ruleName = rule.ruleName || '';
   shiftInfo.startTime = rule.shiftStartTime || '';
   shiftInfo.endTime = rule.shiftEndTime || '';
+  weeklySchedule.value = Array.isArray(rule.weeklySchedule) ? rule.weeklySchedule : [];
 }
 
 async function refreshAll() {