bigfix
This commit is contained in:
@@ -34,6 +34,22 @@ export function isLoggedIn(): boolean {
|
||||
return !!getAccessToken()
|
||||
}
|
||||
|
||||
// token 过期提前量(秒):提前 30s 视为过期,避免请求在途时刚好失效
|
||||
const EXPIRE_LEAD_SECONDS = 30
|
||||
|
||||
/**
|
||||
* access token 是否已过期(仅用于请求前预刷新,不参与登录态判定)
|
||||
* expire_time 单位兼容秒与毫秒:数值大于 1e12 视为毫秒时间戳。
|
||||
*/
|
||||
export function isTokenExpired(): boolean {
|
||||
const expireTime = getExpireTime()
|
||||
if (!expireTime) return false // 未记录过期时间时不臆测,交给后端判定
|
||||
|
||||
const isMillisecond = expireTime > 1e12
|
||||
const expireMs = isMillisecond ? expireTime : expireTime * 1000
|
||||
return Date.now() + EXPIRE_LEAD_SECONDS * 1000 >= expireMs
|
||||
}
|
||||
|
||||
export function clearUserInfo(): void {
|
||||
wx.removeStorageSync(STORAGE_KEYS.USER_INFO)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const STORAGE_KEYS = {
|
||||
EXPIRE_TIME: 'zt_closet_expire_time',
|
||||
USER_INFO: 'zt_closet_user_info',
|
||||
UNREAD: 'zt_closet_unread',
|
||||
OPEN_COLLECT: 'zt_open_collect',
|
||||
}
|
||||
|
||||
// 衣物图片处理状态
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// 分页响应统一兜底:后端 code === 0 时仍可能返回 null / 缺字段,
|
||||
// 直接 res.list.map 会抛错并被页面空的 catch 吞掉,表现为「静默空态」。
|
||||
|
||||
export interface NormalizedPaged<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export function normalizePaged<T>(res: any): NormalizedPaged<T> {
|
||||
const list = res && Array.isArray(res.list) ? (res.list as T[]) : []
|
||||
const total = res && typeof res.total === 'number' ? res.total : list.length
|
||||
return { list, total }
|
||||
}
|
||||
|
||||
// 追加/替换分页数据:refresh 为 true 时用新一页覆盖,否则追加
|
||||
export function mergePaged<T>(oldList: T[], list: T[], refresh: boolean): T[] {
|
||||
return refresh ? list : oldList.concat(list)
|
||||
}
|
||||
|
||||
// 是否还有下一页:total 缺失时退化为「本页满额则假设还有」
|
||||
export function hasMorePaged<T>(list: T[], total: number, pageSize: number): boolean {
|
||||
if (total > 0) return list.length < total
|
||||
return list.length > 0 && list.length % pageSize === 0
|
||||
}
|
||||
+122
-45
@@ -22,9 +22,35 @@ export interface RequestOptions {
|
||||
silentAuth?: boolean // 非关键请求(如未读红点轮询):401 只失败,不触发全局跳登录
|
||||
}
|
||||
|
||||
// 未登录错误码:调用方可通过 e.code === 11001 区分
|
||||
// 未登录错误码:调用方可通过 e.code === NOT_LOGIN_CODE 区分
|
||||
export const NOT_LOGIN_CODE = 11001
|
||||
|
||||
// 登录失效错误码集合:命中后先尝试刷新 token,刷新失败才清理登录态并跳转登录页
|
||||
export const AUTH_EXPIRED_CODES: number[] = [
|
||||
11000, // 请先登录
|
||||
11001, // 登录状态已失效
|
||||
11002, // 登录状态已过期
|
||||
11004, // 请先登录
|
||||
]
|
||||
|
||||
// 请求超时兜底(毫秒):弱网下 wx.request 默认 60s,用户体验极差
|
||||
const REQUEST_TIMEOUT = 15000
|
||||
const UPLOAD_TIMEOUT = 30000
|
||||
|
||||
function isAuthExpiredCode(code: number, statusCode?: number): boolean {
|
||||
return AUTH_EXPIRED_CODES.indexOf(code) >= 0 || statusCode === 401
|
||||
}
|
||||
|
||||
// 登录失效后的收尾:非关键请求只 reject,不动登录态
|
||||
function failAuth(code: number, msg: string, silentAuth: boolean, showError: boolean) {
|
||||
if (!silentAuth) {
|
||||
auth.clearTokens()
|
||||
handleAuthExpired()
|
||||
if (showError) showToast('登录已过期,请重新登录')
|
||||
}
|
||||
return new ApiError(code, msg || '登录已过期')
|
||||
}
|
||||
|
||||
// 登录失效 / 账号禁用后的跳转处理(由 app.ts 注入,避免循环依赖)
|
||||
let onAuthExpired: (() => void) | null = null
|
||||
export function setAuthExpiredHandler(handler: () => void): void {
|
||||
@@ -48,6 +74,7 @@ function doRefresh(): Promise<boolean> {
|
||||
method: 'POST',
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
data: { refresh_token: refreshToken, access_token: accessToken },
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
success: (res) => {
|
||||
const body = res.data as { code: number; data?: LoginTokenResponse }
|
||||
if (body && body.code === 0 && body.data) {
|
||||
@@ -131,7 +158,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
return
|
||||
}
|
||||
|
||||
const doCall = (): void => {
|
||||
const doCall = (retried = false): void => {
|
||||
const finalHeader: Record<string, any> = {
|
||||
'Content-Type': 'application/json',
|
||||
...header,
|
||||
@@ -146,6 +173,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
method,
|
||||
data,
|
||||
header: finalHeader,
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
success: (res) => {
|
||||
const body = res.data as { code: number; msg: string; data?: T; details?: string[] }
|
||||
|
||||
@@ -164,24 +192,20 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
return
|
||||
}
|
||||
|
||||
// Token 失效:尝试刷新后重试一次
|
||||
if (
|
||||
needAuth &&
|
||||
(code === 11001 || code === 11002 || code === 11004 || res.statusCode === 401)
|
||||
) {
|
||||
// Token 失效:刷新后最多重试一次,避免「刷新成功但资源接口持续 401」引发请求风暴
|
||||
if (needAuth && isAuthExpiredCode(code, res.statusCode)) {
|
||||
if (retried) {
|
||||
reject(failAuth(code, body.msg, silentAuth, showError))
|
||||
return
|
||||
}
|
||||
doRefresh().then((ok) => {
|
||||
if (ok) {
|
||||
doCall()
|
||||
doCall(true)
|
||||
return
|
||||
}
|
||||
// 非关键请求(红点轮询、额度展示等)静默失败:
|
||||
// 只 reject,不动登录态、不跳转,避免把已登录用户悄悄踢成未登录
|
||||
if (!silentAuth) {
|
||||
auth.clearTokens()
|
||||
handleAuthExpired()
|
||||
if (showError) showToast('登录已过期,请重新登录')
|
||||
}
|
||||
reject(new ApiError(code, body.msg || '登录已过期'))
|
||||
reject(failAuth(code, body.msg, silentAuth, showError))
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -209,47 +233,100 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// token 已过期:先静默刷新再发请求,省掉一次必然失败的 401 往返
|
||||
// (刷新失败也不直接拒绝:仍发一次,由后端判定,避免网络抖动就把用户踢下线)
|
||||
if (needAuth && auth.isTokenExpired()) {
|
||||
doRefresh().then(() => {
|
||||
doCall()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
doCall()
|
||||
})
|
||||
}
|
||||
|
||||
export interface UploadOptions {
|
||||
showError?: boolean // 默认 true,业务错误自动 toast
|
||||
timeout?: number // 默认 30s
|
||||
silentAuth?: boolean // 非关键上传:401 只失败,不触发全局跳登录
|
||||
}
|
||||
|
||||
// 文件上传(multipart,字段名 file)
|
||||
export function uploadFile<T = any>(filePath: string, name = 'file', url = '/clothes/upload'): Promise<T> {
|
||||
const token = auth.getAccessToken()
|
||||
const header: Record<string, any> = {}
|
||||
if (token) header['Authorization'] = `Bearer ${token}`
|
||||
export function uploadFile<T = any>(
|
||||
filePath: string,
|
||||
name = 'file',
|
||||
url = '/clothes/upload',
|
||||
options: UploadOptions = {},
|
||||
): Promise<T> {
|
||||
const { showError = true, timeout = UPLOAD_TIMEOUT, silentAuth = false } = options
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`,
|
||||
filePath,
|
||||
name,
|
||||
header,
|
||||
success: (res) => {
|
||||
let body: { code: number; msg: string; data?: T; details?: string[] } | null = null
|
||||
try {
|
||||
body = JSON.parse(res.data)
|
||||
} catch (e) {
|
||||
body = null
|
||||
}
|
||||
if (body && typeof body.code === 'number') {
|
||||
// 未登录:直接失败,不发请求
|
||||
if (!auth.isLoggedIn()) {
|
||||
reject(new ApiError(NOT_LOGIN_CODE, '未登录'))
|
||||
return
|
||||
}
|
||||
|
||||
const doUpload = (retried = false): void => {
|
||||
const header: Record<string, any> = {}
|
||||
const token = auth.getAccessToken()
|
||||
if (token) header['Authorization'] = `Bearer ${token}`
|
||||
|
||||
wx.uploadFile({
|
||||
url: url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`,
|
||||
filePath,
|
||||
name,
|
||||
header,
|
||||
timeout,
|
||||
success: (res) => {
|
||||
let body: { code: number; msg: string; data?: T; details?: string[] } | null = null
|
||||
try {
|
||||
body = JSON.parse(res.data)
|
||||
} catch (e) {
|
||||
body = null
|
||||
}
|
||||
|
||||
if (!body || typeof body.code !== 'number') {
|
||||
if (showError) showToast('上传失败,请稍后重试')
|
||||
reject(new Error('upload fail'))
|
||||
return
|
||||
}
|
||||
|
||||
if (body.code === 0) {
|
||||
resolve(body.data as T)
|
||||
} else {
|
||||
const msg =
|
||||
(body.details && body.details[0]) || ERROR_MESSAGES[body.code] || body.msg || '上传失败'
|
||||
showToast(msg)
|
||||
reject(new ApiError(body.code, body.msg || '上传失败', body.details))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
showToast('上传失败,请稍后重试')
|
||||
|
||||
// Token 失效:刷新后最多重试一次
|
||||
if (isAuthExpiredCode(body.code, res.statusCode)) {
|
||||
if (retried) {
|
||||
reject(failAuth(body.code, body.msg, silentAuth, showError))
|
||||
return
|
||||
}
|
||||
doRefresh().then((ok) => {
|
||||
if (ok) {
|
||||
doUpload(true)
|
||||
return
|
||||
}
|
||||
reject(failAuth(body.code, body.msg, silentAuth, showError))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const msg =
|
||||
(body.details && body.details[0]) || ERROR_MESSAGES[body.code] || body.msg || '上传失败'
|
||||
if (showError) showToast(msg)
|
||||
reject(new ApiError(body.code, body.msg || '上传失败', body.details))
|
||||
},
|
||||
fail: () => {
|
||||
if (showError) showToast('上传失败,请检查网络后重试')
|
||||
reject(new Error('upload fail'))
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
showToast('上传失败,请检查网络后重试')
|
||||
reject(new Error('upload fail'))
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
doUpload()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// 自定义 tabBar 实例获取:只有 tab 页自身才有 getTabBar(),
|
||||
// 通知页等非 tab 页调用会拿到 undefined,需从页面栈中查找仍在栈内的 tab 页。
|
||||
|
||||
export function getTabBarInstance(): any {
|
||||
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
|
||||
for (let i = pages.length - 1; i >= 0; i--) {
|
||||
const page = pages[i] as any
|
||||
const tabBar = page && page.getTabBar && page.getTabBar()
|
||||
if (tabBar) return tabBar
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 同步未读红点:优先让 tabBar 重新拉取真实数量,取不到实例时静默跳过
|
||||
export function syncTabBarUnread(force = true): void {
|
||||
const tabBar = getTabBarInstance()
|
||||
if (tabBar && typeof tabBar.syncUnread === 'function') tabBar.syncUnread(force)
|
||||
}
|
||||
Reference in New Issue
Block a user