Просмотр исходного кода

feat(attendance): 优化考勤规则查询逻辑,移除不必要的用户 ID 传递,简化班次信息处理

xieyong 4 дней назад
Родитель
Сommit
5a5b42a8ad
3 измененных файлов с 121 добавлено и 97 удалено
  1. 107 59
      src/api/attendance.js
  2. 6 19
      src/pages/attendance/index.vue
  3. 8 19
      src/pages/home/index.vue

+ 107 - 59
src/api/attendance.js

@@ -522,45 +522,71 @@ export function combineDateTime(dateStr, timeStr) {
 }
 
 // ============ 考勤规则 / 班次详情(启动拉一次,内存缓存) ============
-// 设计:pageRules 返回的考勤规则不含具体班次时间,要 join shiftPlans[].shiftId → getShift。
-//       daily 接口虽然已经返了当天的 scheduledStart/End,但 rule 还含 locations/wifiList/punchMethods/
-//       cardRepair/宽限分钟 等"配置类"数据,独立缓存有意义。
-
-// 考勤规则状态字典(与后端 AttendanceRuleStatus 枚举一致)
-export const ATTENDANCE_RULE_STATUS_LABEL = {
-  DRAFT: '草稿',
-  EXPIRED: '自然到期',
-  ACTIVE: '开启/生效',
-  DISABLED: '停用',
-}
-// 不可打卡的规则状态集合(DRAFT 未生效 / EXPIRED 到期 / DISABLED 停用)
-const PUNCH_BLOCKED_STATUSES = new Set(['DRAFT', 'EXPIRED', 'DISABLED'])
-
-const ruleCache = new Map()   // userId -> { rule, fetchedAt }
-const shiftCache = new Map()  // shiftId -> { shift, fetchedAt }
+// 设计:调员工自助接口 GET /hr/ess/attendance/punch-rule,命中"员工本人的打卡规则"。
+//       返回字段已经够 UI 展示上下班时间(shiftTimeSummary),不需要再 join shiftPlans → getShift。
+//       保留 queryShiftDetail / extractShiftDisplayRange 给将来扩展用。
+//       daily 接口虽然已经返了当天的 scheduledStart/End,但 rule 还含宽限分钟、
+//       打卡地点、WiFi、移动端开关、班次时间摘要 等"配置类"数据,独立缓存有意义。
+//
+// "不可移动端打卡"的判定(来自 /punch-rule 响应):
+//   - exemptFromPunch === true          → 后台给该员工配了"免打卡"
+//   - mobilePunchEnabled === false      → 该规则不允许当前员工用移动端
+//   - ruleId 缺失 / 0                   → 未命中唯一规则(视同 empty,不走 disabled)
+// 后端不再返回 rule.status 枚举(DRAFT/EXPIRED/DISABLED),所以原状态机被替代。
+
+const ruleCache = { rule: null, fetchedAt: 0 }   // 单值缓存:服务端从登录态解析 userId,无需按 userId 分桶
+const shiftCache = new Map()                    // shiftId -> { shift, fetchedAt }(演示模式 / 将来扩展)
 
 const RULE_TTL = 30 * 60 * 1000        // 30 分钟(规则可能调整)
 const SHIFT_TTL = 24 * 60 * 60 * 1000  // 24 小时(班次很少变)
 
