2026-05-29 18:16:34 +08:00
|
|
|
|
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
|
|
|
|
|
|
|
|
|
|
|
|
const baseURL = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
|
|
|
|
|
|
|
|
|
|
|
// AR-5 (会话 45):withCredentials=true 让后端下发的 httpOnly Cookie (simple.auth.token)
|
|
|
|
|
|
// 自动随请求带回。Platform.Server `/api/auth/login` 同时下发 Cookie 和返回 token 字段,
|
|
|
|
|
|
// 过渡期内 Authorization: Bearer header 仍然兼容(旧浏览器 / 跨进程脚本)。
|
|
|
|
|
|
const http: AxiosInstance = axios.create({
|
|
|
|
|
|
baseURL,
|
|
|
|
|
|
timeout: 15000,
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
withCredentials: true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
2026-07-27 15:12:14 +08:00
|
|
|
|
// FormData 必须由浏览器自动带 multipart boundary;清掉默认 application/json。
|
|
|
|
|
|
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
|
|
|
|
|
|
config.headers.delete('Content-Type')
|
|
|
|
|
|
}
|
2026-05-29 18:16:34 +08:00
|
|
|
|
// 双轨:优先用 localStorage 里的 token(过渡期 fallback),Cookie 会自动带;
|
|
|
|
|
|
// 后端首先看 Authorization: Bearer,没有再看 Cookie,两路任一通过即可。
|
|
|
|
|
|
const token = localStorage.getItem('simple.auth.token')
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
config.headers.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
const scope = localStorage.getItem('simple.auth.scope')
|
|
|
|
|
|
if (scope) {
|
|
|
|
|
|
config.headers.set('X-Scope', scope)
|
|
|
|
|
|
}
|
|
|
|
|
|
return config
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把 axios 异常翻译成更友好的中文文案,特别是 SimpleLite 链路:
|
|
|
|
|
|
*
|
|
|
|
|
|
* /api/sl/projection/** → Platform.Server YARP → SimpleLite EmbedIO :8222
|
|
|
|
|
|
*
|
|
|
|
|
|
* 如果 SimpleLite 没启动 / 端口未监听,YARP 一定回 502;超时一般是 504。把这两种
|
|
|
|
|
|
* 情况单独识别,写成「SimpleLite 未连接,请先启动 SimpleLite (端口 8222)」之类,
|
|
|
|
|
|
* 比直接抛 `Request failed with status code 502` 友好得多,也避免用户怀疑前端 bug。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function describeError(err: AxiosError): string {
|
|
|
|
|
|
const status = err.response?.status
|
|
|
|
|
|
const url = err.config?.url ?? ''
|
|
|
|
|
|
const isSimpleLite = url.startsWith('/sl/') || url.startsWith('sl/')
|
|
|
|
|
|
if (status === 502 || status === 504 || err.code === 'ECONNABORTED' || err.code === 'ERR_NETWORK') {
|
|
|
|
|
|
if (isSimpleLite) {
|
|
|
|
|
|
return 'SimpleLite 未连接:请先启动 SimpleLite 后端(监听端口 8222)后重试。'
|
|
|
|
|
|
}
|
|
|
|
|
|
return `网关无法连接到下游服务(${status ?? err.code ?? 'network'})。`
|
|
|
|
|
|
}
|
|
|
|
|
|
// 后端 envelope 模式:{ success: false, message: 'xxx' }
|
|
|
|
|
|
const data = err.response?.data as { message?: string } | undefined
|
|
|
|
|
|
if (data?.message) return data.message
|
|
|
|
|
|
return err.message
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
http.interceptors.response.use(
|
|
|
|
|
|
(resp) => resp,
|
|
|
|
|
|
(err: AxiosError) => {
|
|
|
|
|
|
if (err.response?.status === 401) {
|
|
|
|
|
|
// 会话 45 HC-1:401 全量清 state,避免老 user/scope/perm 残留导致 UI 误判。
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.removeItem('simple.auth.token')
|
|
|
|
|
|
localStorage.removeItem('simple.auth.user')
|
|
|
|
|
|
localStorage.removeItem('simple.auth.scope')
|
|
|
|
|
|
localStorage.removeItem('simple.auth.runMode')
|
|
|
|
|
|
localStorage.removeItem('simple.auth.perm')
|
|
|
|
|
|
} catch { /* 某些隐私模式 / iframe 沙箱可能禁用 localStorage */ }
|
|
|
|
|
|
if (location.pathname !== '/login') location.assign('/login')
|
|
|
|
|
|
}
|
|
|
|
|
|
// 用 Object.defineProperty 覆盖 message,避免后续 `(err as Error).message` 还是看到原文。
|
|
|
|
|
|
const friendly = describeError(err)
|
|
|
|
|
|
try { Object.defineProperty(err, 'message', { value: friendly, configurable: true }) } catch { /* ignore */ }
|
|
|
|
|
|
return Promise.reject(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
export default http
|