/** API 响应解包与错误归一化(任务 22):纯函数,无框架依赖。 */ export const REQUEST_FALLBACK_MESSAGE = '网络异常,请稍后重试' function textOf(value: Record): string { const message = value.message const error = value.error if (typeof message === 'string' && message.trim()) return message.trim() if (typeof error === 'string' && error.trim()) return error.trim() return '' } /** * 统一解包 Java 响应: * - success=false -> 抛错(取 message/error,空则“请求失败”); * - 带非空 data -> 返回 data; * - 否则原样返回(允许 {item}/{items} 等信封由调用方再解)。 */ export function unwrap(payload: unknown): T { const value = payload as Record | null if (!value || typeof value !== 'object') return payload as T if (value.success === false) { throw new Error(textOf(value) || '请求失败') } if ('data' in value && value.data !== null && value.data !== undefined) { return value.data as T } return payload as T } /** 从任意负载取错误文案;缺省回退。 */ export function errorTextOf(payload: unknown): string { if (payload instanceof Error) return payload.message.trim() || REQUEST_FALLBACK_MESSAGE if (payload && typeof payload === 'object') { const text = textOf(payload as Record) if (text) return text } if (typeof payload === 'string' && payload.trim()) return payload.trim() return REQUEST_FALLBACK_MESSAGE } /** 负载/状态码是否 401(未登录)。 */ export function isUnauthorized(payload: unknown): boolean { const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null if (!record || typeof record !== 'object') return false return [record.status, record.statusCode, record.code].some((v) => v === 401) } /** 负载/状态码是否 403(已登录但无后台权限)。 */ export function isForbidden(payload: unknown): boolean { const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null if (!record || typeof record !== 'object') return false return [record.status, record.statusCode, record.code].some((v) => v === 403) } /** 单设备登录:账号已在其他设备登录(4011,与后端 DeviceSessionPolicy.CODE_KICKED 对齐)。 */ export function isKicked(payload: unknown): boolean { const record = payload as { code?: unknown } | null if (!record || typeof record !== 'object') return false return record.code === 4011 } /** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */ export function requestErrorMessage(error: unknown): string { const response = (error as { response?: { data?: unknown; status?: number } })?.response if (response) { const text = errorTextOf(response.data) if (text) return text } return errorTextOf(error) } /** 当前地址作为登录跳转的 redirect 参数(encodeURIComponent 后)。 */ export function loginRedirectTarget(location: { pathname: string; search: string }): string { return encodeURIComponent(`${location.pathname}${location.search}`) } /** 登录页统一入口;401 一律跳此路径。 */ export const LOGIN_PATH = '/login' /** 当前 pathname 是否处于登录页(去掉可能携带的查询串后比较)。 */ export function isLoginLocation(pathname: string): boolean { if (typeof pathname !== 'string') return false const queryAt = pathname.indexOf('?') return (queryAt >= 0 ? pathname.slice(0, queryAt) : pathname) === LOGIN_PATH } /** 把请求 URL(相对或绝对)规整为相对路径,便于与登录入口比较。 */ export function requestRelativePath(url: string): string { if (typeof url !== 'string') return '' const noQuery = url.split('?')[0] const match = noQuery.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(.*)$/) return match ? match[1] : noQuery } /** 401 失败请求本身是否指向登录/认证端点(登录提交失败不应再被踢回登录页)。 */ export function isAuthEndpointRequest(url: string | undefined): boolean { return requestRelativePath(url || '') === LOGIN_PATH } /** * 401 是否应执行“跳 /login”:已处于登录页或失败请求即登录端点时不应再跳, * 否则会在登录页循环跳转或吞掉登录失败反馈。 */ export function shouldRedirectUnauthorized(currentPathname: string, requestUrl?: string): boolean { if (isLoginLocation(currentPathname)) return false if (isAuthEndpointRequest(requestUrl)) return false return true }