-// demo fallback:行政班 + GPS 打卡 + 中盈产业园
+// 把 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 }, …];解析失败返回 []。
+function parseShiftSummary(summary) {
+  if (!summary || typeof summary !== 'string') return []
+  const segments = []
+  // 先按 ";/;" 拆段;不拆也能 fall-through 到单段
+  const parts = summary.split(/[;;]/).map((s) => s.trim()).filter(Boolean)
+  for (const part of parts) {
+    // 优先抓 "(HH:mm:ss-HH:mm:ss)" / "(HH:mm:ss - HH:mm:ss)" 这种"前缀+括号"
+    const bracket = part.match(/\((\d{1,2}:\d{2}(?::\d{2})?)\s*[-~]\s*(\d{1,2}:\d{2}(?::\d{2})?)\)/)
+    let m = bracket
+    if (!m) {
+      // 兜底:段落里直接出现 "HH:mm-HH:mm" / "HH:mm:ss - HH:mm:ss"
+      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
+}
+
+// demo fallback:行政班 + GPS 打卡 + 中盈产业园(匹配 punch-rule 新 schema)
 const DEMO_RULE = {
-  id: 1,
+  ruleId: 1,
+  ruleCode: 'ADMIN_DAY',
   ruleName: '行政班',
   lateGraceMinutes: 0,
   earlyGraceMinutes: 0,
-  locations: [
-    { latitude: 40.0447, longitude: 116.3047, radius: 200, locationName: '中盈产业园 A座', outsidePolicy: 'ALLOW' },
+  effectiveStartDate: '',
+  effectiveEndDate: '',
+  exemptFromPunch: false,
+  mobilePunchEnabled: true,
+  fieldPunchEnabled: false,
+  supportedMobilePunchMethods: ['GPS'],
+  locationSummaries: [
+    { locationName: '中盈产业园 A座', radius: 200, outsidePolicy: 'ALLOW' },
   ],
-  wifiList: [],
-  punchMethods: [{ punchMethod: 'GPS', requiredFlag: 1, combineMode: 'ANY' }],
-  cardRepair: {
-    repairEnabled: 1,
-    monthlyLimitCount: 3,
-    timeLimitDays: 7,
-    allowedTypes: 'MISSING,LATE,EARLY,OTHER',
-    reminderEnabled: 1,
-  },
-  shiftPlans: [{ shiftId: 1, planType: 'WEEKLY', weekDay: 1, needInPunch: 1, needOutPunch: 1 }],
+  wifiDisplayNames: [],
+  shiftTimeSummary: '08:30 - 17:30',
+  message: '',
+  timezone: 'Asia/Shanghai',
+  version: 1,
 }
 const DEMO_SHIFT = {
   id: 1,
@@ -584,59 +610,81 @@ const DEMO_SHIFT = {
 }
 
 /**
- * 查询当前用户的考勤规则(pageRules 第一条)
- * GET /hr/attendance/rules/pageRules?userId=X
- * 返回的考勤规则含 locations/wifiList/punchMethods/shiftPlans/cardRepair/宽限分钟等。
- * 班次具体时间不在 rule 里,要 join shiftPlans[].shiftId → queryShiftDetail
+ * 查询当前登录员工的打卡规则
+ * GET /hr/ess/attendance/punch-rule
+ * 员工、规则范围和业务时刻均由服务端从当前登录态及当前时间解析;不接收 userId。
  *
  * 返回结构(带状态,便于 UI 区分"未配置"vs"已停用"vs"网络错"):
  *   {
  *     rule: object|null,
  *     status: 'ok'|'empty'|'disabled'|'error',
- *     blockedReason?: string,  // 当不可打卡时给出原因(来自 ATTENDANCE_RULE_STATUS_LABEL
+ *     blockedReason?: string,  // 当不可打卡时给出原因(如 "已配置免打卡" / 服务端 message
  *     error?: Error,
  *     loaded: boolean,
  *   }
- *   - ok:       拿到 rule 且 status === 'ACTIVE',可正常打卡
- *   - empty:    接口成功但 list 为空 → 后台没给该用户配置规则
- *   - disabled: rule.status ∈ {DRAFT, EXPIRED, DISABLED} → 不可打卡,blockedReason 给出具体原因
+ *   - ok:       拿到 rule 且可正常打卡
+ *   - empty:    接口成功但 ruleId 未命中 → 后台没给该用户配置规则
+ *   - disabled: exemptFromPunch / mobilePunchEnabled:false → 不可打卡
  *   - error:    接口调用失败(网络/401 等)→ rule 可能是缓存兜底值
  */
-export async function queryAttendanceRule(userId) {
-  if (!userId) return { rule: null, status: 'error', error: new Error('缺少 userId'), loaded: false }
-  if (!isServerMode()) return { rule: DEMO_RULE, status: 'ok', loaded: true }
+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',
+      },
+      status: 'ok',
+      loaded: true,
+    }
+  }
 
   function classify(rule) {
-    if (!rule) return { status: 'empty' }
-    const raw = String(rule.status || '').toUpperCase()
-    if (raw === 'ACTIVE' || raw === '') return { status: 'ok' } // 缺省值兜底(兼容老后端)
-    if (PUNCH_BLOCKED_STATUSES.has(raw)) {
-      return { status: 'disabled', blockedReason: ATTENDANCE_RULE_STATUS_LABEL[raw] || '不可用' }
+    if (!rule || !rule.ruleId) return { status: 'empty' }
+    if (rule.exemptFromPunch === true) {
+      return { status: 'disabled', blockedReason: rule.message || '已配置免打卡' }
+    }
+    if (rule.mobilePunchEnabled === false) {
+      return { status: 'disabled', blockedReason: rule.message || '不允许移动端打卡' }
+    }
+    return { status: 'ok' }
+  }
+
+  // 把 shiftTimeSummary 拆成结构化字段,方便 UI 直接拿 startTime/endTime。
+  // shiftSegments 是全部班次段(多段三班倒用得到),shiftStartTime/End 始终取第一段作"主班次"。
+  function normalize(rule) {
+    if (!rule) return null
+    const segments = parseShiftSummary(rule.shiftTimeSummary)
+    const first = segments[0] || {}
+    return {
+      ...rule,
+      shiftSegments: segments,
+      shiftStartTime: first.start || '',
+      shiftEndTime: first.end || '',
     }
-    // 未知状态:保守按 disabled 处理,避免误打卡
-    return { status: 'disabled', blockedReason: `状态未知(${raw})` }
   }
 
-  const cached = ruleCache.get(userId)
-  if (cached && Date.now() - cached.fetchedAt < RULE_TTL) {
-    return { rule: cached.rule, loaded: true, ...classify(cached.rule) }
+  if (ruleCache.rule && Date.now() - ruleCache.fetchedAt < RULE_TTL) {
+    return { rule: ruleCache.rule, loaded: true, ...classify(ruleCache.rule) }
   }
 
   try {
-    const path = `${serverPath()}/hr/attendance/rules/pageRules`
-    const res = await request({ url: path, method: 'GET', data: { userId, size: 1 } })
-    const list = (res.data && Array.isArray(res.data.list)) ? res.data.list : []
-    const rule = list[0] || null
-    ruleCache.set(userId, { rule, fetchedAt: Date.now() })
+    const path = `${serverPath()}/hr/ess/attendance/punch-rule`
+    const res = await request({ url: path, method: 'GET' })
+    const raw = res.data || null
+    const rule = normalize(raw)
+    ruleCache.rule = rule
+    ruleCache.fetchedAt = Date.now()
     return { rule, loaded: true, ...classify(rule) }
   } catch (error) {
     // 接口失败:用旧缓存兜底(如果有),避免把网络抖动显示成"未配置"
-    const fallback = ruleCache.get(userId)
     return {
-      rule: fallback ? fallback.rule : null,
+      rule: ruleCache.rule,
       status: 'error',
       error,
-      loaded: Boolean(fallback),
+      loaded: Boolean(ruleCache.rule),
     }
   }
 }

+ 6 - 19
src/pages/attendance/index.vue

@@ -121,8 +121,6 @@ import {
   formatDate,
   translateStatus,
   queryAttendanceRule,
-  queryShiftDetail,
-  extractShiftDisplayRange,
 } from "@/api/attendance";
 import { getLocationWithFallback } from "@/utils/location";
 
@@ -293,9 +291,7 @@ async function selectDate(date) {
 }
 
 async function loadShiftInfo() {
-  const user = getCurrentUser() || currentUser || {};
-  const userId = (user && user.raw && user.raw.userId) || (user && user.raw && user.raw.id) || (user && user.id) || "";
-  const result = await queryAttendanceRule(userId);
+  const result = await queryAttendanceRule();
   ruleStatus.value = result.status;
   ruleBlockedReason.value = result.blockedReason || '';
   // 空态:进入考勤页时弹一次 modal(每个 session 只弹一次)
@@ -309,7 +305,7 @@ async function loadShiftInfo() {
     });
     return;
   }
-  // 不可打卡(DRAFT/EXPIRED/DISABLED):弹 modal 告知
+  // 不可打卡(exemptFromPunch / mobilePunchEnabled=false):弹 modal 告知
   if (result.status === 'disabled' && !ruleToastShown.value) {
     ruleToastShown.value = true;
     const reason = result.blockedReason || '不可用';
@@ -330,18 +326,9 @@ async function loadShiftInfo() {
   }
   const rule = result.rule;
   if (!rule) return;
-  const firstPlan = (rule.shiftPlans || [])[0];
-  if (!firstPlan) return;
-  try {
-    const shift = await queryShiftDetail(firstPlan.shiftId);
-    if (!shift) return;
-    const range = extractShiftDisplayRange(shift);
-    shiftInfo.ruleName = rule.ruleName || shift.shiftName || '';
-    shiftInfo.startTime = (range && range.start) || shift.startTime || '';
-    shiftInfo.endTime = (range && range.end) || shift.endTime || '';
-  } catch (_) {
-    // shift 拉取失败不影响 ruleStatus
-  }
+  shiftInfo.ruleName = rule.ruleName || '';
+  shiftInfo.startTime = rule.shiftStartTime || '';
+  shiftInfo.endTime = rule.shiftEndTime || '';
 }
 
 async function refreshAll() {
@@ -386,7 +373,7 @@ async function onPunch() {
       address: loc.address,
     });
     uni.hideLoading();
-    uni.showToast({ title: `打卡成功 #${id}`, icon: "success" });
+    uni.showToast({ title: `打卡成功`, icon: "success" });
     // 打卡成功后:选中今天并刷新月历 + 当日详情
     selectedDate.value = formatDate(new Date());
     await refreshAll();

+ 8 - 19
src/pages/home/index.vue

@@ -113,10 +113,8 @@ import { useCurrentUser } from "@/hooks/useCurrentUser";
 import { getApprovalResults, getCurrentUser } from "@/utils/storage";
 import { go } from "@/utils/router";
 import {
-  extractShiftDisplayRange,
   queryAttendanceRule,
   queryDailyAttendance,
-  queryShiftDetail,
   submitPunch,
 } from "@/api/attendance";
 import { getLocationWithFallback } from "@/utils/location";
@@ -191,25 +189,16 @@ const buttonState = computed(() => {
 // 是否允许打卡:规则未配置 / 已停用 时禁用按钮
 const canPunch = computed(() => ruleStatus.value !== 'empty' && ruleStatus.value !== 'disabled');
 
-async function loadShiftInfo(userId) {
-  const result = await queryAttendanceRule(userId);
+async function loadShiftInfo() {
+  const result = await queryAttendanceRule();
   ruleStatus.value = result.status;
   ruleBlockedReason.value = result.blockedReason || '';
   const rule = result.rule;
   if (!rule) return;
-  const firstPlan = (rule.shiftPlans || [])[0];
-  if (!firstPlan) return;
-  try {
-    const shift = await queryShiftDetail(firstPlan.shiftId);
-    if (!shift) return;
-    const range = extractShiftDisplayRange(shift);
-    shiftInfo.ruleName = rule.ruleName || shift.shiftName || "";
-    shiftInfo.startTime = (range && range.start) || shift.startTime || "";
-    shiftInfo.endTime = (range && range.end) || shift.endTime || "";
-    shiftInfo.loaded = true;
-  } catch (error) {
-    // shift 拉取失败不影响 ruleStatus(rule 已加载)
-  }
+  shiftInfo.ruleName = rule.ruleName || "";
+  shiftInfo.startTime = rule.shiftStartTime || "";
+  shiftInfo.endTime = rule.shiftEndTime || "";
+  shiftInfo.loaded = true;
 }
 
 async function loadDaily() {
@@ -219,7 +208,7 @@ async function loadDaily() {
     const date = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}-${String(new Date().getDate()).padStart(2, "0")}`;
     // 并行:拉 rule + daily(rule 会缓存,daily 每次都拉)
     const [, vo] = await Promise.all([
-      loadShiftInfo(userId),
+      loadShiftInfo(),
       queryDailyAttendance(userId, date),
     ]);
     if (vo) Object.assign(daily, vo);
@@ -262,7 +251,7 @@ async function onPunch() {
       address: loc.address,
     });
     uni.hideLoading();
-    uni.showToast({ title: `打卡成功 #${id}`, icon: "success" });
+    uni.showToast({ title: `打卡成功`, icon: "success" });
     await loadDaily();
   } catch (error) {
     uni.hideLoading();