This commit is contained in:
2026-09-13 01:51:09 +08:00
parent b1a664a768
commit afd0973ce5
32 changed files with 935 additions and 171 deletions
+16
View File
@@ -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)
}
+1
View File
@@ -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',
}
// 衣物图片处理状态
+24
View File
@@ -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
View File
@@ -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()
})
}
+18
View File
@@ -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)
}