Compare commits
3 Commits
feature/hu
...
f97ad7f070
| Author | SHA1 | Date | |
|---|---|---|---|
| f97ad7f070 | |||
| bef64a3ed5 | |||
| d36f7c3373 |
@@ -12,7 +12,6 @@
|
||||
"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,8 +35,6 @@ 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
|
||||
@@ -61,8 +59,6 @@ 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'
|
||||
|
||||
@@ -242,16 +242,11 @@ export const mockApi = {
|
||||
commodityDescription: '面向到店客人的酒店权益商品,需由员工确认后核销。',
|
||||
commodityPackageConfig: [
|
||||
{
|
||||
orderWriteOffItemId: `WOI${order.id}-1`,
|
||||
packageName: order.commodityName,
|
||||
packageContent: `${order.commodityName} x ${order.commodityAmount}`,
|
||||
name: order.commodityName,
|
||||
count: totalCount,
|
||||
unit: '份',
|
||||
writeOffStatus: writeOffCount >= totalCount ? 'WRITTEN_OFF' : 'NOT_WRITE_OFF',
|
||||
legacySnapshot: false,
|
||||
writeOffSupported: true,
|
||||
snapshotOperationSupported: true,
|
||||
packageStatus: writeOffCount >= totalCount ? 1 : 0,
|
||||
writeOffCount
|
||||
}
|
||||
@@ -283,7 +278,7 @@ export const mockApi = {
|
||||
return {
|
||||
success: true,
|
||||
orderId: payload.orderId,
|
||||
orderWriteOffItemId: payload.orderWriteOffItemId,
|
||||
packageName: payload.packageName,
|
||||
message: '核销成功',
|
||||
operatedAt: formatDateTime(new Date())
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export const writeOffOrder = async (payload: WriteOffPayload): Promise<WriteOffR
|
||||
return {
|
||||
success: true,
|
||||
orderId: payload.orderId,
|
||||
orderWriteOffItemId: payload.orderWriteOffItemId,
|
||||
packageName: payload.packageName,
|
||||
message: '核销成功',
|
||||
operatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { showToast } from 'vant'
|
||||
import { refreshAccessTokenOnce } from '@/api/tokenRefresh'
|
||||
import {
|
||||
AUTH_EXPIRED_EVENT,
|
||||
AUTH_TOKEN_UPDATED_EVENT,
|
||||
accessTokenNeedsRefresh,
|
||||
clearAuthStorage,
|
||||
readStoredAccessToken,
|
||||
readStoredRefreshToken
|
||||
} from '@/utils/authStorage'
|
||||
import { AUTH_EXPIRED_EVENT, AUTH_STORAGE_KEYS, clearAuthStorage } from '@/utils/authStorage'
|
||||
import { env } from '@/utils/env'
|
||||
import { StaleRefreshTokenError } from '@/utils/refreshTokenState'
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: env.apiBase,
|
||||
@@ -24,12 +15,6 @@ 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) => {
|
||||
@@ -40,20 +25,6 @@ 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
|
||||
@@ -61,16 +32,10 @@ const createRequestError = (message: string, cause?: unknown, toastShown = false
|
||||
return requestError
|
||||
}
|
||||
|
||||
const isAuthExpired = (
|
||||
status?: number,
|
||||
code?: unknown,
|
||||
message = '',
|
||||
hasBearerToken = false,
|
||||
canUseAuthState = false
|
||||
) => {
|
||||
const isAuthExpired = (status?: number, code?: unknown, message = '', hasBearerToken = false) => {
|
||||
const normalizedCode = String(code || '')
|
||||
return (
|
||||
(canUseAuthState && authExpiredTexts.some((text) => message.includes(text))) ||
|
||||
authExpiredTexts.some((text) => message.includes(text)) ||
|
||||
((status === 401 || normalizedCode === '401') && hasBearerToken)
|
||||
)
|
||||
}
|
||||
@@ -96,44 +61,10 @@ const redirectToLogin = (message: string) => {
|
||||
return true
|
||||
}
|
||||
|
||||
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)
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem(AUTH_STORAGE_KEYS.token)
|
||||
if (token && !getHeader(config.headers, 'Authorization')) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
@@ -146,18 +77,7 @@ 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),
|
||||
!response.config.skipAuth
|
||||
)
|
||||
) {
|
||||
if (canRefreshRequest(response.config) && hasBearerAuthorization(response.config.headers)) {
|
||||
return refreshAndReplayRequest(response.config)
|
||||
}
|
||||
if (isAuthExpired(response.status, payload.code, message, hasBearerAuthorization(response.config.headers))) {
|
||||
const toastShown = redirectToLogin(message)
|
||||
return Promise.reject(createRequestError(message, undefined, toastShown))
|
||||
}
|
||||
@@ -165,26 +85,16 @@ http.interceptors.response.use(
|
||||
}
|
||||
return payload
|
||||
},
|
||||
(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 || '网络异常'
|
||||
(error: AxiosError<{ code?: number | string; msg?: string; message?: string }>) => {
|
||||
const message = error.response?.data?.msg || error.response?.data?.message || error.message || '网络异常'
|
||||
if (
|
||||
isAuthExpired(
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.code,
|
||||
error.response?.status,
|
||||
error.response?.data?.code,
|
||||
message,
|
||||
hasBearerAuthorization(axiosError.config?.headers),
|
||||
!axiosError.config?.skipAuth
|
||||
hasBearerAuthorization(error.config?.headers)
|
||||
)
|
||||
) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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,15 +6,7 @@ import {
|
||||
type OrganizationMemberInfo
|
||||
} from '@/api/auth'
|
||||
import type { TokenResponse } from '@/types/api'
|
||||
import {
|
||||
AUTH_EXPIRED_EVENT,
|
||||
AUTH_STORAGE_KEYS,
|
||||
AUTH_TOKEN_UPDATED_EVENT,
|
||||
clearAuthStorage,
|
||||
persistAuthToken,
|
||||
readStoredAccessToken,
|
||||
readStoredRefreshToken
|
||||
} from '@/utils/authStorage'
|
||||
import { AUTH_EXPIRED_EVENT, AUTH_STORAGE_KEYS, clearAuthStorage } from '@/utils/authStorage'
|
||||
import { env } from '@/utils/env'
|
||||
|
||||
export interface StaffUser {
|
||||
@@ -54,9 +46,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const hasOrganization = computed(() => Boolean(memberInfo.value?.organizationId))
|
||||
|
||||
const persistToken = (payload: TokenResponse, phone: string) => {
|
||||
const storedToken = persistAuthToken(payload)
|
||||
token.value = storedToken.accessToken
|
||||
refreshToken.value = storedToken.refreshToken
|
||||
token.value = payload.access_token
|
||||
refreshToken.value = payload.refresh_token || ''
|
||||
user.value = {
|
||||
id: payload.user_id,
|
||||
username: payload.username || phone,
|
||||
@@ -64,6 +55,10 @@ 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))
|
||||
}
|
||||
|
||||
@@ -90,11 +85,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
memberInfo.value = null
|
||||
}
|
||||
|
||||
const syncStoredToken = () => {
|
||||
token.value = readStoredAccessToken()
|
||||
refreshToken.value = readStoredRefreshToken()
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
resetState()
|
||||
clearAuthStorage()
|
||||
@@ -102,7 +92,6 @@ 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
15
src/types/axios.d.ts
vendored
@@ -1,15 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { PageQuery } from './api'
|
||||
|
||||
export type OrderStatus = '0' | '1' | '2' | '3' | '4' | '5' | '6'
|
||||
export type PayStatus = '0' | '1'
|
||||
export type CommodityTypeCode = '0' | '1' | '2' | '3'
|
||||
export type CommodityTypeCode = '0' | '1' | '2'
|
||||
|
||||
export interface CommodityOrder {
|
||||
id: string
|
||||
@@ -52,7 +52,6 @@ export interface CommodityService {
|
||||
}
|
||||
|
||||
export interface CommodityPackageConfig {
|
||||
orderWriteOffItemId?: string
|
||||
packageName?: string
|
||||
packageContent?: string
|
||||
packageDesc?: string
|
||||
@@ -61,10 +60,6 @@ export interface CommodityPackageConfig {
|
||||
count?: number
|
||||
color?: string
|
||||
unit?: string
|
||||
writeOffStatus?: string | number
|
||||
legacySnapshot?: boolean
|
||||
writeOffSupported?: boolean
|
||||
snapshotOperationSupported?: boolean
|
||||
packageStatus?: number
|
||||
writeOffCount?: number
|
||||
}
|
||||
@@ -126,14 +121,12 @@ export interface UserOrderDetail {
|
||||
|
||||
export interface WriteOffPayload {
|
||||
orderId: string
|
||||
orderWriteOffItemId: string
|
||||
writeOffRequestNo: string
|
||||
packageName?: string
|
||||
}
|
||||
|
||||
export interface WriteOffResult {
|
||||
success: boolean
|
||||
orderId: string
|
||||
orderWriteOffItemId?: string
|
||||
packageName?: string
|
||||
message: string
|
||||
operatedAt: string
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
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' }
|
||||
)
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
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,22 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { parseWriteOffCode } from '../writeOffCode.ts'
|
||||
|
||||
test('parses v2 JSON write off code with item id', () => {
|
||||
assert.deepEqual(
|
||||
parseWriteOffCode('{"v":2,"orderId":"2067459232180035585","orderWriteOffItemId":"item-2","packageName":"全价门票"}'),
|
||||
{
|
||||
orderId: '2067459232180035585',
|
||||
orderWriteOffItemId: 'item-2',
|
||||
packageName: '全价门票',
|
||||
version: 2
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('parses legacy orderId and packageName write off code', () => {
|
||||
assert.deepEqual(parseWriteOffCode('order-1&%E5%A5%97%E9%A4%90A'), {
|
||||
orderId: 'order-1',
|
||||
packageName: '套餐A'
|
||||
})
|
||||
})
|
||||
@@ -1,121 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import {
|
||||
getWriteOffItemAvailability,
|
||||
getWriteOffItemQuantity,
|
||||
getWriteOffStatusText,
|
||||
resolveWriteOffItemSelection,
|
||||
shouldShowManualSelectionHint
|
||||
} from '../writeOffItem.ts'
|
||||
|
||||
const writeOffItemModule = await import('../writeOffItem.ts')
|
||||
|
||||
const items = [
|
||||
{
|
||||
orderWriteOffItemId: 'item-1',
|
||||
name: '双人套餐',
|
||||
count: 2,
|
||||
writeOffCount: 0,
|
||||
writeOffSupported: true,
|
||||
snapshotOperationSupported: true
|
||||
},
|
||||
{
|
||||
orderWriteOffItemId: 'item-2',
|
||||
name: '双人套餐',
|
||||
count: 2,
|
||||
writeOffCount: 1,
|
||||
writeOffSupported: true,
|
||||
snapshotOperationSupported: true
|
||||
}
|
||||
]
|
||||
|
||||
test('calculates total, written off and remaining quantity from item fields', () => {
|
||||
assert.deepEqual(getWriteOffItemQuantity(items[1]), {
|
||||
total: 2,
|
||||
writtenOff: 1,
|
||||
writable: 1,
|
||||
unit: ''
|
||||
})
|
||||
})
|
||||
|
||||
test('marks legacy or unsupported or completed item unavailable', () => {
|
||||
assert.equal(getWriteOffItemAvailability({ ...items[0], legacySnapshot: true }).available, false)
|
||||
assert.equal(getWriteOffItemAvailability({ ...items[0], writeOffSupported: false }).available, false)
|
||||
assert.equal(getWriteOffItemAvailability({ ...items[0], snapshotOperationSupported: false }).available, false)
|
||||
assert.equal(getWriteOffItemAvailability({ ...items[0], writeOffCount: 2 }).available, false)
|
||||
})
|
||||
|
||||
test('locks scanned v2 code to the specified write off item id', () => {
|
||||
const selection = resolveWriteOffItemSelection(items, {
|
||||
source: 'scan',
|
||||
orderWriteOffItemId: 'item-2',
|
||||
packageName: '双人套餐'
|
||||
})
|
||||
|
||||
assert.equal(selection.locked, true)
|
||||
assert.deepEqual(selection.options.map((item) => item.orderWriteOffItemId), ['item-2'])
|
||||
assert.equal(selection.selectedItemId, 'item-2')
|
||||
})
|
||||
|
||||
test('does not auto select when legacy package name matches multiple available items', () => {
|
||||
const selection = resolveWriteOffItemSelection(items, {
|
||||
source: 'scan',
|
||||
packageName: '双人套餐'
|
||||
})
|
||||
|
||||
assert.equal(selection.mismatch, false)
|
||||
assert.equal(selection.requiresManualSelection, true)
|
||||
assert.equal(selection.selectedItemId, '')
|
||||
assert.deepEqual(selection.options.map((item) => item.orderWriteOffItemId), ['item-1', 'item-2'])
|
||||
})
|
||||
|
||||
test('hides manual selection hint after an item is selected', () => {
|
||||
const selection = resolveWriteOffItemSelection(items, {
|
||||
source: 'scan',
|
||||
packageName: '双人套餐'
|
||||
})
|
||||
|
||||
assert.equal(shouldShowManualSelectionHint(selection, ''), true)
|
||||
assert.equal(shouldShowManualSelectionHint(selection, 'item-1'), false)
|
||||
})
|
||||
|
||||
test('does not export legacy package name finder', () => {
|
||||
assert.equal('findWriteOffItem' in writeOffItemModule, false)
|
||||
})
|
||||
|
||||
test('auto selects the only available item matched by legacy package name', () => {
|
||||
const selection = resolveWriteOffItemSelection(
|
||||
[
|
||||
{ ...items[0], writeOffCount: 2 },
|
||||
items[1]
|
||||
],
|
||||
{
|
||||
source: 'scan',
|
||||
packageName: '双人套餐'
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(selection.selectedItemId, 'item-2')
|
||||
assert.equal(selection.requiresManualSelection, false)
|
||||
})
|
||||
|
||||
test('default selection skips unavailable items', () => {
|
||||
const selection = resolveWriteOffItemSelection([
|
||||
{ ...items[0], writeOffCount: 2 },
|
||||
items[1]
|
||||
])
|
||||
|
||||
assert.equal(selection.selectedItemId, 'item-2')
|
||||
})
|
||||
|
||||
test('infers partial write off status from quantity when status is 0', () => {
|
||||
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 0, writeOffCount: 0 }), '待核销')
|
||||
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 0, writeOffCount: 1 }), '部分核销')
|
||||
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 0, writeOffCount: 2 }), '已核销')
|
||||
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 1, writeOffCount: 0 }), '已核销')
|
||||
})
|
||||
|
||||
test('does not override explicit non-zero write off status with quantity inference', () => {
|
||||
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 2, writeOffCount: 0 }), '部分核销')
|
||||
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 'PART_WRITE_OFF', writeOffCount: 0 }), '部分核销')
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { getReusableWriteOffRequestNo, type WriteOffRequestNoState } from '../writeOffRequestNo.ts'
|
||||
|
||||
test('reuses request number for the same write off target', () => {
|
||||
const state: WriteOffRequestNoState = { targetKey: '', requestNo: '' }
|
||||
let sequence = 0
|
||||
const createRequestNo = () => `request-${++sequence}`
|
||||
|
||||
const first = getReusableWriteOffRequestNo(state, 'order-1:item-1', createRequestNo)
|
||||
const retry = getReusableWriteOffRequestNo(state, 'order-1:item-1', createRequestNo)
|
||||
|
||||
assert.equal(first, 'request-1')
|
||||
assert.equal(retry, 'request-1')
|
||||
assert.equal(sequence, 1)
|
||||
})
|
||||
|
||||
test('creates a new request number when the write off target changes', () => {
|
||||
const state: WriteOffRequestNoState = { targetKey: '', requestNo: '' }
|
||||
let sequence = 0
|
||||
const createRequestNo = () => `request-${++sequence}`
|
||||
|
||||
const first = getReusableWriteOffRequestNo(state, 'order-1:item-1', createRequestNo)
|
||||
const second = getReusableWriteOffRequestNo(state, 'order-1:item-2', createRequestNo)
|
||||
|
||||
assert.equal(first, 'request-1')
|
||||
assert.equal(second, 'request-2')
|
||||
assert.equal(sequence, 2)
|
||||
})
|
||||
@@ -1,16 +1,6 @@
|
||||
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
|
||||
@@ -20,70 +10,4 @@ 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)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
}
|
||||
]
|
||||
@@ -29,8 +29,7 @@ export const payStatusText: Record<PayStatus, string> = {
|
||||
export const commodityTypeText: Record<CommodityTypeCode, string> = {
|
||||
'0': '酒店',
|
||||
'1': '门票',
|
||||
'2': '餐饮',
|
||||
'3': '套餐'
|
||||
'2': '餐饮'
|
||||
}
|
||||
|
||||
export const eventStatusText: Record<EventStatus, string> = {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
export interface WriteOffCodePayload {
|
||||
orderId: string
|
||||
orderWriteOffItemId?: string
|
||||
packageName?: string
|
||||
version?: number
|
||||
}
|
||||
|
||||
const safeDecode = (value: string) => {
|
||||
@@ -19,30 +17,6 @@ export const parseWriteOffCode = (rawValue: string): WriteOffCodePayload => {
|
||||
throw new Error('未识别到核销码')
|
||||
}
|
||||
|
||||
if (raw.startsWith('{')) {
|
||||
try {
|
||||
const payload = JSON.parse(raw) as {
|
||||
v?: number
|
||||
orderId?: string
|
||||
orderWriteOffItemId?: string
|
||||
packageName?: string
|
||||
}
|
||||
const orderId = String(payload.orderId || '').trim()
|
||||
if (!orderId) {
|
||||
throw new Error('核销码缺少订单号')
|
||||
}
|
||||
return {
|
||||
orderId,
|
||||
orderWriteOffItemId: payload.orderWriteOffItemId ? String(payload.orderWriteOffItemId).trim() : undefined,
|
||||
packageName: payload.packageName ? String(payload.packageName).trim() : undefined,
|
||||
version: payload.v
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === '核销码缺少订单号') throw error
|
||||
throw new Error('核销码格式不正确')
|
||||
}
|
||||
}
|
||||
|
||||
const splitIndex = raw.indexOf('&')
|
||||
const orderId = safeDecode(splitIndex >= 0 ? raw.slice(0, splitIndex) : raw).trim()
|
||||
const packageName = splitIndex >= 0 ? safeDecode(raw.slice(splitIndex + 1)).trim() : ''
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import type { CommodityPackageConfig } from '@/types/order'
|
||||
|
||||
export interface WriteOffItemQuantity {
|
||||
total: number
|
||||
writtenOff: number
|
||||
writable: number
|
||||
unit: string
|
||||
}
|
||||
|
||||
export interface WriteOffItemAvailability {
|
||||
available: boolean
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface WriteOffItemSelectionInput {
|
||||
source?: string
|
||||
orderWriteOffItemId?: string
|
||||
packageName?: string
|
||||
fallbackName?: string
|
||||
}
|
||||
|
||||
export interface WriteOffItemSelection {
|
||||
options: CommodityPackageConfig[]
|
||||
selectedItemId: string
|
||||
locked: boolean
|
||||
mismatch: boolean
|
||||
requiresManualSelection: boolean
|
||||
}
|
||||
|
||||
const statusTextMap: Record<string, string> = {
|
||||
'0': '待核销',
|
||||
'1': '已核销',
|
||||
'2': '部分核销',
|
||||
NOT_WRITE_OFF: '待核销',
|
||||
WAIT_WRITE_OFF: '待核销',
|
||||
PART_WRITE_OFF: '部分核销',
|
||||
PARTIAL_WRITE_OFF: '部分核销',
|
||||
WRITE_OFF: '已核销',
|
||||
WRITTEN_OFF: '已核销',
|
||||
FINISHED: '已核销'
|
||||
}
|
||||
|
||||
export const toNumber = (value: unknown) => {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : undefined
|
||||
}
|
||||
|
||||
export const getWriteOffItemId = (item?: CommodityPackageConfig) => {
|
||||
return String(item?.orderWriteOffItemId || '').trim()
|
||||
}
|
||||
|
||||
export const getWriteOffItemName = (item?: CommodityPackageConfig, fallbackName = '') => {
|
||||
return (item?.name || item?.packageName || fallbackName || '').trim()
|
||||
}
|
||||
|
||||
export const getWriteOffItemContent = (item?: CommodityPackageConfig) => {
|
||||
return item?.packageContent || item?.packageDesc || item?.content || ''
|
||||
}
|
||||
|
||||
export const getWriteOffItemQuantity = (item?: CommodityPackageConfig): WriteOffItemQuantity => {
|
||||
const total = toNumber(item?.count) ?? 0
|
||||
const writtenOff = Math.min(toNumber(item?.writeOffCount) ?? 0, total)
|
||||
return {
|
||||
total,
|
||||
writtenOff,
|
||||
writable: Math.max(total - writtenOff, 0),
|
||||
unit: item?.unit || ''
|
||||
}
|
||||
}
|
||||
|
||||
export const getWriteOffItemAvailability = (item?: CommodityPackageConfig): WriteOffItemAvailability => {
|
||||
if (!item) return { available: false, reason: '缺少可核销明细' }
|
||||
if (!getWriteOffItemId(item)) return { available: false, reason: '缺少核销明细ID' }
|
||||
if (item.legacySnapshot) return { available: false, reason: '历史未迁移明细不可核销' }
|
||||
if (item.writeOffSupported === false) return { available: false, reason: '当前明细不支持核销' }
|
||||
if (item.snapshotOperationSupported === false) return { available: false, reason: '当前明细不支持快照核销' }
|
||||
if (getWriteOffItemQuantity(item).writable <= 0) return { available: false, reason: '当前明细已核销完' }
|
||||
return { available: true, reason: '' }
|
||||
}
|
||||
|
||||
export const getFirstAvailableWriteOffItem = (items: CommodityPackageConfig[]) => {
|
||||
return items.find((item) => getWriteOffItemAvailability(item).available)
|
||||
}
|
||||
|
||||
const matchesWriteOffItemName = (item: CommodityPackageConfig, packageName: string, fallbackName = '') => {
|
||||
return getWriteOffItemName(item, fallbackName) === packageName || item.packageName === packageName
|
||||
}
|
||||
|
||||
export const resolveWriteOffItemSelection = (
|
||||
items: CommodityPackageConfig[],
|
||||
input: WriteOffItemSelectionInput = {}
|
||||
): WriteOffItemSelection => {
|
||||
const source = String(input.source || '')
|
||||
const itemId = String(input.orderWriteOffItemId || '').trim()
|
||||
const packageName = String(input.packageName || '').trim()
|
||||
const isScan = source === 'scan'
|
||||
const locked = isScan && Boolean(itemId)
|
||||
|
||||
let options = items
|
||||
if (locked) {
|
||||
options = items.filter((item) => getWriteOffItemId(item) === itemId)
|
||||
} else if (isScan && packageName) {
|
||||
options = items.filter((item) => matchesWriteOffItemName(item, packageName, input.fallbackName))
|
||||
}
|
||||
|
||||
const mismatch = isScan && Boolean(itemId || packageName) && options.length === 0
|
||||
const availableOptions = options.filter((item) => getWriteOffItemAvailability(item).available)
|
||||
|
||||
if (locked) {
|
||||
return {
|
||||
options,
|
||||
selectedItemId: getWriteOffItemId(options[0]),
|
||||
locked,
|
||||
mismatch,
|
||||
requiresManualSelection: false
|
||||
}
|
||||
}
|
||||
|
||||
if (isScan && packageName && availableOptions.length > 1) {
|
||||
return {
|
||||
options,
|
||||
selectedItemId: '',
|
||||
locked,
|
||||
mismatch,
|
||||
requiresManualSelection: true
|
||||
}
|
||||
}
|
||||
|
||||
const matchedById = itemId ? options.find((item) => getWriteOffItemId(item) === itemId) : undefined
|
||||
const selectedItem = (
|
||||
matchedById && getWriteOffItemAvailability(matchedById).available
|
||||
? matchedById
|
||||
: availableOptions[0] || matchedById || options[0]
|
||||
)
|
||||
|
||||
return {
|
||||
options,
|
||||
selectedItemId: getWriteOffItemId(selectedItem),
|
||||
locked,
|
||||
mismatch,
|
||||
requiresManualSelection: false
|
||||
}
|
||||
}
|
||||
|
||||
export const shouldShowManualSelectionHint = (selection: WriteOffItemSelection, selectedItemId: string) => {
|
||||
return selection.requiresManualSelection && !String(selectedItemId || '').trim()
|
||||
}
|
||||
|
||||
export const getWriteOffStatusText = (item?: CommodityPackageConfig) => {
|
||||
const status = item?.writeOffStatus ?? item?.packageStatus
|
||||
const key = String(status)
|
||||
const writtenOff = toNumber(item?.writeOffCount)
|
||||
const total = toNumber(item?.count)
|
||||
if (key === '1') return '已核销'
|
||||
if ((status === undefined || status === null || status === '' || key === '0') && (writtenOff !== undefined || total !== undefined)) {
|
||||
const writtenOffCount = Math.max(writtenOff ?? 0, 0)
|
||||
const totalCount = Math.max(total ?? 0, 0)
|
||||
if (writtenOffCount <= 0) return '待核销'
|
||||
if (totalCount > 0 && writtenOffCount >= totalCount) return '已核销'
|
||||
return '部分核销'
|
||||
}
|
||||
if (status === undefined || status === null || status === '') return ''
|
||||
return statusTextMap[key] || key
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export interface WriteOffRequestNoState {
|
||||
targetKey: string
|
||||
requestNo: string
|
||||
}
|
||||
|
||||
export const createWriteOffRequestNo = () => {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
||||
return `h5-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
export const getReusableWriteOffRequestNo = (
|
||||
state: WriteOffRequestNoState,
|
||||
targetKey: string,
|
||||
createRequestNo = createWriteOffRequestNo
|
||||
) => {
|
||||
if (!state.requestNo || state.targetKey !== targetKey) {
|
||||
state.targetKey = targetKey
|
||||
state.requestNo = createRequestNo()
|
||||
}
|
||||
return state.requestNo
|
||||
}
|
||||
@@ -7,15 +7,6 @@ import PageNav from '@/components/PageNav.vue'
|
||||
import StatusTag from '@/components/StatusTag.vue'
|
||||
import type { UserOrderDetail } from '@/types/order'
|
||||
import { canWriteOff, commodityTypeText, payStatusText } from '@/utils/constants'
|
||||
import {
|
||||
getFirstAvailableWriteOffItem,
|
||||
getWriteOffItemAvailability,
|
||||
getWriteOffItemContent,
|
||||
getWriteOffItemId,
|
||||
getWriteOffItemName,
|
||||
getWriteOffItemQuantity,
|
||||
getWriteOffStatusText
|
||||
} from '@/utils/writeOffItem'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -23,8 +14,10 @@ const detail = ref<UserOrderDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const orderId = computed(() => String(route.params.id || ''))
|
||||
const firstWritableItem = computed(() => getFirstAvailableWriteOffItem(detail.value?.commodityPackageConfig || []))
|
||||
const canGoWriteOff = computed(() => Boolean(detail.value && canWriteOff(detail.value.orderStatus) && firstWritableItem.value))
|
||||
const packageName = computed(() => {
|
||||
const first = detail.value?.commodityPackageConfig?.[0]
|
||||
return first?.packageName || first?.name || detail.value?.commodityName || ''
|
||||
})
|
||||
|
||||
const loadDetail = async () => {
|
||||
loading.value = true
|
||||
@@ -36,13 +29,11 @@ const loadDetail = async () => {
|
||||
}
|
||||
|
||||
const goWriteOff = () => {
|
||||
if (!firstWritableItem.value) return
|
||||
router.push({
|
||||
path: '/verify/confirm',
|
||||
query: {
|
||||
orderId: orderId.value,
|
||||
orderWriteOffItemId: getWriteOffItemId(firstWritableItem.value),
|
||||
packageName: getWriteOffItemName(firstWritableItem.value, detail.value?.commodityName)
|
||||
packageName: packageName.value
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -112,24 +103,11 @@ onMounted(loadDetail)
|
||||
|
||||
<section class="panel detail-panel">
|
||||
<h2 class="section-title">套餐内容</h2>
|
||||
<div
|
||||
v-for="(item, index) in detail.commodityPackageConfig"
|
||||
:key="getWriteOffItemId(item) || `${getWriteOffItemName(item, detail.commodityName)}-${index}`"
|
||||
class="package-row"
|
||||
>
|
||||
<div v-for="item in detail.commodityPackageConfig" :key="item.packageName || item.name" class="package-row">
|
||||
<ReceiptText :size="17" />
|
||||
<div>
|
||||
<strong>{{ getWriteOffItemName(item, detail.commodityName) }}</strong>
|
||||
<p v-if="getWriteOffItemContent(item)">{{ getWriteOffItemContent(item) }}</p>
|
||||
<p>
|
||||
<span v-if="getWriteOffStatusText(item)">{{ getWriteOffStatusText(item) }} · </span>
|
||||
已核销 {{ getWriteOffItemQuantity(item).writtenOff }}{{ getWriteOffItemQuantity(item).unit }} /
|
||||
剩余 {{ getWriteOffItemQuantity(item).writable }}{{ getWriteOffItemQuantity(item).unit }} /
|
||||
总数 {{ getWriteOffItemQuantity(item).total }}{{ getWriteOffItemQuantity(item).unit }}
|
||||
</p>
|
||||
<p v-if="!getWriteOffItemAvailability(item).available" class="package-row__warning">
|
||||
{{ getWriteOffItemAvailability(item).reason }}
|
||||
</p>
|
||||
<strong>{{ item.packageName || item.name || detail.commodityName }}</strong>
|
||||
<p>{{ item.packageContent || item.packageDesc || item.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="detail.commodityTip" class="tip-text">{{ detail.commodityTip }}</p>
|
||||
@@ -155,9 +133,9 @@ onMounted(loadDetail)
|
||||
|
||||
<footer v-if="detail && canWriteOff(detail.orderStatus)" class="fixed-action">
|
||||
<div class="fixed-action__inner">
|
||||
<van-button block type="primary" :disabled="!canGoWriteOff" @click="goWriteOff">
|
||||
<van-button block type="primary" @click="goWriteOff">
|
||||
<template #icon><CheckCircle2 :size="18" /></template>
|
||||
{{ canGoWriteOff ? '立即核销' : '暂无可核销明细' }}
|
||||
立即核销
|
||||
</van-button>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -264,10 +242,6 @@ onMounted(loadDetail)
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.package-row__warning {
|
||||
color: var(--rose) !important;
|
||||
}
|
||||
|
||||
.empty-line {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
|
||||
@@ -8,34 +8,25 @@ import PageNav from '@/components/PageNav.vue'
|
||||
import StatusTag from '@/components/StatusTag.vue'
|
||||
import type { CommodityPackageConfig, UserOrderDetail } from '@/types/order'
|
||||
import { canWriteOff } from '@/utils/constants'
|
||||
import {
|
||||
getWriteOffItemAvailability,
|
||||
getWriteOffItemContent,
|
||||
getWriteOffItemId,
|
||||
getWriteOffItemName,
|
||||
getWriteOffItemQuantity,
|
||||
getWriteOffStatusText,
|
||||
resolveWriteOffItemSelection,
|
||||
shouldShowManualSelectionHint,
|
||||
toNumber
|
||||
} from '@/utils/writeOffItem'
|
||||
import {
|
||||
getReusableWriteOffRequestNo,
|
||||
type WriteOffRequestNoState
|
||||
} from '@/utils/writeOffRequestNo'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const detail = ref<UserOrderDetail | null>(null)
|
||||
const selectedItemId = ref('')
|
||||
const writeOffRequestNoState = ref<WriteOffRequestNoState>({ targetKey: '', requestNo: '' })
|
||||
const packageName = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const orderId = computed(() => String(route.query.orderId || ''))
|
||||
const routePackageName = computed(() => String(route.query.packageName || '').trim())
|
||||
const routeWriteOffItemId = computed(() => String(route.query.orderWriteOffItemId || '').trim())
|
||||
const isScanSource = computed(() => String(route.query.source || '') === 'scan')
|
||||
const isScanPackageLocked = computed(() => String(route.query.source || '') === 'scan' && Boolean(routePackageName.value))
|
||||
|
||||
const toNumber = (value: unknown) => {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : undefined
|
||||
}
|
||||
|
||||
const getPackageName = (item?: CommodityPackageConfig) =>
|
||||
(item?.packageName || item?.name || detail.value?.commodityName || '').trim()
|
||||
|
||||
const packageConfigs = computed<CommodityPackageConfig[]>(() => {
|
||||
if (!detail.value) return []
|
||||
@@ -49,61 +40,56 @@ const packageConfigs = computed<CommodityPackageConfig[]>(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const itemSelection = computed(() => {
|
||||
return resolveWriteOffItemSelection(packageConfigs.value, {
|
||||
source: String(route.query.source || ''),
|
||||
orderWriteOffItemId: routeWriteOffItemId.value,
|
||||
packageName: routePackageName.value,
|
||||
fallbackName: detail.value?.commodityName
|
||||
})
|
||||
const packageOptions = computed(() => {
|
||||
if (!isScanPackageLocked.value) return packageConfigs.value
|
||||
return packageConfigs.value.filter((item) => getPackageName(item) === routePackageName.value)
|
||||
})
|
||||
|
||||
const packageOptions = computed(() => itemSelection.value.options)
|
||||
const isScanItemLocked = computed(() => itemSelection.value.locked)
|
||||
const packageMismatch = computed(() => itemSelection.value.mismatch)
|
||||
const showManualSelectionHint = computed(() => shouldShowManualSelectionHint(itemSelection.value, selectedItemId.value))
|
||||
const packageMismatch = computed(() => isScanPackageLocked.value && packageOptions.value.length === 0)
|
||||
|
||||
const selectedPackage = computed(() => {
|
||||
return packageOptions.value.find((item) => getWriteOffItemId(item) === selectedItemId.value)
|
||||
return packageOptions.value.find((item) => getPackageName(item) === packageName.value) || packageOptions.value[0]
|
||||
})
|
||||
|
||||
const quantityInfo = computed(() => getWriteOffItemQuantity(selectedPackage.value))
|
||||
const writtenOffByRecord = computed(() => {
|
||||
if (!detail.value) return 0
|
||||
const selectedName = getPackageName(selectedPackage.value)
|
||||
return (detail.value.writeOffRecordList || []).filter((record) => {
|
||||
if (!selectedName) return true
|
||||
return !record.packageName || record.packageName === selectedName
|
||||
}).length
|
||||
})
|
||||
|
||||
const selectedItemAvailability = computed(() => getWriteOffItemAvailability(selectedPackage.value))
|
||||
const selectedPackageName = computed(() => getWriteOffItemName(selectedPackage.value, detail.value?.commodityName))
|
||||
const quantityInfo = computed(() => {
|
||||
const total = toNumber(selectedPackage.value?.count) ?? toNumber(detail.value?.commodityAmount) ?? 0
|
||||
const packageWriteOffCount = toNumber(selectedPackage.value?.writeOffCount)
|
||||
const writtenOff =
|
||||
packageWriteOffCount ??
|
||||
(selectedPackage.value?.packageStatus === 1 && total > 0 ? total : writtenOffByRecord.value)
|
||||
|
||||
return {
|
||||
total,
|
||||
writtenOff,
|
||||
writable: Math.max(total - writtenOff, 0),
|
||||
unit: selectedPackage.value?.unit || ''
|
||||
}
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return Boolean(
|
||||
detail.value &&
|
||||
canWriteOff(detail.value.orderStatus) &&
|
||||
selectedItemId.value &&
|
||||
packageName.value &&
|
||||
!packageMismatch.value &&
|
||||
selectedItemAvailability.value.available
|
||||
quantityInfo.value.writable > 0
|
||||
)
|
||||
})
|
||||
|
||||
const formatCount = (value: number) => `${value}${quantityInfo.value.unit || ''}`
|
||||
const getErrorMessage = (error: unknown) => (error instanceof Error ? error.message : '核销失败')
|
||||
|
||||
const hasShownToast = (error: unknown) => {
|
||||
return Boolean(error && typeof error === 'object' && (error as { toastShown?: boolean }).toastShown)
|
||||
}
|
||||
|
||||
const getItemLabel = (item: CommodityPackageConfig) => {
|
||||
const quantity = getWriteOffItemQuantity(item)
|
||||
const statusText = getWriteOffStatusText(item)
|
||||
const parts = [`已核销 ${quantity.writtenOff}${quantity.unit}`, `剩余 ${quantity.writable}${quantity.unit}`]
|
||||
if (statusText) parts.unshift(statusText)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
const canSelectItem = (item: CommodityPackageConfig) => {
|
||||
return !isScanItemLocked.value && getWriteOffItemAvailability(item).available
|
||||
}
|
||||
|
||||
const selectPackage = (item: CommodityPackageConfig) => {
|
||||
if (!canSelectItem(item)) return
|
||||
selectedItemId.value = getWriteOffItemId(item)
|
||||
if (isScanPackageLocked.value) return
|
||||
packageName.value = getPackageName(item)
|
||||
}
|
||||
|
||||
const loadDetail = async () => {
|
||||
@@ -115,7 +101,13 @@ const loadDetail = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = await fetchOrderDetail(orderId.value)
|
||||
selectedItemId.value = itemSelection.value.selectedItemId
|
||||
const matchedPackage = routePackageName.value
|
||||
? packageConfigs.value.find((item) => getPackageName(item) === routePackageName.value)
|
||||
: undefined
|
||||
packageName.value =
|
||||
routePackageName.value && (matchedPackage || isScanPackageLocked.value)
|
||||
? routePackageName.value
|
||||
: getPackageName(packageConfigs.value[0])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -128,52 +120,47 @@ const submit = async () => {
|
||||
return
|
||||
}
|
||||
if (packageMismatch.value) {
|
||||
showToast(routeWriteOffItemId.value ? '扫码明细不属于当前订单' : '扫码套餐不属于当前订单')
|
||||
showToast('扫码套餐不属于当前订单')
|
||||
return
|
||||
}
|
||||
if (!selectedItemId.value || !selectedPackage.value) {
|
||||
showToast('请选择核销明细')
|
||||
if (!packageName.value) {
|
||||
showToast('请选择核销套餐')
|
||||
return
|
||||
}
|
||||
if (!selectedItemAvailability.value.available) {
|
||||
showToast(selectedItemAvailability.value.reason)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '确认核销',
|
||||
message: `订单 ${detail.value.orderId} 将核销「${selectedPackageName.value}」,核销后不可撤回。`
|
||||
})
|
||||
} catch {
|
||||
if (quantityInfo.value.writable <= 0) {
|
||||
showToast('当前套餐商品已核销完')
|
||||
return
|
||||
}
|
||||
await showConfirmDialog({
|
||||
title: '确认核销',
|
||||
message: `订单 ${detail.value.orderId} 将核销「${packageName.value}」,核销后不可撤回。`
|
||||
})
|
||||
submitting.value = true
|
||||
const requestNo = getReusableWriteOffRequestNo(
|
||||
writeOffRequestNoState.value,
|
||||
`${detail.value.orderId}:${selectedItemId.value}`
|
||||
)
|
||||
try {
|
||||
const result = await writeOffOrder({
|
||||
orderId: detail.value.orderId,
|
||||
orderWriteOffItemId: selectedItemId.value,
|
||||
writeOffRequestNo: requestNo
|
||||
packageName: packageName.value
|
||||
})
|
||||
router.replace({
|
||||
path: '/verify/result',
|
||||
query: {
|
||||
success: String(result.success),
|
||||
orderId: result.orderId,
|
||||
orderWriteOffItemId: result.orderWriteOffItemId || selectedItemId.value,
|
||||
packageName: selectedPackageName.value,
|
||||
packageName: result.packageName,
|
||||
message: result.message,
|
||||
operatedAt: result.operatedAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error)
|
||||
if (!hasShownToast(error)) {
|
||||
showToast(message)
|
||||
}
|
||||
router.replace({
|
||||
path: '/verify/result',
|
||||
query: {
|
||||
success: 'false',
|
||||
orderId: detail.value.orderId,
|
||||
packageName: packageName.value,
|
||||
message: error instanceof Error ? error.message : '核销失败'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -203,41 +190,25 @@ onMounted(loadDetail)
|
||||
|
||||
<section class="panel detail-panel">
|
||||
<h2 class="section-title">核销内容</h2>
|
||||
<van-radio-group v-model="selectedItemId">
|
||||
<van-radio-group v-model="packageName">
|
||||
<van-cell-group class="package-group">
|
||||
<van-cell v-if="packageMismatch" title="扫码套餐不属于当前订单" label="请返回重新扫码或联系管理员核对订单二维码" />
|
||||
<van-cell
|
||||
v-if="packageMismatch"
|
||||
:title="routeWriteOffItemId ? '扫码明细不属于当前订单' : '扫码套餐不属于当前订单'"
|
||||
label="请返回重新扫码或联系管理员核对订单二维码"
|
||||
/>
|
||||
<van-cell
|
||||
v-for="(item, index) in packageOptions"
|
||||
:key="getWriteOffItemId(item) || `${getWriteOffItemName(item, detail.commodityName)}-${index}`"
|
||||
:clickable="canSelectItem(item)"
|
||||
v-for="item in packageOptions"
|
||||
:key="getPackageName(item)"
|
||||
:clickable="!isScanPackageLocked"
|
||||
@click="selectPackage(item)"
|
||||
>
|
||||
<template #title>
|
||||
<div class="package-title">
|
||||
<ReceiptText :size="17" />
|
||||
<span>{{ getWriteOffItemName(item, detail.commodityName) }}</span>
|
||||
<van-tag v-if="isScanItemLocked" type="success" plain>扫码指定</van-tag>
|
||||
<van-tag v-else-if="isScanSource && routePackageName" type="primary" plain>扫码匹配</van-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #label>
|
||||
<div class="package-label">
|
||||
<span v-if="getWriteOffItemContent(item)">{{ getWriteOffItemContent(item) }}</span>
|
||||
<span>{{ getItemLabel(item) }}</span>
|
||||
<span v-if="!getWriteOffItemAvailability(item).available" class="package-label__warning">
|
||||
{{ getWriteOffItemAvailability(item).reason }}
|
||||
</span>
|
||||
<span>{{ getPackageName(item) }}</span>
|
||||
<van-tag v-if="isScanPackageLocked" type="success" plain>扫码指定</van-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #label>{{ item.packageContent || item.packageDesc || item.content }}</template>
|
||||
<template #right-icon>
|
||||
<van-radio
|
||||
:name="getWriteOffItemId(item)"
|
||||
:disabled="isScanItemLocked || !getWriteOffItemAvailability(item).available"
|
||||
/>
|
||||
<van-radio :name="getPackageName(item)" :disabled="isScanPackageLocked" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
@@ -272,14 +243,6 @@ onMounted(loadDetail)
|
||||
<BadgeCheck :size="20" />
|
||||
<span>该订单当前不可核销,请返回订单详情核对状态。</span>
|
||||
</section>
|
||||
<section v-else-if="showManualSelectionHint" class="panel warning-panel">
|
||||
<BadgeCheck :size="20" />
|
||||
<span>扫码套餐匹配到多个可核销明细,请选择后再核销。</span>
|
||||
</section>
|
||||
<section v-else-if="selectedPackage && !selectedItemAvailability.available" class="panel warning-panel">
|
||||
<BadgeCheck :size="20" />
|
||||
<span>{{ selectedItemAvailability.reason }}</span>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -346,15 +309,6 @@ onMounted(loadDetail)
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.package-label {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.package-label__warning {
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
.warning-panel {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -4,11 +4,6 @@ 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()
|
||||
@@ -23,7 +18,6 @@ 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 {
|
||||
@@ -48,7 +42,6 @@ const stopScan = () => {
|
||||
}
|
||||
detector = null
|
||||
lastDecodeAt = 0
|
||||
lastScanHintAt = 0
|
||||
}
|
||||
|
||||
const goConfirm = (payload: WriteOffCodePayload) => {
|
||||
@@ -57,7 +50,6 @@ const goConfirm = (payload: WriteOffCodePayload) => {
|
||||
query: {
|
||||
orderId: payload.orderId,
|
||||
source: 'scan',
|
||||
...(payload.orderWriteOffItemId ? { orderWriteOffItemId: payload.orderWriteOffItemId } : {}),
|
||||
...(payload.packageName ? { packageName: payload.packageName } : {})
|
||||
}
|
||||
})
|
||||
@@ -95,11 +87,6 @@ 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
|
||||
@@ -108,18 +95,14 @@ const detectLoop = async () => {
|
||||
const now = Date.now()
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && now - lastDecodeAt > 120) {
|
||||
lastDecodeAt = now
|
||||
const value = await detectQrValue(video)
|
||||
const value = detector ? await detectWithBarcodeDetector(video) : detectWithJsQr(video)
|
||||
if (value) {
|
||||
handleCodeValue(value)
|
||||
return
|
||||
}
|
||||
if (!scanError.value && now - lastScanHintAt > 3000) {
|
||||
lastScanHintAt = now
|
||||
scanError.value = '暂未识别到二维码,请将二维码完整放入取景框'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
scanError.value = '扫码识别失败,请调整距离或切换角度后重试'
|
||||
scanError.value = '扫码识别失败,请调整距离或光线后重试'
|
||||
}
|
||||
|
||||
if (scanning.value) {
|
||||
@@ -127,19 +110,6 @@ 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()
|
||||
@@ -153,7 +123,12 @@ const startScan = async () => {
|
||||
scanError.value = ''
|
||||
try {
|
||||
detector = BarcodeDetector ? new BarcodeDetector({ formats: ['qr_code'] }) : null
|
||||
stream = await openCameraStream()
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: { ideal: 'environment' }
|
||||
}
|
||||
})
|
||||
scanning.value = true
|
||||
await nextTick()
|
||||
if (!videoRef.value) return
|
||||
@@ -162,7 +137,9 @@ const startScan = async () => {
|
||||
frameId = window.requestAnimationFrame(detectLoop)
|
||||
} catch (error) {
|
||||
stopScan()
|
||||
scanError.value = getCameraAccessErrorMessage(error)
|
||||
scanError.value = error instanceof Error && error.name === 'NotAllowedError'
|
||||
? '未获得摄像头权限,请允许后重试'
|
||||
: '无法打开摄像头,请检查浏览器权限'
|
||||
showToast(scanError.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
Reference in New Issue
Block a user