xieyong пре 1 месец
родитељ
комит
5c2e897c67
9 измењених фајлова са 1884 додато и 343 уклоњено
  1. 540 0
      src/api/attendance.js
  2. 95 0
      src/api/user.js
  3. 197 106
      src/pages/apply/form.vue
  4. 502 90
      src/pages/attendance/index.vue
  5. 201 28
      src/pages/home/index.vue
  6. 15 11
      src/pages/profile/index.vue
  7. 231 105
      src/utils/auth.js
  8. 68 0
      src/utils/location.js
  9. 35 3
      src/utils/storage.js

+ 540 - 0
src/api/attendance.js

@@ -0,0 +1,540 @@
+import { getServerConfig, getApiBaseUrl, request } from '@/utils/auth'
+import { formatNow } from '@/utils/storage'
+
+// ============ 状态枚举(与后端 statusList / attendanceStatusList 一致) ============
+export const STATUS_LABEL = {
+  NORMAL: '正常',
+  LATE: '迟到',
+  EARLY: '早退',
+  ABSENT: '缺勤',
+  LEAVE: '请假',
+  OVERTIME: '加班',
+  REST: '休息',
+}
+
+// 补卡类型枚举(对应后端 repairType 字段),仅 CARD_REPAIR 申请使用
+export const REPAIR_TYPE_LABEL = {
+  MISSING_IN: '上班漏打卡',
+  MISSING_OUT: '下班漏打卡',
+  LATE: '迟到补卡',
+  EARLY: '早退补卡',
+  ABSENT: '缺勤补卡',
+  OTHER: '其他补卡',
+  NORMAL: '正常补卡',
+}
+export const REPAIR_TYPE_OPTIONS = Object.keys(REPAIR_TYPE_LABEL)
+
+const STATUS_OPTIONS = ['NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'LATE', 'LEAVE', 'ABSENT']
+
+// ============ 时间格式化辅助 ============
+export function formatMonth(date) {
+  const d = date instanceof Date ? date : new Date(date)
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
+}
+
+export function formatDate(date) {
+  const d = date instanceof Date ? date : new Date(date)
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+}
+
+function pickStatus(seed) {
+  // 简单伪随机:同一天生成同一状态
+  return STATUS_OPTIONS[seed % STATUS_OPTIONS.length]
+}
+
+// 把 ISO 或 yyyy-MM-dd 时间字符串截短到 HH:mm(接口响应可能是完整时间)
+function toHHmm(value) {
+  if (!value) return null
+  const m = String(value).match(/T(\d{2}:\d{2})/)
+  if (m) return m[1]
+  return String(value).slice(11, 16) || null
+}
+
+// ============ Demo 内存 mock:仅在 demo 模式使用 ============
+const demoDailyStore = new Map() // 'yyyy-MM-dd' -> DailyVO
+const demoMonthStore = new Map() // 'yyyy-MM' -> DailySummary[]
+let punchIdCounter = 1000
+
+// 班次时间:行政班 08:30 - 17:30
+const SCHEDULED_START = '08:30'
+const SCHEDULED_END = '17:30'
+
+function buildDemoDaily(dateStr, seed) {
+  const d = new Date(dateStr)
+  const dow = d.getDay()
+  const isWeekend = dow === 0 || dow === 6
+  const todayStr = formatDate(new Date())
+
+  if (isWeekend) {
+    return {
+      actualIn: null,
+      actualOut: null,
+      scheduledStart: null,
+      scheduledEnd: null,
+      lateMinutes: 0,
+      earlyMinutes: 0,
+      actualWorkMinutes: 0,
+      requiredMinutes: 0,
+      exceptionCount: 0,
+      statusList: ['REST'],
+    }
+  }
+
+  const status = pickStatus(seed)
+  let actualIn = '08:22'
+  let actualOut = '17:35'
+  let lateMinutes = 0
+  let earlyMinutes = 0
+  let exceptionCount = 0
+  let workMinutes = 480
+  let requiredMinutes = 480
+
+  if (status === 'LATE') {
+    actualIn = '09:18'
+    lateMinutes = 48
+    exceptionCount = 1
+  } else if (status === 'LEAVE') {
+    actualIn = null
+    actualOut = null
+    lateMinutes = 0
+    earlyMinutes = 0
+    workMinutes = 0
+    exceptionCount = 0
+  } else if (status === 'ABSENT') {
+    actualIn = null
+    actualOut = null
+    workMinutes = 0
+    exceptionCount = 1
+  } else if (status === 'OVERTIME') {
+    actualOut = '20:30'
+    workMinutes = 540
+    exceptionCount = 0
+  }
+
+  // 今天:仅打上班卡,下班未打
+  if (dateStr === todayStr) {
+    actualOut = null
+    workMinutes = 0
+    exceptionCount = 0
+  }
+
+  // 历史补卡演示:8 月 4 日异常(exceptionCount=1),用于「补卡申请」入口演示
+  if (dateStr === '2026-08-04') {
+    return {
+      actualIn: null,
+      actualOut: '17:35',
+      scheduledStart: SCHEDULED_START,
+      scheduledEnd: SCHEDULED_END,
+      lateMinutes: 0,
+      earlyMinutes: 0,
+      actualWorkMinutes: 0,
+      requiredMinutes: 480,
+      exceptionCount: 1,
+      statusList: ['ABSENT'],
+    }
+  }
+
+  return {
+    actualIn,
+    actualOut,
+    scheduledStart: SCHEDULED_START,
+    scheduledEnd: SCHEDULED_END,
+    lateMinutes,
+    earlyMinutes,
+    actualWorkMinutes: workMinutes,
+    requiredMinutes,
+    exceptionCount,
+    statusList: status === 'NORMAL' ? ['NORMAL'] : [status],
+  }
+}
+
+function ensureDemoDaily(dateStr) {
+  if (demoDailyStore.has(dateStr)) return demoDailyStore.get(dateStr)
+  const seed = parseInt(dateStr.replaceAll('-', ''), 10) || 0
+  const vo = buildDemoDaily(dateStr, seed)
+  demoDailyStore.set(dateStr, vo)
+  return vo
+}
+
+function demoDailyToSummary(dateStr, vo) {
+  const status = (vo.statusList && vo.statusList[0]) || 'NORMAL'
+  return {
+    attendanceDate: dateStr,
+    lateMinutes: vo.lateMinutes,
+    earlyMinutes: vo.earlyMinutes,
+    actualWorkMinutes: vo.actualWorkMinutes,
+    overtimeMinutes: Math.max(0, vo.actualWorkMinutes - vo.requiredMinutes),
+    leaveMinutes: 0,
+    absenceMinutes: vo.statusList?.includes('ABSENT') ? vo.requiredMinutes : 0,
+    exceptionCount: vo.exceptionCount,
+    attendanceStatusList: status,
+  }
+}
+
+function buildDemoMonth(month) {
+  const [y, m] = month.split('-').map(Number)
+  const total = new Date(y, m, 0).getDate()
+  const list = []
+  for (let d = 1; d <= total; d++) {
+    const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
+    const vo = ensureDemoDaily(dateStr)
+    list.push(demoDailyToSummary(dateStr, vo))
+  }
+  return list
+}
+
+function ensureDemoMonth(month) {
+  if (demoMonthStore.has(month)) return demoMonthStore.get(month)
+  const list = buildDemoMonth(month)
+  demoMonthStore.set(month, list)
+  return list
+}
+
+// ============ 模式判断 ============
+function isServerMode() {
+  return getServerConfig().mode === 'server'
+}
+
+function serverPath() {
+  const base = getApiBaseUrl()
+  if (!base) throw new Error('缺少服务器地址,请先在登录页右上角配置')
+  return base
+}
+
+// ============ 对外 API ============
+
+/**
+ * 查询每日考勤(管理端完整模型)
+ * GET /hr/attendance/daily/{userId}/{date}
+ */
+export async function queryDailyAttendance(userId, date) {
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 200))
+    const vo = ensureDemoDaily(date)
+    return { ...vo }
+  }
+  const path = `${serverPath()}/hr/attendance/daily/${userId}/${date}`
+  const res = await request({ url: path, method: 'GET' })
+  return res.data || null
+}
+
+/**
+ * 查询我的考勤月历(员工自助简化模型)
+ * GET /hr/ess/attendance/month?month=yyyy-MM
+ */
+export async function queryMonthAttendance(month) {
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 250))
+    return ensureDemoMonth(month).map((x) => ({ ...x }))
+  }
+  const path = `${serverPath()}/hr/ess/attendance/month`
+  const res = await request({ url: path, method: 'GET', data: { month } })
+  return Array.isArray(res.data) ? res.data : []
+}
+
+/**
+ * 查询我的单日考勤(员工自助简化模型)
+ * GET /hr/ess/attendance/dates/{date}
+ */
+export async function queryMyDayAttendance(date) {
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 200))
+    const vo = ensureDemoDaily(date)
+    return demoDailyToSummary(date, vo)
+  }
+  const path = `${serverPath()}/hr/ess/attendance/dates/${date}`
+  const res = await request({ url: path, method: 'GET' })
+  return res.data || null
+}
+
+/**
+ * 提交移动端打卡
+ * POST /hr/ess/punches
+ * @returns 打卡记录 id(long)
+ */
+export async function submitPunch({ method, time, lat, lng, accuracy, address, wifiBssid }) {
+  const body = {
+    punchMethod: method,
+    punchTime: time || formatNow(new Date()),
+  }
+  if (method === 'GPS') {
+    body.latitude = lat
+    body.longitude = lng
+    body.accuracy = accuracy
+    if (address) body.address = address
+  } else if (method === 'WIFI') {
+    body.wifiBssid = wifiBssid
+  }
+
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 350))
+    // demo:本地写一份内存记录,并按当前小时判定写 actualIn / actualOut
+    const now = new Date()
+    const todayStr = formatDate(now)
+    const vo = ensureDemoDaily(todayStr)
+    const hh = String(now.getHours()).padStart(2, '0')
+    const mm = String(now.getMinutes()).padStart(2, '0')
+    const hhmm = `${hh}:${mm}`
+    if (now.getHours() < 12) {
+      vo.actualIn = hhmm
+      vo.lateMinutes = vo.scheduledStart && hhmm > vo.scheduledStart ? minutesBetween(vo.scheduledStart, hhmm) : 0
+      if (vo.lateMinutes > 0 && !vo.statusList.includes('LATE')) vo.statusList = ['LATE']
+      vo.exceptionCount = vo.lateMinutes > 0 ? 1 : 0
+    } else {
+      vo.actualOut = hhmm
+      const worked = vo.actualIn ? minutesBetween(vo.actualIn, hhmm) : 0
+      vo.actualWorkMinutes = Math.max(0, worked)
+      if (vo.actualWorkMinutes >= vo.requiredMinutes) {
+        vo.statusList = ['NORMAL']
+        vo.exceptionCount = 0
+      }
+    }
+    // 同步当月缓存
+    const month = formatMonth(now)
+    if (demoMonthStore.has(month)) {
+      demoMonthStore.set(
+        month,
+        demoMonthStore.get(month).map((it) =>
+          it.attendanceDate === todayStr ? demoDailyToSummary(todayStr, vo) : it,
+        ),
+      )
+    }
+    return ++punchIdCounter
+  }
+
+  const path = `${serverPath()}/hr/ess/punches`
+  const res = await request({ url: path, method: 'POST', data: body })
+  return res.data
+}
+
+function minutesBetween(start, end) {
+  const [sh, sm] = start.split(':').map(Number)
+  const [eh, em] = end.split(':').map(Number)
+  return Math.max(0, eh * 60 + em - sh * 60 - sm)
+}
+
+// 工具:把后端返回的 statusList/attendanceStatusList 翻译成中文
+export function translateStatus(statusValue) {
+  if (!statusValue) return ''
+  if (Array.isArray(statusValue)) {
+    return statusValue.map(translateStatus).filter(Boolean).join(' / ')
+  }
+  return STATUS_LABEL[statusValue] || statusValue
+}
+
+// 工具:从 ISO 时间字符串里取 HH:mm
+export { toHHmm as formatTimeOf }
+
+// ============ 补卡申请 ============
+
+/**
+ * 提交补卡申请
+ * POST /hr/attendance/applications
+ * @param {{
+ *   userId: number|string,         // 申请员工主键(int64,服务端期望)
+ *   userName?: string,             // 员工姓名快照
+ *   applyType: string,             // 固定 CARD_REPAIR
+ *   startTime: string,             // ISO date-time,业务开始时间
+ *   repairPunchTime: string,       // ISO date-time,实际补卡打卡时间
+ *   repairType: string,            // MISSING_IN / MISSING_OUT / LATE / EARLY / ABSENT / OTHER / NORMAL
+ *   reason?: string,
+ * }} payload
+ * @returns {Promise<object>} 服务端返回的保存结果(考勤申请保存返回模型)
+ */
+export async function submitAttendanceApplication(payload) {
+  const body = { ...payload }
+  if (!body.applyType) body.applyType = 'CARD_REPAIR'
+
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 300))
+    // demo:返回本地假 id,保持 UI 可继续
+    return {
+      id: ++punchIdCounter,
+      applyType: body.applyType,
+      status: '审批中',
+      submittedAt: new Date().toISOString(),
+      payload: body,
+    }
+  }
+
+  const path = `${serverPath()}/hr/attendance/applications`
+  const res = await request({ url: path, method: 'POST', data: body })
+  return res.data || res
+}
+
+// 把"yyyy-MM-dd" + "HH:mm" 拼成 ISO 字符串(本地时区)
+export function combineDateTime(dateStr, timeStr) {
+  if (!dateStr) return ''
+  if (!timeStr) return `${dateStr}T00:00:00.000`
+  return `${dateStr}T${timeStr}:00.000`
+}
+
+// ============ 考勤规则 / 班次详情(启动拉一次,内存缓存) ============
+// 设计: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 }
+
+const RULE_TTL = 30 * 60 * 1000        // 30 分钟(规则可能调整)
+const SHIFT_TTL = 24 * 60 * 60 * 1000  // 24 小时(班次很少变)
+
+// demo fallback:行政班 + GPS 打卡 + 中盈产业园
+const DEMO_RULE = {
+  id: 1,
+  ruleName: '行政班',
+  lateGraceMinutes: 0,
+  earlyGraceMinutes: 0,
+  locations: [
+    { latitude: 40.0447, longitude: 116.3047, radius: 200, locationName: '中盈产业园 A座', 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 }],
+}
+const DEMO_SHIFT = {
+  id: 1,
+  shiftName: '行政班',
+  shiftType: 'DAY',
+  startTime: '08:30',
+  endTime: '17:30',
+  crossDay: 0,
+  requiredMinutes: 480,
+  lateGraceMinutes: 0,
+  earlyGraceMinutes: 0,
+  punchWindowStartTime: '07:30',
+  punchWindowEndTime: '18:30',
+  punchWindowEndDayOffset: 0,
+  flexibleEnabled: 0,
+  segments: [
+    { segmentNo: 1, startTime: '08:30', endTime: '12:00', needInPunch: 1, needOutPunch: 0, crossDayOffset: 0 },
+    { segmentNo: 2, startTime: '13:00', endTime: '17:30', needInPunch: 0, needOutPunch: 1, crossDayOffset: 0 },
+  ],
+  breaks: [{ breakType: 'LUNCH', startTime: '12:00', endTime: '13:00', countAsWork: 0 }],
+}
+
+/**
+ * 查询当前用户的考勤规则(pageRules 第一条)
+ * GET /hr/attendance/rules/pageRules?userId=X
+ * 返回的考勤规则含 locations/wifiList/punchMethods/shiftPlans/cardRepair/宽限分钟等。
+ * 班次具体时间不在 rule 里,要 join shiftPlans[].shiftId → queryShiftDetail
+ *
+ * 返回结构(带状态,便于 UI 区分"未配置"vs"已停用"vs"网络错"):
+ *   {
+ *     rule: object|null,
+ *     status: 'ok'|'empty'|'disabled'|'error',
+ *     blockedReason?: string,  // 当不可打卡时给出原因(来自 ATTENDANCE_RULE_STATUS_LABEL)
+ *     error?: Error,
+ *     loaded: boolean,
+ *   }
+ *   - ok:       拿到 rule 且 status === 'ACTIVE',可正常打卡
+ *   - empty:    接口成功但 list 为空 → 后台没给该用户配置规则
+ *   - disabled: rule.status ∈ {DRAFT, EXPIRED, DISABLED} → 不可打卡,blockedReason 给出具体原因
+ *   - 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 }
+
+  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] || '不可用' }
+    }
+    // 未知状态:保守按 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) }
+  }
+
+  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() })
+    return { rule, loaded: true, ...classify(rule) }
+  } catch (error) {
+    // 接口失败:用旧缓存兜底(如果有),避免把网络抖动显示成"未配置"
+    const fallback = ruleCache.get(userId)
+    return {
+      rule: fallback ? fallback.rule : null,
+      status: 'error',
+      error,
+      loaded: Boolean(fallback),
+    }
+  }
+}
+
+/**
+ * 查询班次详情(segments + punchWindow)
+ * GET /hr/attendance/shifts/getShift/{id}
+ */
+export async function queryShiftDetail(shiftId) {
+  if (!shiftId) return null
+  if (!isServerMode()) return { ...DEMO_SHIFT, id: shiftId }
+
+  const cached = shiftCache.get(shiftId)
+  if (cached && Date.now() - cached.fetchedAt < SHIFT_TTL) return cached.shift
+
+  const path = `${serverPath()}/hr/attendance/shifts/getShift/${shiftId}`
+  const res = await request({ url: path, method: 'GET' })
+  const shift = res.data || null
+  shiftCache.set(shiftId, { shift, fetchedAt: Date.now() })
+  return shift
+}
+
+/**
+ * 从班次 segments 中提取"显示用的上下班时间"。
+ * - segments 是真正的"要打卡的工作段",needInPunch/needOutPunch 标记
+ * - 顶层 startTime/endTime 是班次总边界(含休息)
+ * - 行政班:08:30-12:00(上班卡)+ 13:00-17:30(下班卡) → 显示 "08:30 - 17:30"
+ * - 三班倒:取第一个 needInPunch=1 的 startTime,最后一个 needOutPunch=1 的 endTime
+ */
+export function extractShiftDisplayRange(shift) {
+  if (!shift) return null
+  const segments = Array.isArray(shift.segments) ? shift.segments : []
+  if (segments.length === 0) {
+    return { start: shift.startTime, end: shift.endTime, segmentCount: 0 }
+  }
+  const inSeg = segments.find((s) => Number(s.needInPunch) === 1)
+  const outSeg = [...segments].reverse().find((s) => Number(s.needOutPunch) === 1)
+  return {
+    start: (inSeg && inSeg.startTime) || shift.startTime,
+    end: (outSeg && outSeg.endTime) || shift.endTime,
+    segmentCount: segments.length,
+  }
+}
+
+/**
+ * 清缓存(切换账号 / 退出登录时调用)
+ */
+export function clearAttendanceCache() {
+  ruleCache.clear()
+  shiftCache.clear()
+}

+ 95 - 0
src/api/user.js

@@ -0,0 +1,95 @@
+import { fetchUserDetail, getApiBaseUrl, getServerConfig, request } from "@/utils/auth";
+import { getCurrentUser, getLoginUser, saveCurrentUser } from "@/utils/storage";
+
+function isServerMode() {
+  return getServerConfig().mode === "server";
+}
+
+function serverPath() {
+  const base = getApiBaseUrl();
+  if (!base) throw new Error("缺少服务器地址,请先在登录页右上角配置");
+  return base;
+}
+
+/**
+ * 修改档案成功后,重新拉一次当前用户的 profile 接口,
+ * 把最新数据落进 storage 并返回。
+ * - demo 模式:不发起请求,直接返回当前缓存的 user
+ * - 服务器模式:调 /main/users/{userId}/profile(鉴权由 request() 自动处理)
+ */
+export async function refetchMyProfile() {
+  if (!isServerMode()) return getCurrentUser();
+  const baseUrl = serverPath();
+  const login = getLoginUser() || {};
+  const userId = login.userId || (getCurrentUser() || {}).id;
+  if (!userId && userId !== 0) throw new Error("缺少用户ID,无法刷新档案");
+  const vo = await fetchUserDetail(baseUrl, userId);
+  if (vo) saveCurrentUser(vo);
+  return vo;
+}
+
+/**
+ * 把前端本地 bankCardList 映射成后端保存参数。
+ * 后端 `accountName` 必填,但前端没有户名字段 → 退回到当前用户的姓名快照。
+ */
+function mapBankCardList(list, fallbackAccountName) {
+  if (!Array.isArray(list)) return undefined;
+  return list.map((c) => ({
+    cardNumber: String(c.cardNumber || "").trim(),
+    openingBank: String(c.openingBank || "").trim(),
+    accountName: String(c.accountName || fallbackAccountName || "").trim(),
+  }));
+}
+
+/**
+ * 新增 / 修改员工档案(用于「我的档案」修改手机号、婚姻、紧急联系人、银行卡)。
+ * 后端约定:
+ *   - 未传 bankCardList:保持既有明细(接口侧兼容旧数据)
+ *   - 空数组:清空
+ *   - 非空数组:整单替换
+ *   - 首条明细自动投影到顶层 cardNumber / openingBank(兼容旧工资卡字段)
+ *
+ * ⚠️ 重要约束:调用方必须**显式**把 `bankCardList` 放在 payload 里才会一并保存。
+ * 修改手机号 / 婚姻 / 紧急联系人时**不要**带 `bankCardList`,避免误传触发整单替换。
+ *
+ * @param {{
+ *   bankCardList?: Array<{ cardNumber: string, openingBank: string, accountName?: string }>,
+ *   phone?: string,
+ *   maritalStatus?: string,
+ *   secondLinkName?: string,
+ *   secondLinkPhone?: string,
+ *   secondLinkRelation?: string,
+ * }} payload
+ */
+export async function saveEmployee(payload = {}) {
+  const user = getCurrentUser() || {};
+  const raw = user.raw || user;
+  const accountName =
+    user.name || raw.userName || raw.name || raw.secondLinkName || "";
+  // 用 delete 把 raw 里的 bankCardList / 顶层兼容 cardNumber / openingBank 删掉,
+  // 避免修改其它字段时把旧的银行卡一并带上去(后端会按"非空整单替换"覆盖)
+  const body = { ...raw };
+  delete body.bankCardList;
+  delete body.cardNumber;
+  delete body.openingBank;
+
+  if ("phone" in payload) body.phone = payload.phone;
+  if ("maritalStatus" in payload) body.maritalStatus = payload.maritalStatus;
+  if ("secondLinkName" in payload) body.secondLinkName = payload.secondLinkName;
+  if ("secondLinkPhone" in payload) body.secondLinkPhone = payload.secondLinkPhone;
+  if ("secondLinkRelation" in payload)
+    body.secondLinkRelation = payload.secondLinkRelation;
+  if (Array.isArray(payload.bankCardList)) {
+    body.bankCardList = [];
+  }
+  // payload 里如果带了空数组:保持原语义(清空);不带或非数组:根本不传,由后端按"未传"语义处理
+
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 250));
+    return { code: 0, data: body, message: "演示模式:本地保存" };
+  }
+
+  const path = `${serverPath()}/main/user/saveNew`;
+  const res = await request({ url: path, method: "POST", data: body });
+  return res;
+}

+ 197 - 106
src/pages/apply/form.vue

@@ -1,107 +1,84 @@
 <template>
   <view class="page-no-tab">
-    <view class="form-intro"
-      ><ModuleIcon :icon="config.icon" :color="config.color" soft /><view
-        ><text class="intro-title">{{ config.title }}</text
-        ><text class="intro-sub">{{ config.subtitle }}</text></view
-      ></view
-    >
-    <view class="steps"
-      ><view class="step active"><view>1</view><text>填写申请</text></view
-      ><view class="step-line"></view
-      ><view class="step"><view>2</view><text>主管审批</text></view
-      ><view class="step-line"></view
-      ><view class="step"><view>3</view><text>完成</text></view></view
-    >
+    <view class="form-intro">
+      <ModuleIcon :icon="config.icon" :color="config.color" soft />
+      <view><text class="intro-title">{{ config.title }}</text><text class="intro-sub">{{ config.subtitle }}</text>
+      </view>
+    </view>
+    <view class="steps">
+      <view class="step active">
+        <view>1</view><text>填写申请</text>
+      </view>
+      <view class="step-line"></view>
+      <view class="step">
+        <view>2</view><text>主管审批</text>
+      </view>
+      <view class="step-line"></view>
+      <view class="step">
+        <view>3</view><text>完成</text>
+      </view>
+    </view>
     <view class="form-card card section">
-      <view class="field"
-        ><text class="label">申请人</text
-        ><view class="readonly"
-          ><view class="avatar small-avatar">{{ currentUser.initials }}</view
-          ><text
-            >{{ currentUser.name }} · {{ currentUser.department }}</text
-          ></view
-        ></view
-      >
+      <view class="field"><text class="label">申请人</text>
+        <view class="readonly">
+          <view class="avatar small-avatar">{{ currentUser.initials }}</view><text>{{ currentUser.name }} · {{
+            currentUser.department }}</text>
+        </view>
+      </view>
+      <view class="divider"></view>
+      <view v-if="type === 'leave'" class="field press" @click="chooseLeave"><text
+          class="label required">请假类型</text><text :class="form.leaveType ? 'value' : 'placeholder'">{{
+            form.leaveType || "请选择"
+          }}</text><text class="arrow">›</text></view>
+      <view v-if="type === 'certificate'" class="field press" @click="chooseCertificate"><text
+          class="label required">证明类型</text><text :class="form.certificateType ? 'value' : 'placeholder'">{{
+            form.certificateType || "请选择"
+          }}</text><text class="arrow">›</text></view>
+      <view v-if="type === 'check'" class="field"><text class="label required">补卡日期</text>
+        <picker mode="date" :value="form.startDate" @change="(e) => (form.startDate = e.detail.value)"><text
+            class="value">{{ form.startDate }}</text></picker><text class="arrow">›</text>
+      </view>
+      <view v-if="type === 'check'" class="divider"></view>
+      <view v-if="type === 'check'" class="field press" @click="chooseRepairType"><text
+          class="label required">补卡类型</text><text :class="form.repairType ? 'value' : 'placeholder'">{{
+            repairTypeLabel
+          }}</text><text class="arrow">›</text></view>
+      <view v-if="type === 'check'" class="divider"></view>
+      <view v-if="type === 'check'" class="field"><text class="label required">打卡时间</text>
+        <picker mode="time" :value="form.repairTime" @change="(e) => (form.repairTime = e.detail.value)"><text
+            class="value">{{ form.repairTime || "请选择" }}</text></picker><text class="arrow">›</text>
+      </view>
+      <template v-if="!['certificate', 'check'].includes(type)">
+        <view class="field"><text class="label required">开始日期</text>
+          <picker mode="date" :value="form.startDate" @change="(e) => (form.startDate = e.detail.value)"><text
+              class="value">{{ form.startDate }}</text></picker><text class="arrow">›</text>
+        </view>
+        <view class="divider"></view>
+        <view class="field"><text class="label required">结束日期</text>
+          <picker mode="date" :value="form.endDate" @change="(e) => (form.endDate = e.detail.value)"><text
+              class="value">{{ form.endDate }}</text></picker><text class="arrow">›</text>
+        </view>
+      </template>
       <view class="divider"></view>
-      <view v-if="type === 'leave'" class="field press" @click="chooseLeave"
-        ><text class="label required">请假类型</text
-        ><text :class="form.leaveType ? 'value' : 'placeholder'">{{
-          form.leaveType || "请选择"
-        }}</text
-        ><text class="arrow">›</text></view
-      >
-      <view
-        v-if="type === 'certificate'"
-        class="field press"
-        @click="chooseCertificate"
-        ><text class="label required">证明类型</text
-        ><text :class="form.certificateType ? 'value' : 'placeholder'">{{
-          form.certificateType || "请选择"
-        }}</text
-        ><text class="arrow">›</text></view
-      >
-      <view v-if="type === 'check'" class="field"
-        ><text class="label required">补卡时间</text
-        ><picker
-          mode="date"
-          :value="form.startDate"
-          @change="(e) => (form.startDate = e.detail.value)"
-          ><text class="value">{{ form.startDate }}</text></picker
-        ><text class="arrow">›</text></view
-      >
-      <template v-if="!['certificate', 'check'].includes(type)"
-        ><view class="field"
-          ><text class="label required">开始日期</text
-          ><picker
-            mode="date"
-            :value="form.startDate"
-            @change="(e) => (form.startDate = e.detail.value)"
-            ><text class="value">{{ form.startDate }}</text></picker
-          ><text class="arrow">›</text></view
-        ><view class="divider"></view
-        ><view class="field"
-          ><text class="label required">结束日期</text
-          ><picker
-            mode="date"
-            :value="form.endDate"
-            @change="(e) => (form.endDate = e.detail.value)"
-            ><text class="value">{{ form.endDate }}</text></picker
-          ><text class="arrow">›</text></view
-        ></template
-      >
+      <view class="field textarea-field"><text class="label"
+          :class="{ required: type !== 'certificate' }">申请事由</text><textarea v-model="form.reason" maxlength="200"
+          :placeholder="reasonPlaceholder" /><text class="counter">{{ form.reason.length }}/200</text></view>
+    </view>
+    <view class="form-card card section">
+      <view class="field"><text class="label">附件</text>
+        <view class="upload press" @click="chooseImage"><text class="plus">+</text><text>拍照或上传</text></view>
+        <view v-if="form.attachment" class="file-name">已选择 1 张图片</view>
+      </view>
       <view class="divider"></view>
-      <view class="field textarea-field"
-        ><text class="label" :class="{ required: type !== 'certificate' }"
-          >申请事由</text
-        ><textarea
-          v-model="form.reason"
-          maxlength="200"
-          :placeholder="reasonPlaceholder"
-        /><text class="counter">{{ form.reason.length }}/200</text></view
-      >
+      <view class="field"><text class="label">审批人</text>
+        <view class="approver approver-auto">
+          <view class="avatar approve-avatar auto">·</view><text>系统自动匹配</text><text class="muted">(直属主管 · 由 BPM
+            引擎分配)</text>
+        </view>
+      </view>
     </view>
-    <view class="form-card card section"
-      ><view class="field"
-        ><text class="label">附件</text
-        ><view class="upload press" @click="chooseImage"
-          ><text class="plus">+</text><text>拍照或上传</text></view
-        ><view v-if="form.attachment" class="file-name"
-          >已选择 1 张图片</view
-        ></view
-      ><view class="divider"></view
-      ><view class="field"
-        ><text class="label">审批人</text
-        ><view class="approver"
-          ><view class="avatar approve-avatar">宋</view><text>宋慧敏</text
-          ><text class="muted">(直属主管)</text></view
-        ></view
-      ></view
-    >
-    <view class="bottom-action safe-bottom"
-      ><button class="draft-btn" @click="saveDraft">存草稿</button
-      ><button class="submit-btn" @click="submit">提交申请</button></view
-    >
+    <view class="bottom-action safe-bottom"><button class="draft-btn" @click="saveDraft">存草稿</button><button
+        class="submit-btn" @click="submit">提交申请</button></view>
   </view>
 </template>
 <script setup>
@@ -117,6 +94,13 @@ import {
   removeDraft,
   saveDraft as persistDraft,
 } from "@/utils/storage";
+import {
+  REPAIR_TYPE_LABEL,
+  REPAIR_TYPE_OPTIONS,
+  combineDateTime,
+  submitAttendanceApplication,
+} from "@/api/attendance";
+import { getServerConfig } from "@/utils/auth";
 const type = ref("leave");
 useAuthGuard();
 const currentUser = useCurrentUser();
@@ -127,6 +111,8 @@ const form = reactive({
   endDate: "2026-08-06",
   reason: "",
   attachment: "",
+  repairType: "",
+  repairTime: "",
 });
 const config = computed(
   () => applicationTypes[type.value] || applicationTypes.leave,
@@ -134,7 +120,12 @@ const config = computed(
 const reasonPlaceholder = computed(() =>
   type.value === "certificate"
     ? "请填写证明用途(选填)"
-    : "请简要说明申请原因",
+    : type.value === "check"
+      ? "请说明补卡原因(如:临时接待客户忘记打卡)"
+      : "请简要说明申请原因",
+);
+const repairTypeLabel = computed(() =>
+  form.repairType ? REPAIR_TYPE_LABEL[form.repairType] : "请选择",
 );
 function syncType(o = {}) {
   const next = o.type || "leave";
@@ -171,6 +162,8 @@ function resetForm() {
     endDate: "2026-08-06",
     reason: "",
     attachment: "",
+    repairType: "",
+    repairTime: "",
   });
 }
 function chooseLeave() {
@@ -187,6 +180,13 @@ function chooseCertificate() {
       (form.certificateType = ["在职证明", "收入证明", "离职证明"][r.tapIndex]),
   });
 }
+function chooseRepairType() {
+  const itemList = REPAIR_TYPE_OPTIONS.map((k) => REPAIR_TYPE_LABEL[k]);
+  uni.showActionSheet({
+    itemList,
+    success: (r) => (form.repairType = REPAIR_TYPE_OPTIONS[r.tapIndex]),
+  });
+}
 function chooseImage() {
   uni.chooseImage({
     count: 1,
@@ -201,13 +201,45 @@ function validate() {
   if (type.value === "leave" && !form.leaveType) return "请选择请假类型";
   if (type.value === "certificate" && !form.certificateType)
     return "请选择证明类型";
-  if (form.endDate < form.startDate) return "结束日期不能早于开始日期";
-  if (!form.reason && type.value !== "certificate") return "请填写申请事由";
+  if (type.value === "check") {
+    if (!form.startDate) return "请选择补卡日期";
+    if (!form.repairType) return "请选择补卡类型";
+    if (!form.repairTime) return "请选择打卡时间";
+    if (!form.reason) return "请填写补卡原因";
+  }
+  if (!["certificate", "check"].includes(type.value) && form.endDate < form.startDate)
+    return "结束日期不能早于开始日期";
+  if (!form.reason && type.value === "certificate") return "";
+  if (!form.reason && !["certificate", "check"].includes(type.value))
+    return "请填写申请事由";
   return "";
 }
-function submit() {
+async function submit() {
   const error = validate();
   if (error) return uni.showToast({ title: error, icon: "none" });
+
+  // 补卡申请:服务器模式先调 POST /hr/attendance/applications
+  if (type.value === "check" && getServerConfig().mode === "server") {
+    try {
+      uni.showLoading({ title: "提交中..." });
+      const user = currentUser || {};
+      const userId = (user && user.raw && user.raw.userId) || (user && user.id) || "";
+      await submitAttendanceApplication({
+        userId,
+        userName: user.name,
+        applyType: "CARD_REPAIR",
+        startTime: combineDateTime(form.startDate, form.repairTime),
+        repairPunchTime: combineDateTime(form.startDate, form.repairTime),
+        repairType: form.repairType,
+        reason: form.reason,
+      });
+      uni.hideLoading();
+    } catch (e) {
+      uni.hideLoading();
+      return uni.showToast({ title: e.message || "补卡提交失败", icon: "none" });
+    }
+  }
+
   addApplication({
     type: config.value.title,
     summary: summary(),
@@ -215,6 +247,17 @@ function submit() {
     color: config.value.color,
   });
   removeDraft(type.value);
+
+  // 补卡:返回上一页(考勤页),由 onShow 重新拉月度 + 当日详情
+  if (type.value === "check") {
+    const pages = getCurrentPages();
+    if (pages.length > 1) {
+      uni.showToast({ title: "补卡申请已提交", icon: "success" });
+      setTimeout(() => uni.navigateBack({ delta: 1 }), 600);
+      return;
+    }
+  }
+
   uni.showModal({
     title: "提交成功",
     content: "申请已提交给宋慧敏审批,可在“我的申请”中查看进度。",
@@ -224,31 +267,43 @@ function submit() {
 }
 function summary() {
   if (type.value === "certificate") return form.certificateType || "在职证明";
-  if (type.value === "check") return `${form.startDate} · 补卡`;
+  if (type.value === "check") {
+    const date = form.startDate || "";
+    const typeLabel = form.repairType ? REPAIR_TYPE_LABEL[form.repairType] : "补卡";
+    return `${date} · ${typeLabel} · ${form.repairTime || ""}`;
+  }
   return `${form.startDate} 至 ${form.endDate}`;
 }
 </script>
 <style scoped lang="scss">
+.page-no-tab {
+  padding-bottom: 140rpx;
+}
+
 .form-intro {
   padding: 30rpx 30rpx 22rpx;
   display: flex;
   align-items: center;
   background: #fff;
 }
-.form-intro > view:last-child {
+
+.form-intro>view:last-child {
   margin-left: 22rpx;
 }
+
 .intro-title {
   display: block;
   font-size: 32rpx;
   font-weight: 600;
 }
+
 .intro-sub {
   display: block;
   margin-top: 9rpx;
   color: #8b939f;
   font-size: 23rpx;
 }
+
 .steps {
   height: 128rpx;
   padding: 18rpx 70rpx 12rpx;
@@ -257,13 +312,15 @@ function summary() {
   background: #fff;
   border-top: 1rpx solid #f2f3f5;
 }
+
 .step {
   width: 92rpx;
   text-align: center;
   color: #a0a7b0;
   font-size: 20rpx;
 }
-.step > view {
+
+.step>view {
   width: 42rpx;
   height: 42rpx;
   margin: 0 auto 8rpx;
@@ -272,22 +329,27 @@ function summary() {
   color: #8b939f;
   line-height: 42rpx;
 }
+
 .step.active {
   color: #1677ff;
 }
-.step.active > view {
+
+.step.active>view {
   background: #1677ff;
   color: #fff;
 }
+
 .step-line {
   flex: 1;
   height: 2rpx;
   margin-top: 20rpx;
   background: #e1e4e8;
 }
+
 .form-card {
   overflow: hidden;
 }
+
 .field {
   position: relative;
   min-height: 108rpx;
@@ -295,16 +357,19 @@ function summary() {
   display: flex;
   align-items: center;
 }
+
 .label {
   width: 180rpx;
   flex-shrink: 0;
   font-size: 28rpx;
 }
+
 .required::before {
   content: "*";
   color: #ee4d4d;
   margin-right: 6rpx;
 }
+
 .readonly,
 .approver {
   flex: 1;
@@ -313,6 +378,7 @@ function summary() {
   justify-content: flex-end;
   font-size: 26rpx;
 }
+
 .small-avatar,
 .approve-avatar {
   width: 52rpx;
@@ -320,27 +386,33 @@ function summary() {
   margin-right: 14rpx;
   font-size: 22rpx;
 }
+
 .value {
   color: #1f2329;
   font-size: 27rpx;
 }
+
 .placeholder {
   color: #b2b7be;
 }
+
 .arrow {
   margin-left: 13rpx;
   color: #c4c8ce;
   font-size: 38rpx;
 }
+
 .textarea-field {
   display: block;
   min-height: 260rpx;
 }
+
 .textarea-field .label {
   display: block;
   width: auto;
   margin-bottom: 20rpx;
 }
+
 .textarea-field textarea {
   width: 100%;
   height: 130rpx;
@@ -349,6 +421,7 @@ function summary() {
   background: #f6f7f8;
   font-size: 27rpx;
 }
+
 .counter {
   position: absolute;
   right: 45rpx;
@@ -356,6 +429,7 @@ function summary() {
   color: #b2b7be;
   font-size: 21rpx;
 }
+
 .upload {
   width: 160rpx;
   height: 132rpx;
@@ -368,15 +442,28 @@ function summary() {
   color: #8b939f;
   font-size: 21rpx;
 }
+
 .plus {
   font-size: 40rpx;
   color: #9aa1aa;
 }
+
 .file-name {
   margin-left: 18rpx;
   color: #07a44d;
   font-size: 22rpx;
 }
+
+.approver-auto .auto {
+  background: #eef3ff;
+  color: #6b7c93;
+  border: 1rpx dashed #b8c4d6;
+}
+
+.approver-auto {
+  color: #8b939f;
+}
+
 .bottom-action {
   position: fixed;
   z-index: 10;
@@ -390,6 +477,7 @@ function summary() {
   background: #fff;
   border-top: 1rpx solid #e8e9eb;
 }
+
 .draft-btn,
 .submit-btn {
   height: 84rpx;
@@ -399,16 +487,19 @@ function summary() {
   justify-content: center;
   font-size: 30rpx;
 }
+
 .draft-btn {
   width: 210rpx;
   border: 1rpx solid #d7dce2;
   color: #4b5563;
 }
+
 .submit-btn {
   flex: 1;
   background: #1677ff;
   color: #fff;
 }
+
 @media (min-width: 800px) {
   .bottom-action {
     max-width: 520px;

+ 502 - 90
src/pages/attendance/index.vue

@@ -1,79 +1,412 @@
 <template>
-  <view class="page-no-tab"
-    ><view class="month-head"
+  <view class="page-no-tab attendance-page">
+    <view class="month-head"
       ><text class="month-arrow press" @click="changeMonth(-1)">‹</text
-      ><text class="month">2026年{{ month }}月</text
+      ><text class="month">{{ monthLabel }}</text
       ><text class="month-arrow press" @click="changeMonth(1)">›</text></view
-    ><view class="summary card section"
-      ><view
-        ><text>{{ month === 8 ? 4 : 22 }}</text
+    >
+
+    <view v-if="ruleStatus === 'empty'" class="rule-banner empty">
+      <text class="rule-banner-title">未配置考勤规则</text>
+      <text class="rule-banner-sub">请联系管理员在后台完成配置后再使用考勤功能</text>
+    </view>
+    <view v-else-if="ruleStatus === 'disabled'" class="rule-banner disabled">
+      <text class="rule-banner-title">考勤规则{{ ruleBlockedReason || '不可用' }}</text>
+      <text class="rule-banner-sub">请联系管理员处理后再使用打卡功能</text>
+    </view>
+    <view v-else-if="ruleStatus === 'error'" class="rule-banner error">
+      <text class="rule-banner-title">考勤规则加载失败</text>
+      <text class="rule-banner-sub">下拉刷新或重新进入页面重试</text>
+    </view>
+
+    <view class="summary card section">
+      <view
+        ><text>{{ summary.attendanceDays }}</text
         ><text>出勤天数</text></view
-      ><view><text>0</text><text>迟到</text></view
       ><view
-        ><text class="danger">{{ month === 8 ? 1 : 0 }}</text
+        ><text :class="{ danger: summary.lateCount > 0 }">{{ summary.lateCount }}</text
+        ><text>迟到</text></view
+      ><view
+        ><text :class="{ danger: summary.abnormalCount > 0 }">{{ summary.abnormalCount }}</text
         ><text>异常</text></view
       ><view
-        ><text>{{ month === 8 ? "96.8%" : "100%" }}</text
+        ><text>{{ summary.attendanceRate }}</text
         ><text>出勤率</text></view
       ></view
-    ><view class="calendar card section"
-      ><view class="week"
+    >
+
+    <view class="calendar card section">
+      <view class="week"
         ><text v-for="w in weeks" :key="w">{{ w }}</text></view
-      ><view class="days"
-        ><view v-for="d in 35" :key="d" class="day" :class="dayClass(d)"
-          ><text v-if="d > 4">{{ d - 4 }}</text
-          ><view v-if="d > 4 && d < 10" class="day-dot"></view></view></view
-      ><view class="legend"
-        ><text><i class="normal"></i>正常</text
-        ><text><i class="abnormal"></i>异常</text
-        ><text><i class="rest"></i>休息</text></view
-      ></view
-    ><view class="section-title"
-      ><text>{{ month === 8 ? "今日" : "月度" }}打卡</text></view
-    ><view class="record card section"
-      ><view class="record-item"
-        ><view class="timeline-dot"></view
-        ><view
-          ><text class="record-time">08:22</text
-          ><text class="record-label">上班打卡 · 正常</text
-          ><text class="record-place">中盈产业园 A座 · Wi-Fi打卡</text></view
-        ></view
-      ><view class="record-line"></view
-      ><view class="record-item"
-        ><view class="timeline-dot gray"></view
+      >
+      <view class="days"
         ><view
-          ><text class="record-time muted">--:--</text
-          ><text class="record-label">下班打卡 · 未打卡</text></view
-        ></view
-      ></view
-    ><button class="check-btn" @click="go('/pages/apply/form?type=check')">
-      发现异常?发起补卡申请
-    </button></view
-  >
+          v-for="(cell, idx) in grid"
+          :key="idx"
+          class="day"
+          :class="cellClass(cell)"
+          @click="cell.date && selectDate(cell.date)"
+        >
+          <text v-if="!cell.blank">{{ cell.day }}</text>
+          <view v-if="cell.dot" :class="['day-dot', cell.dotCls]"></view>
+        </view></view
+      >
+      <view class="legend">
+        <text><i class="normal"></i>正常</text>
+        <text><i class="abnormal"></i>异常</text>
+        <text><i class="leave"></i>请假</text>
+        <text><i class="rest"></i>休息</text>
+      </view>
+    </view>
+
+    <view class="section-title"
+      ><text>{{ selectedLabel }}打卡</text></view
+    >
+    <view class="record card section">
+      <view class="record-row">
+        <view class="record-key">班次</view>
+        <view class="record-value">{{ detail.shiftText }}</view>
+      </view>
+      <view class="record-row">
+        <view class="record-key">上班</view>
+        <view class="record-value">
+          <text class="record-time">{{ detail.inText }}</text>
+          <text class="record-tag">{{ detail.inLabel }}</text>
+        </view>
+      </view>
+      <view class="record-row">
+        <view class="record-key">下班</view>
+        <view class="record-value">
+          <text class="record-time" :class="{ muted: !detail.actualOut }">{{
+            detail.outText
+          }}</text>
+          <text class="record-tag">{{ detail.outLabel }}</text>
+        </view>
+      </view>
+      <view v-if="detail.workText" class="record-row">
+        <view class="record-key">工时</view>
+        <view class="record-value muted">{{ detail.workText }}</view>
+      </view>
+      <view v-if="detail.statusLabel" class="record-row">
+        <view class="record-key">状态</view>
+        <view class="record-value">
+          <text :class="['status-tag', statusTagCls(detail.statusLabel)]">{{
+            detail.statusLabel
+          }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="fab-wrap safe-bottom">
+      <button class="check-fab press" @click="onPunch">立即打卡</button>
+      <text class="fab-extra press" @click="go('/pages/apply/form?type=check')"
+        >发现异常?申请补卡 ›</text
+      >
+    </view>
+  </view>
 </template>
+
 <script setup>
-import { ref } from "vue";
+import { computed, reactive, ref } from "vue";
+import { onShow } from "@dcloudio/uni-app";
 import { useAuthGuard } from "@/hooks/useAuthGuard";
+import { useCurrentUser } from "@/hooks/useCurrentUser";
+import { getCurrentUser } from "@/utils/storage";
 import { go } from "@/utils/router";
-const month = ref(8);
+import {
+  queryDailyAttendance,
+  queryMonthAttendance,
+  submitPunch,
+  formatMonth,
+  formatDate,
+  translateStatus,
+  queryAttendanceRule,
+  queryShiftDetail,
+  extractShiftDisplayRange,
+} from "@/api/attendance";
+import { getLocationWithFallback } from "@/utils/location";
+
 useAuthGuard();
+
+const currentUser = useCurrentUser();
 const weeks = ["日", "一", "二", "三", "四", "五", "六"];
+
+const currentMonth = ref(formatMonth(new Date()));
+const monthList = ref([]); // ESS 月度返回数组
+const selectedDate = ref(formatDate(new Date()));
+const dailyDetail = ref(null); // 管理端 daily 返回(带 actualIn/Out 时间)
+const monthLoading = ref(false);
+// 班次信息:ruleName + 上下班打卡时间(缓存住,detail 里用)
+const shiftInfo = reactive({ ruleName: '', startTime: '', endTime: '' });
+// 考勤规则加载状态:'loading' / 'ok' / 'empty'(未配置) / 'disabled'(不可打卡) / 'error'(接口失败)
+const ruleStatus = ref('loading');
+const ruleBlockedReason = ref(''); // 不可打卡的具体原因:草稿 / 自然到期 / 停用
+const ruleToastShown = ref(false);
+
+const monthLabel = computed(() => {
+  const [y, m] = currentMonth.value.split("-");
+  return `${y}年${Number(m)}月`;
+});
+
+const selectedLabel = computed(() => {
+  if (selectedDate.value === formatDate(new Date())) return "今日";
+  const [, m, d] = selectedDate.value.split("-");
+  return `${Number(m)}月${Number(d)}日`;
+});
+
+const summary = computed(() => {
+  const list = monthList.value || [];
+  let workDays = 0;
+  let attendanceDays = 0;
+  let lateCount = 0;
+  let abnormalCount = 0;
+  for (const item of list) {
+    const status = item.attendanceStatusList
+    if (status === "REST") continue;
+    workDays++
+    if (status === "NORMAL") attendanceDays++
+    if ((item.lateMinutes || 0) > 0) lateCount++
+    if ((item.exceptionCount || 0) > 0) abnormalCount++
+  }
+  const rate = workDays > 0 ? `${((attendanceDays / workDays) * 100).toFixed(1)}%` : "0%"
+  return { attendanceDays, lateCount, abnormalCount, attendanceRate: rate }
+});
+
+const grid = computed(() => {
+  const [y, m] = currentMonth.value.split("-").map(Number);
+  const firstDay = new Date(y, m - 1, 1).getDay();
+  const totalDays = new Date(y, m, 0).getDate();
+  const todayStr = formatDate(new Date());
+  const byDate = new Map();
+  for (const item of monthList.value || []) {
+    const key = (item.attendanceDate || "").slice(0, 10);
+    if (key) byDate.set(key, item);
+  }
+  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);
+    cells.push({
+      date: ds,
+      day: d,
+      data,
+      isToday: ds === todayStr,
+      dot: computeDot(data, ds === todayStr),
+      dotCls: computeDotCls(data, ds === todayStr),
+    });
+  }
+  while (cells.length % 7 !== 0) cells.push({ blank: true });
+  return cells;
+});
+
+function computeDot(data, isToday) {
+  if (!data) return isToday;
+  const status = data.attendanceStatusList;
+  if (isToday) return true;
+  if ((data.exceptionCount || 0) > 0) return true;
+  if (status === "NORMAL") return true;
+  return false;
+}
+
+function computeDotCls(data, isToday) {
+  if (!data) return isToday ? "today" : "";
+  if (isToday) return "today";
+  if ((data.exceptionCount || 0) > 0) return "abnormal";
+  return "normal";
+}
+
+function cellClass(cell) {
+  if (cell.blank) return "blank";
+  const status = cell.data?.attendanceStatusList;
+  const cls = [];
+  if (cell.isToday) cls.push("today");
+  if (status === "REST") cls.push("rest");
+  else if (status === "LEAVE") cls.push("leave");
+  else if ((cell.data?.exceptionCount || 0) > 0) cls.push("abnormal");
+  return cls.join(" ");
+}
+
+const detail = computed(() => {
+  const d = dailyDetail.value || {};
+  const inTime = d.actualIn || null;
+  const outTime = d.actualOut || null;
+  const inText = inTime || "--:--";
+  const outText = outTime || "--:--";
+  const inLabel = inTime
+    ? (d.lateMinutes > 0 ? `迟到 ${d.lateMinutes} 分钟` : "正常")
+    : "未打卡";
+  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 || "今日班次"} · ${start} - ${end}`
+          : "休息日";
+  let workText = "";
+  if (d.actualWorkMinutes || d.requiredMinutes) {
+    workText = `实际 ${d.actualWorkMinutes || 0} 分钟 / 应出勤 ${d.requiredMinutes || 0} 分钟`;
+    if ((d.lateMinutes || 0) > 0) workText += ` · 迟到 ${d.lateMinutes} 分钟`;
+    if ((d.earlyMinutes || 0) > 0) workText += ` · 早退 ${d.earlyMinutes} 分钟`;
+  }
+  const statusLabel = translateStatus(d.statusList);
+  return { shiftText, inText, outText, inLabel, outLabel, workText, statusLabel, actualOut: d.actualOut };
+});
+
+function statusTagCls(label) {
+  if (!label) return "";
+  if (label.includes("正常")) return "status-approved";
+  if (label.includes("迟到") || label.includes("早退") || label.includes("异常") || label.includes("缺勤"))
+    return "status-rejected";
+  return "status-pending";
+}
+
+async function loadMonth(month) {
+  monthLoading.value = true;
+  uni.showLoading({ title: "加载中..." });
+  try {
+    const list = await queryMonthAttendance(month);
+    monthList.value = list;
+  } catch (error) {
+    uni.showToast({ title: error.message || "月历加载失败", icon: "none" });
+  } finally {
+    uni.hideLoading();
+    monthLoading.value = false;
+  }
+}
+
+async function selectDate(date) {
+  selectedDate.value = date;
+  try {
+    const user = getCurrentUser() || currentUser || {};
+    const userId = (user && user.raw && user.raw.userId) || (user && user.id) || "";
+    dailyDetail.value = await queryDailyAttendance(userId, date);
+  } catch (error) {
+    uni.showToast({ title: error.message || "日期详情加载失败", icon: "none" });
+  }
+}
+
+async function loadShiftInfo() {
+  const user = getCurrentUser() || currentUser || {};
+  const userId = (user && user.raw && user.raw.userId) || (user && user.id) || "";
+  const result = await queryAttendanceRule(userId);
+  ruleStatus.value = result.status;
+  ruleBlockedReason.value = result.blockedReason || '';
+  // 空态:进入考勤页时弹一次 modal(每个 session 只弹一次)
+  if (result.status === 'empty' && !ruleToastShown.value) {
+    ruleToastShown.value = true;
+    uni.showModal({
+      title: "未配置考勤规则",
+      content: "尚未为您配置考勤规则,请联系管理员在后台完成配置后再使用考勤功能。",
+      showCancel: false,
+      confirmText: "我知道了",
+    });
+    return;
+  }
+  // 不可打卡(DRAFT/EXPIRED/DISABLED):弹 modal 告知
+  if (result.status === 'disabled' && !ruleToastShown.value) {
+    ruleToastShown.value = true;
+    const reason = result.blockedReason || '不可用';
+    uni.showModal({
+      title: `考勤规则${reason}`,
+      content: `您当前的考勤规则为「${reason}」状态,无法打卡。请联系管理员处理后再使用考勤功能。`,
+      showCancel: false,
+      confirmText: "我知道了",
+    });
+    return;
+  }
+  if (result.status === 'error') {
+    if (!ruleToastShown.value) {
+      ruleToastShown.value = true;
+      uni.showToast({ title: "考勤规则加载失败", icon: "none" });
+    }
+    return;
+  }
+  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
+  }
+}
+
+async function refreshAll() {
+  await loadMonth(currentMonth.value);
+  await selectDate(selectedDate.value);
+}
+
 function changeMonth(step) {
-  const next = month.value + step;
-  if (next < 1 || next > 12) return;
-  month.value = next;
-  uni.showToast({ title: `已切换至${month.value}月`, icon: "none" });
-}
-function dayClass(d) {
-  const date = d - 4;
-  if (d <= 4) return "blank";
-  if (month.value === 8 && date === 5) return "today";
-  if (month.value === 8 && date === 4) return "abnormal";
-  if ([1, 2, 8, 9].includes(date)) return "rest";
-  return "";
-}
+  const [y, m] = currentMonth.value.split("-").map(Number);
+  const next = new Date(y, m - 1 + step, 1);
+  const target = formatMonth(next);
+  const cur = currentMonth.value;
+  // 边界:超过当前月(未来)就停在当前月
+  if (target > formatMonth(new Date())) {
+    uni.showToast({ title: "暂不支持查看未来月份", icon: "none" });
+    return;
+  }
+  if (target === cur) return;
+  currentMonth.value = target;
+  loadMonth(target);
+}
+
+async function onPunch() {
+  // 规则未配置 / 不可打卡:拦截
+  if (ruleStatus.value === 'empty') {
+    return uni.showToast({ title: "未配置考勤规则,请联系管理员", icon: "none" });
+  }
+  if (ruleStatus.value === 'disabled') {
+    const reason = ruleBlockedReason.value || '不可用';
+    return uni.showToast({ title: `考勤规则${reason},暂不能打卡`, icon: "none" });
+  }
+  uni.showLoading({ title: "定位中..." });
+  try {
+    const loc = await getLocationWithFallback();
+    const time = new Date().toISOString();
+    const id = await submitPunch({
+      method: "GPS",
+      time,
+      lat: loc.latitude,
+      lng: loc.longitude,
+      accuracy: loc.accuracy,
+      address: loc.address,
+    });
+    uni.hideLoading();
+    uni.showToast({ title: `打卡成功 #${id}`, icon: "success" });
+    // 打卡成功后:选中今天并刷新月历 + 当日详情
+    selectedDate.value = formatDate(new Date());
+    await refreshAll();
+  } catch (error) {
+    uni.hideLoading();
+    uni.showToast({ title: error.message || "打卡失败", icon: "none" });
+  }
+}
+
+onShow(async () => {
+  await loadShiftInfo();
+  await loadMonth(currentMonth.value);
+  await selectDate(selectedDate.value);
+});
 </script>
+
 <style scoped>
+.attendance-page {
+  padding-bottom: 220rpx;
+}
 .month-head {
   height: 100rpx;
   display: flex;
@@ -82,6 +415,19 @@ function dayClass(d) {
   gap: 60rpx;
   background: #fff;
 }
+.rule-banner {
+  margin: 16rpx 24rpx 0;
+  padding: 22rpx 26rpx;
+  border-radius: 14rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+.rule-banner.empty { background: #fff7e6; color: #b46500; }
+.rule-banner.disabled { background: #f5f5f5; color: #5f6b7a; border: 1rpx solid #d9dde3; }
+.rule-banner.error { background: #fff1f0; color: #c2362d; }
+.rule-banner-title { font-size: 27rpx; font-weight: 600; }
+.rule-banner-sub { font-size: 22rpx; opacity: .85; }
 .month {
   font-size: 31rpx;
   font-weight: 600;
@@ -113,6 +459,9 @@ function dayClass(d) {
   font-size: 21rpx;
   font-weight: 400;
 }
+.summary text.danger {
+  color: #ee4d4d;
+}
 .calendar {
   padding: 20rpx;
 }
@@ -139,6 +488,10 @@ function dayClass(d) {
 .day.rest {
   color: #b2b7be;
 }
+.day.leave {
+  color: #b07900;
+  background: #fff7e0;
+}
 .day.abnormal {
   color: #ee4d4d;
   background: #fff1f1;
@@ -155,20 +508,21 @@ function dayClass(d) {
   border-radius: 50%;
   background: #07c160;
 }
-.day.abnormal .day-dot {
+.day-dot.abnormal {
   background: #ee4d4d;
 }
-.day.today .day-dot {
+.day-dot.today {
   background: #fff;
 }
 .legend {
   display: flex;
   justify-content: center;
-  gap: 38rpx;
+  gap: 26rpx;
   padding: 22rpx 0 6rpx;
   border-top: 1rpx solid #edf0f2;
   color: #8b939f;
   font-size: 21rpx;
+  flex-wrap: wrap;
 }
 .legend i {
   display: inline-block;
@@ -183,56 +537,114 @@ function dayClass(d) {
 .abnormal {
   background: #ee4d4d;
 }
+.leave {
+  background: #f6bd16;
+}
 .rest {
   background: #cfd4da;
 }
+.section-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 24rpx 24rpx 12rpx;
+  font-size: 27rpx;
+  font-weight: 600;
+  color: #1f2329;
+}
 .record {
-  padding: 28rpx;
+  margin: 0 24rpx;
+  padding: 12rpx 28rpx;
 }
-.record-item {
+.record-row {
   display: flex;
+  align-items: center;
+  min-height: 84rpx;
+  border-bottom: 1rpx solid #f2f4f7;
 }
-.timeline-dot {
-  width: 20rpx;
-  height: 20rpx;
-  margin: 8rpx 24rpx 0 4rpx;
-  border: 5rpx solid #cfe1ff;
-  border-radius: 50%;
-  background: #1677ff;
+.record-row:last-child {
+  border-bottom: 0;
 }
-.timeline-dot.gray {
-  border-color: #e6e8eb;
-  background: #b2b7be;
+.record-key {
+  width: 96rpx;
+  color: #8b939f;
+  font-size: 25rpx;
+}
+.record-value {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  font-size: 27rpx;
+}
+.record-value.muted {
+  color: #8b939f;
+  font-size: 23rpx;
 }
 .record-time {
   font-size: 31rpx;
   font-weight: 600;
 }
-.record-label {
-  margin-left: 18rpx;
-  font-size: 25rpx;
+.record-time.muted {
+  color: #c4c8ce;
+  font-weight: 400;
 }
-.record-place {
-  display: block;
-  margin-top: 12rpx;
+.record-tag {
+  font-size: 22rpx;
   color: #8b939f;
+}
+.status-tag {
+  display: inline-block;
+  height: 32rpx;
+  padding: 0 14rpx;
+  border-radius: 16rpx;
   font-size: 22rpx;
+  line-height: 32rpx;
 }
-.record-line {
-  width: 2rpx;
-  height: 48rpx;
-  margin: 3rpx 0 3rpx 13rpx;
-  background: #dfe3e8;
-}
-.check-btn {
-  height: 84rpx;
-  margin: 24rpx;
-  border-radius: 12rpx;
-  background: #eaf3ff;
-  color: #1677ff;
+.status-tag.status-approved {
+  color: #07a44d;
+  background: #e8f8ef;
+}
+.status-tag.status-rejected {
+  color: #ee4d4d;
+  background: #ffebed;
+}
+.status-tag.status-pending {
+  color: #b07900;
+  background: #fff7e0;
+}
+.fab-wrap {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  padding: 16rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
+  background: #fff;
+  border-top: 1rpx solid #edf0f2;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 10rpx;
+}
+.check-fab {
+  width: 100%;
+  height: 88rpx;
+  border-radius: 44rpx;
+  background: #1677ff;
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
   display: flex;
   align-items: center;
   justify-content: center;
-  font-size: 27rpx;
+  border: 0;
+  box-shadow: 0 10rpx 24rpx rgba(22, 119, 255, 0.24);
+}
+.check-fab::after {
+  border: 0;
+}
+.fab-extra {
+  font-size: 23rpx;
+  color: #1677ff;
 }
 </style>

+ 201 - 28
src/pages/home/index.vue

@@ -5,39 +5,36 @@
       <view class="hero-top">
         <view>
           <text class="greeting">下午好,{{ currentUser.name }}</text>
-          <text class="date">2026年8月5日 · 星期三</text>
+          <text class="date">{{ todayText }}</text>
         </view>
         <view class="message press" @click="go('/pages/notice/index')">
           <text>◯</text><view class="dot"></view>
         </view>
       </view>
-      <view class="check-card">
+      <view class="check-card press" @click="go('/pages/attendance/index')">
         <view class="check-main">
           <view>
             <text class="check-title">今日考勤</text>
-            <text class="check-sub">行政班 · 08:30 - 17:30</text>
+            <text class="check-sub">{{ shiftText }}</text>
+          </view>
+          <view :class="['check-button', buttonState.cls]">
+            <text v-if="buttonState.mark" class="check-mark">{{ buttonState.mark }}</text>
+            <text>{{ buttonState.label }}</text>
           </view>
-          <view class="check-button done"
-            ><text class="check-mark">✓</text><text>已打卡</text></view
-          >
         </view>
         <view class="check-times">
           <view class="time-item"
-            ><text class="time">08:22</text
-            ><text class="time-label">上班 · 正常</text></view
+            ><text class="time">{{ inText }}</text
+            ><text class="time-label">上班 · {{ inLabel }}</text></view
           >
           <view class="time-line"></view>
           <view class="time-item"
-            ><text class="time muted">--:--</text
-            ><text class="time-label">下班 · 未打卡</text></view
-          >
-          <view
-            class="check-detail press"
-            @click="go('/pages/attendance/index')"
-            >详情 ›</view
+            ><text class="time" :class="{ muted: !daily.actualOut }">{{ outText }}</text
+            ><text class="time-label">下班 · {{ outLabel }}</text></view
           >
         </view>
       </view>
+      <button class="check-fab press" :class="{ disabled: !canPunch }" @click.stop="onPunch">{{ canPunch ? '立即打卡' : '暂停打卡' }}</button>
     </view>
 
     <view class="quick card section">
@@ -109,25 +106,170 @@
 <script setup>
 import AppTabBar from "@/components/AppTabBar.vue";
 import ModuleIcon from "@/components/ModuleIcon.vue";
-import { ref } from "vue";
+import { computed, reactive, ref } from "vue";
 import { onShow } from "@dcloudio/uni-app";
 import { quickActions, notices } from "@/data/mock";
 import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { getApprovalResults } from "@/utils/storage";
+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";
+
 const pendingCount = ref(3);
 const currentUser = useCurrentUser();
-onShow(
-  () =>
-    (pendingCount.value = Math.max(
-      0,
-      3 - Object.keys(getApprovalResults()).length,
-    )),
-);
+// 班次信息:从考勤规则 + 班次详情拉(启动时一次性缓存)
+const shiftInfo = reactive({
+  ruleName: "",
+  startTime: "",
+  endTime: "",
+  loaded: false,
+});
+// 考勤规则状态:'ok' = 已配置 / 'empty' = 未配置 / 'disabled' = 不可打卡 / 'error' = 接口失败 / 'loading' = 未加载
+const ruleStatus = ref('loading');
+// 不可打卡的具原因:如 "草稿" / "自然到期" / "停用"
+const ruleBlockedReason = ref('');
+const daily = reactive({
+  actualIn: null,
+  actualOut: null,
+  scheduledStart: null,
+  scheduledEnd: null,
+  lateMinutes: 0,
+  earlyMinutes: 0,
+  statusList: [],
+});
+
+const todayText = computed(() => {
+  const d = new Date();
+  const weeks = ["日", "一", "二", "三", "四", "五", "六"];
+  return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 · 星期${weeks[d.getDay()]}`;
+});
+
+const shiftText = computed(() => {
+  // 未配置 / 不可打卡:给明确提示而不是显示 08:30
+  if (ruleStatus.value === 'empty') return "未配置考勤规则";
+  if (ruleStatus.value === 'disabled') return `考勤规则${ruleBlockedReason.value || '不可用'},暂不能打卡`;
+  if (ruleStatus.value === 'error' && !shiftInfo.startTime) return "考勤规则加载失败";
+  // 优先用 daily 里的 scheduledStart/End(当天后端 join 过)
+  // 缺失时再用 shiftInfo 兜底
+  const start = daily.scheduledStart || shiftInfo.startTime;
+  const end = daily.scheduledEnd || shiftInfo.endTime;
+  if (!start || !end) return "休息日";
+  const name = shiftInfo.ruleName || "今日班次";
+  return `${name} · ${start} - ${end}`;
+});
+
+const inText = computed(() => daily.actualIn || "--:--");
+const outText = computed(() => daily.actualOut || "--:--");
+
+const inLabel = computed(() => {
+  if (!daily.actualIn) return "未打卡";
+  if (daily.lateMinutes > 0) return `迟到 ${daily.lateMinutes} 分钟`;
+  return "正常";
+});
+
+const outLabel = computed(() => {
+  if (!daily.actualOut) return "未打卡";
+  if (daily.earlyMinutes > 0) return `早退 ${daily.earlyMinutes} 分钟`;
+  return "正常";
+});
+
+const buttonState = computed(() => {
+  const { actualIn, actualOut, lateMinutes } = daily;
+  if (actualIn && actualOut) return { cls: "done", mark: "✓", label: "已完成" };
+  if (actualIn && lateMinutes > 0)
+    return { cls: "late", mark: "", label: `迟到 ${lateMinutes} 分钟` };
+  if (actualIn) return { cls: "done", mark: "✓", label: "已打卡" };
+  return { cls: "pending", mark: "", label: "打卡" };
+});
+
+// 是否允许打卡:规则未配置 / 已停用 时禁用按钮
+const canPunch = computed(() => ruleStatus.value !== 'empty' && ruleStatus.value !== 'disabled');
+
+async function loadShiftInfo(userId) {
+  const result = await queryAttendanceRule(userId);
+  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 已加载)
+  }
+}
+
+async function loadDaily() {
+  try {
+    const user = getCurrentUser() || currentUser || {};
+    const userId = (user && user.raw && user.raw.userId) || (user && user.id) || "";
+    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),
+      queryDailyAttendance(userId, date),
+    ]);
+    if (vo) Object.assign(daily, vo);
+  } catch (error) {
+    // 静默失败:UI 保留空态
+  }
+}
+
+onShow(() => {
+  pendingCount.value = Math.max(
+    0,
+    3 - Object.keys(getApprovalResults()).length,
+  );
+  loadDaily();
+});
+
 function openQuick(item) {
   if (item.key === "more") return uni.reLaunch({ url: "/pages/work/index" });
   go(`/pages/apply/form?type=${item.key}`);
 }
+
+async function onPunch() {
+  // 规则未配置 / 不可打卡:拦截
+  if (ruleStatus.value === 'empty') {
+    return uni.showToast({ title: "未配置考勤规则,请联系管理员", icon: "none" });
+  }
+  if (ruleStatus.value === 'disabled') {
+    const reason = ruleBlockedReason.value || '不可用';
+    return uni.showToast({ title: `考勤规则${reason},暂不能打卡`, icon: "none" });
+  }
+  uni.showLoading({ title: "定位中..." });
+  try {
+    const loc = await getLocationWithFallback();
+    const time = new Date().toISOString();
+    const id = await submitPunch({
+      method: "GPS",
+      time,
+      lat: loc.latitude,
+      lng: loc.longitude,
+      accuracy: loc.accuracy,
+      address: loc.address,
+    });
+    uni.hideLoading();
+    uni.showToast({ title: `打卡成功 #${id}`, icon: "success" });
+    await loadDaily();
+  } catch (error) {
+    uni.hideLoading();
+    uni.showToast({ title: error.message || "打卡失败", icon: "none" });
+  }
+}
 </script>
 
 <style scoped lang="scss">
@@ -209,9 +351,19 @@ function openQuick(item) {
   align-items: center;
   justify-content: center;
   gap: 8rpx;
+  font-size: 25rpx;
+}
+.check-button.done {
   color: #07893f;
   background: #e8f8ef;
-  font-size: 25rpx;
+}
+.check-button.pending {
+  color: #fff;
+  background: #1677ff;
+}
+.check-button.late {
+  color: #fff;
+  background: #ff8f3f;
 }
 .check-mark {
   font-size: 30rpx;
@@ -232,6 +384,10 @@ function openQuick(item) {
   font-size: 34rpx;
   font-weight: 600;
 }
+.time.muted {
+  color: #c4c8ce;
+  font-weight: 400;
+}
 .time-label {
   display: block;
   margin-top: 7rpx;
@@ -244,10 +400,27 @@ function openQuick(item) {
   margin: 0 16rpx;
   background: #d9dde3;
 }
-.check-detail {
-  margin-left: auto;
+.check-fab {
+  margin: 24rpx 0 0;
+  height: 88rpx;
+  border-radius: 44rpx;
+  background: #fff;
   color: #1677ff;
-  font-size: 24rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 0;
+  box-shadow: 0 10rpx 24rpx rgba(4, 49, 108, 0.16);
+}
+.check-fab::after {
+  border: 0;
+}
+.check-fab.disabled {
+  background: #d9dde3;
+  color: #8b939f;
+  box-shadow: none;
 }
 .quick {
   display: grid;

+ 15 - 11
src/pages/profile/index.vue

@@ -63,6 +63,7 @@ import AppTabBar from "@/components/AppTabBar.vue";
 import { useCurrentUser } from "@/hooks/useCurrentUser";
 import { go } from "@/utils/router";
 import { clearDemoStorage, getApplications, logout } from "@/utils/storage";
+import { serverLogout } from "@/utils/auth";
 const applyingCount = ref(0);
 const currentUser = useCurrentUser();
 onShow(
@@ -137,18 +138,21 @@ function clickOther(item) {
     },
   });
 }
-function confirmLogout() {
-  uni.showModal({
-    title: "退出登录",
-    content: "确定退出当前账号吗?",
-    confirmText: "退出",
-    confirmColor: "#ee4d4d",
-    success: (result) => {
-      if (!result.confirm) return;
-      logout();
-      uni.reLaunch({ url: "/pages/login/index" });
-    },
+async function confirmLogout() {
+  const result = await new Promise((resolve) => {
+    uni.showModal({
+      title: "退出登录",
+      content: "确定退出当前账号吗?",
+      confirmText: "退出",
+      confirmColor: "#ee4d4d",
+      success: resolve,
+    });
   });
+  if (!result.confirm) return;
+  // 服务器模式:先通知后端作废 token,再清本地 storage,最后跳登录页
+  await serverLogout();
+  logout();
+  uni.reLaunch({ url: "/pages/login/index" });
 }
 </script>
 <style scoped lang="scss">

+ 231 - 105
src/utils/auth.js

@@ -7,67 +7,78 @@ import {
   saveLoginSession,
   savePermissionData,
   saveRememberedCredentials,
-} from './storage'
+} from "./storage";
+import { clearAttendanceCache } from "@/api/attendance";
 
-const SERVER_KEY = 'apiInfo'
+const SERVER_KEY = "apiInfo";
 const DEFAULT_SERVER = {
-  mode: 'demo',
-  protocol: 'http://',
-  hostname: '',
-  port: '',
-}
+  mode: "demo",
+  protocol: "http://",
+  hostname: "",
+  port: "",
+};
 
 export function getServerConfig() {
-  const saved = uni.getStorageSync(SERVER_KEY) || {}
+  const saved = uni.getStorageSync(SERVER_KEY) || {};
   return {
     ...DEFAULT_SERVER,
     ...saved,
     protocol: saved.protocol || saved.protocal || DEFAULT_SERVER.protocol,
-  }
+  };
 }
 
 export function saveServerConfig(config) {
   const normalized = {
-    mode: config.mode === 'server' ? 'server' : 'demo',
-    protocol: config.protocol === 'https://' ? 'https://' : 'http://',
-    hostname: String(config.hostname || '').trim().replace(/^https?:\/\//, '').replace(/\/$/, ''),
-    port: String(config.port || '').trim(),
-  }
-  uni.setStorageSync(SERVER_KEY, normalized)
-  return normalized
+    mode: config.mode === "server" ? "server" : "demo",
+    protocol: config.protocol === "https://" ? "https://" : "http://",
+    hostname: String(config.hostname || "")
+      .trim()
+      .replace(/^https?:\/\//, "")
+      .replace(/\/$/, ""),
+    port: String(config.port || "").trim(),
+  };
+  uni.setStorageSync(SERVER_KEY, normalized);
+  return normalized;
 }
 
 // H5 dev(pnpm dev:h5)走 Vite 代理避开浏览器 CORS;其他场景仍用配置里的真实地址
 const useDevProxy =
-  typeof window !== 'undefined' &&
-  typeof process !== 'undefined' &&
-  process.env?.NODE_ENV !== 'production'
+  typeof window !== "undefined" &&
+  typeof process !== "undefined" &&
+  process.env?.NODE_ENV !== "production";
 
 export function getApiBaseUrl(config = getServerConfig()) {
-  if (config.mode !== 'server' || !config.hostname) return ''
-  if (useDevProxy) return '/api'
-  const port = config.port ? `:${config.port}` : ''
-  return `${config.protocol}${config.hostname}${port}/api`
+  if (config.mode !== "server" || !config.hostname) return "";
+  if (useDevProxy) return "/api";
+  const port = config.port ? `:${config.port}` : "";
+  return `${config.protocol}${config.hostname}${port}/api`;
 }
 
-function request({ url, method = 'GET', data, token, sessionId, timeout = 15000 }) {
+export function request({
+  url,
+  method = "GET",
+  data,
+  token,
+  sessionId,
+  timeout = 15000,
+}) {
   return new Promise((resolve, reject) => {
     // 未显式传 token 时自动从登录态读取,登录后所有请求自动带上凭证
-    let effectiveToken = token
-    let effectiveSessionId = sessionId
+    let effectiveToken = token;
+    let effectiveSessionId = sessionId;
     if (!effectiveToken) {
-      const stored = getAuthCredentials()
-      effectiveToken = stored.token
-      if (!effectiveSessionId) effectiveSessionId = stored.sessionId
+      const stored = getAuthCredentials();
+      effectiveToken = stored.token;
+      if (!effectiveSessionId) effectiveSessionId = stored.sessionId;
     }
     const header = effectiveToken
       ? {
-          'content-type': 'application/json',
-          'zoomwin-token': effectiveToken,
+          "content-type": "application/json",
+          "zoomwin-token": effectiveToken,
           Authorization: effectiveToken,
-          'zoomwin-sid': effectiveSessionId || '',
+          "zoomwin-sid": effectiveSessionId || "",
         }
-      : { 'content-type': 'application/json', platform: 'wxapp' }
+      : { "content-type": "application/json", platform: "wxapp" };
 
     uni.request({
       url,
@@ -77,129 +88,244 @@ function request({ url, method = 'GET', data, token, sessionId, timeout = 15000
       timeout,
       success: (response) => {
         if (response.statusCode === 401) {
-          // 服务端认为凭证过期:清本地会话 + 跳登录页
-          logout()
-          uni.showToast({ title: '登录已过期,请重新登录', icon: 'none', duration: 1500 })
-          setTimeout(() => uni.reLaunch({ url: '/pages/login/index' }), 1200)
-          reject(new Error('身份验证已过期,请重新登录'))
-          return
+          // 服务端认为凭证过期:清本地会话 + 清考勤缓存 + 跳登录页
+          logout();
+          clearAttendanceCache();
+          uni.showToast({
+            title: "登录已过期,请重新登录",
+            icon: "none",
+            duration: 1500,
+          });
+          setTimeout(() => uni.reLaunch({ url: "/pages/login/index" }), 1200);
+          reject(new Error("身份验证已过期,请重新登录"));
+          return;
         }
-        const body = response.data || {}
+        const body = response.data || {};
         if (response.statusCode < 200 || response.statusCode >= 300) {
-          reject(new Error(body.message || `服务器响应异常(${response.statusCode})`))
-          return
+          reject(
+            new Error(
+              body.message || `服务器响应异常(${response.statusCode})`,
+            ),
+          );
+          return;
         }
         if (body.code !== undefined && Number(body.code) !== 0) {
-          reject(new Error(body.message || '请求失败'))
-          return
+          reject(new Error(body.message || "请求失败"));
+          return;
         }
-        resolve(body)
+        resolve(body);
       },
-      fail: (error) => reject(new Error(error.errMsg || '无法连接服务器')),
-    })
-  })
+      fail: (error) => reject(new Error(error.errMsg || "无法连接服务器")),
+    });
+  });
 }
 
 export async function testServerConnection(config) {
   const normalized = {
-    mode: 'server',
-    protocol: config.protocol === 'https://' ? 'https://' : 'http://',
-    hostname: String(config.hostname || '').trim().replace(/^https?:\/\//, '').replace(/\/$/, ''),
-    port: String(config.port || '').trim(),
-  }
-  const baseUrl = getApiBaseUrl(normalized)
-  if (!baseUrl) throw new Error('请填写服务器地址')
+    mode: "server",
+    protocol: config.protocol === "https://" ? "https://" : "http://",
+    hostname: String(config.hostname || "")
+      .trim()
+      .replace(/^https?:\/\//, "")
+      .replace(/\/$/, ""),
+    port: String(config.port || "").trim(),
+  };
+  const baseUrl = getApiBaseUrl(normalized);
+  if (!baseUrl) throw new Error("请填写服务器地址");
   await request({
     url: `${baseUrl}/main/connection/getConnectionTest`,
     timeout: 5000,
-  })
-  return true
+  });
+  return true;
 }
 
 function collectAuthorities(tree) {
-  const authorities = []
+  const authorities = [];
   const walk = (list) => {
     for (const item of Array.isArray(list) ? list : []) {
-      if (item && item.menuType === 2) authorities.push(item)
-      if (item?.children?.length) walk(item.children)
+      if (item && item.menuType === 2) authorities.push(item);
+      if (item?.children?.length) walk(item.children);
     }
-  }
-  walk(tree)
-  return authorities
+  };
+  walk(tree);
+  return authorities;
 }
 
 async function loadPermissionTree(baseUrl) {
   const response = await request({
     url: `${baseUrl}/system/resources/getResourcesTreePDA`,
-  })
-  const tree = Array.isArray(response.data) ? response.data : []
-  savePermissionData(tree, collectAuthorities(tree))
-  return tree
+  });
+  const tree = Array.isArray(response.data) ? response.data : [];
+  savePermissionData(tree, collectAuthorities(tree));
+  return tree;
 }
 
 // 拉取当前登录人的扩展资料(部门、岗位、角色等),登录后调用一次写入本地
 export async function fetchCurrentUser(baseUrl) {
-  if (!baseUrl) throw new Error('缺少 baseUrl')
+  if (!baseUrl) throw new Error("缺少 baseUrl");
   const response = await request({
     url: `${baseUrl}/system/account/getLoginUser`,
-  })
-  return response.data || null
+  });
+  return response.data || null;
+}
+
+// 拉取员工完整档案(含 deptName / postId / secondLinkName / secondLinkPhone),
+// 与 getLoginUser 合并补全部门岗位与紧急联系人
+// profile 响应把用户数据放在 user 子对象里,内部摊平到顶层
+// 岗位名称不在 profile 直接返回,而是 postId(逗号分隔的 id 串),
+// 内部再调 /hr/position/page 用 id 匹配出 positionName 后回填到返回对象的 position 字段
+export async function fetchUserDetail(baseUrl, userId) {
+  if (!baseUrl) throw new Error("缺少 baseUrl");
+  if (!userId && userId !== 0) throw new Error("缺少用户ID");
+  const response = await request({
+    url: `${baseUrl}/main/users/${userId}/profile`,
+  });
+  const data = response.data || null;
+  if (!data) return null;
+  // 嵌套 user 包装时,把 user 的字段提到顶层(deptName / secondLinkName 等),
+  // 同时保留顶层其他字段(postId 等);mapCurrentUser 不必关心嵌套结构
+  const profile = data.user && typeof data.user === "object"
+    ? { ...data, ...data.user }
+    : data;
+  try {
+    const names = await resolvePostIds(baseUrl, profile.postId);
+    if (names) profile.position = names;
+  } catch (error) {
+    // 岗位解析失败不影响其他字段
+  }
+  return profile;
+}
+
+// 把 profile.postId("1111,2222")解析成岗位名称字符串,用 / 拼接多个岗位
+async function resolvePostIds(baseUrl, postIdString) {
+  if (!postIdString) return "";
+  const ids = String(postIdString)
+    .split(",")
+    .map((s) => s.trim())
+    .filter(Boolean);
+  if (!ids.length) return "";
+  const response = await request({
+    url: `${baseUrl}/hr/position/page`,
+    method: "POST",
+    data: { pageNum: 1, size: 1000, status: 1 },
+  });
+  const pageData = response.data || {};
+  const records = Array.isArray(pageData.list)
+    ? pageData.list
+    : Array.isArray(pageData)
+      ? pageData
+      : [];
+  const names = ids
+    .map((id) => {
+      const match = records.find((r) => String(r.id) === id);
+      return match ? String(match.positionName || match.name || "") : "";
+    })
+    .filter(Boolean);
+  return names.join("、");
 }
 
 export async function performLogin({ account, password, rememberPassword }) {
-  const config = getServerConfig()
-  const loginName = String(account || '').trim()
-  const loginPwd = String(password || '')
-
-  if (config.mode !== 'server') {
-    await new Promise((resolve) => setTimeout(resolve, 450))
-    if (loginName.toUpperCase() !== DEMO_ACCOUNT || loginPwd !== DEMO_PASSWORD) {
-      throw new Error('工号或密码错误')
+  const config = getServerConfig();
+  const loginName = String(account || "").trim();
+  const loginPwd = String(password || "");
+
+  if (config.mode !== "server") {
+    await new Promise((resolve) => setTimeout(resolve, 450));
+    if (
+      loginName.toUpperCase() !== DEMO_ACCOUNT ||
+      loginPwd !== DEMO_PASSWORD
+    ) {
+      throw new Error("工号或密码错误");
     }
     const data = {
       token: `demo-token-${Date.now()}`,
-      sessionId: 'demo-session',
+      sessionId: "demo-session",
       userId: DEMO_ACCOUNT,
       loginName: DEMO_ACCOUNT,
-      userName: '陈晓雨',
-    }
-    saveLoginSession(data, loginName)
-    savePermissionData([], [])
-    saveRememberedCredentials({ account: loginName, password: loginPwd, rememberPassword })
-    return data
+      userName: "陈晓雨",
+    };
+    saveLoginSession(data, loginName);
+    savePermissionData([], []);
+    saveRememberedCredentials({
+      account: loginName,
+      password: loginPwd,
+      rememberPassword,
+    });
+    return data;
   }
 
-  const baseUrl = getApiBaseUrl(config)
-  if (!baseUrl) throw new Error('请先配置服务器地址')
+  const baseUrl = getApiBaseUrl(config);
+  if (!baseUrl) throw new Error("请先配置服务器地址");
   const response = await request({
     url: `${baseUrl}/main/user/login`,
-    method: 'POST',
+    method: "POST",
     data: { loginName, loginPwd },
-  })
-  const data = response.data || {}
-  if (!data.token) throw new Error('登录响应中缺少 Token')
+  });
+  const data = response.data || {};
+  if (!data.token) throw new Error("登录响应中缺少 Token");
 
-  saveLoginSession(data, loginName)
-  saveRememberedCredentials({ account: loginName, password: loginPwd, rememberPassword })
+  saveLoginSession(data, loginName);
+  saveRememberedCredentials({
+    account: loginName,
+    password: loginPwd,
+    rememberPassword,
+  });
   try {
-    await loadPermissionTree(baseUrl)
+    await loadPermissionTree(baseUrl);
   } catch (error) {
-    savePermissionData([], [])
-    uni.showToast({ title: error.message || '权限加载失败', icon: 'none' })
+    savePermissionData([], []);
+    uni.showToast({ title: error.message || "权限加载失败", icon: "none" });
   }
   try {
-    const currentUser = await fetchCurrentUser(baseUrl)
-    if (currentUser) saveCurrentUser(currentUser)
+    // 部门/岗位在 getLoginUser 里没有,单独调 getById 拿,再合并
+    const [basic, detail] = await Promise.allSettled([
+      fetchCurrentUser(baseUrl),
+      fetchUserDetail(baseUrl, data.userId),
+    ]);
+    const basicData = basic.status === "fulfilled" ? basic.value : null;
+    const detailData = detail.status === "fulfilled" ? detail.value : null;
+    // getById 字段覆盖基础信息(deptName / postName / joinDate 等)
+    const merged = detailData
+      ? { ...(basicData || {}), ...detailData }
+      : basicData;
+    if (merged) saveCurrentUser(merged);
+    if (detail.status === "rejected") {
+      uni.showToast({
+        title: detail.reason?.message || "员工档案加载失败",
+        icon: "none",
+      });
+    }
   } catch (error) {
-    uni.showToast({ title: error.message || '用户信息加载失败', icon: 'none' })
+    uni.showToast({ title: error.message || "用户信息加载失败", icon: "none" });
   }
-  return data
+  return data;
 }
 
 export async function getCompanyBranding(config = getServerConfig()) {
-  if (config.mode !== 'server') return null
-  const baseUrl = getApiBaseUrl(config)
-  if (!baseUrl) return null
-  const response = await request({ url: `${baseUrl}/pda/mes/us/indexName`, timeout: 5000 })
-  return response.data || null
+  if (config.mode !== "server") return null;
+  const baseUrl = getApiBaseUrl(config);
+  if (!baseUrl) return null;
+  const response = await request({
+    url: `${baseUrl}/pda/mes/us/indexName`,
+    timeout: 5000,
+  });
+  return response.data || null;
+}
+
+// 服务器模式下退出登录:通知后端作废 token,再由页面清理本地 storage 并跳登录页
+// 演示模式或未配置服务器时直接返回(不抛错),由调用方继续清理本地
+export async function serverLogout() {
+  if (getServerConfig().mode !== "server") return;
+  const baseUrl = getApiBaseUrl();
+  if (!baseUrl) return;
+  try {
+    await request({
+      url: `${baseUrl}/main/user/logout`,
+      method: "POST",
+      timeout: 5000,
+    });
+  } catch (error) {
+    // 服务器登出失败(网络/401/token 过期等)不阻塞本地清理
+    console.warn("[auth] server logout failed:", error?.message || error);
+  }
 }

+ 68 - 0
src/utils/location.js

@@ -0,0 +1,68 @@
+// 跨端统一封装定位 API。
+// H5 走 navigator.geolocation(注意 http://localhost 浏览器可能拒绝);
+// 小程序 / APP 走 uni.getLocation(gcj02 坐标系)。
+// 全部返回 Promise<{latitude, longitude, accuracy}>,失败 reject 带中文 message。
+export function getLocation(options = {}) {
+  const timeout = 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
+  // eslint-disable-next-line no-unreachable
+  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
+}
+
+// 定位失败时的兜底坐标:中盈产业园参考点(北京西二旗)。
+// 仅用于 H5 演示环境(HTTPS 限制或权限被拒时),让打卡流程可以走通。
+export const DEMO_LOCATION = {
+  latitude: 40.0447,
+  longitude: 116.3047,
+  accuracy: 35,
+  address: '中盈产业园 A座',
+}
+
+export async function getLocationWithFallback(options) {
+  try {
+    return await getLocation(options)
+  } catch (error) {
+    // H5 演示:定位不可用时使用兜底坐标,UI 与接口调用与真实定位一致
+    // eslint-disable-next-line no-console
+    console.warn('[location] fallback to demo coords:', error.message)
+    return DEMO_LOCATION
+  }
+}

+ 35 - 3
src/utils/storage.js

@@ -109,20 +109,52 @@ export function saveCurrentUser(data) {
   uni.setStorageSync(CURRENT_USER_KEY, data)
 }
 
-// 把 /system/account/getLoginUser 的 CurrentUserVO 映射成页面里使用的字段
+// 把 /system/account/getLoginUser 与 /main/users/{userId}/profile 合并后的 VO 映射成页面字段
+// - 部门:vo.deptName
+// - 岗位:fetchUserDetail 已用 vo.postId 解析并写入 vo.position;这里兜底再读 postName / positionList
+// - 入职时间:profile 用的是 joinDate,getLoginUser 用 entryDate,两边都兼容
+// - 紧急联系人:secondLinkName + secondLinkPhone(关系 secondLinkRelation 可选带入)
 function mapCurrentUser(vo) {
   if (!vo || typeof vo !== 'object') return null
   const name = String(vo.name || '')
+  let position = vo.position || ''
+  if (!position) position = vo.postName || ''
+  if (!position && Array.isArray(vo.positionList)) {
+    const primary = vo.positionList.find((p) => p && p.primaryFlag === 1)
+    position = primary ? String(primary.positionName || '') : ''
+  }
+  const emergencyName = vo.secondLinkName || ''
+  const emergencyPhone = vo.secondLinkPhone || ''
+  const emergencyRelation = vo.secondLinkRelation || ''
+  // 拼成 "姓名(关系)· 电话" 或 "姓名 · 电话"
+  const emergencyParts = []
+  if (emergencyName && emergencyRelation) emergencyParts.push(`${emergencyName}(${emergencyRelation})`)
+  else if (emergencyName) emergencyParts.push(emergencyName)
+  if (emergencyPhone) emergencyParts.push(emergencyPhone)
+  const emergencyContact = emergencyParts.join(' · ')
+  const rawBankCards = Array.isArray(vo.bankCardList) ? vo.bankCardList : []
+  const bankCardList = rawBankCards.map((c) => ({
+    id: String(c.id ?? c.cardId ?? c.cardNumber ?? ''),
+    cardNumber: String(c.cardNumber ?? c.cardNo ?? ''),
+    openingBank: String(c.openingBank ?? c.bankName ?? ''),
+    branch: String(c.branch ?? c.subBranch ?? ''),
+  }))
   return {
     id: vo.jobNumber || vo.loginName || '',
     name,
     initials: name ? name.charAt(0) : '',
     department: vo.deptName || '',
-    position: vo.postName || '',
+    position,
     company: vo.groupName || '',
     phone: vo.phone || '',
-    entryDate: vo.entryDate || '',
+    entryDate: vo.joinDate || vo.entryDate || '',
     workAge: vo.workAge || '',
+    emergencyContact,
+    maritalStatus: vo.maritalStatus || vo.marital || '',
+    emergencyName: emergencyName,
+    emergencyPhone: emergencyPhone,
+    emergencyRelation: emergencyRelation,
+    bankCardList,
     raw: vo,
   }
 }