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
This commit is contained in:
+121
-170
@@ -1,220 +1,171 @@
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { Message, Notification } from '@arco-design/web-vue'
|
||||
import type { BizError } from '@/apis/types'
|
||||
import axios from 'axios'
|
||||
import NProgress from 'nprogress'
|
||||
import router from '@/router'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
// 配置 NProgress
|
||||
NProgress.configure({ showSpinner: false })
|
||||
import { message } from '@/utils/naive'
|
||||
|
||||
/**
|
||||
* 本项目后端响应契约(《03-接口设计》§1.3):
|
||||
* 后端响应契约(《03-接口设计》§1.3):`{ code, msg, data }`。
|
||||
*
|
||||
* { code: 0 | 4xxxx | 5xxxx, msg: string, data: T }
|
||||
* 这一层做三件事,做完之后业务代码里几乎不再出现 `code`:
|
||||
* 1. 成功(`code === 0`)时**直接 resolve `data`**(`await` 拿到的就是数据);
|
||||
* 2. 失败时**弹一次提示**并 reject 一个带 `bizCode` 的错误(想按码分支也能拿);
|
||||
* 3. 带上 `Authorization: Bearer` 与平台代管头 `X-Tenant-Id`。
|
||||
*
|
||||
* 与模板内部的 ApiRes 契约不同,差别有三处,都在下面这层适配里统一处理:
|
||||
*
|
||||
* | 维度 | 本项目后端 | 模板内部约定 |
|
||||
* |----------|--------------------|--------------------|
|
||||
* | 成功判定 | code === 0 | success: boolean |
|
||||
* | 提示文案 | msg | message |
|
||||
* | 分页列表 | { list, total } | { records, total } |
|
||||
*
|
||||
* ⚠️ 适配放在这里而不是逐个改 api 文件:这三条是全后端统一的契约,
|
||||
* 分散到每个 listAPI 去转换,漏一个就是一个"列表永远空着"的 bug。
|
||||
* ⚠️ 401/403/429 **不走业务码**:后端对它们用真实 HTTP 状态码 ——
|
||||
* 所以下面的错误分支才是它们的归属地。别在业务码里找 40301。
|
||||
*/
|
||||
|
||||
/** 本项目后端的成功码 */
|
||||
const CODE_SUCCESS = 0
|
||||
/**
|
||||
* 未登录 / token 失效。
|
||||
*
|
||||
* 只有这一个业务码需要在这里特殊处理(清 token + 跳登录)。
|
||||
* 40301 无权限与 42901 限流<b>不在这里判</b> —— 后端对这两个用的是**真实 HTTP 状态码**
|
||||
* (403 / 429,见《03-接口设计》§1.3),由下面的传输层错误分支统一处理。
|
||||
* 所以这里不要再补 40301 / 42901 的常量:加了也不会被走到,
|
||||
* 反而会让人以为"业务码里也会出现 40301",从而在错的地方排查问题。
|
||||
*/
|
||||
/** 未登录 / token 失效(唯一需要在业务码里特殊处理的一个) */
|
||||
const CODE_UNAUTHORIZED = 40101
|
||||
|
||||
/** 传输层 HTTP 状态码文案(业务错误不走这里,见下方响应拦截器) */
|
||||
const StatusCodeMessage: Record<number, string> = {
|
||||
400: '请求错误(400)',
|
||||
401: '未授权,请重新登录(401)',
|
||||
403: '拒绝访问(403)',
|
||||
404: '请求出错(404)',
|
||||
408: '请求超时(408)',
|
||||
500: '服务器错误(500)',
|
||||
501: '服务未实现(501)',
|
||||
502: '网络错误(502)',
|
||||
503: '服务不可用(503)',
|
||||
504: '网络超时(504)'
|
||||
const STATUS_TEXT: Record<number, string> = {
|
||||
400: '请求参数有误(400)',
|
||||
401: '登录已失效,请重新登录',
|
||||
403: '无权限访问(403)',
|
||||
404: '接口不存在(404)',
|
||||
408: '请求超时(408)',
|
||||
429: '操作过于频繁,请稍后再试',
|
||||
500: '服务器错误(500)',
|
||||
502: '网关错误(502)',
|
||||
503: '服务不可用(503)',
|
||||
504: '网关超时(504)'
|
||||
}
|
||||
|
||||
/** 创建 axios 实例 */
|
||||
const http: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_PREFIX,
|
||||
timeout: 30 * 1000
|
||||
timeout: 60 * 1000
|
||||
})
|
||||
|
||||
/**
|
||||
* 把分页字段补一份 records。
|
||||
* 后端返回 { list, total },模板的 useTable / PageRes 读 records。
|
||||
* 补而不替:list 原样保留,两边都能取到,避免破坏原有调用。
|
||||
* 平台运营的「当前学校」——请求头 `X-Tenant-Id`。
|
||||
*
|
||||
* 放在模块级变量而不是从 store 读:store 引 http 会成环。由 store 在变更时调 setter
|
||||
* 推过来,方向永远是 store → http,环就断了。
|
||||
*/
|
||||
function normalizePageShape(payload: unknown): unknown {
|
||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const obj = payload as Record<string, unknown>
|
||||
if (Array.isArray(obj.list) && typeof obj.total === 'number' && obj.records === undefined) {
|
||||
return { ...obj, records: obj.list }
|
||||
}
|
||||
}
|
||||
return payload
|
||||
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 toLogin() {
|
||||
if (redirecting) return
|
||||
function handleUnauthorized(): void {
|
||||
if (redirecting) {
|
||||
return
|
||||
}
|
||||
redirecting = true
|
||||
const back = encodeURIComponent(router.currentRoute.value.fullPath)
|
||||
router.replace(`/login?redirect=${back}`).finally(() => {
|
||||
try {
|
||||
onUnauthorized?.()
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
redirecting = false
|
||||
}, 800)
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
http.interceptors.request.use(
|
||||
(config: AxiosRequestConfig) => {
|
||||
NProgress.start()
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
if (!config.headers) {
|
||||
config.headers = {}
|
||||
}
|
||||
// 本项目后端用标准的 Authorization: Bearer(《03-接口设计》§1.2),
|
||||
// 不是模板默认的 token 自定义头
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
NProgress.done()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
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) => {
|
||||
NProgress.done()
|
||||
const raw = response.data
|
||||
const envelope = response.data
|
||||
|
||||
// 非信封响应(文件流等)直接放行
|
||||
if (!raw || typeof raw.code !== 'number') {
|
||||
// 非信封响应(文件流 / 第三方回调)原样放行
|
||||
if (!envelope || typeof envelope.code !== 'number') {
|
||||
return response
|
||||
}
|
||||
|
||||
// 未登录:清 token 并跳登录
|
||||
if (raw.code === CODE_UNAUTHORIZED) {
|
||||
toLogin()
|
||||
return Promise.reject(new Error(raw.msg || '登录已失效,请重新登录'))
|
||||
}
|
||||
|
||||
// 业务成功:适配成模板的 ApiRes 形状
|
||||
if (raw.code === CODE_SUCCESS) {
|
||||
response.data = {
|
||||
code: 200,
|
||||
success: true,
|
||||
message: raw.msg || 'success',
|
||||
data: normalizePageShape(raw.data)
|
||||
}
|
||||
if (envelope.code === 0) {
|
||||
response.data = envelope.data
|
||||
return response
|
||||
}
|
||||
|
||||
// 业务失败:按文案长度选择 Message(短)或 Notification(长),与原实现一致
|
||||
const message = raw.msg || '服务器端错误'
|
||||
if (message.length <= 15) {
|
||||
Message.error(message)
|
||||
} else {
|
||||
Notification.error(message)
|
||||
if (envelope.code === CODE_UNAUTHORIZED) {
|
||||
handleUnauthorized()
|
||||
return Promise.reject(makeError(envelope.msg || '登录已失效,请重新登录', envelope.code, envelope.data))
|
||||
}
|
||||
// 把后端业务码附在错误上,便于调用方按码分支(如租户重名)
|
||||
const err = new Error(message) as Error & { bizCode?: number, bizData?: unknown }
|
||||
err.bizCode = raw.code
|
||||
err.bizData = raw.data
|
||||
return Promise.reject(err)
|
||||
|
||||
const text = envelope.msg || '操作失败'
|
||||
message.error(text)
|
||||
return Promise.reject(makeError(text, envelope.code, envelope.data))
|
||||
},
|
||||
(error) => {
|
||||
NProgress.done()
|
||||
Message.clear()
|
||||
const response = Object.assign({}, error.response)
|
||||
|
||||
// 401/403/429 这三个后端用真实 HTTP 状态码(《03-接口设计》§1.3)
|
||||
if (response?.status === 401) {
|
||||
toLogin()
|
||||
} else {
|
||||
const msg = StatusCodeMessage[response?.status] || '系统异常, 请检查网络或联系管理员!'
|
||||
Message.error(msg)
|
||||
const status: number | undefined = error?.response?.status
|
||||
if (status === 401) {
|
||||
handleUnauthorized()
|
||||
return Promise.reject(makeError('登录已失效,请重新登录', undefined, undefined, 401))
|
||||
}
|
||||
|
||||
const err = new Error(error.message) as Error & { status?: number }
|
||||
err.status = response?.status
|
||||
return Promise.reject(err)
|
||||
const text = STATUS_TEXT[status ?? 0] || '网络异常,请检查网络后重试'
|
||||
message.error(text)
|
||||
return Promise.reject(makeError(text, undefined, undefined, status))
|
||||
}
|
||||
)
|
||||
|
||||
/** 封装请求方法 */
|
||||
const request = <T = unknown>(config: AxiosRequestConfig): Promise<ApiRes<T>> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.request<T>(config)
|
||||
.then((res: AxiosResponse) => resolve(res.data))
|
||||
.catch((err: { message: string }) => reject(err))
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 把 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)
|
||||
|
||||
/** GET 请求 */
|
||||
const get = <T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<ApiRes<T>> => {
|
||||
return request({
|
||||
method: 'get',
|
||||
url,
|
||||
params,
|
||||
...config
|
||||
})
|
||||
}
|
||||
const get = <T = unknown>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> =>
|
||||
unwrap<T>(http.get(url, { params, ...config }))
|
||||
|
||||
/** POST 请求 */
|
||||
const post = <T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<ApiRes<T>> => {
|
||||
return request({
|
||||
method: 'post',
|
||||
url,
|
||||
data: params,
|
||||
...config
|
||||
})
|
||||
}
|
||||
const post = <T = unknown>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> =>
|
||||
unwrap<T>(http.post(url, data, config))
|
||||
|
||||
/** PUT 请求(本项目后端大量使用 PUT,模板原来只有 get/post) */
|
||||
const put = <T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<ApiRes<T>> => {
|
||||
return request({
|
||||
method: 'put',
|
||||
url,
|
||||
data: params,
|
||||
...config
|
||||
})
|
||||
}
|
||||
const put = <T = unknown>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> =>
|
||||
unwrap<T>(http.put(url, data, config))
|
||||
|
||||
/** DELETE 请求 */
|
||||
const del = <T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiRes<T>> => {
|
||||
return request({
|
||||
method: 'delete',
|
||||
url,
|
||||
...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 }
|
||||
|
||||
Reference in New Issue
Block a user