5b877451eb
旧模板的 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
172 lines
6.1 KiB
TypeScript
172 lines
6.1 KiB
TypeScript
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 }
|