Merge branch 'develop' of https://github.com/Frappet/bma-ehr-checkin into develop
All checks were successful
Build & Deploy on Dev / build (push) Successful in 4m32s

This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-05-15 14:02:06 +07:00
commit d4f53653d2
9 changed files with 899 additions and 124 deletions

View file

@ -1,52 +1,171 @@
import { useQuasar } from 'quasar'
import { onBeforeUnmount, ref } from 'vue'
import { usePrivacyStore } from '@/stores/privacy'
type BrowserPermissionState = PermissionState | 'unsupported'
type PermissionQueryName = 'camera' | 'geolocation'
export function usePermissions() {
const $q = useQuasar()
const privacyStore = usePrivacyStore()
const cameraPermissionState = ref<BrowserPermissionState>('prompt')
const locationPermissionState = ref<BrowserPermissionState>('prompt')
// const checkCameraPermission = (): boolean => {
// if (!privacyStore.isAccepted) {
// privacyStore.modalPrivacy = true
// $q.notify({
// type: 'warning',
// message: 'กรุณายอมรับนโยบายคุ้มครองข้อมูลส่วนบุคคลก่อนใช้งานกล้อง',
// position: 'top',
// })
// return false
// }
// return true
// }
let cameraPermissionStatus: PermissionStatus | null = null
let locationPermissionStatus: PermissionStatus | null = null
// const checkLocationPermission = (): boolean => {
// if (!privacyStore.isAccepted) {
// privacyStore.modalPrivacy = true
// $q.notify({
// type: 'warning',
// message: 'กรุณายอมรับนโยบายคุ้มครองข้อมูลส่วนบุคคลก่อนใช้งานแผนที่',
// position: 'top',
// })
// return false
// }
// return true
// }
const isPermissionsApiSupported = () =>
typeof navigator !== 'undefined' && 'permissions' in navigator
const setPermissionState = (
target: typeof cameraPermissionState | typeof locationPermissionState,
state: BrowserPermissionState
) => {
target.value = state
}
const setPermissionChangeListener = (
name: PermissionQueryName,
status: PermissionStatus
) => {
status.onchange = () => {
const target =
name === 'camera' ? cameraPermissionState : locationPermissionState
setPermissionState(target, status.state)
}
}
async function queryPermissionState(name: PermissionQueryName) {
if (!isPermissionsApiSupported()) {
return null
}
try {
return await navigator.permissions.query({ name } as PermissionDescriptor)
} catch (error) {
return null
}
}
async function syncPermissionState(name: PermissionQueryName) {
const target =
name === 'camera' ? cameraPermissionState : locationPermissionState
const previousStatus =
name === 'camera' ? cameraPermissionStatus : locationPermissionStatus
if (previousStatus) {
previousStatus.onchange = null
}
const status = await queryPermissionState(name)
if (!status) {
if (target.value === 'prompt') {
setPermissionState(target, 'unsupported')
}
return
}
if (name === 'camera') {
cameraPermissionStatus = status
} else {
locationPermissionStatus = status
}
setPermissionState(target, status.state)
setPermissionChangeListener(name, status)
}
async function syncPermissionStates() {
await Promise.all([
syncPermissionState('camera'),
syncPermissionState('geolocation'),
])
}
const checkPrivacyAccepted = (): boolean => {
if (!privacyStore.isAccepted) {
privacyStore.modalPrivacy = true
// $q.notify({
// type: 'warning',
// message: 'กรุณายอมรับนโยบายคุ้มครองข้อมูลส่วนบุคคลก่อนใช้งาน',
// position: 'center',
// })
return false
}
return true
}
async function requestCameraPermission() {
if (!checkPrivacyAccepted()) {
return false
}
if (cameraPermissionState.value === 'granted') {
return true
}
if (!navigator.mediaDevices?.getUserMedia) {
setPermissionState(cameraPermissionState, 'unsupported')
return false
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user' },
audio: false,
})
stream.getTracks().forEach((track) => track.stop())
setPermissionState(cameraPermissionState, 'granted')
return true
} catch (error) {
setPermissionState(cameraPermissionState, 'denied')
return false
} finally {
await syncPermissionState('camera')
}
}
async function requestLocationPermission() {
if (!checkPrivacyAccepted()) {
return false
}
if (locationPermissionState.value === 'granted') {
return true
}
if (!navigator.geolocation) {
setPermissionState(locationPermissionState, 'unsupported')
return false
}
return new Promise<boolean>((resolve) => {
navigator.geolocation.getCurrentPosition(
async () => {
setPermissionState(locationPermissionState, 'granted')
await syncPermissionState('geolocation')
resolve(true)
},
async () => {
setPermissionState(locationPermissionState, 'denied')
await syncPermissionState('geolocation')
resolve(false)
}
)
})
}
onBeforeUnmount(() => {
if (cameraPermissionStatus) {
cameraPermissionStatus.onchange = null
}
if (locationPermissionStatus) {
locationPermissionStatus.onchange = null
}
})
return {
// checkCameraPermission,
// checkLocationPermission,
cameraPermissionState,
locationPermissionState,
checkPrivacyAccepted,
syncPermissionStates,
requestCameraPermission,
requestLocationPermission,
}
}

