Files
xiaotutongxue/frontend-pc/src/utils/http.ts
T
lovife 5b877451eb refactor(pc): 从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI
旧模板的 193 个文件整体删除,换成纯 Vue 3.5 + Naive UI 2.42 的树。

- 去掉 auto-import(组件与 API 全显式 import)、v-hasPerm(改 user.has('码'),全库 8 处)、
  Stylelint;n-tag 的 type 统一过 utils/tag.ts 转换(Arco 色词直接透传会静默变灰)
- 图标改为构建期抽取:scripts/extract-pc-icons.py -> icons/icon-data.ts(37 个),
  内网离线部署不会白屏
- 重搭后修掉 6 类"编译 / lint / typecheck 全绿、界面上就是坏的"故障:
  ① NGrid 的 cols 写成像素值(不是断点名 -> 永远只 1 列,工作台因此错乱)
  ② n-tab-pane 用 tab 属性(只渲染空注释节点,整排 tab 一个字都不显示)
  ③ 漏 import 被当未知自定义元素(样式整块丢) ④ .n-card__content 类名不存在
  (高度链第一环失效,表格行在 DOM 里但屏幕上没有) ⑤ 填报单状态映射错位一格
  ⑥ 有标题的卡片没有上内边距(naive 的 padding-top 只给"内容区是长子"的卡片)
- 卡片式版面提成全局基线:styles/index.scss 的 xt-* 令牌 + .page-card / .toolbar /
  .section-title,页面只引用、不再各写一套
- 品牌:改名「深学在线」,文字标换成几何标(方 = 卷宗 / 环 = 在线,不用文字字形);
  index.html 的页签图标从 404 的 /favicon.ico 改指真实存在的 /favicon.svg
2026-10-10 16:48:46 +08:00

172 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { BizError } from '@/apis/types'
import axios from 'axios'
import { getToken } from '@/utils/auth'
import { message } from '@/utils/naive'
/**
* 后端响应契约(《03-接口设计》§1.3):`{ code, msg, data }`。
*
* 这一层做三件事,做完之后业务代码里几乎不再出现 `code`:
* 1. 成功(`code === 0`)时**直接 resolve `data`**(`await` 拿到的就是数据);
* 2. 失败时**弹一次提示**并 reject 一个带 `bizCode` 的错误(想按码分支也能拿);
* 3. 带上 `Authorization: Bearer` 与平台代管头 `X-Tenant-Id`。
*
* ⚠️ 401/403/429 **不走业务码**:后端对它们用真实 HTTP 状态码 ——
* 所以下面的错误分支才是它们的归属地。别在业务码里找 40301。
*/
/** 未登录 / token 失效(唯一需要在业务码里特殊处理的一个) */
const CODE_UNAUTHORIZED = 40101
const STATUS_TEXT: Record<number, string> = {
400: '请求参数有误(400)',
401: '登录已失效,请重新登录',
403: '无权限访问(403)',
404: '接口不存在(404)',
408: '请求超时(408)',
429: '操作过于频繁,请稍后再试',
500: '服务器错误(500)',
502: '网关错误(502)',
503: '服务不可用(503)',
504: '网关超时(504)'
}
const http: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_PREFIX,
timeout: 60 * 1000
})
/**
* 平台运营的「当前学校」——请求头 `X-Tenant-Id`。
*
* 放在模块级变量而不是从 store 读:store 引 http 会成环。由 store 在变更时调 setter
* 推过来,方向永远是 store → http,环就断了。
*/
let actingTenantId: number | null = null
export function setActingTenantHeader(tenantId: number | null): void {
actingTenantId = tenantId
}
/** 未登录时的处理(由 router 注册,避免 http → router → views → http 的循环引用) */
type UnauthorizedHandler = () => void
let onUnauthorized: UnauthorizedHandler | null = null
export function setUnauthorizedHandler(handler: UnauthorizedHandler): void {
onUnauthorized = handler
}
let redirecting = false
function handleUnauthorized(): void {
if (redirecting) {
return
}
redirecting = true
try {
onUnauthorized?.()
} finally {
setTimeout(() => {
redirecting = false
}, 1000)
}
}
function makeError(text: string, bizCode?: number, bizData?: unknown, status?: number): BizError {
const err = new Error(text) as BizError
err.bizCode = bizCode
err.bizData = bizData
err.status = status
return err
}
http.interceptors.request.use((config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
if (actingTenantId) {
config.headers['X-Tenant-Id'] = String(actingTenantId)
}
return config
})
http.interceptors.response.use(
(response: AxiosResponse) => {
const envelope = response.data
// 非信封响应(文件流 / 第三方回调)原样放行
if (!envelope || typeof envelope.code !== 'number') {
return response
}
if (envelope.code === 0) {
response.data = envelope.data
return response
}
if (envelope.code === CODE_UNAUTHORIZED) {
handleUnauthorized()
return Promise.reject(makeError(envelope.msg || '登录已失效,请重新登录', envelope.code, envelope.data))
}
const text = envelope.msg || '操作失败'
message.error(text)
return Promise.reject(makeError(text, envelope.code, envelope.data))
},
(error) => {
const status: number | undefined = error?.response?.status
if (status === 401) {
handleUnauthorized()
return Promise.reject(makeError('登录已失效,请重新登录', undefined, undefined, 401))
}
const text = STATUS_TEXT[status ?? 0] || '网络异常,请检查网络后重试'
message.error(text)
return Promise.reject(makeError(text, undefined, undefined, status))
}
)
/**
* 把 axios 的响应**在运行时**取出 `data`。
*
* ⚠️ 这一句是整个请求层最容易写错的地方,这里踩过一次:
* 上面的响应拦截器是按 axios 的约定写的 —— 它**只能返回一个"响应对象"**,
* 于是 `response.data = envelope.data` 只是把信封里的 `data` 换成了业务数据,
* **promise 本身仍然 resolve 成那个响应对象**。
* 如果这里只做类型断言(`as unknown as Promise<T>`)而不真的取 `.data`,
* 那么所有 `await getXxx()` 拿到的都是 AxiosResponse ——
* 表现是"接口明明返回了数据,页面却什么也没有"(`page.list` 是 undefined),
* 而且**不报任何错**。登录页是第一个撞上的:它拿公钥去加密,拿到的是个对象。
*
* 所以:拦截器负责"拆信封并弹提示",这里负责"把 data 交出去",
* 两件事都必须做,缺一不可。
*/
const unwrap = <T>(promise: Promise<AxiosResponse>): Promise<T> =>
promise.then((response) => response.data as T)
const get = <T = unknown>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> =>
unwrap<T>(http.get(url, { params, ...config }))
const post = <T = unknown>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> =>
unwrap<T>(http.post(url, data, config))
const put = <T = unknown>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> =>
unwrap<T>(http.put(url, data, config))
const del = <T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> =>
unwrap<T>(http.delete(url, config))
/**
* 任意方法的原样请求(上传 FormData、要自定义 timeout 时用)。
*
* ⚠️ 与上面四个一样,返回的是**拆过信封的 data** —— 调用方不要再写 `res.data`。
* 要拿原始响应(文件流)请用下面导出的 `raw`。
*/
const request = <T = unknown>(config: AxiosRequestConfig): Promise<T> =>
unwrap<T>(http.request(config))
/** 需要拿原始响应(文件流)时用它,例如导出包下载 */
export const raw = http
export default { get, post, put, delete: del, request }