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
+9
View File
@@ -0,0 +1,9 @@
import type * as T from './type'
import http from '@/utils/http'
export type * from './type'
/** 获取地区列表 */
export const getProvinceCityArea = (params: { type: 'province' | 'city' | 'area', code?: string }) => {
return http.get<T.AreaItem[]>('/area/getProvinceCityArea', params)
}
+5
View File
@@ -0,0 +1,5 @@
export interface AreaItem {
label: string
code: string
children?: AreaItem[]
}
+39
View File
@@ -0,0 +1,39 @@
import http from '@/utils/http'
interface DefaultP {
GetListParams?: Record<string, any>
GetDetailParams?: { id: string }
AddParams?: Record<string, any>
UpdateParams?: Record<string, any>
DeleteParams?: { ids: string[] }
}
// 获取基础接口
export function getBaseApi<T, P extends DefaultP = DefaultP>(params: { baseUrl: string }) {
const { baseUrl } = params
const baseApi = {
// 获取分页列表
getList(params?: P['GetListParams'] & { page?: number, size?: number }) {
return http.get<PageRes<T[]>>(`${baseUrl}/getList`, params)
},
// 获取详情
getDetail(params: P['GetDetailParams']) {
return http.get<T>(`${baseUrl}/getDetail`, params)
},
// 新增
add(params: P['AddParams']) {
return http.post<T>(`${baseUrl}/add`, params)
},
// 修改
update(params: P['UpdateParams']) {
return http.post<T>(`${baseUrl}/update`, params)
},
// 删除
delete(params: P['DeleteParams']) {
return http.post<boolean>(`${baseUrl}/delete`, params)
}
}
return baseApi
}
+9
View File
@@ -0,0 +1,9 @@
import type * as T from './type'
import http from '@/utils/http'
export type * from './type'
/** 获取分类树 */
export function getCateTree() {
return http.get<T.CateTreeItem[]>('/cate/getCateTree')
}
+8
View File
@@ -0,0 +1,8 @@
export interface CateTreeItem {
id: string
name: string
pid: string | null
type: string | null
disabled: boolean
children: CateTreeItem[]
}
+9
View File
@@ -0,0 +1,9 @@
import type * as T from './type'
import http from '@/utils/http'
export type * from './type'
/** 获取文件列表 */
export function getFileList(params: { fileType: string | number }) {
return http.get<PageRes<T.FileItem[]>>('/file/getFileList', params)
}
+13
View File
@@ -0,0 +1,13 @@
// 文件
export interface FileItem {
id: string
type: string
name: string
extendName: string
src: string
updateTime: string
isDir: boolean
filePath: string
size: number
[propName: string]: any // 一个 interface 中任意属性只能有一个
}
+7
View File
@@ -0,0 +1,7 @@
import type * as T from './type'
import { getBaseApi } from '@/apis/base'
export type * from './type'
/** 人物模块 */
export const baseAPI = getBaseApi<T.ListItem>({ baseUrl: '/person' })
+16
View File
@@ -0,0 +1,16 @@
// 表格项目
export interface ListItem {
id: string
name: string
account: string
avatar: string
gender: Gender
phone: string
email: string
createTime: string
address: string
age: number
status: Status
hobby: string[]
remark: string
}
+15
View File
@@ -0,0 +1,15 @@
import { getBaseApi } from '@/apis/base'
export interface ListItem {
id: string
name: string
sort: number
status: Status
createTime: string
parentId: string
children?: ListItem[]
description: string
}
/** 部门模块 */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/dept' })
+44
View File
@@ -0,0 +1,44 @@
import type { SelectOptionData } from '@arco-design/web-vue'
import { getBaseApi } from '@/apis/base'
import http from '@/utils/http'
export interface ListItem {
id: string
createUserString: string
createTime: string
name: string
code: string
sort: number
status: Status
description: string
}
export type DictDetail = ListItem
export type DictDataItem = {
id: string
name: string
value: string | number
sort: number
status: Status
remark?: string
createTime?: string
}
/** 字典模块 */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/dict' })
/** 字典数据列表 */
export function getDictDataList(params: { code: string, name?: string, status?: string } & Pagination) {
return http.get<PageRes<DictDataItem[]>>('/system/dict/getDictDataList', params)
}
/** 字典数据详情 */
export function getDictDataDetail(params: { id: string, code: string }) {
return http.get<DictDataItem>('/system/dict/getDictDataDetail', params)
}
/** 获取字典数据映射 */
export function getDictData() {
return http.get<Record<string, SelectOptionData[]>>('/system/dict/getDictData')
}
+39
View File
@@ -0,0 +1,39 @@
import { getBaseApi } from '@/apis/base'
import http from '@/utils/http'
export interface ListItem {
activeMenu: string
alwaysShow: boolean
breadcrumb: boolean
children: ListItem[]
component: string
hidden: boolean
icon: string
id: string
keepAlive: boolean
parentId: string
path: string
permission: string
redirect: string
roles: string[]
showInTabs: boolean
sort: number
status: Status
title: string
type: 1 | 2 | 3
affix: boolean
}
export interface MenuOptionsItem {
id: string
title: string
children: MenuOptionsItem[]
}
/** 菜单模块 */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/menu' })
/** 获取角色分配权限菜单树 */
export function getMenuOptions() {
return http.get<MenuOptionsItem[]>('/system/menu/getMenuOptions')
}
+23
View File
@@ -0,0 +1,23 @@
import { getBaseApi } from '@/apis/base'
import http from '@/utils/http'
export interface ListItem {
id: string
createUserString: string
createTime: string
disabled: boolean
name: string
code: string
sort: number
status: Status
type: number
description: string
}
/** 角色模块 */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/role' })
/** 获取角色权限 */
export function getRoleMenuIds(params: { role: string }) {
return http.get<string[]>('/system/role/getRoleMenuIds', params)
}
+24
View File
@@ -0,0 +1,24 @@
import { getBaseApi } from '@/apis/base'
export interface ListItem {
id: string
createUserString: string
createTime: string
disabled: boolean
deptId: string
deptName: string
username: string
nickname: string
gender: Gender
avatar: string
email: string
phone: string
status: Status
type: 1 | 2
description: string
roleIds: string[]
roleNames: string[]
}
/** 用户模块 */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/user' })
+11
View File
@@ -0,0 +1,11 @@
import http from '@/utils/http'
/** 请求成功的接口 */
export function getTestSuccess() {
return http.get<string>('/test/success')
}
/** 请求失败的接口 */
export function getTestFail() {
return http.get<any>('/test/fail')
}
+24
View File
@@ -0,0 +1,24 @@
import type * as T from './type'
import http from '@/utils/http'
export type * from './type'
/** 登录 */
export function login(data: { username: string, password: string }) {
return http.post<T.Login>('/user/login', data)
}
/** 退出登录 */
export function logout() {
return http.post('/user/logout')
}
/** 获取用户信息 */
export const getUserInfo = () => {
return http.get<T.UserInfo>('/user/getUserInfo')
}
/** 获取用户路由信息 */
export const getUserRoutes = () => {
return http.get<T.UserRouteItem[]>('/user/getUserRoutes')
}
+34
View File
@@ -0,0 +1,34 @@
export interface UserInfo {
id: string
nickname: string
avatar: string
roles: string[]
permissions: string[]
}
export interface Login {
token: string
}
export interface UserRouteItem {
activeMenu: string
alwaysShow: boolean
breadcrumb: boolean
children: UserRouteItem[]
component: string
hidden: boolean
icon: string
id: string
keepAlive: boolean
parentId: string
path: string
permission: string
redirect: string
roles: string[]
showInTabs: boolean
sort: number
status: Status
title: string
type: 1 | 2 | 3
affix: boolean
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 本项目业务接口的公共出口与类型。
*
* 与模板的 `src/apis/*` 分开,是因为两边契约不同:
* - 模板:`/user/login`、`{ success, message }`、`{ records, total }`
* - 本项目:`/auth/login`(《03-接口设计》§5)、`{ code, msg, data }`、`{ list, total }`
* 形状适配统一在 `@/utils/http` 里做,这里只负责路径与类型。
*/
import http from '@/utils/http'
export { http }
/** 登录接口(§5.1) */
export interface LoginReq {
username: string
/** RSA 公钥加密后的 Base64,不是明文 */
password: string
/** 账号在多个租户下重名时必填 */
tenantCode?: string
captchaKey?: string
captchaCode?: string
}
export interface XtUserInfo {
id: number
username: string
realName: string
userType: number
avatarUrl: string | null
orgId: number | null
}
export interface XtTenantInfo {
id: number
tenantCode: string
tenantName: string
logoUrl: string | null
}
/** 登录 / 当前用户响应(§5.1 / §5.2) */
export interface LoginVO {
token: string | null
expireSeconds: number
user: XtUserInfo
tenant: XtTenantInfo | null
roleCodes: string[]
/** 菜单键(决定左侧导航) */
menus: string[]
/** 细粒度权限码(决定按钮显隐) */
permissions: string[]
}
/** 图形验证码 */
export interface CaptchaVO {
captchaKey: string
/** data:image/png;base64,... */
image: string
expireSeconds: number
}
// ── 认证(§5.1 / §5.2 / §5.9)──────────────────────────
/** 登录(密码需先经 {@link encryptPassword} 加密) */
export function login(data: LoginReq) {
return http.post<LoginVO>('/auth/login', data)
}
/** 当前用户 + 菜单 + 权限码 */
export function getMe() {
return http.get<LoginVO>('/auth/me')
}
/** 登出(服务端会把 token 加入黑名单) */
export function logout() {
return http.post<void>('/auth/logout')
}
/** 图形验证码(连续失败 2 次后服务端会要求) */
export function getCaptcha() {
return http.get<CaptchaVO>('/auth/captcha')
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 组织架构接口(《03-接口设计》§6.1~§6.3)。
*
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
/** 组织树节点(§6.1) */
export interface OrgTreeVO {
id: number
orgName: string
/** 1 学校 / 2 院系或专业 / 3 班级 / 4 报名点 */
orgType: number
orgCode: string | null
leaderUserId: number | null
leaderName: string | null
sortNo: number
status: number
studentCount: number | null
children: OrgTreeVO[]
}
/** 新增 / 修改请求(§6.2) */
export interface OrgUpsertReq {
parentId?: number | null
orgName: string
orgType: number
orgCode: string
leaderUserId?: number | null
sortNo?: number
}
/**
* 组织树。
* @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开)
*/
export function getOrgTree(params: { withCount?: boolean, rootId?: number } = {}) {
return http.get<OrgTreeVO[]>('/orgs/tree', params)
}
/** 新增,返回新节点 id */
export function addOrg(data: OrgUpsertReq) {
return http.post<number>('/orgs', data)
}
/** 修改;变更层级时服务端会自动重算整棵子树的 ancestor_path */
export function updateOrg(id: number, data: OrgUpsertReq) {
return http.put<void>(`/orgs/${id}`, data)
}
/** 删除;有下级组织或账号时返回 40015 并给出占用清单 */
export function deleteOrg(id: number) {
return http.delete<void>(`/orgs/${id}`)
}
/** 组织类型 → 中文名(与 DDL 的 org_type 对齐) */
export const ORG_TYPE_NAMES: Record<number, string> = {
1: '学校',
2: '院系 / 专业',
3: '班级',
4: '报名点'
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 租户管理接口(《03-接口设计》§6.5~§6.6)。
*
* ⚠️ 全部接口**仅平台运营可用**。学校管理员调用会得到 40301,
* 前端据此隐藏入口(见 `@/config/xt-routes` 的菜单过滤)。
*/
import http from '@/utils/http'
/** 租户(§6.5) */
export interface TenantVO {
id: number
tenantCode: string
tenantName: string
tenantType: number
contactName: string | null
contactPhone: string | null
packageId: number | null
expireTime: string | null
status: number
logoUrl: string | null
createTime: string | null
}
/** 开通租户请求(§6.6) */
export interface TenantCreateReq {
tenantName: string
tenantCode: string
tenantType: number
contactName: string
contactPhone: string
packageId?: number
expireTime: string
adminUsername: string
adminRealName: string
adminPhone: string
}
/** 开通结果:初始密码**只返回这一次** */
export interface TenantCreateResult {
id: number
adminInitialPassword: string
miniProgramScene: string
}
/** 分页查询参数(后端用 pageNum/pageSize,模板的 useTable 用 page/size) */
export interface TenantQuery {
keyword?: string
status?: number
expireBefore?: string
pageNum?: number
pageSize?: number
}
export function getTenantList(params: TenantQuery = {}) {
return http.get<{ list: TenantVO[], total: number }>('/tenants', params)
}
export function getTenantDetail(id: number) {
return http.get<TenantVO>(`/tenants/${id}`)
}
/**
* 开通租户:一次请求完成「建租户 + 发放 6 个内置角色 + 创建首个管理员」。
* 返回的 `adminInitialPassword` 只在这一次响应里,界面必须让用户当场记下。
*/
export function createTenant(data: TenantCreateReq) {
return http.post<TenantCreateResult>('/tenants', data)
}
/** 租户类型 → 中文名 */
export const TENANT_TYPE_NAMES: Record<number, string> = {
1: '学校',
2: '认定机构',
3: '报名点',
4: '企业'
}
/** 租户状态 → 文案与颜色 */
export const TENANT_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '已停用', color: 'gray' },
1: { text: '正常', color: 'green' },
2: { text: '试用', color: 'orange' }
}