Merge branch 'develop' into dev

* develop:
  add mock location
This commit is contained in:
Warunee Tamkoo 2026-03-06 23:19:43 +07:00
commit 03fcee5b46
4 changed files with 325 additions and 95 deletions

View file

@ -5,6 +5,7 @@ import axios from 'axios'
import { useCounterMixin } from '@/stores/mixin' import { useCounterMixin } from '@/stores/mixin'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { usePrivacyStore } from '@/stores/privacy' import { usePrivacyStore } from '@/stores/privacy'
import { useLocationValidation } from '@/composables/useLocationValidation'
const mixin = useCounterMixin() const mixin = useCounterMixin()
const { messageError } = mixin const { messageError } = mixin
@ -12,9 +13,11 @@ const privacyStore = usePrivacyStore()
// import type { LocationObject } from '@/interface/index/Main' // import type { LocationObject } from '@/interface/index/Main'
const mapElement = ref<HTMLElement | null>(null) const mapElement = ref<HTMLElement | null>(null)
const emit = defineEmits(['update:location']) const emit = defineEmits(['update:location', 'locationStatus', 'mockDetected'])
const $q = useQuasar() const $q = useQuasar()
const { validateLocation, showMockWarning } = useLocationValidation()
function updateLocation(latitude: number, longitude: number, namePOI: string) { function updateLocation(latitude: number, longitude: number, namePOI: string) {
// event parent component props // event parent component props
emit('update:location', latitude, longitude, namePOI) emit('update:location', latitude, longitude, namePOI)
@ -199,7 +202,7 @@ async function initializeMap() {
} }
} }
const locationGranted = ref(false) const locationGranted = ref<boolean>(false)
// Function to request location permission // Function to request location permission
const requestLocationPermission = () => { const requestLocationPermission = () => {
// privacy // privacy
@ -223,8 +226,30 @@ const requestLocationPermission = () => {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
async (position) => { async (position) => {
// Permission granted // Validate location first
locationGranted.value = true const validationResult = validateLocation(position)
// Always emit mockDetected event (regardless of result)
if (validationResult.isMockDetected) {
showMockWarning(validationResult)
emit('mockDetected', validationResult)
}
// Check for critical errors (invalid coordinates) that prevent showing location
const hasCriticalErrors = validationResult.errors.some(error =>
error.includes('พิกัดตำแหน่งไม่ถูกต้อง')
)
if (hasCriticalErrors) {
locationGranted.value = false
emit('locationStatus', false)
messageError($q, '', validationResult.errors[0])
return
}
// Permission granted based on mock detection
locationGranted.value = !validationResult.isMockDetected
emit('locationStatus', !validationResult.isMockDetected)
const { latitude, longitude } = position.coords const { latitude, longitude } = position.coords
// console.log('Current position:', latitude, longitude) // console.log('Current position:', latitude, longitude)
@ -242,6 +267,7 @@ const requestLocationPermission = () => {
(error) => { (error) => {
// Permission denied // Permission denied
locationGranted.value = false locationGranted.value = false
emit('locationStatus', false)
switch (error.code) { switch (error.code) {
case error.PERMISSION_DENIED: case error.PERMISSION_DENIED:
@ -272,6 +298,7 @@ const requestLocationPermission = () => {
defineExpose({ defineExpose({
requestLocationPermission, requestLocationPermission,
locationGranted,
}) })
</script> </script>

View file

@ -0,0 +1,169 @@
import { ref } from 'vue'
import { useQuasar } from 'quasar'
import { useCounterMixin } from '@/stores/mixin'
export interface LocationValidationResult {
isValid: boolean
isMockDetected: boolean
confidence: 'low' | 'medium' | 'high'
warnings: string[]
errors: string[]
}
export interface PositionSnapshot {
latitude: number
longitude: number
timestamp: number
}
// Configuration constants - exported for documentation and testing purposes
export const VALIDATION_CONFIG = {
MAX_TIMESTAMP_AGE_MS: 60_000, // 60 seconds - maximum acceptable age of location data
MAX_ACCURACY_METERS: 100, // 100 meters - maximum acceptable GPS accuracy
MAX_SPEED_MS: 100, // 100 m/s (~360 km/h) - maximum plausible movement speed
POSITION_HISTORY_SIZE: 5, // number of positions to keep for pattern detection
MOCK_INDICATOR_THRESHOLD: 3, // threshold for mock detection (indicators >= 3 = mock)
} as const
export function useLocationValidation() {
const $q = useQuasar()
const { messageError } = useCounterMixin()
// Thai error messages - exported for i18n consistency
const errorMessages = {
MOCK_DETECTED: 'ตรวจพบว่าตำแหน่ง GPS อาจไม่ถูกต้อง กรุณาปิดแอปจำลองตำแหน่งและลองใหม่',
INVALID_COORDINATES: 'พิกัดตำแหน่งไม่ถูกต้อง กรุณาลองใหม่',
STALE_TIMESTAMP: 'ข้อมูลตำแหน่งเก่าเกินไป กรุณารับสัญญาณ GPS ใหม่',
POOR_ACCURACY: 'ความแม่นยำตำแหน่งต่ำเกินไป กรุณาตรวจสอบการรับสัญญาณ GPS',
IMPOSSIBLE_SPEED: 'ตรวจพบการเคลื่อนที่ด้วยความเร็วผิดปกติ อาจเป็นการจำลองตำแหน่ง',
}
const previousPositions = ref<PositionSnapshot[]>([])
// คำนวณระยะห่างระหว่าง 2 จุด (Haversine formula)
const haversineDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
const R = 6371e3 // Earth's radius in meters
const φ1 = (lat1 * Math.PI) / 180
const φ2 = (lat2 * Math.PI) / 180
const Δφ = ((lat2 - lat1) * Math.PI) / 180
const Δλ = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
// ตรวจสอบพิกัดถูกต้อง
const validateCoordinates = (lat: number, lon: number): boolean => {
return (
lat >= -90 && lat <= 90 &&
lon >= -180 && lon <= 180 &&
!isNaN(lat) && !isNaN(lon) &&
!(lat === 0 && lon === 0) // Mock มักใช้ 0,0
)
}
// ตรวจสอบความแม่นยำ
const validateAccuracy = (accuracy: number | null): boolean => {
if (accuracy === null) return true
return accuracy <= VALIDATION_CONFIG.MAX_ACCURACY_METERS
}
// ตรวจสอบ Timestamp
const validateTimestamp = (timestamp: number): boolean => {
const now = Date.now()
const age = Math.abs(now - timestamp)
return age <= VALIDATION_CONFIG.MAX_TIMESTAMP_AGE_MS
}
// คำนวณความเร็ว
const calculateSpeed = (pos1: PositionSnapshot, pos2: PositionSnapshot): number => {
const distance = haversineDistance(pos1.latitude, pos1.longitude, pos2.latitude, pos2.longitude)
const timeDiff = Math.abs(pos2.timestamp - pos1.timestamp) / 1000 // seconds
return timeDiff > 0 ? distance / timeDiff : 0
}
// ตรวจสอบความเร็วปกติ
const validateSpeed = (current: PositionSnapshot, previous: PositionSnapshot): boolean => {
const speed = calculateSpeed(previous, current)
return speed <= VALIDATION_CONFIG.MAX_SPEED_MS
}
// Main validation function
const validateLocation = (position: GeolocationPosition): LocationValidationResult => {
const warnings: string[] = []
const errors: string[] = []
let mockIndicators = 0
const { latitude, longitude, accuracy } = position.coords
const { timestamp } = position
// 1. Coordinate validation
if (!validateCoordinates(latitude, longitude)) {
errors.push(errorMessages.INVALID_COORDINATES)
mockIndicators += 3
}
// 2. Timestamp validation
if (!validateTimestamp(timestamp)) {
errors.push(errorMessages.STALE_TIMESTAMP)
mockIndicators += 2
}
// 3. Accuracy validation
if (!validateAccuracy(accuracy)) {
warnings.push(errorMessages.POOR_ACCURACY)
mockIndicators += 1
}
// 4. Compare with previous positions
if (previousPositions.value.length > 0) {
const previous = previousPositions.value[previousPositions.value.length - 1]
if (!validateSpeed({ latitude, longitude, timestamp }, previous)) {
errors.push(errorMessages.IMPOSSIBLE_SPEED)
mockIndicators += 3
}
}
// Store current position
previousPositions.value.push({ latitude, longitude, timestamp })
if (previousPositions.value.length > VALIDATION_CONFIG.POSITION_HISTORY_SIZE) {
previousPositions.value.shift()
}
// Determine result
const isMockDetected = mockIndicators >= VALIDATION_CONFIG.MOCK_INDICATOR_THRESHOLD
const isValid = errors.length === 0
let confidence: 'low' | 'medium' | 'high' = 'low'
if (mockIndicators >= 5) confidence = 'high'
else if (mockIndicators >= 3) confidence = 'medium'
return {
isValid,
isMockDetected,
confidence,
warnings,
errors,
}
}
const showMockWarning = (result: LocationValidationResult) => {
if (!result.isMockDetected) return
messageError($q, null, errorMessages.MOCK_DETECTED)
}
const resetValidation = () => {
previousPositions.value = []
}
return {
validateLocation,
showMockWarning,
resetValidation,
}
}

View file

@ -32,6 +32,8 @@ const endTimeAfternoon = ref<string>('12:00:00') //เวลาเช็คเ
const isLoadingCheckTime = ref<boolean>(false) // const isLoadingCheckTime = ref<boolean>(false) //
const disabledBtn = ref<boolean>(false) const disabledBtn = ref<boolean>(false)
const locationGranted = ref<boolean>(false)
const isMockLocationDetected = ref<boolean>(false)
/** /**
* fetch เชคเวลาตองลงเวลาเขาหรอออกงาน * fetch เชคเวลาตองลงเวลาเขาหรอออกงาน
@ -113,6 +115,21 @@ async function updateLocation(
formLocation.POI = namePOI formLocation.POI = namePOI
} }
/**
* บคาสถานะ location จาก AscGISMap
*/
function onLocationStatus(status: boolean) {
locationGranted.value = status
}
/**
* บค mock location detection จาก AscGISMap
*/
function onMockDetected(result: any) {
isMockLocationDetected.value = true
disabledBtn.value = true
}
const location = ref<string>('') // const location = ref<string>('') //
const model = ref<string>('') // const model = ref<string>('') //
// //
@ -595,6 +612,16 @@ watch(
} }
} }
) )
watch(
() => locationGranted.value,
(newVal) => {
// Removed auto-reset of isMockLocationDetected to prevent
// clearing mock detection state when permission is granted.
// Mock detection state should only be reset after explicit user action
// or after a successful validation without mock indicators.
}
)
</script> </script>
<template> <template>
@ -667,6 +694,8 @@ watch(
v-if="$q.screen.gt.xs" v-if="$q.screen.gt.xs"
ref="mapRef" ref="mapRef"
@update:location="updateLocation" @update:location="updateLocation"
@location-status="onLocationStatus"
@mock-detected="onMockDetected"
/> />
</div> </div>
</div> </div>
@ -833,7 +862,12 @@ watch(
</div> </div>
<div class="col-12" v-if="$q.screen.xs"> <div class="col-12" v-if="$q.screen.xs">
<MapCheck ref="mapRef" @update:location="updateLocation" /> <MapCheck
ref="mapRef"
@update:location="updateLocation"
@location-status="onLocationStatus"
@mock-detected="onMockDetected"
/>
</div> </div>
</div> </div>
<!-- กรอกขอม หนามอถ --> <!-- กรอกขอม หนามอถ -->
@ -982,7 +1016,7 @@ watch(
push push
size="18px" size="18px"
:class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'" :class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'"
:disable="disabledBtn ? true : camera && img ? false : true" :disable="disabledBtn || !locationGranted || isMockLocationDetected ? true : camera && img ? false : true"
@click="validateForm" @click="validateForm"
:loading="inQueue" :loading="inQueue"
/> />
@ -1097,7 +1131,7 @@ watch(
push push
size="18px" size="18px"
:class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'" :class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'"
:disable="disabledBtn ? true : camera && img ? false : true" :disable="disabledBtn || !locationGranted || isMockLocationDetected ? true : camera && img ? false : true"
@click="validateForm" @click="validateForm"
:loading="inQueue" :loading="inQueue"
/> />

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue' import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@ -11,8 +11,8 @@ import { useCounterMixin } from '@/stores/mixin'
import { usePrivacyStore } from '@/stores/privacy' import { usePrivacyStore } from '@/stores/privacy'
import { usePositionKeycloakStore } from '@/stores/positionKeycloak' import { usePositionKeycloakStore } from '@/stores/positionKeycloak'
import type { notiType } from '@/interface/index/Main' // import type { notiType } from '@/interface/index/Main'
import type { Noti } from '@/interface/response/Main' // import type { Noti } from '@/interface/response/Main'
import DialogHeader from '@/components/DialogHeader.vue' import DialogHeader from '@/components/DialogHeader.vue'
import PopupPrivacy from '@/components/PopupPrivacy.vue' import PopupPrivacy from '@/components/PopupPrivacy.vue'
@ -22,10 +22,10 @@ const mixin = useCounterMixin()
const privacyStore = usePrivacyStore() const privacyStore = usePrivacyStore()
const positionKeycloakStore = usePositionKeycloakStore() const positionKeycloakStore = usePositionKeycloakStore()
const { const {
date2Thai, // date2Thai,
hideLoader, hideLoader,
messageError, messageError,
dialogRemove, // dialogRemove,
success, success,
showLoader, showLoader,
} = mixin } = mixin
@ -40,17 +40,17 @@ const configParam = {
const modalReset = ref<boolean>(false) // popup reset password const modalReset = ref<boolean>(false) // popup reset password
const fullName = ref<string>('') // const fullName = ref<string>('') //
const notiTrigger = ref<boolean>(false) // , // const notiTrigger = ref<boolean>(false) // ,
const notiList = ref<notiType[]>([]) // // const notiList = ref<notiType[]>([]) //
// const totalNotiList = ref<number>(0) // // const totalNotiList = ref<number>(0) //
// const totalNoti = ref<number>(0) // // const totalNoti = ref<number>(0) //
const statusLoad = ref<boolean>(false) // // const statusLoad = ref<boolean>(false) //
const modalDebug = ref<boolean>(false) // popup debug const modalDebug = ref<boolean>(false) // popup debug
// //
const thaiOptions: Intl.DateTimeFormatOptions = { // const thaiOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit', // hour: '2-digit',
minute: '2-digit', // minute: '2-digit',
} // }
const oldPassWord = ref<string>('') const oldPassWord = ref<string>('')
@ -76,63 +76,63 @@ const isPwdReNewOld = ref<boolean>(true)
// }) // })
// } // }
/** // /**
* งกนดงขอมลรายการแจงเต // *
* @param index page องการโหลดขอม // * @param index page
* @param type DEL อโหลดหลงลบขอความ, NOMAL อกรณโหลดขอมลปก // * @param type DEL , NOMAL
*/ // */
async function fetchNotifications(index: number, type: string) { // async function fetchNotifications(index: number, type: string) {
await http // await http
.get(config.API.msgNotificate + `?page=${index}&pageSize=${20}`) // .get(config.API.msgNotificate + `?page=${index}&pageSize=${20}`)
.then((res) => { // .then((res) => {
const response = res.data.result.data // const response = res.data.result.data
totalNotiList.value = res.data.result.total // totalNotiList.value = res.data.result.total
const list: notiType[] = [] // const list: notiType[] = []
if (type === 'DEL') { // if (type === 'DEL') {
notiList.value = [] // notiList.value = []
} // }
response.map((e: Noti) => { // response.map((e: Noti) => {
list.push({ // list.push({
id: e.id, // id: e.id,
sender: // sender:
e.createdFullName == '' || e.createdFullName == null // e.createdFullName == '' || e.createdFullName == null
? 'เจ้าหน้าที่'[0] // ? ''[0]
: e.createdFullName[0], // : e.createdFullName[0],
body: e.body ?? '', // body: e.body ?? '',
timereceive: e.receiveDate, // timereceive: e.receiveDate,
isOpen: e.isOpen, // isOpen: e.isOpen,
}) // })
}) // })
notiList.value.push(...list) // notiList.value.push(...list)
statusLoad.value = totalNotiList.value === 0 ? true : false // statusLoad.value = totalNotiList.value === 0 ? true : false
}) // })
.catch((err) => { // .catch((err) => {
messageError($q, err) // messageError($q, err)
}) // })
} // }
/** // /**
* function ลบรายการแจงเต // * function
* @param id noti // * @param id noti
*/ // */
async function onClickDelete(id: string, index: number) { // async function onClickDelete(id: string, index: number) {
dialogRemove($q, async () => { // dialogRemove($q, async () => {
await http // await http
.delete(config.API.msgId(id)) // .delete(config.API.msgId(id))
.then(async () => { // .then(async () => {
notiList.value.splice(index, 1) // notiList.value.splice(index, 1)
totalNotiList.value-- // totalNotiList.value--
notiList.value.length === 14 && (await fetchNotifications(1, 'DEL')) // notiList.value.length === 14 && (await fetchNotifications(1, 'DEL'))
success($q, 'ลบข้อมูลสำเร็จ') // success($q, '')
}) // })
.catch((e) => { // .catch((e) => {
messageError($q, e) // messageError($q, e)
}) // })
.finally(async () => { // .finally(async () => {
hideLoader() // hideLoader()
}) // })
}) // })
} // }
/*** function logout */ /*** function logout */
function onClickLogout() { function onClickLogout() {
@ -148,25 +148,25 @@ function onClickLogout() {
}) })
} }
const page = ref<number>(0) // const page = ref<number>(0)
/** // /**
* โหลดรายการแจงเตอนเพมเม scroll // * scroll
* @param index // * @param index
* @param done // * @param done
*/ // */
function onLoad(index: number, done: Function) { // function onLoad(index: number, done: Function) {
if ( // if (
notiList.value.length < totalNotiList.value || // notiList.value.length < totalNotiList.value ||
(notiList.value.length == 0 && totalNotiList.value === 0) // (notiList.value.length == 0 && totalNotiList.value === 0)
) { // ) {
page.value++ // page.value++
setTimeout(async () => { // setTimeout(async () => {
await fetchNotifications(page.value, 'NOMAL') // await fetchNotifications(page.value, 'NOMAL')
await done() // await done()
}, 1500) // }, 1500)
} // }
} // }
// landing page redirect // landing page redirect
const landingPageUrl = ref<string>(configParam.landingPageUrl) const landingPageUrl = ref<string>(configParam.landingPageUrl)
@ -247,7 +247,7 @@ async function onSubmit() {
}) })
} }
function ruleNewPassWord(val: string) { function ruleNewPassWord(val: string): Promise<string | boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!val) { if (!val) {
return resolve('กรุณากรอกรหัสผ่านใหม่') return resolve('กรุณากรอกรหัสผ่านใหม่')
@ -268,7 +268,7 @@ function ruleNewPassWord(val: string) {
}) })
} }
function ruleReNewPassWord(val: string) { function ruleReNewPassWord(val: string): Promise<string | boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!val) { if (!val) {
return resolve('กรุณายืนยันรหัสผ่านใหม่') return resolve('กรุณายืนยันรหัสผ่านใหม่')