| | |
| | | <script lang="ts" setup> |
| | | import type { AttendanceClockLocation, AttendanceClockResponse } from '#/api/hrm/attendance/record'; |
| | | |
| | | import { ref } from 'vue'; |
| | | |
| | | import { useVbenModal } from '#/packages/effects/common-ui/src'; |
| | |
| | | |
| | | const emit = defineEmits(['success']); |
| | | |
| | | const location = ref('公司前台'); |
| | | const loading = ref(false); |
| | | const DEFAULT_LOCATION = '公司前台'; |
| | | |
| | | /** 上班打卡 */ |
| | | async function handleClockIn() { |
| | | const location = ref(DEFAULT_LOCATION); |
| | | const loading = ref(false); |
| | | const locating = ref(false); |
| | | |
| | | /** |
| | | * 获取浏览器定位并统一为 WGS84 交给后端校验。 |
| | | * 定位不可用(不支持、拒绝授权、非 HTTPS)时返回 null, |
| | | * 由后端按考勤规则决定是否允许无定位打卡。 |
| | | */ |
| | | function getCurrentLocation(): Promise<AttendanceClockLocation | null> { |
| | | if (!navigator.geolocation) { |
| | | return Promise.resolve(null); |
| | | } |
| | | return new Promise((resolve) => { |
| | | navigator.geolocation.getCurrentPosition( |
| | | (position) => |
| | | resolve({ |
| | | longitude: Number(position.coords.longitude.toFixed(7)), |
| | | latitude: Number(position.coords.latitude.toFixed(7)), |
| | | accuracy: Number(position.coords.accuracy.toFixed(2)), |
| | | coordinateSystem: 'WGS84', |
| | | }), |
| | | () => resolve(null), |
| | | { enableHighAccuracy: true, maximumAge: 0, timeout: 15_000 }, |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | /** 未启用定位范围校验时后端不返回距离,直接提示成功 */ |
| | | function buildSuccessMessage( |
| | | action: string, |
| | | result?: AttendanceClockResponse, |
| | | ) { |
| | | if (result?.distanceMeters === null || result?.distanceMeters === undefined) { |
| | | return `${action}成功`; |
| | | } |
| | | const place = result.locationName ? `距${result.locationName}` : '距打卡地点'; |
| | | return `${action}成功,${place} ${Number(result.distanceMeters).toFixed(1)} 米`; |
| | | } |
| | | |
| | | async function submitClock(action: 'in' | 'out') { |
| | | if (loading.value) { |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | await clockIn({ location: location.value }); |
| | | message.success('上班打卡成功'); |
| | | locating.value = true; |
| | | const position = await getCurrentLocation(); |
| | | locating.value = false; |
| | | if (!position) { |
| | | message.warning( |
| | | '未获取到定位,将按打卡地点文本提交;若考勤规则要求定位,请开启定位权限后重试(非 HTTPS 环境浏览器会禁用定位)', |
| | | ); |
| | | } |
| | | const locationText = location.value.trim() || undefined; |
| | | const result = |
| | | action === 'in' |
| | | ? await clockIn(position, locationText) |
| | | : await clockOut(position, locationText); |
| | | message.success( |
| | | buildSuccessMessage(action === 'in' ? '上班打卡' : '下班打卡', result), |
| | | ); |
| | | emit('success'); |
| | | } catch (e: any) { |
| | | message.error(e.message || '打卡失败'); |
| | | } catch (error: any) { |
| | | // 接口错误已由请求拦截器统一提示,这里只兜底本地异常,避免重复弹出 |
| | | const handledByInterceptor = Boolean( |
| | | error?.response || error?.data || error?.isAxiosError || error?.code, |
| | | ); |
| | | if (!handledByInterceptor) { |
| | | message.error(error?.message || '打卡失败'); |
| | | } |
| | | } finally { |
| | | locating.value = false; |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | /** 上班打卡 */ |
| | | function handleClockIn() { |
| | | return submitClock('in'); |
| | | } |
| | | |
| | | /** 下班打卡 */ |
| | | async function handleClockOut() { |
| | | loading.value = true; |
| | | try { |
| | | await clockOut({ location: location.value }); |
| | | message.success('下班打卡成功'); |
| | | emit('success'); |
| | | } catch (e: any) { |
| | | message.error(e.message || '打卡失败'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | function handleClockOut() { |
| | | return submitClock('out'); |
| | | } |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | const [Modal] = useVbenModal({ |
| | | onOpenChange(isOpen: boolean) { |
| | | if (!isOpen) { |
| | | location.value = '公司前台'; |
| | | location.value = DEFAULT_LOCATION; |
| | | } |
| | | }, |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal title="打卡" class="w-[400px]"> |
| | | <Modal title="打卡" class="w-[420px]"> |
| | | <div class="p-4"> |
| | | <div class="mb-4"> |
| | | <label class="mr-2">打卡地点:</label> |
| | | <a-input |
| | | v-model:value="location" |
| | | placeholder="请输入打卡地点" |
| | | style="width: 200px" |
| | | style="width: 220px" |
| | | /> |
| | | </div> |
| | | <div class="mb-4 text-xs text-gray-500"> |
| | | 点击打卡后会自动获取当前 GPS 位置,并按考勤规则中的地点与允许半径校验;浏览器定位不可用时,将按上方地点文本提交。 |
| | | </div> |
| | | <div class="flex gap-4"> |
| | | <a-button |
| | | type="primary" |
| | | size="large" |
| | | :loading="loading" |
| | | :disabled="loading" |
| | | @click="handleClockIn" |
| | | > |
| | | 上班打卡 |
| | |
| | | <a-button |
| | | size="large" |
| | | :loading="loading" |
| | | :disabled="loading" |
| | | @click="handleClockOut" |
| | | > |
| | | 下班打卡 |
| | | </a-button> |
| | | </div> |
| | | <div v-if="locating" class="mt-2 text-xs text-gray-400"> |
| | | 正在获取定位… |
| | | </div> |
| | | </div> |
| | | </Modal> |
| | | </template> |
| | | </template> |