develop #10
@@ -12,6 +12,7 @@
|
|||||||
"preview": "vite preview --host 0.0.0.0",
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
"preview:test": "vite preview --host 0.0.0.0 --mode test",
|
"preview:test": "vite preview --host 0.0.0.0 --mode test",
|
||||||
"preview:prod": "vite preview --host 0.0.0.0 --mode production",
|
"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"
|
"typecheck": "vue-tsc -b"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const getImageCodeUrl = (imageRandomStr: string) => {
|
|||||||
export const sendMobileCode = async (payload: SendMobileCodePayload) => {
|
export const sendMobileCode = async (payload: SendMobileCodePayload) => {
|
||||||
if (env.useMock) return mockApi.sendMobileCode(payload)
|
if (env.useMock) return mockApi.sendMobileCode(payload)
|
||||||
return await http.get<boolean>(joinUrl(env.adminBase, 'platformUser/sendMobileCode', payload.phone), {
|
return await http.get<boolean>(joinUrl(env.adminBase, 'platformUser/sendMobileCode', payload.phone), {
|
||||||
|
skipAuth: true,
|
||||||
|
skipAuthRefresh: true,
|
||||||
headers: {
|
headers: {
|
||||||
clientId: env.clientId,
|
clientId: env.clientId,
|
||||||
clientConfigId: env.clientConfigId
|
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, {
|
return await http.post<TokenResponse>(joinUrl(env.authBase, 'oauth2/token'), form, {
|
||||||
|
skipAuth: true,
|
||||||
|
skipAuthRefresh: true,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: basicAuth(),
|
Authorization: basicAuth(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
'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 { 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 { env } from '@/utils/env'
|
||||||
|
import { StaleRefreshTokenError } from '@/utils/refreshTokenState'
|
||||||
|
|
||||||
const http = axios.create({
|
const http = axios.create({
|
||||||
baseURL: env.apiBase,
|
baseURL: env.apiBase,
|
||||||
@@ -15,6 +24,12 @@ export type RequestError = Error & {
|
|||||||
|
|
||||||
let isRedirectingToLogin = false
|
let isRedirectingToLogin = false
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener(AUTH_TOKEN_UPDATED_EVENT, () => {
|
||||||
|
isRedirectingToLogin = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const authExpiredTexts = ['请求令牌已过期', '令牌已过期', 'token已过期', 'Token已过期', 'invalid_token']
|
const authExpiredTexts = ['请求令牌已过期', '令牌已过期', 'token已过期', 'Token已过期', 'invalid_token']
|
||||||
|
|
||||||
const getHeader = (headers: unknown, key: string) => {
|
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 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 createRequestError = (message: string, cause?: unknown, toastShown = false) => {
|
||||||
const requestError = new Error(message) as RequestError
|
const requestError = new Error(message) as RequestError
|
||||||
requestError.cause = cause
|
requestError.cause = cause
|
||||||
@@ -32,10 +61,16 @@ const createRequestError = (message: string, cause?: unknown, toastShown = false
|
|||||||
return requestError
|
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 || '')
|
const normalizedCode = String(code || '')
|
||||||
return (
|
return (
|
||||||
authExpiredTexts.some((text) => message.includes(text)) ||
|
(canUseAuthState && authExpiredTexts.some((text) => message.includes(text))) ||
|
||||||
((status === 401 || normalizedCode === '401') && hasBearerToken)
|
((status === 401 || normalizedCode === '401') && hasBearerToken)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -61,10 +96,44 @@ const redirectToLogin = (message: string) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
http.interceptors.request.use((config) => {
|
const canRefreshRequest = (config?: InternalAxiosRequestConfig) => {
|
||||||
const token = localStorage.getItem(AUTH_STORAGE_KEYS.token)
|
return Boolean(config && !config.skipAuth && !config.skipAuthRefresh && !config.authRetry && readStoredRefreshToken())
|
||||||
if (token && !getHeader(config.headers, 'Authorization')) {
|
}
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
|
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
|
return config
|
||||||
})
|
})
|
||||||
@@ -77,7 +146,18 @@ http.interceptors.response.use(
|
|||||||
return payload.data === undefined ? payload : payload.data
|
return payload.data === undefined ? payload : payload.data
|
||||||
}
|
}
|
||||||
const message = payload.msg || payload.message || '请求失败'
|
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)
|
const toastShown = redirectToLogin(message)
|
||||||
return Promise.reject(createRequestError(message, undefined, toastShown))
|
return Promise.reject(createRequestError(message, undefined, toastShown))
|
||||||
}
|
}
|
||||||
@@ -85,16 +165,26 @@ http.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
},
|
},
|
||||||
(error: AxiosError<{ code?: number | string; msg?: string; message?: string }>) => {
|
(error: AxiosError<{ code?: number | string; msg?: string; message?: string }> | RequestError) => {
|
||||||
const message = error.response?.data?.msg || error.response?.data?.message || error.message || '网络异常'
|
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 (
|
if (
|
||||||
isAuthExpired(
|
isAuthExpired(
|
||||||
error.response?.status,
|
axiosError.response?.status,
|
||||||
error.response?.data?.code,
|
axiosError.response?.data?.code,
|
||||||
message,
|
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)
|
const toastShown = redirectToLogin(message)
|
||||||
return Promise.reject(createRequestError(message, error, toastShown))
|
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
|
type OrganizationMemberInfo
|
||||||
} from '@/api/auth'
|
} from '@/api/auth'
|
||||||
import type { TokenResponse } from '@/types/api'
|
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'
|
import { env } from '@/utils/env'
|
||||||
|
|
||||||
export interface StaffUser {
|
export interface StaffUser {
|
||||||
@@ -46,8 +54,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const hasOrganization = computed(() => Boolean(memberInfo.value?.organizationId))
|
const hasOrganization = computed(() => Boolean(memberInfo.value?.organizationId))
|
||||||
|
|
||||||
const persistToken = (payload: TokenResponse, phone: string) => {
|
const persistToken = (payload: TokenResponse, phone: string) => {
|
||||||
token.value = payload.access_token
|
const storedToken = persistAuthToken(payload)
|
||||||
refreshToken.value = payload.refresh_token || ''
|
token.value = storedToken.accessToken
|
||||||
|
refreshToken.value = storedToken.refreshToken
|
||||||
user.value = {
|
user.value = {
|
||||||
id: payload.user_id,
|
id: payload.user_id,
|
||||||
username: payload.username || phone,
|
username: payload.username || phone,
|
||||||
@@ -55,10 +64,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
tenantId: payload.tenant_id
|
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))
|
localStorage.setItem(AUTH_STORAGE_KEYS.user, JSON.stringify(user.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +90,11 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
memberInfo.value = null
|
memberInfo.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncStoredToken = () => {
|
||||||
|
token.value = readStoredAccessToken()
|
||||||
|
refreshToken.value = readStoredRefreshToken()
|
||||||
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
resetState()
|
resetState()
|
||||||
clearAuthStorage()
|
clearAuthStorage()
|
||||||
@@ -92,6 +102,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener(AUTH_EXPIRED_EVENT, resetState)
|
window.addEventListener(AUTH_EXPIRED_EVENT, resetState)
|
||||||
|
window.addEventListener(AUTH_TOKEN_UPDATED_EVENT, syncStoredToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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 = {
|
export const AUTH_STORAGE_KEYS = {
|
||||||
token: 'hotel_h5_access_token',
|
token: 'hotel_h5_access_token',
|
||||||
refreshToken: 'hotel_h5_refresh_token',
|
refreshToken: 'hotel_h5_refresh_token',
|
||||||
|
expiresIn: 'hotel_h5_expires_in',
|
||||||
|
accessTokenExpireAt: 'hotel_h5_access_token_expire_at',
|
||||||
user: 'hotel_h5_user',
|
user: 'hotel_h5_user',
|
||||||
member: 'hotel_h5_member'
|
member: 'hotel_h5_member'
|
||||||
} as const
|
} as const
|
||||||
@@ -10,4 +20,70 @@ export const clearAuthStorage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AUTH_EXPIRED_EVENT = 'hotel-h5-auth-expired'
|
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 { showToast } from 'vant'
|
||||||
import { QrCode, X } from 'lucide-vue-next'
|
import { QrCode, X } from 'lucide-vue-next'
|
||||||
import jsQR from 'jsqr'
|
import jsQR from 'jsqr'
|
||||||
|
import {
|
||||||
|
cameraConstraintCandidates,
|
||||||
|
getCameraAccessErrorMessage,
|
||||||
|
shouldTryNextCameraConstraint
|
||||||
|
} from '@/utils/cameraAccess'
|
||||||
import { parseWriteOffCode, type WriteOffCodePayload } from '@/utils/writeOffCode'
|
import { parseWriteOffCode, type WriteOffCodePayload } from '@/utils/writeOffCode'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -18,6 +23,7 @@ let canvas: HTMLCanvasElement | null = null
|
|||||||
let canvasContext: CanvasRenderingContext2D | null = null
|
let canvasContext: CanvasRenderingContext2D | null = null
|
||||||
let frameId = 0
|
let frameId = 0
|
||||||
let lastDecodeAt = 0
|
let lastDecodeAt = 0
|
||||||
|
let lastScanHintAt = 0
|
||||||
|
|
||||||
const getBarcodeDetector = () => {
|
const getBarcodeDetector = () => {
|
||||||
return (window as unknown as {
|
return (window as unknown as {
|
||||||
@@ -42,6 +48,7 @@ const stopScan = () => {
|
|||||||
}
|
}
|
||||||
detector = null
|
detector = null
|
||||||
lastDecodeAt = 0
|
lastDecodeAt = 0
|
||||||
|
lastScanHintAt = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const goConfirm = (payload: WriteOffCodePayload) => {
|
const goConfirm = (payload: WriteOffCodePayload) => {
|
||||||
@@ -87,6 +94,11 @@ const detectWithJsQr = (video: HTMLVideoElement) => {
|
|||||||
return jsQR(imageData.data, width, height, { inversionAttempts: 'dontInvert' })?.data || ''
|
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 () => {
|
const detectLoop = async () => {
|
||||||
if (!scanning.value || !videoRef.value) return
|
if (!scanning.value || !videoRef.value) return
|
||||||
const video = videoRef.value
|
const video = videoRef.value
|
||||||
@@ -95,14 +107,18 @@ const detectLoop = async () => {
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && now - lastDecodeAt > 120) {
|
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && now - lastDecodeAt > 120) {
|
||||||
lastDecodeAt = now
|
lastDecodeAt = now
|
||||||
const value = detector ? await detectWithBarcodeDetector(video) : detectWithJsQr(video)
|
const value = await detectQrValue(video)
|
||||||
if (value) {
|
if (value) {
|
||||||
handleCodeValue(value)
|
handleCodeValue(value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!scanError.value && now - lastScanHintAt > 3000) {
|
||||||
|
lastScanHintAt = now
|
||||||
|
scanError.value = '暂未识别到二维码,请将二维码完整放入取景框'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
scanError.value = '扫码识别失败,请调整距离或光线后重试'
|
scanError.value = '扫码识别失败,请调整距离或切换角度后重试'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scanning.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 () => {
|
const startScan = async () => {
|
||||||
if (scanning.value || loading.value) return
|
if (scanning.value || loading.value) return
|
||||||
const BarcodeDetector = getBarcodeDetector()
|
const BarcodeDetector = getBarcodeDetector()
|
||||||
@@ -123,12 +152,7 @@ const startScan = async () => {
|
|||||||
scanError.value = ''
|
scanError.value = ''
|
||||||
try {
|
try {
|
||||||
detector = BarcodeDetector ? new BarcodeDetector({ formats: ['qr_code'] }) : null
|
detector = BarcodeDetector ? new BarcodeDetector({ formats: ['qr_code'] }) : null
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
stream = await openCameraStream()
|
||||||
audio: false,
|
|
||||||
video: {
|
|
||||||
facingMode: { ideal: 'environment' }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (!videoRef.value) return
|
if (!videoRef.value) return
|
||||||
@@ -137,9 +161,7 @@ const startScan = async () => {
|
|||||||
frameId = window.requestAnimationFrame(detectLoop)
|
frameId = window.requestAnimationFrame(detectLoop)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stopScan()
|
stopScan()
|
||||||
scanError.value = error instanceof Error && error.name === 'NotAllowedError'
|
scanError.value = getCameraAccessErrorMessage(error)
|
||||||
? '未获得摄像头权限,请允许后重试'
|
|
||||||
: '无法打开摄像头,请检查浏览器权限'
|
|
||||||
showToast(scanError.value)
|
showToast(scanError.value)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
Reference in New Issue
Block a user