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:
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface AreaItem {
|
||||
label: string
|
||||
code: string
|
||||
children?: AreaItem[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface CateTreeItem {
|
||||
id: string
|
||||
name: string
|
||||
pid: string | null
|
||||
type: string | null
|
||||
disabled: boolean
|
||||
children: CateTreeItem[]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 中任意属性只能有一个
|
||||
}
|
||||
@@ -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' })
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' })
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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: '报名点'
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
Reference in New Issue
Block a user