diff --git a/package.json b/package.json index f4b60b6..edf70a8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "preview": "vite preview --host 0.0.0.0", "preview:test": "vite preview --host 0.0.0.0 --mode test", "preview:prod": "vite preview --host 0.0.0.0 --mode production", + "test:unit": "node --test --experimental-strip-types src/utils/__tests__/*.test.ts", "typecheck": "vue-tsc -b" }, "dependencies": { diff --git a/src/api/auth.ts b/src/api/auth.ts index eb49856..c5c45c8 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -35,6 +35,8 @@ export const getImageCodeUrl = (imageRandomStr: string) => { export const sendMobileCode = async (payload: SendMobileCodePayload) => { if (env.useMock) return mockApi.sendMobileCode(payload) return await http.get(joinUrl(env.adminBase, 'platformUser/sendMobileCode', payload.phone), { + skipAuth: true, + skipAuthRefresh: true, headers: { clientId: env.clientId, clientConfigId: env.clientConfigId @@ -59,6 +61,8 @@ export const loginByMobile = async (phone: string, code: string): Promise(joinUrl(env.authBase, 'oauth2/token'), form, { + skipAuth: true, + skipAuthRefresh: true, headers: { Authorization: basicAuth(), 'Content-Type': 'application/x-www-form-urlencoded' diff --git a/src/api/request.ts b/src/api/request.ts index f74d6c4..25abf52 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -1,7 +1,16 @@ -import axios, { AxiosError } from 'axios' +import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' import { showToast } from 'vant' -import { AUTH_EXPIRED_EVENT, AUTH_STORAGE_KEYS, clearAuthStorage } from '@/utils/authStorage' +import { refreshAccessTokenOnce } from '@/api/tokenRefresh' +import { + AUTH_EXPIRED_EVENT, + AUTH_TOKEN_UPDATED_EVENT, + accessTokenNeedsRefresh, + clearAuthStorage, + readStoredAccessToken, + readStoredRefreshToken +} from '@/utils/authStorage' import { env } from '@/utils/env' +import { StaleRefreshTokenError } from '@/utils/refreshTokenState' const http = axios.create({ baseURL: env.apiBase, @@ -15,6 +24,12 @@ export type RequestError = Error & { let isRedirectingToLogin = false +if (typeof window !== 'undefined') { + window.addEventListener(AUTH_TOKEN_UPDATED_EVENT, () => { + isRedirectingToLogin = false + }) +} + const authExpiredTexts = ['请求令牌已过期', '令牌已过期', 'token已过期', 'Token已过期', 'invalid_token'] const getHeader = (headers: unknown, key: string) => { @@ -25,6 +40,20 @@ const getHeader = (headers: unknown, key: string) => { const hasBearerAuthorization = (headers: unknown) => getHeader(headers, 'Authorization').startsWith('Bearer ') +const setHeader = (headers: unknown, key: string, value: string) => { + if (!headers || typeof headers !== 'object') return + const maybeHeaders = headers as Record & { set?: (name: string, value: string) => void } + if (typeof maybeHeaders.set === 'function') { + maybeHeaders.set(key, value) + return + } + maybeHeaders[key] = value +} + +const setBearerAuthorization = (config: InternalAxiosRequestConfig, token: string) => { + setHeader(config.headers, 'Authorization', `Bearer ${token}`) +} + const createRequestError = (message: string, cause?: unknown, toastShown = false) => { const requestError = new Error(message) as RequestError requestError.cause = cause @@ -32,10 +61,16 @@ const createRequestError = (message: string, cause?: unknown, toastShown = false return requestError } -const isAuthExpired = (status?: number, code?: unknown, message = '', hasBearerToken = false) => { +const isAuthExpired = ( + status?: number, + code?: unknown, + message = '', + hasBearerToken = false, + canUseAuthState = false +) => { const normalizedCode = String(code || '') return ( - authExpiredTexts.some((text) => message.includes(text)) || + (canUseAuthState && authExpiredTexts.some((text) => message.includes(text))) || ((status === 401 || normalizedCode === '401') && hasBearerToken) ) } @@ -61,10 +96,44 @@ const redirectToLogin = (message: string) => { return true } -http.interceptors.request.use((config) => { - const token = localStorage.getItem(AUTH_STORAGE_KEYS.token) - if (token && !getHeader(config.headers, 'Authorization')) { - config.headers.Authorization = `Bearer ${token}` +const canRefreshRequest = (config?: InternalAxiosRequestConfig) => { + return Boolean(config && !config.skipAuth && !config.skipAuthRefresh && !config.authRetry && readStoredRefreshToken()) +} + +const refreshAndReplayRequest = async (config: InternalAxiosRequestConfig) => { + config.authRetry = true + let token = '' + try { + token = await refreshAccessTokenOnce() + } catch (error) { + return redirectAfterRefreshFailure('登录已失效,请重新登录', error) + } + setBearerAuthorization(config, token) + return await http.request(config) +} + +const redirectAfterRefreshFailure = (message: string, error: unknown) => { + if (error instanceof StaleRefreshTokenError) { + return Promise.reject(createRequestError(error.message, error, true)) + } + + const finalMessage = message || (error instanceof Error ? error.message : '') || '登录已失效,请重新登录' + redirectToLogin(finalMessage) + return Promise.reject(createRequestError(finalMessage, error, true)) +} + +http.interceptors.request.use(async (config) => { + if (canRefreshRequest(config) && accessTokenNeedsRefresh()) { + try { + await refreshAccessTokenOnce() + } catch (error) { + return redirectAfterRefreshFailure('登录已失效,请重新登录', error) + } + } + + const token = readStoredAccessToken() + if (!config.skipAuth && token && !getHeader(config.headers, 'Authorization')) { + setBearerAuthorization(config, token) } return config }) @@ -77,7 +146,18 @@ http.interceptors.response.use( return payload.data === undefined ? payload : payload.data } const message = payload.msg || payload.message || '请求失败' - if (isAuthExpired(response.status, payload.code, message, hasBearerAuthorization(response.config.headers))) { + if ( + isAuthExpired( + response.status, + payload.code, + message, + hasBearerAuthorization(response.config.headers), + !response.config.skipAuth + ) + ) { + if (canRefreshRequest(response.config) && hasBearerAuthorization(response.config.headers)) { + return refreshAndReplayRequest(response.config) + } const toastShown = redirectToLogin(message) return Promise.reject(createRequestError(message, undefined, toastShown)) } @@ -85,16 +165,26 @@ http.interceptors.response.use( } return payload }, - (error: AxiosError<{ code?: number | string; msg?: string; message?: string }>) => { - const message = error.response?.data?.msg || error.response?.data?.message || error.message || '网络异常' + (error: AxiosError<{ code?: number | string; msg?: string; message?: string }> | RequestError) => { + if ((error as RequestError).toastShown) { + return Promise.reject(error) + } + + const axiosError = error as AxiosError<{ code?: number | string; msg?: string; message?: string }> + const message = axiosError.response?.data?.msg || axiosError.response?.data?.message || error.message || '网络异常' if ( isAuthExpired( - error.response?.status, - error.response?.data?.code, + axiosError.response?.status, + axiosError.response?.data?.code, message, - hasBearerAuthorization(error.config?.headers) + hasBearerAuthorization(axiosError.config?.headers), + !axiosError.config?.skipAuth ) ) { + const originalConfig = axiosError.config + if (originalConfig && canRefreshRequest(originalConfig) && hasBearerAuthorization(originalConfig.headers)) { + return refreshAndReplayRequest(originalConfig) + } const toastShown = redirectToLogin(message) return Promise.reject(createRequestError(message, error, toastShown)) } diff --git a/src/api/tokenRefresh.ts b/src/api/tokenRefresh.ts new file mode 100644 index 0000000..532867c --- /dev/null +++ b/src/api/tokenRefresh.ts @@ -0,0 +1,111 @@ +import axios from 'axios' +import type { TokenResponse } from '@/types/api' +import { + persistAuthToken, + readStoredRefreshToken +} from '@/utils/authStorage' +import { env } from '@/utils/env' +import { ensureRefreshTokenStillCurrent } from '@/utils/refreshTokenState' +import { joinUrl } from '@/utils/url' + +const refreshHttp = axios.create({ + baseURL: env.apiBase, + timeout: 15000 +}) + +let refreshTokenPromise: Promise | null = null +let refreshTokenPromiseKey = '' + +const basicAuth = () => { + const raw = `${env.clientId}:${env.clientSecret}` + return `Basic ${window.btoa(raw)}` +} + +const unwrapTokenResponse = (payload: unknown): TokenResponse => { + if (!payload || typeof payload !== 'object') { + throw new Error('登录凭证刷新失败') + } + + const maybePayload = payload as Partial & { + code?: number | string + data?: unknown + msg?: string + message?: string + } + + if (typeof maybePayload.access_token === 'string') { + return maybePayload as TokenResponse + } + + if (maybePayload.data && typeof maybePayload.data === 'object') { + const maybeData = maybePayload.data as Partial + if (typeof maybeData.access_token === 'string') { + return maybeData as TokenResponse + } + } + + throw new Error(maybePayload.msg || maybePayload.message || '登录凭证刷新失败') +} + +const assertRefreshTokenStillCurrent = (refreshToken: string) => { + ensureRefreshTokenStillCurrent(refreshToken, readStoredRefreshToken()) +} + +const requestRefreshToken = async (refreshToken: string) => { + if (!refreshToken) { + throw new Error('登录已失效,请重新登录') + } + + if (env.useMock) { + assertRefreshTokenStillCurrent(refreshToken) + const storedToken = persistAuthToken( + { + access_token: `mock-token-${Date.now()}`, + refresh_token: refreshToken, + expires_in: 43200 + }, + refreshToken + ) + return storedToken.accessToken + } + + const form = new URLSearchParams() + form.set('grant_type', 'refresh_token') + form.set('refresh_token', refreshToken) + + let response + try { + response = await refreshHttp.post(joinUrl(env.authBase, 'oauth2/token'), form, { + headers: { + Authorization: basicAuth(), + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + } catch (error) { + assertRefreshTokenStillCurrent(refreshToken) + throw error + } + const tokenPayload = unwrapTokenResponse(response.data) + assertRefreshTokenStillCurrent(refreshToken) + const storedToken = persistAuthToken(tokenPayload, refreshToken) + return storedToken.accessToken +} + +export const refreshAccessTokenOnce = () => { + const refreshToken = readStoredRefreshToken() + if (!refreshToken) { + return Promise.reject(new Error('登录已失效,请重新登录')) + } + + if (!refreshTokenPromise || refreshTokenPromiseKey !== refreshToken) { + refreshTokenPromiseKey = refreshToken + const currentRefreshTokenPromise = requestRefreshToken(refreshToken).finally(() => { + if (refreshTokenPromise === currentRefreshTokenPromise && refreshTokenPromiseKey === refreshToken) { + refreshTokenPromise = null + refreshTokenPromiseKey = '' + } + }) + refreshTokenPromise = currentRefreshTokenPromise + } + return refreshTokenPromise +} diff --git a/src/stores/auth.ts b/src/stores/auth.ts index b1f0dfe..c656cc9 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -6,7 +6,15 @@ import { type OrganizationMemberInfo } from '@/api/auth' import type { TokenResponse } from '@/types/api' -import { AUTH_EXPIRED_EVENT, AUTH_STORAGE_KEYS, clearAuthStorage } from '@/utils/authStorage' +import { + AUTH_EXPIRED_EVENT, + AUTH_STORAGE_KEYS, + AUTH_TOKEN_UPDATED_EVENT, + clearAuthStorage, + persistAuthToken, + readStoredAccessToken, + readStoredRefreshToken +} from '@/utils/authStorage' import { env } from '@/utils/env' export interface StaffUser { @@ -46,8 +54,9 @@ export const useAuthStore = defineStore('auth', () => { const hasOrganization = computed(() => Boolean(memberInfo.value?.organizationId)) const persistToken = (payload: TokenResponse, phone: string) => { - token.value = payload.access_token - refreshToken.value = payload.refresh_token || '' + const storedToken = persistAuthToken(payload) + token.value = storedToken.accessToken + refreshToken.value = storedToken.refreshToken user.value = { id: payload.user_id, username: payload.username || phone, @@ -55,10 +64,6 @@ export const useAuthStore = defineStore('auth', () => { tenantId: payload.tenant_id } - localStorage.setItem(AUTH_STORAGE_KEYS.token, token.value) - if (refreshToken.value) { - localStorage.setItem(AUTH_STORAGE_KEYS.refreshToken, refreshToken.value) - } localStorage.setItem(AUTH_STORAGE_KEYS.user, JSON.stringify(user.value)) } @@ -85,6 +90,11 @@ export const useAuthStore = defineStore('auth', () => { memberInfo.value = null } + const syncStoredToken = () => { + token.value = readStoredAccessToken() + refreshToken.value = readStoredRefreshToken() + } + const logout = () => { resetState() clearAuthStorage() @@ -92,6 +102,7 @@ export const useAuthStore = defineStore('auth', () => { if (typeof window !== 'undefined') { window.addEventListener(AUTH_EXPIRED_EVENT, resetState) + window.addEventListener(AUTH_TOKEN_UPDATED_EVENT, syncStoredToken) } return { diff --git a/src/types/axios.d.ts b/src/types/axios.d.ts new file mode 100644 index 0000000..11024bf --- /dev/null +++ b/src/types/axios.d.ts @@ -0,0 +1,15 @@ +import 'axios' + +declare module 'axios' { + export interface AxiosRequestConfig { + skipAuth?: boolean + skipAuthRefresh?: boolean + authRetry?: boolean + } + + export interface InternalAxiosRequestConfig { + skipAuth?: boolean + skipAuthRefresh?: boolean + authRetry?: boolean + } +} diff --git a/src/utils/__tests__/cameraAccess.test.ts b/src/utils/__tests__/cameraAccess.test.ts new file mode 100644 index 0000000..f742675 --- /dev/null +++ b/src/utils/__tests__/cameraAccess.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { getCameraAccessErrorMessage, shouldTryNextCameraConstraint } from '../cameraAccess.ts' + +const makeError = (name: string) => ({ name }) as DOMException + +test('maps permission denied camera error', () => { + assert.equal(getCameraAccessErrorMessage(makeError('NotAllowedError')), '未获得摄像头权限,请允许后重试') +}) + +test('maps occupied camera error to system or app usage hint', () => { + assert.equal( + getCameraAccessErrorMessage(makeError('NotReadableError')), + '摄像头可能被其他应用占用,或系统隐私权限未开启' + ) +}) + +test('retries next constraint only for constraint or device selection errors', () => { + assert.equal(shouldTryNextCameraConstraint(makeError('OverconstrainedError')), true) + assert.equal(shouldTryNextCameraConstraint(makeError('NotFoundError')), true) + assert.equal(shouldTryNextCameraConstraint(makeError('NotAllowedError')), false) +}) diff --git a/src/utils/__tests__/refreshTokenState.test.ts b/src/utils/__tests__/refreshTokenState.test.ts new file mode 100644 index 0000000..bc2c368 --- /dev/null +++ b/src/utils/__tests__/refreshTokenState.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { ensureRefreshTokenStillCurrent, isRefreshTokenStillCurrent } from '../refreshTokenState.ts' + +test('detects stale refresh token when current token changed', () => { + assert.equal(isRefreshTokenStillCurrent('old-refresh-token', 'new-refresh-token'), false) +}) + +test('allows same refresh token to continue', () => { + assert.equal(isRefreshTokenStillCurrent('same-refresh-token', 'same-refresh-token'), true) +}) + +test('throws stale refresh token error when token changed', () => { + assert.throws( + () => ensureRefreshTokenStillCurrent('old-refresh-token', 'new-refresh-token'), + { name: 'StaleRefreshTokenError' } + ) +}) diff --git a/src/utils/__tests__/tokenExpiry.test.ts b/src/utils/__tests__/tokenExpiry.test.ts new file mode 100644 index 0000000..7adb3e2 --- /dev/null +++ b/src/utils/__tests__/tokenExpiry.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + calculateAccessTokenExpireAt, + normalizeExpiresInSeconds, + shouldRefreshAccessToken +} from '../tokenExpiry.ts' + +test('calculates access token expire timestamp from expires_in seconds', () => { + assert.equal(calculateAccessTokenExpireAt(1_000, 30), 31_000) +}) + +test('normalizes invalid expires_in values to zero', () => { + assert.equal(normalizeExpiresInSeconds(undefined), 0) + assert.equal(normalizeExpiresInSeconds(-1), 0) + assert.equal(normalizeExpiresInSeconds(Number.NaN), 0) +}) + +test('refreshes when remaining access token lifetime is inside threshold', () => { + assert.equal(shouldRefreshAccessToken(1_000 + 7 * 60 * 1_000, 1_000, 8 * 60 * 1_000), true) + assert.equal(shouldRefreshAccessToken(1_000 + 9 * 60 * 1_000, 1_000, 8 * 60 * 1_000), false) +}) + +test('does not refresh when expire timestamp is unknown', () => { + assert.equal(shouldRefreshAccessToken(0, 1_000, 8 * 60 * 1_000), false) +}) diff --git a/src/utils/authStorage.ts b/src/utils/authStorage.ts index b2907a0..f18ad7c 100644 --- a/src/utils/authStorage.ts +++ b/src/utils/authStorage.ts @@ -1,6 +1,16 @@ +import type { TokenResponse } from '@/types/api' +import { + DEFAULT_ACCESS_TOKEN_REFRESH_THRESHOLD_MS, + calculateAccessTokenExpireAt, + normalizeExpiresInSeconds, + shouldRefreshAccessToken +} from '@/utils/tokenExpiry' + export const AUTH_STORAGE_KEYS = { token: 'hotel_h5_access_token', refreshToken: 'hotel_h5_refresh_token', + expiresIn: 'hotel_h5_expires_in', + accessTokenExpireAt: 'hotel_h5_access_token_expire_at', user: 'hotel_h5_user', member: 'hotel_h5_member' } as const @@ -10,4 +20,70 @@ export const clearAuthStorage = () => { } export const AUTH_EXPIRED_EVENT = 'hotel-h5-auth-expired' +export const AUTH_TOKEN_UPDATED_EVENT = 'hotel-h5-auth-token-updated' +export interface StoredAuthToken { + accessToken: string + refreshToken: string + expiresIn: number + accessTokenExpireAt: number +} + +const dispatchWindowEvent = (eventName: string) => { + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event(eventName)) + } +} + +export const readStoredAccessToken = () => localStorage.getItem(AUTH_STORAGE_KEYS.token) || '' + +export const readStoredRefreshToken = () => localStorage.getItem(AUTH_STORAGE_KEYS.refreshToken) || '' + +export const readStoredAccessTokenExpireAt = () => { + const raw = localStorage.getItem(AUTH_STORAGE_KEYS.accessTokenExpireAt) + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : 0 +} + +export const persistAuthToken = ( + payload: TokenResponse, + fallbackRefreshToken = '', + nowMs = Date.now() +): StoredAuthToken => { + if (!payload.access_token) { + throw new Error('登录凭证异常,请重新登录') + } + + const expiresIn = normalizeExpiresInSeconds(payload.expires_in) + const accessTokenExpireAt = calculateAccessTokenExpireAt(nowMs, expiresIn) + const refreshToken = payload.refresh_token || fallbackRefreshToken + const storedToken: StoredAuthToken = { + accessToken: payload.access_token, + refreshToken, + expiresIn, + accessTokenExpireAt + } + + localStorage.setItem(AUTH_STORAGE_KEYS.token, storedToken.accessToken) + if (storedToken.refreshToken) { + localStorage.setItem(AUTH_STORAGE_KEYS.refreshToken, storedToken.refreshToken) + } else { + localStorage.removeItem(AUTH_STORAGE_KEYS.refreshToken) + } + + if (storedToken.expiresIn > 0) { + localStorage.setItem(AUTH_STORAGE_KEYS.expiresIn, String(storedToken.expiresIn)) + localStorage.setItem(AUTH_STORAGE_KEYS.accessTokenExpireAt, String(storedToken.accessTokenExpireAt)) + } else { + localStorage.removeItem(AUTH_STORAGE_KEYS.expiresIn) + localStorage.removeItem(AUTH_STORAGE_KEYS.accessTokenExpireAt) + } + + dispatchWindowEvent(AUTH_TOKEN_UPDATED_EVENT) + return storedToken +} + +export const accessTokenNeedsRefresh = ( + nowMs = Date.now(), + thresholdMs = DEFAULT_ACCESS_TOKEN_REFRESH_THRESHOLD_MS +) => shouldRefreshAccessToken(readStoredAccessTokenExpireAt(), nowMs, thresholdMs) diff --git a/src/utils/cameraAccess.ts b/src/utils/cameraAccess.ts new file mode 100644 index 0000000..f962037 --- /dev/null +++ b/src/utils/cameraAccess.ts @@ -0,0 +1,50 @@ +export const getCameraAccessErrorName = (error: unknown) => { + return error && typeof error === 'object' && 'name' in error ? String((error as { name?: string }).name || '') : '' +} + +export const getCameraAccessErrorMessage = (error: unknown) => { + const name = getCameraAccessErrorName(error) + if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { + return '未获得摄像头权限,请允许后重试' + } + if (name === 'NotReadableError' || name === 'TrackStartError') { + return '摄像头可能被其他应用占用,或系统隐私权限未开启' + } + if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { + return '未检测到可用摄像头,请检查设备或切换浏览器' + } + if (name === 'OverconstrainedError' || name === 'ConstraintNotSatisfiedError') { + return '当前摄像头参数不兼容,请重试或切换浏览器' + } + if (name === 'SecurityError') { + return '当前页面无法使用摄像头,请使用 HTTPS 地址访问' + } + return '无法打开摄像头,请检查系统权限或摄像头占用情况' +} + +export const shouldTryNextCameraConstraint = (error: unknown) => { + const name = getCameraAccessErrorName(error) + return ['OverconstrainedError', 'ConstraintNotSatisfiedError', 'NotFoundError', 'DevicesNotFoundError'].includes(name) +} + +export const cameraConstraintCandidates: MediaStreamConstraints[] = [ + { + audio: false, + video: { + facingMode: { ideal: 'environment' }, + width: { ideal: 1280 }, + height: { ideal: 720 } + } + }, + { + audio: false, + video: { + width: { ideal: 1280 }, + height: { ideal: 720 } + } + }, + { + audio: false, + video: true + } +] diff --git a/src/utils/refreshTokenState.ts b/src/utils/refreshTokenState.ts new file mode 100644 index 0000000..ff95b6a --- /dev/null +++ b/src/utils/refreshTokenState.ts @@ -0,0 +1,16 @@ +export class StaleRefreshTokenError extends Error { + constructor() { + super('登录状态已变化,请重试') + this.name = 'StaleRefreshTokenError' + } +} + +export const isRefreshTokenStillCurrent = (startedRefreshToken: string, currentRefreshToken: string) => { + return Boolean(startedRefreshToken && currentRefreshToken && startedRefreshToken === currentRefreshToken) +} + +export const ensureRefreshTokenStillCurrent = (startedRefreshToken: string, currentRefreshToken: string) => { + if (!isRefreshTokenStillCurrent(startedRefreshToken, currentRefreshToken)) { + throw new StaleRefreshTokenError() + } +} diff --git a/src/utils/tokenExpiry.ts b/src/utils/tokenExpiry.ts new file mode 100644 index 0000000..e922bbb --- /dev/null +++ b/src/utils/tokenExpiry.ts @@ -0,0 +1,20 @@ +export const DEFAULT_ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 8 * 60 * 1000 + +export const normalizeExpiresInSeconds = (expiresIn?: number) => { + if (!Number.isFinite(expiresIn) || !expiresIn || expiresIn <= 0) return 0 + return Math.floor(expiresIn) +} + +export const calculateAccessTokenExpireAt = (nowMs: number, expiresIn?: number) => { + const normalizedExpiresIn = normalizeExpiresInSeconds(expiresIn) + return normalizedExpiresIn > 0 ? nowMs + normalizedExpiresIn * 1000 : 0 +} + +export const shouldRefreshAccessToken = ( + accessTokenExpireAt: number, + nowMs = Date.now(), + thresholdMs = DEFAULT_ACCESS_TOKEN_REFRESH_THRESHOLD_MS +) => { + if (!Number.isFinite(accessTokenExpireAt) || accessTokenExpireAt <= 0) return false + return accessTokenExpireAt - nowMs <= thresholdMs +} diff --git a/src/views/verify/VerifyView.vue b/src/views/verify/VerifyView.vue index 7b8dc23..a8a627f 100644 --- a/src/views/verify/VerifyView.vue +++ b/src/views/verify/VerifyView.vue @@ -4,6 +4,11 @@ import { useRouter } from 'vue-router' import { showToast } from 'vant' import { QrCode, X } from 'lucide-vue-next' import jsQR from 'jsqr' +import { + cameraConstraintCandidates, + getCameraAccessErrorMessage, + shouldTryNextCameraConstraint +} from '@/utils/cameraAccess' import { parseWriteOffCode, type WriteOffCodePayload } from '@/utils/writeOffCode' const router = useRouter() @@ -18,6 +23,7 @@ let canvas: HTMLCanvasElement | null = null let canvasContext: CanvasRenderingContext2D | null = null let frameId = 0 let lastDecodeAt = 0 +let lastScanHintAt = 0 const getBarcodeDetector = () => { return (window as unknown as { @@ -42,6 +48,7 @@ const stopScan = () => { } detector = null lastDecodeAt = 0 + lastScanHintAt = 0 } const goConfirm = (payload: WriteOffCodePayload) => { @@ -87,6 +94,11 @@ const detectWithJsQr = (video: HTMLVideoElement) => { return jsQR(imageData.data, width, height, { inversionAttempts: 'dontInvert' })?.data || '' } +const detectQrValue = async (video: HTMLVideoElement) => { + const detectorValue = detector ? await detectWithBarcodeDetector(video).catch(() => '') : '' + return detectorValue || detectWithJsQr(video) +} + const detectLoop = async () => { if (!scanning.value || !videoRef.value) return const video = videoRef.value @@ -95,14 +107,18 @@ const detectLoop = async () => { const now = Date.now() if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && now - lastDecodeAt > 120) { lastDecodeAt = now - const value = detector ? await detectWithBarcodeDetector(video) : detectWithJsQr(video) + const value = await detectQrValue(video) if (value) { handleCodeValue(value) return } + if (!scanError.value && now - lastScanHintAt > 3000) { + lastScanHintAt = now + scanError.value = '暂未识别到二维码,请将二维码完整放入取景框' + } } } catch { - scanError.value = '扫码识别失败,请调整距离或光线后重试' + scanError.value = '扫码识别失败,请调整距离或切换角度后重试' } if (scanning.value) { @@ -110,6 +126,19 @@ const detectLoop = async () => { } } +const openCameraStream = async () => { + let lastError: unknown = null + for (const constraints of cameraConstraintCandidates) { + try { + return await navigator.mediaDevices.getUserMedia(constraints) + } catch (error) { + lastError = error + if (!shouldTryNextCameraConstraint(error)) break + } + } + throw lastError +} + const startScan = async () => { if (scanning.value || loading.value) return const BarcodeDetector = getBarcodeDetector() @@ -123,12 +152,7 @@ const startScan = async () => { scanError.value = '' try { detector = BarcodeDetector ? new BarcodeDetector({ formats: ['qr_code'] }) : null - stream = await navigator.mediaDevices.getUserMedia({ - audio: false, - video: { - facingMode: { ideal: 'environment' } - } - }) + stream = await openCameraStream() scanning.value = true await nextTick() if (!videoRef.value) return @@ -137,9 +161,7 @@ const startScan = async () => { frameId = window.requestAnimationFrame(detectLoop) } catch (error) { stopScan() - scanError.value = error instanceof Error && error.name === 'NotAllowedError' - ? '未获得摄像头权限,请允许后重试' - : '无法打开摄像头,请检查浏览器权限' + scanError.value = getCameraAccessErrorMessage(error) showToast(scanError.value) } finally { loading.value = false