index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. <template>
  2. <view class="page-no-tab attendance-page">
  3. <view class="month-head"
  4. ><text class="month-arrow press" @click="changeMonth(-1)">‹</text
  5. ><text class="month">{{ monthLabel }}</text
  6. ><text class="month-arrow press" @click="changeMonth(1)">›</text></view
  7. >
  8. <view v-if="ruleStatus === 'empty'" class="rule-banner empty">
  9. <text class="rule-banner-title">未配置考勤规则</text>
  10. <text class="rule-banner-sub">请联系管理员在后台完成配置后再使用考勤功能</text>
  11. </view>
  12. <view v-else-if="ruleStatus === 'disabled'" class="rule-banner disabled">
  13. <text class="rule-banner-title">考勤规则{{ ruleBlockedReason || '不可用' }}</text>
  14. <text class="rule-banner-sub">请联系管理员处理后再使用打卡功能</text>
  15. </view>
  16. <view v-else-if="ruleStatus === 'error'" class="rule-banner error">
  17. <text class="rule-banner-title">考勤规则加载失败</text>
  18. <text class="rule-banner-sub">下拉刷新或重新进入页面重试</text>
  19. </view>
  20. <view class="summary card section">
  21. <view
  22. ><text>{{ summary.attendanceDays }}</text
  23. ><text>出勤天数</text></view
  24. ><view
  25. ><text :class="{ danger: summary.lateCount > 0 }">{{ summary.lateCount }}</text
  26. ><text>迟到</text></view
  27. ><view
  28. ><text :class="{ danger: summary.abnormalCount > 0 }">{{ summary.abnormalCount }}</text
  29. ><text>异常</text></view
  30. ><view
  31. ><text>{{ summary.attendanceRate }}</text
  32. ><text>出勤率</text></view
  33. ></view
  34. >
  35. <view class="calendar card section">
  36. <view class="week"
  37. ><text v-for="w in weeks" :key="w">{{ w }}</text></view
  38. >
  39. <view class="days"
  40. ><view
  41. v-for="(cell, idx) in grid"
  42. :key="idx"
  43. class="day"
  44. :class="cellClass(cell)"
  45. @click="cell.date && selectDate(cell.date)"
  46. >
  47. <text v-if="!cell.blank">{{ cell.day }}</text>
  48. <view v-if="cell.dot" :class="['day-dot', cell.dotCls]"></view>
  49. </view></view
  50. >
  51. <view class="legend">
  52. <text><i class="normal"></i>正常</text>
  53. <text><i class="abnormal"></i>异常</text>
  54. <text><i class="leave"></i>请假</text>
  55. <text><i class="rest"></i>休息</text>
  56. </view>
  57. </view>
  58. <view class="section-title"
  59. ><text>{{ selectedLabel }}打卡</text></view
  60. >
  61. <view class="record card section">
  62. <view class="record-row">
  63. <view class="record-key">班次</view>
  64. <view class="record-value">{{ detail.shiftText }}</view>
  65. </view>
  66. <view class="record-row">
  67. <view class="record-key">上班</view>
  68. <view class="record-value">
  69. <text class="record-time">{{ detail.inText }}</text>
  70. <text class="record-tag">{{ detail.inLabel }}</text>
  71. </view>
  72. </view>
  73. <view class="record-row">
  74. <view class="record-key">下班</view>
  75. <view class="record-value">
  76. <text class="record-time" :class="{ muted: !detail.actualOut }">{{
  77. detail.outText
  78. }}</text>
  79. <text class="record-tag">{{ detail.outLabel }}</text>
  80. </view>
  81. </view>
  82. <view v-if="detail.workText" class="record-row">
  83. <view class="record-key">工时</view>
  84. <view class="record-value muted">{{ detail.workText }}</view>
  85. </view>
  86. <view v-if="detail.statusLabel" class="record-row">
  87. <view class="record-key">状态</view>
  88. <view class="record-value">
  89. <text :class="['status-tag', statusTagCls(detail.statusLabel)]">{{
  90. detail.statusLabel
  91. }}</text>
  92. </view>
  93. </view>
  94. </view>
  95. <view class="fab-wrap safe-bottom">
  96. <button class="check-fab press" @click="onPunch">立即打卡</button>
  97. <text class="fab-extra press" @click="go('/pages/apply/form?type=check')"
  98. >发现异常?申请补卡 ›</text
  99. >
  100. </view>
  101. </view>
  102. </template>
  103. <script setup>
  104. import { computed, reactive, ref } from "vue";
  105. import { onShow } from "@dcloudio/uni-app";
  106. import { useAuthGuard } from "@/hooks/useAuthGuard";
  107. import { useCurrentUser } from "@/hooks/useCurrentUser";
  108. import { getCurrentUser } from "@/utils/storage";
  109. import { go } from "@/utils/router";
  110. import {
  111. queryDailyAttendance,
  112. queryMonthAttendance,
  113. submitPunch,
  114. formatMonth,
  115. formatDate,
  116. translateStatus,
  117. queryAttendanceRule,
  118. } from "@/api/attendance";
  119. import { getLocationWithFallback } from "@/utils/location";
  120. import { extractTime } from "@/utils/format";
  121. useAuthGuard();
  122. const currentUser = useCurrentUser();
  123. const weeks = ["日", "一", "二", "三", "四", "五", "六"];
  124. const currentMonth = ref(formatMonth(new Date()));
  125. const monthList = ref([]); // ESS 月度返回数组
  126. const selectedDate = ref(formatDate(new Date()));
  127. const dailyDetail = ref(null); // 管理端 daily 返回(带 actualIn/Out 时间)
  128. const monthLoading = ref(false);
  129. // 班次信息:ruleName + 上下班打卡时间(缓存住,detail 里用)
  130. const shiftInfo = reactive({ ruleName: '', startTime: '', endTime: '' });
  131. // 考勤规则加载状态:'loading' / 'ok' / 'empty'(未配置) / 'disabled'(不可打卡) / 'error'(接口失败)
  132. const ruleStatus = ref('loading');
  133. const ruleBlockedReason = ref(''); // 不可打卡的具体原因:草稿 / 自然到期 / 停用
  134. const ruleToastShown = ref(false);
  135. const monthLabel = computed(() => {
  136. const [y, m] = currentMonth.value.split("-");
  137. return `${y}年${Number(m)}月`;
  138. });
  139. const selectedLabel = computed(() => {
  140. if (selectedDate.value === formatDate(new Date())) return "今日";
  141. const [, m, d] = selectedDate.value.split("-");
  142. return `${Number(m)}月${Number(d)}日`;
  143. });
  144. const summary = computed(() => {
  145. const list = monthList.value || [];
  146. let workDays = 0;
  147. let attendanceDays = 0;
  148. let lateCount = 0;
  149. let abnormalCount = 0;
  150. for (const item of list) {
  151. const status = item.attendanceStatusList
  152. if (status === "REST") continue;
  153. workDays++
  154. if (status === "NORMAL") attendanceDays++
  155. if ((item.lateMinutes || 0) > 0) lateCount++
  156. if ((item.exceptionCount || 0) > 0) abnormalCount++
  157. }
  158. const rate = workDays > 0 ? `${((attendanceDays / workDays) * 100).toFixed(1)}%` : "0%"
  159. return { attendanceDays, lateCount, abnormalCount, attendanceRate: rate }
  160. });
  161. const grid = computed(() => {
  162. const [y, m] = currentMonth.value.split("-").map(Number);
  163. const firstDay = new Date(y, m - 1, 1).getDay();
  164. const totalDays = new Date(y, m, 0).getDate();
  165. const todayStr = formatDate(new Date());
  166. const byDate = new Map();
  167. for (const item of monthList.value || []) {
  168. const key = (item.attendanceDate || "").slice(0, 10);
  169. if (key) byDate.set(key, item);
  170. }
  171. const cells = [];
  172. for (let i = 0; i < firstDay; i++) cells.push({ blank: true });
  173. for (let d = 1; d <= totalDays; d++) {
  174. const ds = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
  175. const data = byDate.get(ds);
  176. cells.push({
  177. date: ds,
  178. day: d,
  179. data,
  180. isToday: ds === todayStr,
  181. dot: computeDot(data, ds === todayStr),
  182. dotCls: computeDotCls(data, ds === todayStr),
  183. });
  184. }
  185. while (cells.length % 7 !== 0) cells.push({ blank: true });
  186. return cells;
  187. });
  188. function computeDot(data, isToday) {
  189. if (!data) return isToday;
  190. const status = data.attendanceStatusList;
  191. if (isToday) return true;
  192. if ((data.exceptionCount || 0) > 0) return true;
  193. if (status === "NORMAL") return true;
  194. return false;
  195. }
  196. function computeDotCls(data, isToday) {
  197. if (!data) return isToday ? "today" : "";
  198. if (isToday) return "today";
  199. if ((data.exceptionCount || 0) > 0) return "abnormal";
  200. return "normal";
  201. }
  202. function cellClass(cell) {
  203. if (cell.blank) return "blank";
  204. const status = cell.data?.attendanceStatusList;
  205. const cls = [];
  206. if (cell.isToday) cls.push("today");
  207. if (status === "REST") cls.push("rest");
  208. else if (status === "LEAVE") cls.push("leave");
  209. else if ((cell.data?.exceptionCount || 0) > 0) cls.push("abnormal");
  210. return cls.join(" ");
  211. }
  212. const detail = computed(() => {
  213. const d = dailyDetail.value || {};
  214. const inTime = d.actualIn || null;
  215. const outTime = d.actualOut || null;
  216. const inText = inTime ? extractTime(inTime) : "--:--";
  217. const outText = outTime ? extractTime(outTime) : "--:--";
  218. const inLabel = inTime
  219. ? (d.lateMinutes > 0 ? `迟到 ${d.lateMinutes} 分钟` : "正常")
  220. : "未打卡";
  221. const outLabel = outTime
  222. ? (d.earlyMinutes > 0 ? `早退 ${d.earlyMinutes} 分钟` : "正常")
  223. : "未打卡";
  224. const start = d.scheduledStart || shiftInfo.startTime;
  225. const end = d.scheduledEnd || shiftInfo.endTime;
  226. const shiftText =
  227. ruleStatus.value === 'empty'
  228. ? "未配置考勤规则"
  229. : ruleStatus.value === 'disabled'
  230. ? `考勤规则${ruleBlockedReason.value || '不可用'}`
  231. : start && end
  232. ? `${shiftInfo.ruleName || "今日班次"} · ${extractTime(start)} - ${extractTime(end)}`
  233. : "休息日";
  234. let workText = "";
  235. if (d.actualWorkMinutes || d.requiredMinutes) {
  236. workText = `实际 ${d.actualWorkMinutes || 0} 分钟 / 应出勤 ${d.requiredMinutes || 0} 分钟`;
  237. if ((d.lateMinutes || 0) > 0) workText += ` · 迟到 ${d.lateMinutes} 分钟`;
  238. if ((d.earlyMinutes || 0) > 0) workText += ` · 早退 ${d.earlyMinutes} 分钟`;
  239. }
  240. const statusLabel = translateStatus(d.statusList);
  241. return { shiftText, inText, outText, inLabel, outLabel, workText, statusLabel, actualOut: d.actualOut };
  242. });
  243. function statusTagCls(label) {
  244. if (!label) return "";
  245. if (label.includes("正常")) return "status-approved";
  246. if (label.includes("迟到") || label.includes("早退") || label.includes("异常") || label.includes("缺勤"))
  247. return "status-rejected";
  248. return "status-pending";
  249. }
  250. async function loadMonth(month) {
  251. monthLoading.value = true;
  252. uni.showLoading({ title: "加载中..." });
  253. try {
  254. const list = await queryMonthAttendance(month);
  255. monthList.value = list;
  256. } catch (error) {
  257. uni.showToast({ title: error.message || "月历加载失败", icon: "none" });
  258. } finally {
  259. uni.hideLoading();
  260. monthLoading.value = false;
  261. }
  262. }
  263. async function selectDate(date) {
  264. selectedDate.value = date;
  265. try {
  266. const user = getCurrentUser() || currentUser || {};
  267. const userId = (user && user.raw && user.raw.userId) || (user && user.raw && user.raw.id) || (user && user.id) || "";
  268. dailyDetail.value = await queryDailyAttendance(userId, date);
  269. } catch (error) {
  270. uni.showToast({ title: error.message || "日期详情加载失败", icon: "none" });
  271. }
  272. }
  273. async function loadShiftInfo() {
  274. const result = await queryAttendanceRule();
  275. ruleStatus.value = result.status;
  276. ruleBlockedReason.value = result.blockedReason || '';
  277. // 空态:进入考勤页时弹一次 modal(每个 session 只弹一次)
  278. if (result.status === 'empty' && !ruleToastShown.value) {
  279. ruleToastShown.value = true;
  280. uni.showModal({
  281. title: "未配置考勤规则",
  282. content: "尚未为您配置考勤规则,请联系管理员在后台完成配置后再使用考勤功能。",
  283. showCancel: false,
  284. confirmText: "我知道了",
  285. });
  286. return;
  287. }
  288. // 不可打卡(exemptFromPunch / mobilePunchEnabled=false):弹 modal 告知
  289. if (result.status === 'disabled' && !ruleToastShown.value) {
  290. ruleToastShown.value = true;
  291. const reason = result.blockedReason || '不可用';
  292. uni.showModal({
  293. title: `考勤规则${reason}`,
  294. content: `您当前的考勤规则为「${reason}」状态,无法打卡。请联系管理员处理后再使用考勤功能。`,
  295. showCancel: false,
  296. confirmText: "我知道了",
  297. });
  298. return;
  299. }
  300. if (result.status === 'error') {
  301. if (!ruleToastShown.value) {
  302. ruleToastShown.value = true;
  303. uni.showToast({ title: "考勤规则加载失败", icon: "none" });
  304. }
  305. return;
  306. }
  307. const rule = result.rule;
  308. if (!rule) return;
  309. shiftInfo.ruleName = rule.ruleName || '';
  310. shiftInfo.startTime = rule.shiftStartTime || '';
  311. shiftInfo.endTime = rule.shiftEndTime || '';
  312. }
  313. async function refreshAll() {
  314. await loadMonth(currentMonth.value);
  315. await selectDate(selectedDate.value);
  316. }
  317. function changeMonth(step) {
  318. const [y, m] = currentMonth.value.split("-").map(Number);
  319. const next = new Date(y, m - 1 + step, 1);
  320. const target = formatMonth(next);
  321. const cur = currentMonth.value;
  322. // 边界:超过当前月(未来)就停在当前月
  323. if (target > formatMonth(new Date())) {
  324. uni.showToast({ title: "暂不支持查看未来月份", icon: "none" });
  325. return;
  326. }
  327. if (target === cur) return;
  328. currentMonth.value = target;
  329. loadMonth(target);
  330. }
  331. async function onPunch() {
  332. // 规则未配置 / 不可打卡:拦截
  333. if (ruleStatus.value === 'empty') {
  334. return uni.showToast({ title: "未配置考勤规则,请联系管理员", icon: "none" });
  335. }
  336. if (ruleStatus.value === 'disabled') {
  337. const reason = ruleBlockedReason.value || '不可用';
  338. return uni.showToast({ title: `考勤规则${reason},暂不能打卡`, icon: "none" });
  339. }
  340. uni.showLoading({ title: "定位中..." });
  341. try {
  342. const loc = await getLocationWithFallback();
  343. const time = new Date().toISOString();
  344. const id = await submitPunch({
  345. method: "GPS",
  346. time,
  347. lat: loc.latitude,
  348. lng: loc.longitude,
  349. accuracy: loc.accuracy,
  350. address: loc.address,
  351. });
  352. uni.hideLoading();
  353. uni.showToast({ title: `打卡成功`, icon: "success" });
  354. // 打卡成功后:选中今天并刷新月历 + 当日详情
  355. selectedDate.value = formatDate(new Date());
  356. await refreshAll();
  357. } catch (error) {
  358. uni.hideLoading();
  359. uni.showToast({ title: error.message || "打卡失败", icon: "none" });
  360. }
  361. }
  362. onShow(async () => {
  363. await loadShiftInfo();
  364. await loadMonth(currentMonth.value);
  365. await selectDate(selectedDate.value);
  366. });
  367. </script>
  368. <style scoped>
  369. .attendance-page {
  370. padding-bottom: 220rpx;
  371. }
  372. .month-head {
  373. height: 100rpx;
  374. display: flex;
  375. align-items: center;
  376. justify-content: center;
  377. gap: 60rpx;
  378. background: #fff;
  379. }
  380. .rule-banner {
  381. margin: 16rpx 24rpx 0;
  382. padding: 22rpx 26rpx;
  383. border-radius: 14rpx;
  384. display: flex;
  385. flex-direction: column;
  386. gap: 6rpx;
  387. }
  388. .rule-banner.empty { background: #fff7e6; color: #b46500; }
  389. .rule-banner.disabled { background: #f5f5f5; color: #5f6b7a; border: 1rpx solid #d9dde3; }
  390. .rule-banner.error { background: #fff1f0; color: #c2362d; }
  391. .rule-banner-title { font-size: 27rpx; font-weight: 600; }
  392. .rule-banner-sub { font-size: 22rpx; opacity: .85; }
  393. .month {
  394. font-size: 31rpx;
  395. font-weight: 600;
  396. }
  397. .month-arrow {
  398. color: #8b939f;
  399. font-size: 43rpx;
  400. }
  401. .summary {
  402. display: flex;
  403. padding: 28rpx 8rpx;
  404. }
  405. .summary > view {
  406. flex: 1;
  407. text-align: center;
  408. border-right: 1rpx solid #edf0f2;
  409. }
  410. .summary > view:last-child {
  411. border: 0;
  412. }
  413. .summary text {
  414. display: block;
  415. font-size: 31rpx;
  416. font-weight: 600;
  417. }
  418. .summary text + text {
  419. margin-top: 9rpx;
  420. color: #8b939f;
  421. font-size: 21rpx;
  422. font-weight: 400;
  423. }
  424. .summary text.danger {
  425. color: #ee4d4d;
  426. }
  427. .calendar {
  428. padding: 20rpx;
  429. }
  430. .week,
  431. .days {
  432. display: grid;
  433. grid-template-columns: repeat(7, 1fr);
  434. }
  435. .week text {
  436. height: 55rpx;
  437. text-align: center;
  438. color: #8b939f;
  439. font-size: 22rpx;
  440. }
  441. .day {
  442. position: relative;
  443. height: 76rpx;
  444. display: flex;
  445. flex-direction: column;
  446. align-items: center;
  447. justify-content: center;
  448. border-radius: 10rpx;
  449. }
  450. .day.rest {
  451. color: #b2b7be;
  452. }
  453. .day.leave {
  454. color: #b07900;
  455. background: #fff7e0;
  456. }
  457. .day.abnormal {
  458. color: #ee4d4d;
  459. background: #fff1f1;
  460. }
  461. .day.today {
  462. color: #fff;
  463. background: #1677ff;
  464. }
  465. .day-dot {
  466. position: absolute;
  467. bottom: 7rpx;
  468. width: 7rpx;
  469. height: 7rpx;
  470. border-radius: 50%;
  471. background: #07c160;
  472. }
  473. .day-dot.abnormal {
  474. background: #ee4d4d;
  475. }
  476. .day-dot.today {
  477. background: #fff;
  478. }
  479. .legend {
  480. display: flex;
  481. justify-content: center;
  482. gap: 26rpx;
  483. padding: 22rpx 0 6rpx;
  484. border-top: 1rpx solid #edf0f2;
  485. color: #8b939f;
  486. font-size: 21rpx;
  487. flex-wrap: wrap;
  488. }
  489. .legend i {
  490. display: inline-block;
  491. width: 11rpx;
  492. height: 11rpx;
  493. margin-right: 8rpx;
  494. border-radius: 50%;
  495. }
  496. .normal {
  497. background: #07c160;
  498. }
  499. .abnormal {
  500. background: #ee4d4d;
  501. }
  502. .leave {
  503. background: #f6bd16;
  504. }
  505. .rest {
  506. background: #cfd4da;
  507. }
  508. .section-title {
  509. display: flex;
  510. align-items: center;
  511. justify-content: space-between;
  512. padding: 24rpx 24rpx 12rpx;
  513. font-size: 27rpx;
  514. font-weight: 600;
  515. color: #1f2329;
  516. }
  517. .record {
  518. margin: 0 24rpx;
  519. padding: 12rpx 28rpx;
  520. }
  521. .record-row {
  522. display: flex;
  523. align-items: center;
  524. min-height: 84rpx;
  525. border-bottom: 1rpx solid #f2f4f7;
  526. }
  527. .record-row:last-child {
  528. border-bottom: 0;
  529. }
  530. .record-key {
  531. width: 96rpx;
  532. color: #8b939f;
  533. font-size: 25rpx;
  534. }
  535. .record-value {
  536. flex: 1;
  537. display: flex;
  538. align-items: center;
  539. gap: 16rpx;
  540. font-size: 27rpx;
  541. }
  542. .record-value.muted {
  543. color: #8b939f;
  544. font-size: 23rpx;
  545. }
  546. .record-time {
  547. font-size: 31rpx;
  548. font-weight: 600;
  549. }
  550. .record-time.muted {
  551. color: #c4c8ce;
  552. font-weight: 400;
  553. }
  554. .record-tag {
  555. font-size: 22rpx;
  556. color: #8b939f;
  557. }
  558. .status-tag {
  559. display: inline-block;
  560. height: 32rpx;
  561. padding: 0 14rpx;
  562. border-radius: 16rpx;
  563. font-size: 22rpx;
  564. line-height: 32rpx;
  565. }
  566. .status-tag.status-approved {
  567. color: #07a44d;
  568. background: #e8f8ef;
  569. }
  570. .status-tag.status-rejected {
  571. color: #ee4d4d;
  572. background: #ffebed;
  573. }
  574. .status-tag.status-pending {
  575. color: #b07900;
  576. background: #fff7e0;
  577. }
  578. .fab-wrap {
  579. position: fixed;
  580. left: 0;
  581. right: 0;
  582. bottom: 0;
  583. padding: 16rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
  584. background: #fff;
  585. border-top: 1rpx solid #edf0f2;
  586. display: flex;
  587. flex-direction: column;
  588. align-items: center;
  589. gap: 10rpx;
  590. }
  591. .check-fab {
  592. width: 100%;
  593. height: 88rpx;
  594. border-radius: 44rpx;
  595. background: #1677ff;
  596. color: #fff;
  597. font-size: 30rpx;
  598. font-weight: 600;
  599. display: flex;
  600. align-items: center;
  601. justify-content: center;
  602. border: 0;
  603. box-shadow: 0 10rpx 24rpx rgba(22, 119, 255, 0.24);
  604. }
  605. .check-fab::after {
  606. border: 0;
  607. }
  608. .fab-extra {
  609. font-size: 23rpx;
  610. color: #1677ff;
  611. }
  612. </style>