优化登录,优化阅读

This commit is contained in:
truewhile
2026-10-03 21:00:16 +08:00
parent 5242d54a06
commit b0f1ab39b9
22 changed files with 1513 additions and 155 deletions
+23 -23
View File
@@ -2,9 +2,12 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
import type { RefreshOutcome } from '../utils/authRefresh'
// Single axios instance used by every API helper. Adds the JWT to outgoing
// requests and routes 401s back to the login page.
// requests and refreshes it on 401. Only an explicit server-side rejection of
// the refresh token sends the user back to the login page; transient failures
// (deploy/restart window, gateway errors, timeouts) keep the stored session.
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
@@ -92,33 +95,30 @@ api.interceptors.response.use(
originalRequest._retry = true
isRefreshing = true
try {
const refreshed = await useAuthStore.getState().tokenRefresh()
if (refreshed) {
const newToken = useAuthStore.getState().token
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
onTokenRefreshed(newToken || '')
isRefreshing = false
return api(originalRequest)
// 刷新可能因为部署/重启窗口的瞬时故障失败。只有服务端明确判定
// 凭证失效(401/403)时才清空会话并跳登录页;网络错误、网关 502、
// 超时只是暂时不可用,保留本地令牌等下一次重试。
const outcome: RefreshOutcome = await useAuthStore
.getState()
.refreshSession()
.catch((): RefreshOutcome => 'transient')
isRefreshing = false
if (outcome === 'refreshed') {
const newToken = useAuthStore.getState().token
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
} catch (refreshError) {
isRefreshing = false
onTokenRefreshFailed(refreshError)
onTokenRefreshed(newToken || '')
return api(originalRequest)
}
onTokenRefreshFailed(err)
if (outcome === 'invalid') {
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(refreshError)
}
// Refresh failed, logout
isRefreshing = false
onTokenRefreshFailed(err)
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(err)
}
+8 -2
View File
@@ -366,9 +366,15 @@ export const readerAPI = {
.then((r) => r.data.chapters ?? []),
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
bookContent: (id: string, chapter: number) =>
// signal:换章/换源/离开页面时取消在途请求——慢源上一章要等十几秒,不取消的话
// 旧源的响应会白等,换源后还可能把旧源的正文写进新书的缓存。
bookContent: (id: string, chapter: number, signal?: AbortSignal) =>
api
.get<ReaderChapterContent>(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
.get<ReaderChapterContent>(`/reader/books/${id}/content`, {
params: { chapter },
timeout: LONG_REQUEST_TIMEOUT,
signal,
})
.then((r) => r.data),
// ── 替换净化规则 ──
+50 -31
View File
@@ -1,37 +1,56 @@
// 令牌刷新 API 模块
import { api } from './client'
// 令牌刷新 API 模块。
import { RefreshRequestError, type RefreshTokens } from '../utils/authRefresh'
// 刷新令牌请求/响应
export interface RefreshTokenRequest {
refresh_token: string
}
const REFRESH_ENDPOINT = '/api/auth/refresh'
export interface RefreshTokenResponse {
token: string
refresh_token: string
expires_in: number
token_type: string
}
// 刷新是短请求:网关/服务重启时尽快失败并让调用方重试,
// 不要用默认的长超时把页面卡在等待里。
const REFRESH_TIMEOUT_MS = 15_000
// 刷新访问令牌。
//
// 后端响应封装在 { code, message, data } 里,需解包 .data。
// /auth/login 的响应是直接展开的({tokens:..., user:...}),
// /auth/refresh 的响应是包装过的 — 这里负责拉平成前端使用的 shape。
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
const resp = await api.post<{
code: number
message: string
data: RefreshTokenResponse
}>('/auth/refresh', { refresh_token: refreshToken })
const body = resp.data
if (!body || !body.data || !body.data.token) {
throw new Error(body?.message || 'refresh failed')
/**
* 用 refresh token 换取新的令牌对。
*
* 刻意使用 fetch 而不是共享的 axios 实例:
* - 刷新请求的失败不能进入 401 拦截器,否则会递归触发刷新/登出;
* - 调用方需要拿到 HTTP 状态码,以区分「凭证失效」和「服务暂时不可用」。
*
* 服务端响应形如 { code, message, data: { token, refresh_token, ... } }。
*/
export async function requestRefreshTokens(refreshToken: string): Promise<RefreshTokens> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
let resp: Response
try {
resp = await fetch(REFRESH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify({ refresh_token: refreshToken }),
signal: controller.signal,
})
} catch {
// 网络不可达、被中止或超时:没有 HTTP 状态码,按「暂时不可用」处理。
throw new RefreshRequestError('refresh request failed')
} finally {
clearTimeout(timer)
}
return body.data
}
// 登出
export async function logout(): Promise<void> {
await api.post('/me/logout')
const body = (await resp.json().catch(() => null)) as
| { code?: number; message?: string; data?: Partial<RefreshTokens> }
| null
if (!resp.ok) {
throw new RefreshRequestError(body?.message ?? 'refresh failed', resp.status)
}
const token = body?.data?.token
const nextRefreshToken = body?.data?.refresh_token
if (!token || !nextRefreshToken) {
// 200 但没有可用令牌(例如被网关/代理改写了响应):同样按暂时不可用处理,
// 不要据此清空用户会话。
throw new RefreshRequestError('malformed refresh response')
}
return { token, refresh_token: nextRefreshToken }
}