Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ac3a3109b | |||
| 46c51fac3b | |||
| 202318c169 | |||
|
|
e1962d79bb | ||
|
|
d4ae2f56a0 | ||
|
|
a45c1cae90 | ||
|
|
8240a0b3c5 | ||
| 7edfaa4e4f | |||
| 02f1fd417d | |||
| f53327e2d9 | |||
| 9a2f5b503b | |||
|
|
fa855b30c8 | ||
|
|
66a48f3830 | ||
|
|
e2f22cc9c0 |
8 changed files with 447 additions and 596 deletions
155
CLAUDE.md
Normal file
155
CLAUDE.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is an HRMS (Human Resource Management System) check-in/check-out web application built for the Bangkok Metropolitan Administration (BMA). The application allows employees to record their work attendance using geolocation verification and camera features.
|
||||
|
||||
**Tech Stack:**
|
||||
- Vue 3 with Composition API and TypeScript
|
||||
- Vite for build tooling
|
||||
- Quasar Framework for UI components
|
||||
- Pinia for state management
|
||||
- Vue Router for routing
|
||||
- Keycloak for authentication
|
||||
- ArcGIS API and Google Maps for location features
|
||||
- PWA capabilities with offline support
|
||||
- Docker deployment with nginx
|
||||
|
||||
**Development Server:** Runs on port 3008
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start dev server on port 3008
|
||||
|
||||
# Building
|
||||
npm run build # Build for production
|
||||
npm run preview # Preview production build on port 3008
|
||||
|
||||
# Testing
|
||||
npm run test:unit # Run unit tests with vitest
|
||||
npm run test:e2e # Run e2e tests with cypress
|
||||
npm run test:e2e:dev # Run cypress in dev mode
|
||||
|
||||
# Code Quality
|
||||
npm run lint # Lint and auto-fix code
|
||||
npm run format # Format code with prettier
|
||||
npm run type-check # TypeScript type checking
|
||||
|
||||
# Docker
|
||||
docker buildx build --platform=linux/amd64 -f docker/Dockerfile . -t hrms-checkin:0.1
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API layer modules (checkin, history, message)
|
||||
├── components/ # Reusable Vue components
|
||||
├── composables/ # Vue composables (e.g., usePermissions)
|
||||
├── interface/ # TypeScript type definitions
|
||||
│ └── response/ # API response types
|
||||
├── plugins/ # Vue plugins (auth, http, keycloak)
|
||||
├── router/ # Vue Router configuration
|
||||
├── stores/ # Pinia stores for state management
|
||||
├── style/ # Global styles and Quasar variables
|
||||
├── utils/ # Utility functions
|
||||
└── views/ # Page-level Vue components
|
||||
```
|
||||
|
||||
### Key Architecture Patterns
|
||||
|
||||
**Authentication Flow:**
|
||||
- Uses Keycloak for SSO authentication
|
||||
- Tokens stored in cookies with `BMAHRISCKI_KEYCLOAK_IDENTITY` key
|
||||
- Auth state managed in `src/plugins/auth.ts`
|
||||
- Router guards enforce authentication on protected routes
|
||||
- 401 responses trigger automatic logout via axios interceptor
|
||||
|
||||
**State Management:**
|
||||
- `stores/mixin.ts` - Global utilities (Thai date formatting, dialogs, loaders, error handling)
|
||||
- `stores/chekin.ts` - Check-in history and status management
|
||||
- `stores/privacy.ts` - Privacy consent management
|
||||
- `stores/positionKeycloak.ts` - User position/role data
|
||||
|
||||
**API Layer:**
|
||||
- Base axios instance in `src/plugins/http.ts` with automatic token injection
|
||||
- API endpoints organized by feature in `src/api/`
|
||||
- Environment-specific API URLs configured in `src/api/index.ts`
|
||||
- Production API URL set via `VITE_API_URI_CONFIG` env variable
|
||||
|
||||
**Routing:**
|
||||
- Main layout (`MainView`) wraps authenticated routes
|
||||
- Protected routes require `meta.Auth: true`
|
||||
- Auth check runs in router beforeEach guard
|
||||
- Public routes: `/login`, `/auth`, `/reset-password`, `/history`
|
||||
|
||||
**Geolocation & Maps:**
|
||||
- ArcGIS JS API for advanced mapping features
|
||||
- Google Maps integration for location services
|
||||
- Camera integration for check-in photo verification
|
||||
- Privacy consent required before accessing camera/location
|
||||
|
||||
**Date/Time Handling:**
|
||||
- Thai Buddhist calendar (BE) conversion (+543 years)
|
||||
- Thai month names (full and abbreviated)
|
||||
- `date-fns-tz` for timezone handling (Asia/Bangkok)
|
||||
- Fiscal year calculation (October start)
|
||||
|
||||
### Component Conventions
|
||||
|
||||
- Components use Quasar UI components (`q-btn`, `q-dialog`, etc.)
|
||||
- Thai language throughout the UI
|
||||
- Custom dialogs use `CustomDialog.vue` component
|
||||
- Loading states use Quasar's `QSpinnerCube`
|
||||
- Error dialogs prevent overlap with `activeErrorDialog` tracking
|
||||
|
||||
### Localization
|
||||
|
||||
- Quasar configured with Thai language (`quasar/lang/th`)
|
||||
- All UI text in Thai
|
||||
- Date formatting uses `date2Thai()` and `monthYear2Thai()` from mixin store
|
||||
- Status labels translated: ABSENT→'ขาดราชการ', NORMAL→'ปกติ', LATE→'สาย'
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required in `.env.production`:
|
||||
- `VITE_API_URI_CONFIG` - Production API base URL
|
||||
- `VITE_URL_SSO` - Keycloak SSO logout URL
|
||||
- `VITE_URL_USER` - User service URL for redirects
|
||||
|
||||
### PWA Configuration
|
||||
|
||||
- Auto-update registration with `vite-plugin-pwa`
|
||||
- Manifest: "HRMS-Checkin" / "HRMS Checkin"
|
||||
- Icons: 192x192 and 512x512 PNG
|
||||
- Service worker registered in `src/registerServiceWorker.ts`
|
||||
|
||||
## Development Notes
|
||||
|
||||
**When adding new features:**
|
||||
1. Create API functions in `src/api/`
|
||||
2. Define TypeScript interfaces in `src/interface/response/`
|
||||
3. Use Pinia stores for complex state, composables for reusable logic
|
||||
4. Follow Thai localization patterns for user-facing text
|
||||
5. Ensure privacy consent flow for camera/location features
|
||||
|
||||
**When working with dates:**
|
||||
- Always use `date2Thai()` or `convertDateToAPI()` from mixin store
|
||||
- Dates stored in BE format (year + 543)
|
||||
- API expects `yyyy-MM-dd` or `yyyy-MM-dd HH:mm:ss` format
|
||||
|
||||
**When handling errors:**
|
||||
- Use `messageError()` from mixin store for consistent error dialogs
|
||||
- 401 errors trigger automatic logout
|
||||
- Dialog overlap prevention built into error handling
|
||||
|
||||
**When running tests:**
|
||||
- Unit tests use Vitest with jsdom environment
|
||||
- E2E tests use Cypress with baseUrl: `http://localhost:4173`
|
||||
- Test files follow pattern: `*.cy.{js,ts}` or `*.spec.{js,ts}`
|
||||
|
|
@ -5,51 +5,20 @@ import axios from 'axios'
|
|||
import { useCounterMixin } from '@/stores/mixin'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { usePrivacyStore } from '@/stores/privacy'
|
||||
import { useLocationValidation } from '@/composables/useLocationValidation'
|
||||
|
||||
const mixin = useCounterMixin()
|
||||
const { messageError } = mixin
|
||||
const privacyStore = usePrivacyStore()
|
||||
|
||||
// import type { LocationObject } from '@/interface/index/Main'
|
||||
const mapElement = ref<HTMLElement | null>(null)
|
||||
const emit = defineEmits(['update:location', 'locationStatus', 'mockDetected'])
|
||||
const emit = defineEmits(['update:location'])
|
||||
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) {
|
||||
// ส่ง event ไปยัง parent component เพื่ออัพเดทค่า props
|
||||
emit('update:location', latitude, longitude, namePOI)
|
||||
}
|
||||
|
||||
const poiPlaceName = ref<string>('') // ชื่อพื้นที่ใกล้เคียง
|
||||
const isMapReady = ref<boolean>(false)
|
||||
|
||||
// Replace ArcGIS api key
|
||||
const apiKey = ref<string>(
|
||||
|
|
@ -58,7 +27,7 @@ const apiKey = ref<string>(
|
|||
)
|
||||
const zoomMap = ref<number>(18)
|
||||
|
||||
async function initializeMap(position: GeolocationPosition) {
|
||||
async function initializeMap() {
|
||||
try {
|
||||
// Load modules of ArcGIS
|
||||
loadModules([
|
||||
|
|
@ -69,149 +38,159 @@ async function initializeMap(position: GeolocationPosition) {
|
|||
'esri/Graphic',
|
||||
'esri/layers/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({
|
||||
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({
|
||||
container: 'mapViewDisplay',
|
||||
map: map,
|
||||
center: {
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
}, // Set the initial map center current position
|
||||
const mapView = new MapView({
|
||||
container: 'mapViewDisplay',
|
||||
map: map,
|
||||
center: {
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
}, // Set the initial map center current position
|
||||
|
||||
zoom: zoomMap.value,
|
||||
constraints: {
|
||||
snapToZoom: false, // Disables snapping to the zoom level
|
||||
minZoom: zoomMap.value, // Set minimum zoom level
|
||||
maxZoom: zoomMap.value, // Set maximum zoom level (same as minZoom for fixed zoom)
|
||||
},
|
||||
zoom: zoomMap.value,
|
||||
constraints: {
|
||||
snapToZoom: false, // Disables snapping to the zoom level
|
||||
minZoom: zoomMap.value, // Set minimum zoom level
|
||||
maxZoom: zoomMap.value, // Set maximum zoom level (same as minZoom for fixed zoom)
|
||||
},
|
||||
|
||||
ui: {
|
||||
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)
|
||||
ui: {
|
||||
components: [], // Empty array to remove all default UI components
|
||||
},
|
||||
})
|
||||
.catch(async (error) => {
|
||||
// console.error('Error fetching points of interest:', error)
|
||||
// Get POI place ยิงไปขอที่ server arcgis ไม่ต้องใช้ token
|
||||
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,
|
||||
},
|
||||
|
||||
// ตำแหน่งของผู้ใช้
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
.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,
|
||||
})
|
||||
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((error) => {
|
||||
// Keep map visible even when POI lookup fails.
|
||||
poiPlaceName.value = 'ไม่พบข้อมูล'
|
||||
updateLocation(latitude, longitude, poiPlaceName.value)
|
||||
})
|
||||
})
|
||||
updateLocation(latitude, longitude, poiPlaceName.value)
|
||||
})
|
||||
.catch(async (error) => {
|
||||
// console.error('Error fetching points of interest:', error)
|
||||
// Get POI place ยิงไปขอที่ server arcgis ไม่ต้องใช้ token
|
||||
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) {
|
||||
console.error('Error loading the map', error)
|
||||
|
|
@ -242,35 +221,10 @@ const requestLocationPermission = () => {
|
|||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
const sampledPosition = await getPositionForValidation(position)
|
||||
|
||||
// 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.
|
||||
// Permission granted
|
||||
locationGranted.value = true
|
||||
emit('locationStatus', true)
|
||||
|
||||
const { latitude, longitude } = sampledPosition.coords
|
||||
const { latitude, longitude } = position.coords
|
||||
// console.log('Current position:', latitude, longitude)
|
||||
|
||||
if (!latitude || !longitude) {
|
||||
|
|
@ -280,13 +234,12 @@ const requestLocationPermission = () => {
|
|||
|
||||
// Center map on user's location if map is initialized
|
||||
if (privacyStore.isAccepted) {
|
||||
await initializeMap(sampledPosition)
|
||||
await initializeMap()
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
// Permission denied
|
||||
locationGranted.value = false
|
||||
emit('locationStatus', false)
|
||||
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
|
|
@ -311,19 +264,18 @@ const requestLocationPermission = () => {
|
|||
break
|
||||
}
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
|
||||
{ enableHighAccuracy: true }
|
||||
)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
requestLocationPermission,
|
||||
locationGranted,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="!isMapReady" class="col-12">
|
||||
<div v-if="!poiPlaceName" class="col-12">
|
||||
<q-skeleton
|
||||
:height="$q.screen.gt.xs ? '35vh' : '45px'"
|
||||
width="100%"
|
||||
|
|
@ -333,7 +285,7 @@ defineExpose({
|
|||
</div>
|
||||
|
||||
<q-card
|
||||
v-show="isMapReady"
|
||||
v-show="poiPlaceName"
|
||||
bordered
|
||||
flat
|
||||
class="col-12 bg-grey-2 shadow-0"
|
||||
|
|
@ -355,7 +307,7 @@ defineExpose({
|
|||
>
|
||||
พื้นที่ใกล้เคียง
|
||||
<span class="q-px-sm">:</span>
|
||||
{{ poiPlaceName || 'ไม่พบข้อมูล' }}
|
||||
{{ poiPlaceName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -372,7 +324,7 @@ defineExpose({
|
|||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
{{ poiPlaceName || 'ไม่พบข้อมูล' }}
|
||||
{{ poiPlaceName }}
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import http from '@/plugins/http'
|
|||
import config from '@/app.config'
|
||||
|
||||
import DialogHeader from '@/components/DialogHeader.vue'
|
||||
import FooterContact from '@/components/FooterContact.vue'
|
||||
|
||||
const $q = useQuasar()
|
||||
const store = usePositionKeycloakStore()
|
||||
|
|
@ -175,7 +176,7 @@ function onClose() {
|
|||
|
||||
<template>
|
||||
<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">
|
||||
<DialogHeader :tittle="title" :close="onClose" />
|
||||
<q-separator />
|
||||
|
|
@ -304,6 +305,9 @@ function onClose() {
|
|||
</q-uploader>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="text-caption text-grey-8 q-mb-sm">
|
||||
ผู้ดูแลระบบจะติดต่อกลับผ่านทางอีเมลที่ท่านระบุ กรุณาตรวจสอบอีเมลของท่านเป็นระยะ
|
||||
</div>
|
||||
<div class="row col-12 q-col-gutter-sm">
|
||||
<div class="col-xs-12 col-md-6 col-lg-6">
|
||||
<q-input
|
||||
|
|
@ -314,10 +318,11 @@ function onClose() {
|
|||
class="inputgreen"
|
||||
hide-bottom-space
|
||||
:rules="[
|
||||
() =>
|
||||
!!formData.email ||
|
||||
!!formData.phone ||
|
||||
'กรุณากรอกอีเมลหรือเบอร์โทรติดต่อกลับ',
|
||||
(val: string) => !!val || 'กรุณากรอกที่อยู่อีเมล',
|
||||
(val: string) => {
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailPattern.test(val) || 'กรุณากรอกที่อยู่อีเมลในรูปแบบที่ถูกต้อง';
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -329,12 +334,6 @@ function onClose() {
|
|||
v-model="formData.phone"
|
||||
class="inputgreen"
|
||||
hide-bottom-space
|
||||
:rules="[
|
||||
() =>
|
||||
!!formData.email ||
|
||||
!!formData.phone ||
|
||||
'กรุณากรอกอีเมลหรือเบอร์โทรติดต่อกลับ',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -343,7 +342,9 @@ function onClose() {
|
|||
</q-card-section>
|
||||
|
||||
<q-separator />
|
||||
<q-card-actions align="right">
|
||||
<q-card-actions class="q-px-md items-center">
|
||||
<FooterContact />
|
||||
<q-space />
|
||||
<q-btn
|
||||
type="submit"
|
||||
for="#submitForm"
|
||||
|
|
|
|||
17
src/components/FooterContact.vue
Normal file
17
src/components/FooterContact.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<div class="row items-center justify-center q-gutter-sm">
|
||||
<q-icon name="support_agent" color="primary" size="sm" />
|
||||
<span class="text-body2">
|
||||
พบปัญหาการใช้งานกรุณาติดต่อผู้ดูแลระบบ
|
||||
<span class="text-weight-medium text-primary"
|
||||
><a href="tel:0882649800" style="text-decoration: none; color: inherit"
|
||||
>088-264-9800</a
|
||||
></span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -108,6 +108,7 @@ const checkIfScrollable = () => {
|
|||
transition-show="slide-up"
|
||||
transition-hide="slide-down"
|
||||
:maximized="$q.screen.lt.sm"
|
||||
@show="checkIfScrollable"
|
||||
>
|
||||
<q-card class="privacy-card" style="max-width: 560px; max-height: 95vh">
|
||||
<!-- Header -->
|
||||
|
|
|
|||
|
|
@ -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 { useCounterMixin } from '@/stores/mixin'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useLocationValidation } from '@/composables/useLocationValidation'
|
||||
import { usePrivacyStore } from '@/stores/privacy'
|
||||
|
||||
import type { FormRef, OptionReason } from '@/interface/response/checkin'
|
||||
|
|
@ -19,8 +18,6 @@ const mixin = useCounterMixin()
|
|||
const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin
|
||||
const $q = useQuasar()
|
||||
const { checkPrivacyAccepted } = usePermissions()
|
||||
const { validateLocation, showMockWarning, resetValidation } =
|
||||
useLocationValidation()
|
||||
const privacyStore = usePrivacyStore()
|
||||
const MOCK_CHECK_DELAY_MS = 800
|
||||
|
||||
|
|
@ -36,8 +33,6 @@ const endTimeAfternoon = ref<string>('12:00:00') //เวลาเช็คเ
|
|||
|
||||
const isLoadingCheckTime = ref<boolean>(false) // ตัวแปรสำหรับการโหลด
|
||||
const disabledBtn = ref<boolean>(false)
|
||||
const locationGranted = ref<boolean>(false)
|
||||
const isMockLocationDetected = ref<boolean>(false)
|
||||
|
||||
/**
|
||||
* fetch เช็คเวลาต้องลงเวลาเข้าหรือออกงาน
|
||||
|
|
@ -119,26 +114,7 @@ async function updateLocation(
|
|||
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() {
|
||||
locationGranted.value = false
|
||||
formLocation.lat = 0
|
||||
formLocation.lng = 0
|
||||
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 model = ref<string>('') // สถานที่ทำงาน
|
||||
// ตัวเลือกสถานที่ทำงาน
|
||||
|
|
@ -267,6 +187,9 @@ const cameraIsOn = ref<boolean>(false)
|
|||
const img = ref<any>(undefined)
|
||||
const photoWidth = ref<number>(350)
|
||||
const photoHeight = ref<number>(350)
|
||||
const availableCameras = ref<any[]>([])
|
||||
const currentCameraIndex = ref<number>(0)
|
||||
const currentCameraType = ref<'front' | 'back' | 'unknown'>('unknown')
|
||||
|
||||
const intervalId = ref<number | undefined>(undefined) // ต้องใช้ตัวแปรเก็บค่า interval
|
||||
|
||||
|
|
@ -382,6 +305,16 @@ async function stopChecking() {
|
|||
}
|
||||
}
|
||||
|
||||
function identifyCameraType(label: string): 'front' | 'back' | 'unknown' {
|
||||
const lowerLabel = label.toLowerCase()
|
||||
if (lowerLabel.includes('front') || lowerLabel.includes('user') || lowerLabel.includes('face')) {
|
||||
return 'front'
|
||||
} else if (lowerLabel.includes('back') || lowerLabel.includes('environment') || lowerLabel.includes('rear')) {
|
||||
return 'back'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** function เปิดกล้อง*/
|
||||
async function openCamera() {
|
||||
// เช็คสิทธิ์ privacy ก่อนเปิดกล้อง
|
||||
|
|
@ -395,7 +328,11 @@ async function openCamera() {
|
|||
await camera.value?.stop()
|
||||
} else {
|
||||
await camera.value?.start()
|
||||
await changeCamera() // ต้องรอให้ start() เสร็จก่อน
|
||||
const devices: any = await camera.value?.devices(['videoinput'])
|
||||
if (devices) {
|
||||
availableCameras.value = devices
|
||||
await changeCamera()
|
||||
}
|
||||
}
|
||||
cameraIsOn.value = !cameraIsOn.value
|
||||
} else {
|
||||
|
|
@ -409,10 +346,59 @@ async function openCamera() {
|
|||
}
|
||||
|
||||
/** change camera device*/
|
||||
async function changeCamera() {
|
||||
const devices: any = await camera.value?.devices(['videoinput'])
|
||||
const device = await devices[0]
|
||||
camera.value?.changeCamera(device.deviceId)
|
||||
async function changeCamera(targetCameraType?: 'front' | 'back') {
|
||||
try {
|
||||
const devices: any = await camera.value?.devices(['videoinput'])
|
||||
|
||||
if (!devices || devices.length === 0) {
|
||||
console.warn('No cameras found')
|
||||
return
|
||||
}
|
||||
|
||||
availableCameras.value = devices
|
||||
|
||||
if (devices.length === 1 || !targetCameraType) {
|
||||
const device = devices[0]
|
||||
await camera.value?.changeCamera(device.deviceId)
|
||||
currentCameraIndex.value = 0
|
||||
currentCameraType.value = identifyCameraType(device.label || '')
|
||||
return
|
||||
}
|
||||
|
||||
const matchingCameras = devices.filter((device: any) =>
|
||||
identifyCameraType(device.label || '') === targetCameraType
|
||||
)
|
||||
|
||||
if (matchingCameras.length > 0) {
|
||||
const targetDevice = matchingCameras[0]
|
||||
await camera.value?.changeCamera(targetDevice.deviceId)
|
||||
currentCameraIndex.value = devices.indexOf(targetDevice)
|
||||
currentCameraType.value = targetCameraType
|
||||
} else {
|
||||
const nextIndex = (currentCameraIndex.value + 1) % devices.length
|
||||
const nextDevice = devices[nextIndex]
|
||||
await camera.value?.changeCamera(nextDevice.deviceId)
|
||||
currentCameraIndex.value = nextIndex
|
||||
currentCameraType.value = identifyCameraType(nextDevice.label || '')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error switching camera:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** switch camera device*/
|
||||
async function switchCamera() {
|
||||
if (availableCameras.value.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
// สลับแค่ระหว่างกล้อง 2 ตัวแรก (กล้องหน้าและหลังหลัก)
|
||||
const targetIndex = currentCameraIndex.value === 0 ? 1 : 0
|
||||
const targetDevice = availableCameras.value[targetIndex]
|
||||
|
||||
await camera.value?.changeCamera(targetDevice.deviceId)
|
||||
currentCameraIndex.value = targetIndex
|
||||
currentCameraType.value = identifyCameraType(targetDevice.label || '')
|
||||
}
|
||||
|
||||
/** function ถ่ายรูป*/
|
||||
|
|
@ -462,11 +448,6 @@ async function validateForm() {
|
|||
}
|
||||
}
|
||||
if (hasError.every((result) => result === true)) {
|
||||
const isLocationValid = await revalidateLocationBeforeSubmit()
|
||||
if (!isLocationValid) {
|
||||
return
|
||||
}
|
||||
|
||||
if (statusCheckin.value == false) {
|
||||
getCheck()
|
||||
} else if (statusCheckin.value) {
|
||||
|
|
@ -511,11 +492,6 @@ async function confirm() {
|
|||
return
|
||||
}
|
||||
|
||||
const isLocationValid = await revalidateLocationBeforeSubmit()
|
||||
if (!isLocationValid) {
|
||||
return
|
||||
}
|
||||
|
||||
showLoader()
|
||||
const isLocation = workplace.value === 'in-place' //*true คือ ณ สถานที่ตั้ง, false คือ นอกสถานที่ตั้ง
|
||||
const locationName = workplace.value === 'in-place' ? '' : useLocation.value
|
||||
|
|
@ -734,16 +710,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>
|
||||
|
||||
<template>
|
||||
|
|
@ -816,8 +782,6 @@ watch(
|
|||
v-if="$q.screen.gt.xs"
|
||||
ref="mapRef"
|
||||
@update:location="updateLocation"
|
||||
@location-status="onLocationStatus"
|
||||
@mock-detected="onMockDetected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -861,6 +825,16 @@ watch(
|
|||
v-if="$q.screen.gt.xs"
|
||||
class="absolute-bottom-right q-ma-md"
|
||||
>
|
||||
<q-btn
|
||||
v-if="availableCameras.length > 1 && img == null"
|
||||
round
|
||||
push
|
||||
icon="flip_camera_ios"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-mr-sm"
|
||||
@click="switchCamera"
|
||||
/>
|
||||
<q-btn
|
||||
v-if="img == null"
|
||||
round
|
||||
|
|
@ -885,6 +859,16 @@ watch(
|
|||
class="absolute-bottom text-subtitle2 text-center q-py-sm"
|
||||
style="background: #00000021"
|
||||
>
|
||||
<q-btn
|
||||
v-if="availableCameras.length > 1 && img == null"
|
||||
round
|
||||
icon="flip_camera_ios"
|
||||
size="16px"
|
||||
style="background: #424242; color: white"
|
||||
@click="switchCamera"
|
||||
unelevated
|
||||
class="q-mr-xs"
|
||||
/>
|
||||
<q-btn
|
||||
round
|
||||
v-if="img == null"
|
||||
|
|
@ -984,12 +968,7 @@ watch(
|
|||
</div>
|
||||
|
||||
<div class="col-12" v-if="$q.screen.xs">
|
||||
<MapCheck
|
||||
ref="mapRef"
|
||||
@update:location="updateLocation"
|
||||
@location-status="onLocationStatus"
|
||||
@mock-detected="onMockDetected"
|
||||
/>
|
||||
<MapCheck ref="mapRef" @update:location="updateLocation" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- กรอกข้อมูล หน้ามือถือ -->
|
||||
|
|
@ -1138,13 +1117,7 @@ watch(
|
|||
push
|
||||
size="18px"
|
||||
:class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'"
|
||||
:disable="
|
||||
disabledBtn || !locationGranted || isMockLocationDetected
|
||||
? true
|
||||
: camera && img
|
||||
? false
|
||||
: true
|
||||
"
|
||||
:disable="disabledBtn ? true : camera && img ? false : true"
|
||||
@click="validateForm"
|
||||
:loading="inQueue"
|
||||
/>
|
||||
|
|
@ -1259,13 +1232,7 @@ watch(
|
|||
push
|
||||
size="18px"
|
||||
:class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'"
|
||||
:disable="
|
||||
disabledBtn || !locationGranted || isMockLocationDetected
|
||||
? true
|
||||
: camera && img
|
||||
? false
|
||||
: true
|
||||
"
|
||||
:disable="disabledBtn ? true : camera && img ? false : true"
|
||||
@click="validateForm"
|
||||
:loading="inQueue"
|
||||
/>
|
||||
|
|
@ -1402,4 +1369,4 @@ watch(
|
|||
rgba(2, 169, 152, 1) 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
@ -17,6 +17,7 @@ import { usePositionKeycloakStore } from '@/stores/positionKeycloak'
|
|||
import DialogHeader from '@/components/DialogHeader.vue'
|
||||
import PopupPrivacy from '@/components/PopupPrivacy.vue'
|
||||
import DialogDebug from '@/components/DialogDebug.vue'
|
||||
import FooterContact from '@/components/FooterContact.vue'
|
||||
|
||||
const mixin = useCounterMixin()
|
||||
const privacyStore = usePrivacyStore()
|
||||
|
|
@ -561,6 +562,14 @@ onMounted(async () => {
|
|||
<router-view :key="$route.fullPath" />
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
|
||||
<!-- Footer -->
|
||||
<q-footer
|
||||
class="bg-grey-1 text-dark q-pa-md"
|
||||
:class="$q.screen.xs ? 'hidden' : ''"
|
||||
>
|
||||
<footer-contact />
|
||||
</q-footer>
|
||||
</q-layout>
|
||||
|
||||
<q-dialog v-model="modalReset" persistent>
|
||||
|
|
@ -714,4 +723,8 @@ onMounted(async () => {
|
|||
background-color: #016987;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue