fix remove code check fake location
All checks were successful
Build & Deploy on Dev / build (push) Successful in 2m12s
All checks were successful
Build & Deploy on Dev / build (push) Successful in 2m12s
This commit is contained in:
parent
02f1fd417d
commit
7edfaa4e4f
4 changed files with 157 additions and 579 deletions
|
|
@ -5,51 +5,20 @@ 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
|
||||||
const privacyStore = usePrivacyStore()
|
const privacyStore = usePrivacyStore()
|
||||||
|
|
||||||
// import type { LocationObject } from '@/interface/index/Main'
|
const emit = defineEmits(['update:location'])
|
||||||
const mapElement = ref<HTMLElement | null>(null)
|
|
||||||
const emit = defineEmits(['update:location', 'locationStatus', 'mockDetected'])
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
|
|
||||||
const { validateLocation } = useLocationValidation()
|
|
||||||
const MOCK_CHECK_DELAY_MS = 800
|
|
||||||
|
|
||||||
function wait(ms: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentPositionAsync(options?: PositionOptions) {
|
|
||||||
return new Promise<GeolocationPosition>((resolve, reject) => {
|
|
||||||
navigator.geolocation.getCurrentPosition(resolve, reject, options)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPositionForValidation(fallback: GeolocationPosition) {
|
|
||||||
await wait(MOCK_CHECK_DELAY_MS)
|
|
||||||
try {
|
|
||||||
return await getCurrentPositionAsync({
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 10000,
|
|
||||||
maximumAge: 0,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
// Keep the original reading if the second sampling fails.
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
const poiPlaceName = ref<string>('') // ชื่อพื้นที่ใกล้เคียง
|
const poiPlaceName = ref<string>('') // ชื่อพื้นที่ใกล้เคียง
|
||||||
const isMapReady = ref<boolean>(false)
|
|
||||||
|
|
||||||
// Replace ArcGIS api key
|
// Replace ArcGIS api key
|
||||||
const apiKey = ref<string>(
|
const apiKey = ref<string>(
|
||||||
|
|
@ -58,7 +27,7 @@ const apiKey = ref<string>(
|
||||||
)
|
)
|
||||||
const zoomMap = ref<number>(18)
|
const zoomMap = ref<number>(18)
|
||||||
|
|
||||||
async function initializeMap(position: GeolocationPosition) {
|
async function initializeMap() {
|
||||||
try {
|
try {
|
||||||
// Load modules of ArcGIS
|
// Load modules of ArcGIS
|
||||||
loadModules([
|
loadModules([
|
||||||
|
|
@ -69,149 +38,159 @@ async function initializeMap(position: GeolocationPosition) {
|
||||||
'esri/Graphic',
|
'esri/Graphic',
|
||||||
'esri/layers/TileLayer',
|
'esri/layers/TileLayer',
|
||||||
]).then(async ([esriConfig, Map, MapView, Point, Graphic, TileLayer]) => {
|
]).then(async ([esriConfig, Map, MapView, Point, Graphic, TileLayer]) => {
|
||||||
|
// Set apiKey
|
||||||
|
// esriConfig.apiKey =
|
||||||
|
// 'AAPK4f700a4324d04e9f8a1a134e0771ac45FXWawdCl-OotFfr52gz9XKxTDJTpDzw_YYcwbmKDDyAJswf14FoPyw0qBkN64DvP'
|
||||||
|
|
||||||
|
// Create a FeatureLayer using a custom server URL
|
||||||
|
// const hillshadeLayer = new TileLayer({
|
||||||
|
// url: `https://bmagis.bangkok.go.th/arcgis/rest/services/cache/BMA_3D_2D_Cache/MapServer`,
|
||||||
|
// })
|
||||||
|
|
||||||
const map = new Map({
|
const map = new Map({
|
||||||
basemap: 'streets',
|
basemap: 'streets',
|
||||||
|
// basemap: 'arcgis-topographic',
|
||||||
|
// layers: [hillshadeLayer],
|
||||||
})
|
})
|
||||||
|
|
||||||
const { latitude, longitude } = position.coords
|
navigator.geolocation.getCurrentPosition(async (position) => {
|
||||||
|
const { latitude, longitude } = position.coords
|
||||||
|
|
||||||
const mapView = new MapView({
|
const mapView = new MapView({
|
||||||
container: 'mapViewDisplay',
|
container: 'mapViewDisplay',
|
||||||
map: map,
|
map: map,
|
||||||
center: {
|
center: {
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
}, // Set the initial map center current position
|
}, // Set the initial map center current position
|
||||||
|
|
||||||
zoom: zoomMap.value,
|
zoom: zoomMap.value,
|
||||||
constraints: {
|
constraints: {
|
||||||
snapToZoom: false, // Disables snapping to the zoom level
|
snapToZoom: false, // Disables snapping to the zoom level
|
||||||
minZoom: zoomMap.value, // Set minimum zoom level
|
minZoom: zoomMap.value, // Set minimum zoom level
|
||||||
maxZoom: zoomMap.value, // Set maximum zoom level (same as minZoom for fixed zoom)
|
maxZoom: zoomMap.value, // Set maximum zoom level (same as minZoom for fixed zoom)
|
||||||
},
|
},
|
||||||
|
|
||||||
ui: {
|
ui: {
|
||||||
components: [], // Empty array to remove all default UI components
|
components: [], // Empty array to remove all default UI components
|
||||||
},
|
},
|
||||||
})
|
|
||||||
isMapReady.value = true
|
|
||||||
|
|
||||||
// ตำแหน่งของผู้ใช้
|
|
||||||
const userPoint = new Point({ longitude, latitude })
|
|
||||||
const userSymbol = {
|
|
||||||
type: 'picture-marker',
|
|
||||||
url: 'http://maps.google.com/mapfiles/ms/icons/red.png',
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
}
|
|
||||||
const userGraphic = new Graphic({
|
|
||||||
geometry: userPoint,
|
|
||||||
symbol: userSymbol,
|
|
||||||
})
|
|
||||||
mapView.graphics.add(userGraphic)
|
|
||||||
// Get POI place ยิงไปขอที่ server ของกทม.ก่อน
|
|
||||||
await axios
|
|
||||||
.get(
|
|
||||||
'https://bmagis.bangkok.go.th/portal/sharing/servers/e4732c3a9fe549ab8bc697573b468f68/rest/services/World/GeocodeServer/reverseGeocode/',
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
f: 'json', // Format JSON response
|
|
||||||
distance: 2000,
|
|
||||||
category: 'POI',
|
|
||||||
location: {
|
|
||||||
spatialReference: { wkid: 4326 },
|
|
||||||
x: longitude,
|
|
||||||
y: latitude,
|
|
||||||
},
|
|
||||||
token: apiKey.value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((response) => {
|
|
||||||
// console.log('poi', response.data.location)
|
|
||||||
poiPlaceName.value = response.data.address
|
|
||||||
? response.data.address.PlaceName === ''
|
|
||||||
? response.data.address.ShortLabel
|
|
||||||
: response.data.address.PlaceName
|
|
||||||
: 'ไม่พบข้อมูล'
|
|
||||||
const poiPoint = new Point({
|
|
||||||
longitude: response.data.location.x,
|
|
||||||
latitude: response.data.location.y,
|
|
||||||
})
|
|
||||||
const poiSymbol = {
|
|
||||||
type: 'picture-marker',
|
|
||||||
url: 'http://maps.google.com/mapfiles/ms/icons/blue.png',
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
}
|
|
||||||
const poiGraphic = new Graphic({
|
|
||||||
geometry: poiPoint,
|
|
||||||
symbol: poiSymbol,
|
|
||||||
})
|
|
||||||
mapView.graphics.add(poiGraphic)
|
|
||||||
// อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI
|
|
||||||
mapView.goTo({
|
|
||||||
target: [userPoint, poiPoint],
|
|
||||||
zoom: zoomMap.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
updateLocation(latitude, longitude, poiPlaceName.value)
|
|
||||||
})
|
})
|
||||||
.catch(async (error) => {
|
|
||||||
// console.error('Error fetching points of interest:', error)
|
// ตำแหน่งของผู้ใช้
|
||||||
// Get POI place ยิงไปขอที่ server arcgis ไม่ต้องใช้ token
|
const userPoint = new Point({ longitude, latitude })
|
||||||
await axios
|
const userSymbol = {
|
||||||
.get(
|
type: 'picture-marker',
|
||||||
'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode/',
|
url: 'http://maps.google.com/mapfiles/ms/icons/red.png',
|
||||||
{
|
width: '32px',
|
||||||
params: {
|
height: '32px',
|
||||||
f: 'json', // Format JSON response
|
}
|
||||||
distance: 2000,
|
const userGraphic = new Graphic({
|
||||||
category: 'POI',
|
geometry: userPoint,
|
||||||
location: {
|
symbol: userSymbol,
|
||||||
spatialReference: { wkid: 4326 },
|
})
|
||||||
x: longitude,
|
mapView.graphics.add(userGraphic)
|
||||||
y: latitude,
|
// Get POI place ยิงไปขอที่ server ของกทม.ก่อน
|
||||||
},
|
await axios
|
||||||
|
.get(
|
||||||
|
'https://bmagis.bangkok.go.th/portal/sharing/servers/e4732c3a9fe549ab8bc697573b468f68/rest/services/World/GeocodeServer/reverseGeocode/',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
f: 'json', // Format JSON response
|
||||||
|
distance: 2000,
|
||||||
|
category: 'POI',
|
||||||
|
location: {
|
||||||
|
spatialReference: { wkid: 4326 },
|
||||||
|
x: longitude,
|
||||||
|
y: latitude,
|
||||||
},
|
},
|
||||||
}
|
token: apiKey.value,
|
||||||
)
|
},
|
||||||
.then((response) => {
|
}
|
||||||
// console.log('poi', response.data.location)
|
)
|
||||||
poiPlaceName.value = response.data.address
|
.then((response) => {
|
||||||
? response.data.address.PlaceName === ''
|
// console.log('poi', response.data.location)
|
||||||
? response.data.address.ShortLabel
|
poiPlaceName.value = response.data.address
|
||||||
: response.data.address.PlaceName
|
? response.data.address.PlaceName === ''
|
||||||
: 'ไม่พบข้อมูล'
|
? response.data.address.ShortLabel
|
||||||
const poiPoint = new Point({
|
: response.data.address.PlaceName
|
||||||
longitude: response.data.location.x,
|
: 'ไม่พบข้อมูล'
|
||||||
latitude: response.data.location.y,
|
const poiPoint = new Point({
|
||||||
})
|
longitude: response.data.location.x,
|
||||||
const poiSymbol = {
|
latitude: response.data.location.y,
|
||||||
type: 'picture-marker',
|
})
|
||||||
url: 'http://maps.google.com/mapfiles/ms/icons/blue.png',
|
const poiSymbol = {
|
||||||
width: '32px',
|
type: 'picture-marker',
|
||||||
height: '32px',
|
url: 'http://maps.google.com/mapfiles/ms/icons/blue.png',
|
||||||
}
|
width: '32px',
|
||||||
const poiGraphic = new Graphic({
|
height: '32px',
|
||||||
geometry: poiPoint,
|
}
|
||||||
symbol: poiSymbol,
|
const poiGraphic = new Graphic({
|
||||||
})
|
geometry: poiPoint,
|
||||||
mapView.graphics.add(poiGraphic)
|
symbol: poiSymbol,
|
||||||
// อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI
|
})
|
||||||
mapView.goTo({
|
mapView.graphics.add(poiGraphic)
|
||||||
target: [userPoint, poiPoint],
|
// อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI
|
||||||
zoom: zoomMap.value,
|
mapView.goTo({
|
||||||
})
|
target: [userPoint, poiPoint],
|
||||||
|
zoom: zoomMap.value,
|
||||||
|
})
|
||||||
|
|
||||||
updateLocation(latitude, longitude, poiPlaceName.value)
|
updateLocation(latitude, longitude, poiPlaceName.value)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(async (error) => {
|
||||||
// Keep map visible even when POI lookup fails.
|
// console.error('Error fetching points of interest:', error)
|
||||||
poiPlaceName.value = 'ไม่พบข้อมูล'
|
// Get POI place ยิงไปขอที่ server arcgis ไม่ต้องใช้ token
|
||||||
updateLocation(latitude, longitude, poiPlaceName.value)
|
await axios
|
||||||
})
|
.get(
|
||||||
})
|
'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode/',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
f: 'json', // Format JSON response
|
||||||
|
distance: 2000,
|
||||||
|
category: 'POI',
|
||||||
|
location: {
|
||||||
|
spatialReference: { wkid: 4326 },
|
||||||
|
x: longitude,
|
||||||
|
y: latitude,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
// console.log('poi', response.data.location)
|
||||||
|
poiPlaceName.value = response.data.address
|
||||||
|
? response.data.address.PlaceName === ''
|
||||||
|
? response.data.address.ShortLabel
|
||||||
|
: response.data.address.PlaceName
|
||||||
|
: 'ไม่พบข้อมูล'
|
||||||
|
const poiPoint = new Point({
|
||||||
|
longitude: response.data.location.x,
|
||||||
|
latitude: response.data.location.y,
|
||||||
|
})
|
||||||
|
const poiSymbol = {
|
||||||
|
type: 'picture-marker',
|
||||||
|
url: 'http://maps.google.com/mapfiles/ms/icons/blue.png',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
}
|
||||||
|
const poiGraphic = new Graphic({
|
||||||
|
geometry: poiPoint,
|
||||||
|
symbol: poiSymbol,
|
||||||
|
})
|
||||||
|
mapView.graphics.add(poiGraphic)
|
||||||
|
// อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI
|
||||||
|
mapView.goTo({
|
||||||
|
target: [userPoint, poiPoint],
|
||||||
|
zoom: zoomMap.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
updateLocation(latitude, longitude, poiPlaceName.value)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// console.error('Error fetching points of interest:', error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading the map', error)
|
console.error('Error loading the map', error)
|
||||||
|
|
@ -242,35 +221,10 @@ const requestLocationPermission = () => {
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
async (position) => {
|
async (position) => {
|
||||||
const sampledPosition = await getPositionForValidation(position)
|
// Permission granted
|
||||||
|
|
||||||
// Validate location first
|
|
||||||
const validationResult = validateLocation(sampledPosition)
|
|
||||||
|
|
||||||
// Do not block map preview on initial mock detection.
|
|
||||||
// The hard-stop warning is enforced at submit time in HomeView.
|
|
||||||
if (validationResult.isMockDetected) {
|
|
||||||
locationGranted.value = true
|
|
||||||
emit('locationStatus', true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 for map preview state.
|
|
||||||
locationGranted.value = true
|
locationGranted.value = true
|
||||||
emit('locationStatus', true)
|
|
||||||
|
|
||||||
const { latitude, longitude } = sampledPosition.coords
|
const { latitude, longitude } = position.coords
|
||||||
// console.log('Current position:', latitude, longitude)
|
// console.log('Current position:', latitude, longitude)
|
||||||
|
|
||||||
if (!latitude || !longitude) {
|
if (!latitude || !longitude) {
|
||||||
|
|
@ -280,13 +234,12 @@ const requestLocationPermission = () => {
|
||||||
|
|
||||||
// Center map on user's location if map is initialized
|
// Center map on user's location if map is initialized
|
||||||
if (privacyStore.isAccepted) {
|
if (privacyStore.isAccepted) {
|
||||||
await initializeMap(sampledPosition)
|
await initializeMap()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(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:
|
||||||
|
|
@ -311,19 +264,18 @@ const requestLocationPermission = () => {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
|
{ enableHighAccuracy: true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
requestLocationPermission,
|
requestLocationPermission,
|
||||||
locationGranted,
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
<div v-if="!isMapReady" class="col-12">
|
<div v-if="!poiPlaceName" class="col-12">
|
||||||
<q-skeleton
|
<q-skeleton
|
||||||
:height="$q.screen.gt.xs ? '35vh' : '45px'"
|
:height="$q.screen.gt.xs ? '35vh' : '45px'"
|
||||||
width="100%"
|
width="100%"
|
||||||
|
|
@ -333,7 +285,7 @@ defineExpose({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-card
|
<q-card
|
||||||
v-show="isMapReady"
|
v-show="poiPlaceName"
|
||||||
bordered
|
bordered
|
||||||
flat
|
flat
|
||||||
class="col-12 bg-grey-2 shadow-0"
|
class="col-12 bg-grey-2 shadow-0"
|
||||||
|
|
@ -355,7 +307,7 @@ defineExpose({
|
||||||
>
|
>
|
||||||
พื้นที่ใกล้เคียง
|
พื้นที่ใกล้เคียง
|
||||||
<span class="q-px-sm">:</span>
|
<span class="q-px-sm">:</span>
|
||||||
{{ poiPlaceName || 'ไม่พบข้อมูล' }}
|
{{ poiPlaceName }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -372,7 +324,7 @@ defineExpose({
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
{{ poiPlaceName || 'ไม่พบข้อมูล' }}
|
{{ poiPlaceName }}
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ function onClose() {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-dialog v-model="modal" persistent>
|
<q-dialog v-model="modal" persistent>
|
||||||
<q-card style="width: 700px; max-width: 80vw">
|
<q-card style="width: 700px; max-width: 90vw">
|
||||||
<q-form greedy @submit.prevent @validation-success="onSubmit">
|
<q-form greedy @submit.prevent @validation-success="onSubmit">
|
||||||
<DialogHeader :tittle="title" :close="onClose" />
|
<DialogHeader :tittle="title" :close="onClose" />
|
||||||
<q-separator />
|
<q-separator />
|
||||||
|
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
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)
|
|
||||||
SUSPICIOUS_ACCURACY_MAX: 5, // accuracy ≤ 5m AND integer = suspicious (real GPS never rounds to whole number)
|
|
||||||
// Geographic service area (Thailand) to reduce spoofing risk from remote fake coordinates.
|
|
||||||
SERVICE_AREA: {
|
|
||||||
MIN_LAT: 5.0,
|
|
||||||
MAX_LAT: 21.0,
|
|
||||||
MIN_LON: 97.0,
|
|
||||||
MAX_LON: 106.0,
|
|
||||||
},
|
|
||||||
} 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:
|
|
||||||
'ตรวจพบการเคลื่อนที่ด้วยความเร็วผิดปกติ อาจเป็นการจำลองตำแหน่ง',
|
|
||||||
SUSPICIOUS_ACCURACY: 'ตรวจพบค่าความแม่นยำที่ผิดปกติ อาจเป็นการจำลองตำแหน่ง',
|
|
||||||
DUPLICATE_POSITION: 'ตรวจพบพิกัดตำแหน่งซ้ำกัน อาจเป็นการจำลองตำแหน่ง',
|
|
||||||
OUT_OF_SERVICE_AREA:
|
|
||||||
'ตรวจพบตำแหน่งนอกพื้นที่ใช้งานของระบบ กรุณาปิดแอปจำลองตำแหน่งและลองใหม่',
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ตรวจสอบค่าความแม่นยำที่ผิดปกติ (mock apps มักรายงานค่าที่เป็นเลขจำนวนเต็มสวยงาม เช่น 5.0, 3.0)
|
|
||||||
const hasSuspiciousPerfectAccuracy = (accuracy: number | null): boolean => {
|
|
||||||
if (accuracy === null) return false
|
|
||||||
return (
|
|
||||||
Number.isInteger(accuracy) &&
|
|
||||||
accuracy <= VALIDATION_CONFIG.SUSPICIOUS_ACCURACY_MAX
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ตรวจสอบพิกัดซ้ำกันทุกค่า (real GPS มีการสั่นเล็กน้อย mock app มักคืนพิกัดเดิมซ้ำ)
|
|
||||||
const hasDuplicateCoordinates = (lat: number, lon: number): boolean => {
|
|
||||||
return previousPositions.value.some(
|
|
||||||
(p) => p.latitude === lat && p.longitude === lon
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWithinServiceArea = (lat: number, lon: number): boolean => {
|
|
||||||
const area = VALIDATION_CONFIG.SERVICE_AREA
|
|
||||||
return (
|
|
||||||
lat >= area.MIN_LAT &&
|
|
||||||
lat <= area.MAX_LAT &&
|
|
||||||
lon >= area.MIN_LON &&
|
|
||||||
lon <= area.MAX_LON
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1.1 Service-area validation (critical for remote spoofed coordinates)
|
|
||||||
if (!isWithinServiceArea(latitude, longitude)) {
|
|
||||||
errors.push(errorMessages.OUT_OF_SERVICE_AREA)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Suspiciously perfect accuracy — real GPS never rounds to a whole-number ≤ 5m
|
|
||||||
if (hasSuspiciousPerfectAccuracy(accuracy)) {
|
|
||||||
warnings.push(errorMessages.SUSPICIOUS_ACCURACY)
|
|
||||||
mockIndicators += 2
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Exact duplicate coordinates — real GPS always drifts slightly between readings
|
|
||||||
if (
|
|
||||||
previousPositions.value.length > 0 &&
|
|
||||||
hasDuplicateCoordinates(latitude, longitude)
|
|
||||||
) {
|
|
||||||
warnings.push(errorMessages.DUPLICATE_POSITION)
|
|
||||||
mockIndicators += 2
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, '', errorMessages.MOCK_DETECTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetValidation = () => {
|
|
||||||
previousPositions.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
validateLocation,
|
|
||||||
showMockWarning,
|
|
||||||
resetValidation,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ import config from '@/app.config'
|
||||||
import http from '@/plugins/http'
|
import http from '@/plugins/http'
|
||||||
import { useCounterMixin } from '@/stores/mixin'
|
import { useCounterMixin } from '@/stores/mixin'
|
||||||
import { usePermissions } from '@/composables/usePermissions'
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
import { useLocationValidation } from '@/composables/useLocationValidation'
|
|
||||||
import { usePrivacyStore } from '@/stores/privacy'
|
import { usePrivacyStore } from '@/stores/privacy'
|
||||||
|
|
||||||
import type { FormRef, OptionReason } from '@/interface/response/checkin'
|
import type { FormRef, OptionReason } from '@/interface/response/checkin'
|
||||||
|
|
@ -19,8 +18,6 @@ const mixin = useCounterMixin()
|
||||||
const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin
|
const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const { checkPrivacyAccepted } = usePermissions()
|
const { checkPrivacyAccepted } = usePermissions()
|
||||||
const { validateLocation, showMockWarning, resetValidation } =
|
|
||||||
useLocationValidation()
|
|
||||||
const privacyStore = usePrivacyStore()
|
const privacyStore = usePrivacyStore()
|
||||||
const MOCK_CHECK_DELAY_MS = 800
|
const MOCK_CHECK_DELAY_MS = 800
|
||||||
|
|
||||||
|
|
@ -36,8 +33,6 @@ 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 เช็คเวลาต้องลงเวลาเข้าหรือออกงาน
|
||||||
|
|
@ -119,26 +114,7 @@ async function updateLocation(
|
||||||
formLocation.POI = namePOI
|
formLocation.POI = namePOI
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* รับค่าสถานะ location จาก AscGISMap
|
|
||||||
*/
|
|
||||||
function onLocationStatus(status: boolean) {
|
|
||||||
locationGranted.value = status
|
|
||||||
if (status) {
|
|
||||||
isMockLocationDetected.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* รับค่า mock location detection จาก AscGISMap
|
|
||||||
*/
|
|
||||||
function onMockDetected(result: any) {
|
|
||||||
isMockLocationDetected.value = !!result?.isMockDetected
|
|
||||||
disabledBtn.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetLocationForRetry() {
|
function resetLocationForRetry() {
|
||||||
locationGranted.value = false
|
|
||||||
formLocation.lat = 0
|
formLocation.lat = 0
|
||||||
formLocation.lng = 0
|
formLocation.lng = 0
|
||||||
formLocation.POI = ''
|
formLocation.POI = ''
|
||||||
|
|
@ -174,62 +150,6 @@ async function getDelayedFreshPosition() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revalidateLocationBeforeSubmit() {
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
messageError(
|
|
||||||
$q,
|
|
||||||
'',
|
|
||||||
'ไม่สามารถระบุตำแหน่งปัจจุบันได้ เบราว์เซอร์ของคุณไม่รองรับ Geolocation'
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If previous attempt was mock, clear history so fresh GPS is not compared
|
|
||||||
// against spoofed coordinates and incorrectly flagged as impossible speed.
|
|
||||||
if (isMockLocationDetected.value) {
|
|
||||||
resetValidation()
|
|
||||||
}
|
|
||||||
|
|
||||||
const position = await getDelayedFreshPosition()
|
|
||||||
|
|
||||||
const validationResult = validateLocation(position)
|
|
||||||
|
|
||||||
if (validationResult.isMockDetected) {
|
|
||||||
isMockLocationDetected.value = true
|
|
||||||
disabledBtn.value = false
|
|
||||||
resetValidation()
|
|
||||||
resetLocationForRetry()
|
|
||||||
showMockWarning(validationResult)
|
|
||||||
mapRef.value?.requestLocationPermission()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validationResult.errors.length > 0) {
|
|
||||||
disabledBtn.value = false
|
|
||||||
resetValidation()
|
|
||||||
resetLocationForRetry()
|
|
||||||
messageError($q, '', validationResult.errors[0])
|
|
||||||
mapRef.value?.requestLocationPermission()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
locationGranted.value = true
|
|
||||||
isMockLocationDetected.value = false
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
disabledBtn.value = false
|
|
||||||
resetLocationForRetry()
|
|
||||||
messageError(
|
|
||||||
$q,
|
|
||||||
'',
|
|
||||||
'ไม่สามารถตรวจสอบตำแหน่งล่าสุดก่อนลงเวลาได้ กรุณาลองใหม่อีกครั้ง'
|
|
||||||
)
|
|
||||||
mapRef.value?.requestLocationPermission()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const location = ref<string>('') // พื้นที่ใกล้เคียง
|
const location = ref<string>('') // พื้นที่ใกล้เคียง
|
||||||
const model = ref<string>('') // สถานที่ทำงาน
|
const model = ref<string>('') // สถานที่ทำงาน
|
||||||
// ตัวเลือกสถานที่ทำงาน
|
// ตัวเลือกสถานที่ทำงาน
|
||||||
|
|
@ -462,11 +382,6 @@ async function validateForm() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasError.every((result) => result === true)) {
|
if (hasError.every((result) => result === true)) {
|
||||||
const isLocationValid = await revalidateLocationBeforeSubmit()
|
|
||||||
if (!isLocationValid) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusCheckin.value == false) {
|
if (statusCheckin.value == false) {
|
||||||
getCheck()
|
getCheck()
|
||||||
} else if (statusCheckin.value) {
|
} else if (statusCheckin.value) {
|
||||||
|
|
@ -511,11 +426,6 @@ async function confirm() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLocationValid = await revalidateLocationBeforeSubmit()
|
|
||||||
if (!isLocationValid) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showLoader()
|
showLoader()
|
||||||
const isLocation = workplace.value === 'in-place' //*true คือ ณ สถานที่ตั้ง, false คือ นอกสถานที่ตั้ง
|
const isLocation = workplace.value === 'in-place' //*true คือ ณ สถานที่ตั้ง, false คือ นอกสถานที่ตั้ง
|
||||||
const locationName = workplace.value === 'in-place' ? '' : useLocation.value
|
const locationName = workplace.value === 'in-place' ? '' : useLocation.value
|
||||||
|
|
@ -734,16 +644,6 @@ 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>
|
||||||
|
|
@ -816,8 +716,6 @@ 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>
|
||||||
|
|
@ -984,12 +882,7 @@ watch(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12" v-if="$q.screen.xs">
|
<div class="col-12" v-if="$q.screen.xs">
|
||||||
<MapCheck
|
<MapCheck ref="mapRef" @update:location="updateLocation" />
|
||||||
ref="mapRef"
|
|
||||||
@update:location="updateLocation"
|
|
||||||
@location-status="onLocationStatus"
|
|
||||||
@mock-detected="onMockDetected"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- กรอกข้อมูล หน้ามือถือ -->
|
<!-- กรอกข้อมูล หน้ามือถือ -->
|
||||||
|
|
@ -1138,13 +1031,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="
|
:disable="disabledBtn ? true : camera && img ? false : true"
|
||||||
disabledBtn || !locationGranted || isMockLocationDetected
|
|
||||||
? true
|
|
||||||
: camera && img
|
|
||||||
? false
|
|
||||||
: true
|
|
||||||
"
|
|
||||||
@click="validateForm"
|
@click="validateForm"
|
||||||
:loading="inQueue"
|
:loading="inQueue"
|
||||||
/>
|
/>
|
||||||
|
|
@ -1259,13 +1146,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="
|
:disable="disabledBtn ? true : camera && img ? false : true"
|
||||||
disabledBtn || !locationGranted || isMockLocationDetected
|
|
||||||
? true
|
|
||||||
: camera && img
|
|
||||||
? false
|
|
||||||
: true
|
|
||||||
"
|
|
||||||
@click="validateForm"
|
@click="validateForm"
|
||||||
:loading="inQueue"
|
:loading="inQueue"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue