develop #10

Merged
huangting merged 2 commits from develop into master 2026-07-11 15:58:50 +08:00
14 changed files with 514 additions and 32 deletions

View File

@@ -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": {

View File

@@ -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<boolean>(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<TokenR
}
return await http.post<TokenResponse>(joinUrl(env.authBase, 'oauth2/token'), form, {
skipAuth: true,
skipAuthRefresh: true,
headers: {
Authorization: basicAuth(),
'Content-Type': 'application/x-www-form-urlencoded'

View File

@@ -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<string, unknown> & { 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))
}

111
src/api/tokenRefresh.ts Normal file
View File

@@ -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<string> | 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<TokenResponse> & {
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<TokenResponse>
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<TokenResponse>(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
}

View File

@@ -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 {

15
src/types/axios.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
import 'axios'
declare module 'axios' {
export interface AxiosRequestConfig<D = any> {
skipAuth?: boolean
skipAuthRefresh?: boolean
authRetry?: boolean
}
export interface InternalAxiosRequestConfig<D = any> {
skipAuth?: boolean
skipAuthRefresh?: boolean
authRetry?: boolean
}
}

View File

@@ -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)
})

View File

@@ -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' }
)
})

View File

@@ -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)
})

View File

@@ -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)

50
src/utils/cameraAccess.ts Normal file
View File

@@ -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
}
]

View File

@@ -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()
}
}

20
src/utils/tokenExpiry.ts Normal file
View File

@@ -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
}

View File

@@ -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