View file

@ -1,5 +1,13 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch, onBeforeUnmount, computed } from 'vue'
import {
ref,
reactive,
onMounted,
watch,
onBeforeUnmount,
computed,
nextTick,
} from 'vue'
import { useQuasar } from 'quasar'
import { format } from 'date-fns'
import Camera from 'simple-vue-camera'
@ -18,7 +26,12 @@ import MapCheck from '@/components/AscGISMap.vue'
const mixin = useCounterMixin()
const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin
const $q = useQuasar()
const { checkPrivacyAccepted } = usePermissions()
const {
cameraPermissionState,
checkPrivacyAccepted,
syncPermissionStates,
requestCameraPermission,
} = usePermissions()
const privacyStore = usePrivacyStore()
const positionKeycloakStore = usePositionKeycloakStore()
const MOCK_CHECK_DELAY_MS = 800
@ -50,10 +63,40 @@ const msgCheckTime = ref<string>('') // ข้อความแจ้งเต
const isDisabledCheckTime = ref<boolean>(true) //
const isErr = ref<boolean | null>(null) //
const endTimeAfternoon = ref<string>('12:00:00') //
const crossDayCheckoutWarning = ref<string>('')
const isLoadingCheckTime = ref<boolean>(false) //
const disabledBtn = ref<boolean>(false)
function parseApiDateTime(value?: string | null) {
if (!value) return null
const normalizedValue = value.includes('T') ? value : value.replace(' ', 'T')
const parsedDate = new Date(normalizedValue)
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
}
function isSameCalendarDay(firstDate: Date, secondDate: Date) {
return (
firstDate.getFullYear() === secondDate.getFullYear() &&
firstDate.getMonth() === secondDate.getMonth() &&
firstDate.getDate() === secondDate.getDate()
)
}
function updateCrossDayCheckoutWarning(checkInTime?: string | null) {
crossDayCheckoutWarning.value = ''
const checkInDate = parseApiDateTime(checkInTime)
if (!checkInDate) return
if (!isSameCalendarDay(checkInDate, new Date())) {
crossDayCheckoutWarning.value = `คุณยังไม่ได้ลงเวลาออกของวันที่ ${date2Thai(
checkInDate
)} กรณาลงเวลาออกกอน งจะลงเวลาเขาของวนนได`
}
}
/**
* fetch เชคเวลาตองลงเวลาเขาหรอออกงาน
*/
@ -66,6 +109,7 @@ async function fetchCheckTime(load: any = true) {
statusCheckin.value = data.checkInId ? false : true
checkInId.value = data.checkInId ? data.checkInId : ''
updateCrossDayCheckoutWarning(data.checkInTime)
endTimeAfternoon.value = data.endTimeAfternoon
isDisabledCheckTime.value = isDisabledCheckTime.value ? true : false
})
@ -89,7 +133,7 @@ const formattedM = ref()
const formattedH = ref()
const formattedHH = ref()
const formattedA = ref()
const clockInterval = ref<number | undefined>()
const clockInterval = ref<ReturnType<typeof setInterval> | undefined>()
/** function อัพเดทเวลา*/
function updateClock() {
@ -118,6 +162,7 @@ const useLocation = ref<string>('') //กรณีเลือกนอกสถ
const fileImg = ref<any>() //
const remark = ref<string>('') //
const checkInId = ref<string>('') //Id check-in
const MIN_CAPTURE_FILE_SIZE_BYTES = 5 * 1024
/**
* funciton เรยกพดละต ดลองต
@ -150,6 +195,131 @@ function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getCameraVideoElement() {
return camera.value?.video
}
function prepareInlineCameraVideo(videoElement: HTMLVideoElement) {
videoElement.autoplay = true
videoElement.muted = true
videoElement.setAttribute('autoplay', '')
videoElement.setAttribute('muted', '')
videoElement.setAttribute('playsinline', '')
videoElement.setAttribute('webkit-playsinline', 'true')
}
function isInlineCameraPreviewReady(videoElement: HTMLVideoElement) {
return (
Boolean(videoElement.srcObject) &&
videoElement.videoWidth > 0 &&
videoElement.videoHeight > 0 &&
videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
)
}
async function waitForCameraInstance() {
for (let attempt = 0; attempt < 10; attempt += 1) {
await nextTick()
if (camera.value) {
return camera.value
}
await wait(50)
}
return null
}
async function stopInlineCamera() {
camera.value?.stop()
cameraIsOn.value = false
}
async function remountInlineCameraComponent() {
await stopInlineCamera()
cameraMountKey.value += 1
await nextTick()
await wait(INLINE_CAMERA_REMOUNT_DELAY_MS)
}
async function ensureInlineCameraPreviewReady() {
const deadline = Date.now() + INLINE_CAMERA_PREVIEW_TIMEOUT_MS
let lastPlaybackError: unknown = null
while (Date.now() < deadline) {
const videoElement = getCameraVideoElement()
if (videoElement) {
prepareInlineCameraVideo(videoElement)
if (isInlineCameraPreviewReady(videoElement)) {
return
}
if (videoElement.srcObject) {
try {
await videoElement.play()
} catch (error) {
lastPlaybackError = error
}
}
}
await wait(INLINE_CAMERA_PREVIEW_POLL_MS)
}
if (lastPlaybackError instanceof Error) {
throw lastPlaybackError
}
throw new Error('Inline camera preview did not become ready in time')
}
async function syncActiveCameraDevices() {
const devices: any = await camera.value?.devices(['videoinput'])
if (!devices) {
return
}
availableCameras.value = devices
const activeId = camera.value?.currentDeviceID()
const activeIdx = activeId
? devices.findIndex((device: any) => device.deviceId === activeId)
: -1
currentCameraIndex.value = activeIdx >= 0 ? activeIdx : 0
currentCameraType.value = resolveCameraType(
devices[currentCameraIndex.value]?.label || '',
'front'
)
}
async function startInlineCamera(recoveryAttempt = 0): Promise<void> {
const cameraInstance = await waitForCameraInstance()
if (!cameraInstance) {
throw new Error('Camera component is unavailable')
}
try {
currentCameraType.value = 'front'
await cameraInstance.start()
await ensureInlineCameraPreviewReady()
await syncActiveCameraDevices()
cameraIsOn.value = true
} catch (error) {
await stopInlineCamera()
if (recoveryAttempt < MAX_INLINE_CAMERA_RECOVERY_ATTEMPTS) {
await remountInlineCameraComponent()
return startInlineCamera(recoveryAttempt + 1)
}
throw error
}
}
async function getDelayedFreshPosition() {
const firstPosition = await getCurrentPositionAsync({
enableHighAccuracy: true,
@ -215,11 +385,227 @@ const cameraIsOn = ref<boolean>(false)
const img = ref<any>(undefined)
const photoWidth = ref<number>(350)
const photoHeight = ref<number>(350)
const cameraMountKey = ref<number>(0)
const availableCameras = ref<any[]>([])
const currentCameraIndex = ref<number>(0)
const currentCameraType = ref<'front' | 'back' | 'unknown'>('unknown')
const intervalId = ref<number | undefined>(undefined) // interval
/**
* Detect real iOS/iPadOS devices. Desktop mobile emulation can spoof the
* iPhone user-agent, so rely on platform + touch capability instead.
*/
const isIOSDevice: boolean = (() => {
const platform = navigator.platform || ''
const hasTouchPoints = navigator.maxTouchPoints > 1
return (
/iPhone|iPad|iPod/.test(platform) ||
(platform === 'MacIntel' && hasTouchPoints)
)
})()
const isAndroidDevice: boolean = /Android/i.test(navigator.userAgent)
/**
* `preferNativePhotoCapture` starts false on every device inline camera is
* always attempted first. It is set to true only by
* `switchToNativePhotoCapture()` / `enableNativePhotoCaptureFallback()` when
* inline capture is proven to be unsupported or has failed. This covers iOS
* Safari too: getUserMedia is supported there since iOS 14.3, so we try
* inline first and fall back gracefully on first failure.
*/
const preferNativePhotoCapture = ref<boolean>(false)
const useNativePhotoCapture = computed(() => preferNativePhotoCapture.value)
const centeredPreviewImageStyle = Object.freeze({
objectFit: 'cover',
objectPosition: 'center center',
})
const INLINE_CAMERA_PREVIEW_TIMEOUT_MS = isIOSDevice ? 2500 : 1800
const INLINE_CAMERA_PREVIEW_POLL_MS = 120
const INLINE_CAMERA_REMOUNT_DELAY_MS = 120
const MAX_INLINE_CAMERA_RECOVERY_ATTEMPTS = 1
/** Ref for the hidden file input used as native camera fallback */
const nativePhotoInput = ref<HTMLInputElement | null>(null)
const DEFAULT_NORMALIZED_IMAGE_QUALITY = 0.92
interface NormalizePhotoOptions {
fileName?: string
mirrorHorizontally?: boolean
quality?: number
}
function clearSelectedPhoto() {
if (img.value) {
URL.revokeObjectURL(img.value)
img.value = undefined
}
fileImg.value = undefined
}
async function canDecodeImageBlob(blob: Blob) {
if (!blob.type.startsWith('image/')) {
return false
}
const imageUrl = URL.createObjectURL(blob)
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const element = new Image()
element.onload = () => {
if (element.naturalWidth > 0 && element.naturalHeight > 0) {
resolve(element)
} else {
reject(new Error('Image has invalid dimensions'))
}
}
element.onerror = () => reject(new Error('Image decode failed'))
element.src = imageUrl
})
return image.naturalWidth > 0 && image.naturalHeight > 0
} catch (error) {
console.error('Error decoding captured image:', error)
return false
} finally {
URL.revokeObjectURL(imageUrl)
}
}
async function isValidCapturedImage(blob: Blob) {
if (blob.size < MIN_CAPTURE_FILE_SIZE_BYTES) {
return false
}
return canDecodeImageBlob(blob)
}
async function loadImageFromBlob(blob: Blob) {
const imageUrl = URL.createObjectURL(blob)
try {
return await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('Image load failed'))
image.src = imageUrl
})
} finally {
URL.revokeObjectURL(imageUrl)
}
}
async function normalizePhotoFile(
source: Blob,
options: NormalizePhotoOptions = {}
) {
const fileName =
options.fileName ??
(source instanceof File && source.name
? source.name
: `photo_${Date.now()}.jpg`)
const imageType =
source.type && source.type.startsWith('image/') ? source.type : 'image/jpeg'
if (!options.mirrorHorizontally) {
if (source instanceof File && source.name === fileName) {
return source
}
return new File([source], fileName, { type: imageType })
}
const image = await loadImageFromBlob(source)
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext('2d')
if (!context) {
throw new Error('Canvas context unavailable')
}
context.translate(canvas.width, 0)
context.scale(-1, 1)
context.drawImage(image, 0, 0, canvas.width, canvas.height)
const normalizedBlob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(
resolve,
imageType,
options.quality ?? DEFAULT_NORMALIZED_IMAGE_QUALITY
)
})
if (!normalizedBlob) {
throw new Error('Failed to normalize image')
}
return new File([normalizedBlob], fileName, { type: imageType })
}
async function assignSelectedPhoto(file: File) {
clearSelectedPhoto()
fileImg.value = file
img.value = URL.createObjectURL(file)
}
function openNativePhotoCapture() {
void stopInlineCamera()
if (nativePhotoInput.value) {
nativePhotoInput.value.value = ''
nativePhotoInput.value.click()
}
}
function enableNativePhotoCaptureFallback() {
preferNativePhotoCapture.value = true
void stopInlineCamera()
clearSelectedPhoto()
}
function switchToNativePhotoCapture() {
enableNativePhotoCaptureFallback()
openNativePhotoCapture()
}
/**
* Called when the user selects / captures a photo via the native file-input
* fallback. Keeps the same `img` + `fileImg` state that capturePhoto()
* sets so the upload flow is identical.
*/
async function onNativePhotoSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (!(await isValidCapturedImage(file))) {
clearSelectedPhoto()
messageError($q, '', 'ไม่สามารถอ่านไฟล์รูปภาพได้ กรุณาถ่ายรูปใหม่อีกครั้ง')
return
}
try {
const normalizedFile = await normalizePhotoFile(file, {
fileName: file.name,
mirrorHorizontally: !isIOSDevice,
})
if (!(await isValidCapturedImage(normalizedFile))) {
throw new Error('Normalized image is invalid')
}
await assignSelectedPhoto(normalizedFile)
} catch (error) {
console.error('Error normalizing native photo:', error)
clearSelectedPhoto()
messageError($q, error, 'ไม่สามารถเตรียมรูปภาพได้ กรุณาถ่ายรูปใหม่อีกครั้ง')
}
}
const intervalId = ref<ReturnType<typeof setInterval> | undefined>(undefined)
/**
* เรมจาก onMounted #1 เช status
@ -339,6 +725,14 @@ function identifyCameraType(label: string): 'front' | 'back' | 'unknown' {
return 'unknown'
}
function resolveCameraType(
label: string,
fallbackType: 'front' | 'back'
): 'front' | 'back' {
const detectedType = identifyCameraType(label)
return detectedType === 'unknown' ? fallbackType : detectedType
}
/** function เปิดกล้อง*/
async function openCamera() {
// privacy
@ -346,20 +740,35 @@ async function openCamera() {
return
}
if (!isPermissionCameraDenied.value) {
// change camera device
if (cameraIsOn.value) {
camera.value?.stop()
} else {
await camera.value?.start()
const devices: any = await camera.value?.devices(['videoinput'])
if (devices) {
availableCameras.value = devices
await changeCamera()
}
// If inline capture previously failed on this device, fall back to the
// native still-photo picker instead of reopening the live camera.
if (useNativePhotoCapture.value) {
openNativePhotoCapture()
return
}
if (cameraIsOn.value) {
await stopInlineCamera()
return
}
if (
(isIOSDevice || isAndroidDevice) &&
!useNativePhotoCapture.value &&
(cameraPermissionState.value === 'denied' ||
cameraPermissionState.value === 'unsupported')
) {
switchToNativePhotoCapture()
return
}
const hasCameraPermission = await requestCameraPermission()
if (!hasCameraPermission) {
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
switchToNativePhotoCapture()
return
}
cameraIsOn.value = !cameraIsOn.value
} else {
messageError(
$q,
'',
@ -367,6 +776,19 @@ async function openCamera() {
)
return
}
try {
await startInlineCamera()
} catch (error) {
console.error('Error opening camera:', error)
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
switchToNativePhotoCapture()
return
}
messageError($q, error, 'ไม่สามารถเปิดกล้องได้ กรุณาลองใหม่อีกครั้ง')
}
}
/** change camera device*/
@ -383,9 +805,16 @@ async function changeCamera(targetCameraType?: 'front' | 'back') {
if (devices.length === 1 || !targetCameraType) {
const device = devices[0]
const fallbackType =
targetCameraType ??
(currentCameraType.value === 'back' ? 'back' : 'front')
await camera.value?.changeCamera(device.deviceId)
await ensureInlineCameraPreviewReady()
currentCameraIndex.value = 0
currentCameraType.value = identifyCameraType(device.label || '')
currentCameraType.value = resolveCameraType(
device.label || '',
fallbackType
)
return
}
@ -397,14 +826,22 @@ async function changeCamera(targetCameraType?: 'front' | 'back') {
if (matchingCameras.length > 0) {
const targetDevice = matchingCameras[0]
await camera.value?.changeCamera(targetDevice.deviceId)
await ensureInlineCameraPreviewReady()
currentCameraIndex.value = devices.indexOf(targetDevice)
currentCameraType.value = targetCameraType
currentCameraType.value = resolveCameraType(
targetDevice.label || '',
targetCameraType
)
} else {
const nextIndex = (currentCameraIndex.value + 1) % devices.length
const nextDevice = devices[nextIndex]
await camera.value?.changeCamera(nextDevice.deviceId)
await ensureInlineCameraPreviewReady()
currentCameraIndex.value = nextIndex
currentCameraType.value = identifyCameraType(nextDevice.label || '')
currentCameraType.value = resolveCameraType(
nextDevice.label || '',
targetCameraType
)
}
} catch (error) {
console.error('Error switching camera:', error)
@ -420,34 +857,75 @@ async function switchCamera() {
// 2 ()
const targetIndex = currentCameraIndex.value === 0 ? 1 : 0
const targetDevice = availableCameras.value[targetIndex]
const fallbackType = currentCameraType.value === 'back' ? 'front' : 'back'
await camera.value?.changeCamera(targetDevice.deviceId)
await ensureInlineCameraPreviewReady()
currentCameraIndex.value = targetIndex
currentCameraType.value = identifyCameraType(targetDevice.label || '')
currentCameraType.value = resolveCameraType(
targetDevice.label || '',
fallbackType
)
}
/** function ถ่ายรูป*/
async function capturePhoto() {
const imageBlob: any = await camera.value?.snapshot(
{ width: photoWidth.value, height: photoHeight.value },
'image/png',
0.5
)
if (!imageBlob) return
const fileName = 'photo.jpg'
//
const file = new File([imageBlob], fileName, { type: 'image/png' })
fileImg.value = file
//
camera.value?.stop()
const url = URL.createObjectURL(imageBlob)
img.value = url
try {
const imageBlob: any = await camera.value?.snapshot(
{ width: photoWidth.value, height: photoHeight.value },
'image/jpeg',
0.8
)
if (!imageBlob || !(await isValidCapturedImage(imageBlob))) {
if (isIOSDevice || isAndroidDevice) {
switchToNativePhotoCapture()
return
}
messageError($q, '', 'ไม่สามารถถ่ายรูปได้ กรุณาลองใหม่อีกครั้ง')
return
}
const fileName = `photo_${Date.now()}.jpg`
const normalizedFile = await normalizePhotoFile(imageBlob, {
fileName,
mirrorHorizontally: false,
quality: 0.8,
})
if (!(await isValidCapturedImage(normalizedFile))) {
throw new Error('Normalized image is invalid')
}
await assignSelectedPhoto(normalizedFile)
//
await stopInlineCamera()
} catch (error) {
console.error('Error capturing photo:', error)
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
switchToNativePhotoCapture()
return
}
messageError($q, error, 'ไม่สามารถถ่ายรูปได้ กรุณาลองใหม่อีกครั้ง')
}
}
/** function เปลี่ยนรูปภาพ*/
function refreshPhoto() {
img.value = undefined
camera.value?.start()
async function refreshPhoto() {
try {
clearSelectedPhoto()
if (useNativePhotoCapture.value) {
openNativePhotoCapture()
return
}
await startInlineCamera()
} catch (error) {
console.error('Error refreshing photo:', error)
messageError($q, error, 'ไม่สามารถเปิดกล้องได้ กรุณาลองใหม่อีกครั้ง')
}
}
/** ref validate*/
@ -509,6 +987,7 @@ const mapRef = computed(() =>
)
const timeChickin = ref<string>('') //,
const displayedCheckDate = ref<Date>(new Date())
/** function ยืนยันการลงเวลาเข้า - ออก*/
async function confirm() {
@ -524,6 +1003,16 @@ async function confirm() {
return
}
if (!fileImg.value || !(await isValidCapturedImage(fileImg.value))) {
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
enableNativePhotoCaptureFallback()
}
clearSelectedPhoto()
disabledBtn.value = false
messageError($q, '', 'รูปภาพไม่สมบูรณ์ กรุณาถ่ายรูปใหม่อีกครั้งก่อนลงเวลา')
return
}
showLoader()
const isLocation = workplace.value === 'in-place' //*true , false
const locationName = workplace.value === 'in-place' ? '' : useLocation.value
@ -541,6 +1030,7 @@ async function confirm() {
.then(async (res) => {
const data = await res.data.result
const dateObject = new Date(data.date)
displayedCheckDate.value = dateObject
checkDate.value = data.date ? true : false
const options: Intl.DateTimeFormatOptions = {
hour12: false,
@ -627,7 +1117,7 @@ async function onClickConfirm() {
try {
showLoader()
cameraIsOn.value = false
img.value = undefined
clearSelectedPhoto()
modalTime.value = false
if (!statusCheckin.value) {
statusCheckin.value = true
@ -665,13 +1155,8 @@ const inQueue = ref<boolean>(false)
//
function resetCameraAndImage() {
if (img.value) {
img.value = undefined
}
if (cameraIsOn.value && camera.value) {
camera.value.stop()
cameraIsOn.value = false
}
clearSelectedPhoto()
void stopInlineCamera()
}
//
@ -697,16 +1182,6 @@ function handleVisibilityChange() {
}
}
const isPermissionCameraDenied = ref<boolean>(false) //
async function requestCamera() {
try {
await navigator.mediaDevices.getUserMedia({ video: true })
} catch (err) {
isPermissionCameraDenied.value = true
}
}
/** Hook*/
onMounted(async () => {
// clock
@ -723,10 +1198,11 @@ onMounted(async () => {
startChecking()
}
await syncPermissionStates()
// privacy
if (privacyStore.isAccepted) {
mapRef.value?.requestLocationPermission()
requestCamera()
}
})
@ -765,10 +1241,10 @@ onBeforeUnmount(() => {
watch(
() => privacyStore.isAccepted,
(newVal) => {
async (newVal) => {
if (newVal) {
await syncPermissionStates()
mapRef.value?.requestLocationPermission()
requestCamera()
}
}
)
@ -854,6 +1330,16 @@ watch(
class="col-xs-12 col-md-11 q-pa-md q-col-gutter-md row q-pt-lg"
v-if="!isDisabledCheckTime"
>
<div v-if="crossDayCheckoutWarning" class="col-12">
<q-banner
rounded
class="bg-white text-orange-10 warning-banner"
>
<q-icon name="warning" color="warning" size="20px" />
{{ crossDayCheckoutWarning }}
</q-banner>
</div>
<div class="col-xs-12 col-sm-8 gt-xs">
<div class="col-12">
<MapCheck
@ -863,7 +1349,7 @@ watch(
/>
</div>
</div>
<div class="col-xs-12 col-sm-4 gt-xs">
<div v-if="$q.screen.gt.xs" class="col-xs-12 col-sm-4">
<q-card
:class="
$q.screen.xs ? 'card-container-xs' : 'card-container'
@ -885,17 +1371,38 @@ watch(
</div>
<div class="col-12 row items-center">
<!-- แสดงกลองตอนกดถายภาพ -->
<Camera
:key="`desktop-${cameraMountKey}`"
:resolution="{ width: photoWidth, height: photoHeight }"
ref="camera"
:class="['camera-preview']"
:autoplay="false"
:style="!img ? 'display: block' : 'display: none'"
:playsinline="true"
:facingMode="'user'"
/>
<!-- แสดงรปเมอกด capture -->
<div v-if="img" class="image-container">
<q-img :src="img" class="image-element"></q-img>
<q-img
:src="img"
class="image-element"
:img-style="centeredPreviewImageStyle"
></q-img>
<!-- Native capture: cameraIsOn stays false so show retake here
the refresh button here instead of inside v-if="cameraIsOn" -->
<div
v-if="useNativePhotoCapture"
class="absolute-bottom-right q-ma-md"
>
<q-btn
round
push
icon="refresh"
size="md"
color="negative"
@click="refreshPhoto"
/>
</div>
</div>
<div v-if="cameraIsOn">
@ -996,20 +1503,42 @@ watch(
</div>
<div class="col-12 row items-center">
<!-- แสดงกลองตอนกดถายภาพ -->
<Camera
:key="`mobile-${cameraMountKey}`"
:resolution="{
width: photoWidth,
height: photoHeight,
}"
ref="camera"
:class="['camera-preview']"
:autoplay="false"
:style="!img ? 'display: block' : 'display: none'"
:playsinline="true"
:facingMode="'user'"
/>
<!-- แสดงรปเมอกด capture -->
<div v-if="img" class="image-container">
<q-img :src="img" class="image-element"></q-img>
<q-img
:src="img"
class="image-element"
:img-style="centeredPreviewImageStyle"
></q-img>
<!-- Native capture: cameraIsOn stays false so show retake here
the refresh button here instead of inside v-if="cameraIsOn" -->
<div
v-if="useNativePhotoCapture"
class="absolute-bottom text-subtitle2 text-center q-py-sm"
style="background: #00000021"
>
<q-btn
round
icon="refresh"
size="18px"
style="background: #263238; color: white"
@click="refreshPhoto"
unelevated
/>
</div>
</div>
<div v-if="cameraIsOn">
@ -1429,7 +1958,7 @@ watch(
<div
class="col-12 text-subtitle1 text-center text-white text-weight-medium"
>
{{ date2Thai(Thai) }}
{{ date2Thai(displayedCheckDate) }}
</div>
<div class="row col-12 justify-center q-pt-sm">
<!-- <div class="text-h3 text-white text-weight-bold"></div> -->
@ -1463,6 +1992,20 @@ watch(
</q-card-actions>
</q-card>
</q-dialog>
<!--
Native still-image fallback: hidden file input.
Activated only after inline camera is proven unsupported or fails on any
platform including iOS. Never used as the first-choice path.
-->
<input
ref="nativePhotoInput"
type="file"
accept="image/*"
capture="user"
style="display: none"
@change="onNativePhotoSelected"
/>
</template>
<style scoped>
@ -1499,6 +2042,8 @@ watch(
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 8px;
}
.image-element {
@ -1513,6 +2058,14 @@ watch(
height: 100%;
}
/* Keep the live camera preview visually aligned with the normalized saved photo. */
.camera-preview :deep(video) {
width: 100%;
height: 100%;
object-fit: cover;
-webkit-object-fit: cover;
}
.bg-topOut {
background: rgb(39, 50, 56);
background: linear-gradient(
@ -1535,6 +2088,11 @@ watch(
animation: bounce 2s infinite;
}
.warning-banner {
border: 1px solid #ffcc80;
border-left: 4px solid #fb8c00;
}
@keyframes bounce {
0%,
100% {

View file

@ -133,14 +133,19 @@ const photoHeight = ref<number>(350)
/** function เปิดกล้อง */
async function openCamera() {
// change camera device
if (cameraIsOn.value) {
camera.value?.stop()
} else {
await camera.value?.start()
changeCamera()
try {
// change camera device
if (cameraIsOn.value) {
camera.value?.stop()
} else {
await camera.value?.start()
changeCamera()
}
cameraIsOn.value = !cameraIsOn.value
} catch (error) {
console.error('Error opening camera:', error)
messageError($q, error, 'ไม่สามารถเปิดกล้องได้ กรุณาลองใหม่อีกครั้ง')
}
cameraIsOn.value = !cameraIsOn.value
}
/** change camera device */
@ -152,25 +157,49 @@ async function changeCamera() {
/** function ถ่ายรูป*/
async function capturePhoto() {
const imageBlob: any = await camera.value?.snapshot(
{ width: photoWidth.value, height: photoHeight.value },
'image/png',
0.5
)
const fileName = 'photo.png'
//
const file = new File([imageBlob], fileName, { type: 'image/png' })
fileImg.value = file
//
camera.value?.stop()
const url = URL.createObjectURL(imageBlob)
img.value = url
try {
const imageBlob: any = await camera.value?.snapshot(
{ width: photoWidth.value, height: photoHeight.value },
'image/jpeg',
0.8
)
if (!imageBlob) {
messageError($q, '', 'ไม่สามารถถ่ายรูปได้ กรุณาลองใหม่อีกครั้ง')
return
}
const fileName = `photo_${Date.now()}.jpg`
//
const file = new File([imageBlob], fileName, { type: 'image/jpeg' })
fileImg.value = file
// URL ( memory leak)
if (img.value) {
URL.revokeObjectURL(img.value)
}
//
camera.value?.stop()
const url = URL.createObjectURL(imageBlob)
img.value = url
} catch (error) {
console.error('Error capturing photo:', error)
messageError($q, error, 'ไม่สามารถถ่ายรูปได้ กรุณาลองใหม่อีกครั้ง')
}
}
/** function เปลี่ยนรูปภาพ*/
function refreshPhoto() {
img.value = undefined
camera.value?.start()
async function refreshPhoto() {
try {
// URL ( memory leak)
if (img.value) {
URL.revokeObjectURL(img.value)
img.value = undefined
}
await camera.value?.start()
} catch (error) {
console.error('Error refreshing photo:', error)
messageError($q, error, 'ไม่สามารถเปิดกล้องได้ กรุณาลองใหม่อีกครั้ง')
}
}
/** ref validate*/
@ -335,7 +364,8 @@ onMounted(async () => {
:resolution="{ width: photoWidth, height: photoHeight }"
ref="camera"
:autoplay="false"
:style="!img ? 'display: block' : 'display: none'"
:playsinline="true"
:facingMode="'user'"
/>
<!-- แสดงรปเมอกด capture -->
@ -569,4 +599,12 @@ onMounted(async () => {
width: 100%;
height: 100%;
}
/* iOS-specific video fixes */
.card-container video {
width: 100%;
height: 100%;
object-fit: cover;
-webkit-object-fit: cover;
}
</style>