feat(frontend): 管理端改用 Gi Admin Pro 框架 + 腾讯云主题

前端此前只有本地实现、从未入库,本次首次纳入版本管理。

框架切换
  · 以 Gi Admin Pro(Vue3 + Vite + TS + Arco Design Vue + Pinia)为底
  · 剥离 14 个演示页面目录 / 121 个文件(demo 28、file 20、form 19、system 18…)
  · 依赖 dependencies 35 → 19,devDependencies 31 → 27
  · 保留框架核心:layout、Gi* 组件库、hooks、权限指令、错误页、登录页

主题:腾讯云 / TDesign 官方色板
  · 主色 #0052D9(腾讯蓝)、成功 #00A870、警告 #ED7B2F、危险 #E34D59
  · 页面底色 #f3f3f3、文字 rgba(0,0,0,90/60/40/26%)
  · 覆盖 Arco 底层色板 + 显式语义别名(primary/success/warning/danger/link/neutral)
  · 三个改了不生效也不报错的坑已记入注释:
      1) Arco 色值是逗号分隔 RGB 三元组,不是 #RRGGBB
      2) Arco 把色板定义在 body 而非 :root,必须写 `:root, body`
      3) setting.json 的 themeColor 会以内联样式写进 body,优先级高于样式表

对接后端(三处契约差异在 @/utils/http 统一适配)
  · 响应信封 {code,msg,data} → {code,success,message,data}
  · Token 头 token → Authorization: Bearer
  · 分页 {list,total} → 补一份 records,list 原样保留
  · 40101 → 登录页;登录密码 RSA 加密(公钥走 /auth/public-key,轮换无需重建前端)
  · 路由:后端只下发菜单键,前端在本地路由表上过滤(有哪些页面归代码,谁能看归后端)

已实现页面(浏览器实测通过)
  · 登录:RSA 加密 + 自适应验证码/租户码 + 账号锁定提示
  · 工作台:真实数据(组织 6 个 / 角色 1 个 / 权限 25 项)
  · 组织架构:三级树 + CRUD + 占用校验
  · 租户管理:开通租户 + 初始密码只显示一次的强确认
  · 待建页:未实现菜单显示所属里程碑
  · 权限菜单:学校管理员看不到「租户管理」

视觉资产按腾讯云主题重做
  · logo.svg(渐变圆角方 + 填)、favicon.svg(小尺寸做减法)、login-hero.svg(材料归集)
  · 登录页点阵纹理走主题变量,换主色自动跟随
  · 文案改为产品实际能力:自动改名 / 强制压缩 / 隐私保护

清理模板遗留(约 17MB + 三处外部依赖)
  · 百度统计脚本(上报到 gi-demo 作者的统计账号)
  · 用户菜单「项目地址」(跳转模板作者的 Gitee 仓库)
  · 两份 8.8MB mp3、388KB favicon.ico、57KB 模板插画、未引用字体与 loading.gif
  · 「个人中心」指向已删除的 /system/account(点了 404)
  · 头部头像在无 avatarUrl 时渲染成裂图 → 改为姓名首字兜底

后端修复
  · 新增 GET /auth/public-key(前端加密登录密码用)
  · DataSeeder.ancestor_path 生成错误:子节点本该是 /16/17/ 却写成 /17/
    该字段是「按院系统计」的索引,错了会漏人/多人且不报错 —— 改为从库里读父节点路径
  · seedDemoOrgs 曾放在租户作用域外 + admin 早退分支之后,导致启动失败 / 永不执行
  · 示范租户补一套三级组织树(1 学校 / 2 院系 / 3 班级),供界面演示
This commit is contained in:
2026-09-28 20:49:55 +08:00
parent 9d7a8ca845
commit 87ece5a971
237 changed files with 25215 additions and 26 deletions
+216
View File
@@ -0,0 +1,216 @@
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { Message, Notification } from '@arco-design/web-vue'
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 })
/**
* 本项目后端响应契约(《03-接口设计》§1.3):
*
* { code: 0 | 4xxxx | 5xxxx, msg: string, data: T }
*
* 与模板内部的 ApiRes 契约不同,差别有三处,都在下面这层适配里统一处理:
*
* | 维度 | 本项目后端 | 模板内部约定 |
* |----------|--------------------|--------------------|
* | 成功判定 | code === 0 | success: boolean |
* | 提示文案 | msg | message |
* | 分页列表 | { list, total } | { records, total } |
*
* ⚠️ 适配放在这里而不是逐个改 api 文件:这三条是全后端统一的契约,
* 分散到每个 listAPI 去转换,漏一个就是一个"列表永远空着"的 bug。
*/
/** 本项目后端的成功码 */
const CODE_SUCCESS = 0
/** 未登录 / token 失效 */
const CODE_UNAUTHORIZED = 40101
/** 无权限(含跨租户) */
const CODE_FORBIDDEN = 40301
/** 限流 */
const CODE_RATE_LIMITED = 42901
/** 传输层 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)'
}
/** 创建 axios 实例 */
const http: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_PREFIX,
timeout: 30 * 1000
})
/**
* 把分页字段补一份 records。
* 后端返回 { list, total },模板的 useTable / PageRes 读 records。
* 补而不替:list 原样保留,两边都能取到,避免破坏原有调用。
*/
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 redirecting = false
function toLogin() {
if (redirecting) return
redirecting = true
const back = encodeURIComponent(router.currentRoute.value.fullPath)
router.replace(`/login?redirect=${back}`).finally(() => {
setTimeout(() => {
redirecting = false
}, 800)
})
}
// 请求拦截器
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)
}
)
// 响应拦截器
http.interceptors.response.use(
(response: AxiosResponse) => {
NProgress.done()
const raw = response.data
// 非信封响应(文件流等)直接放行
if (!raw || typeof raw.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)
}
return response
}
// 业务失败:按文案长度选择 Message(短)或 Notification(长),与原实现一致
const message = raw.msg || '服务器端错误'
if (message.length <= 15) {
Message.error(message)
} else {
Notification.error(message)
}
// 把后端业务码附在错误上,便于调用方按码分支(如租户重名)
const err = new Error(message) as Error & { bizCode?: number, bizData?: unknown }
err.bizCode = raw.code
err.bizData = raw.data
return Promise.reject(err)
},
(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 err = new Error(error.message) as Error & { status?: number }
err.status = response?.status
return Promise.reject(err)
}
)
/** 封装请求方法 */
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))
})
}
/** GET 请求 */
const get = <T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<ApiRes<T>> => {
return request({
method: '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
})
}
/** 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
})
}
/** DELETE 请求 */
const del = <T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiRes<T>> => {
return request({
method: 'delete',
url,
...config
})
}
export default { get, post, put, delete: del, request }