Merge pull request 'develop' (#10) from develop into master
Reviewed-on: #10
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
111
src/api/tokenRefresh.ts
Normal 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
|
||||
}
|
||||
@@ -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
15
src/types/axios.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
22
src/utils/__tests__/cameraAccess.test.ts
Normal file
22
src/utils/__tests__/cameraAccess.test.ts
Normal 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)
|
||||
})
|
||||
18
src/utils/__tests__/refreshTokenState.test.ts
Normal file
18
src/utils/__tests__/refreshTokenState.test.ts
Normal 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' }
|
||||
)
|
||||
})
|
||||
26
src/utils/__tests__/tokenExpiry.test.ts
Normal file
26
src/utils/__tests__/tokenExpiry.test.ts
Normal 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)
|
||||
})
|
||||
@@ -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
50
src/utils/cameraAccess.ts
Normal 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
|
||||
}
|
||||
]
|
||||
16
src/utils/refreshTokenState.ts
Normal file
16
src/utils/refreshTokenState.ts
Normal 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
20
src/utils/tokenExpiry.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user