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
+39
View File
@@ -0,0 +1,39 @@
/** Token 存储键名 */
const TOKEN_KEY = 'token'
/**
* 检查用户是否已登录
* @description 通过检查 localStorage 中是否存在 token 来判断用户登录状态
* @returns {boolean} 是否已登录
*/
const isLogin = () => {
return !!localStorage.getItem(TOKEN_KEY)
}
/**
* 获取用户 Token
* @description 从 localStorage 中获取存储的 token
* @returns {string | null} 用户 token,未登录时返回 null
*/
const getToken = () => {
return localStorage.getItem(TOKEN_KEY)
}
/**
* 设置用户 Token
* @description 将用户 token 存储到 localStorage 中
* @param token - 用户 token
*/
const setToken = (token: string) => {
localStorage.setItem(TOKEN_KEY, token)
}
/**
* 清除用户 Token
* @description 从 localStorage 中移除用户 token
*/
const clearToken = () => {
localStorage.removeItem(TOKEN_KEY)
}
export { clearToken, getToken, isLogin, setToken }
+103
View File
@@ -0,0 +1,103 @@
import type { Component } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { h, ref } from 'vue'
interface CreateSelectDialogParams {
title: string
component: Component
componentProps?: Record<string, any>
tip?: string
}
/**
* 选择弹窗配置选项接口
* @template T 选中数据的类型
*/
interface SelectDialogOptions<T> {
/** 弹窗标题(会覆盖创建参数中的title) */
title?: string
/** 是否允许多选 */
multiple?: boolean
/** 查询参数,通常用于初始化数据 */
queryParams?: Record<string, any>
/** 传递给组件的额外属性 */
componentProps?: Record<string, any>
/** 点击确定按钮后的回调函数 */
onOk?: (data: T) => void
/** 点击确定前的校验函数,返回Promise<boolean>决定是否允许确定 */
onBeforeOk?: (data: T) => Promise<boolean>
}
/**
* 创建一个选择类型的弹窗工厂函数
*
* 该函数返回一个创建特定类型选择弹窗的方法,适用于需要从列表中选择数据的场景。
* 内部使用Vue的createVNode动态渲染组件,并通过ref获取组件实例的方法。
*
* @template T 选中数据的类型
* @param {CreateSelectDialogParams} params 创建弹窗所需的基本参数
* @returns {(options: SelectDialogOptions<T>) => void} 可配置的弹窗创建函数
*
* @example
* // 创建一个用户选择弹窗
* const selectUserDialog = createSelectDialog({
* title: '选择用户',
* component: UserSelectComponent,
* tip: '请至少选择一个用户'
* })
*
* // 打开弹窗并处理选择结果
* selectUserDialog({
* multiple: true,
* queryParams: { status: 'active' },
* onOk: (selectedUsers) => {
* console.log('已选择用户:', selectedUsers)
* }
* })
*/
export const createSelectDialog = <T = any>(params: CreateSelectDialogParams) => {
return (options: SelectDialogOptions<T>) => {
const TableRef = ref<any>()
Modal.open({
// 优先使用options中的title,否则使用params中的title
title: options.title || params.title,
// 动态渲染传入的组件,设置ref引用并合并属性
content: () => h(params.component, {
ref: (e: any) => (TableRef.value = e),
multiple: options.multiple,
queryParams: options.queryParams,
...params.componentProps,
...options.componentProps
}),
// 设置弹窗宽度自适应
width: 'calc(100% - 20px)',
modalStyle: { maxWidth: '1000px' },
bodyStyle: { overflow: 'hidden', height: '500px', padding: 0 },
onBeforeOk: async () => {
// 检查组件是否暴露了必要的getSelectedData方法
if (!TableRef.value?.getSelectedData) {
Message.warning('组件必须暴露getSelectedData方法')
return false
}
// 获取选中的数据
const data = TableRef.value?.getSelectedData?.() || []
// 验证是否选择了数据
if (!data.length) {
Message.warning(params.tip || '请选择数据')
return false
}
// 如果提供了前置校验函数,则调用并根据结果决定是否继续
if (options?.onBeforeOk) {
return await options.onBeforeOk(data)
}
// 调用确定回调函数,传递选中的数据
options.onOk?.(data)
return true
}
})
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 根据文件url获取文件名
* @param url 文件url
*/
function getFileName(url: string) {
const num = url.lastIndexOf('/') + 1
let fileName = url.substring(num)
// 把参数和文件名分割开
fileName = decodeURI(fileName.split('?')[0])
return fileName
}
/**
* 根据文件地址下载文件
* @param {*} sUrl
*/
export function downloadByUrl({
url,
target = '_blank',
fileName
}: {
url: string
target?: '_self' | '_blank'
fileName?: string
}): Promise<boolean> {
// 是否同源
const isSameHost = new URL(url).host === location.host
return new Promise<boolean>((resolve, reject) => {
if (isSameHost) {
const link = document.createElement('a')
link.href = url
link.target = target
if (link.download !== undefined) {
link.download = fileName || getFileName(url)
}
if (document.createEvent) {
const e = document.createEvent('MouseEvents')
e.initEvent('click', true, true)
link.dispatchEvent(e)
return resolve(true)
}
if (!url.includes('?')) {
url += '?download'
}
window.open(url, target)
return resolve(true)
} else {
const canvas = document.createElement('canvas')
const img = document.createElement('img')
img.setAttribute('crossOrigin', 'Anonymous')
img.src = url
img.onload = () => {
canvas.width = img.width
canvas.height = img.height
const context = canvas.getContext('2d')!
context.drawImage(img, 0, 0, img.width, img.height)
// window.navigator.msSaveBlob(canvas.msToBlob(),'image.jpg');
// saveAs(imageDataUrl, '附件');
canvas.toBlob((blob) => {
const link = document.createElement('a')
if (!blob) return
link.href = window.URL.createObjectURL(blob)
link.download = getFileName(url)
link.click()
URL.revokeObjectURL(link.href)
resolve(true)
}, 'image/jpeg')
}
img.onerror = (e) => reject(e)
}
})
}
+71
View File
@@ -0,0 +1,71 @@
/** 文件类型列表项接口 */
export interface FileTypeListItem {
/** 显示名称 */
name: string
/** 类型值 */
value: string
/** 菜单图标 */
menuIcon: string
/** 文件图标 */
icon: string
}
/**
* 文件类型列表
* @description 定义系统中支持的文件类型分类
*/
export const FILE_TYPE_LIST: FileTypeListItem[] = [
{ name: '全部', value: '0', menuIcon: 'file-all', icon: 'icon-stamp' },
{ name: '图片', value: '1', menuIcon: 'file-image', icon: 'icon-file-image' },
{ name: '文档', value: '2', menuIcon: 'file-txt', icon: 'icon-file' },
{ name: '视频', value: '3', menuIcon: 'file-video', icon: 'icon-video-camera' },
{ name: '音频', value: '4', menuIcon: 'file-music', icon: 'icon-file-audio' },
{ name: '其他', value: '5', menuIcon: 'file-other', icon: 'icon-bulb' }
]
/** 文件扩展名与图标映射接口 */
export interface FileExtendNameIconMap {
[key: string]: string
}
/**
* 文件类型图标映射
* @description 定义文件扩展名与对应图标的映射关系
*/
export const FILE_ICON_MAP: FileExtendNameIconMap = {
// 音频文件
mp3: 'file-music',
// 视频文件
mp4: 'file-video',
// 文件夹
dir: 'file-dir',
// Office 文件
ppt: 'file-ppt',
doc: 'file-wps',
docx: 'file-wps',
xls: 'file-excel',
xlsx: 'file-excel',
// 文本文件
txt: 'file-txt',
// 压缩文件
rar: 'file-rar',
zip: 'file-zip',
// 网页文件
html: 'file-html',
css: 'file-css',
js: 'file-js',
// 未知文件
other: 'file-other'
}
/**
* 图片文件类型列表
* @description 系统支持的图片文件扩展名
*/
export const IMAGE_TYPES = ['jpg', 'png', 'gif', 'jpeg']
/**
* Office 文件类型列表
* @description 系统支持的 Office 文件扩展名
*/
export const OFFICE_TYPES = ['ppt', 'pptx', 'doc', 'docx', 'xls', 'xlsx']
+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 }
+297
View File
@@ -0,0 +1,297 @@
import { camelCase, upperFirst } from 'lodash-es'
import { browse, mapTree } from 'xe-utils'
import { isExternal } from '@/utils/validate'
export * from './modules/getFieldToRangTimeForm'
export * from './modules/getRangeTimeToFieldForm'
/**
* @description 工具函数库
* @recommend 优先使用 lodash-es、xe-utils、dayjs 等成熟库的方法
*/
// =============== 类型定义 ===============
/** 字符串去除空格的位置 */
type TrimPosition = 'both' | 'left' | 'right' | 'all'
/** 树形结构基础接口 */
interface TreeNode {
children?: TreeNode[]
[key: string]: any
}
/** 可排序的树节点 */
interface SortableTreeNode extends TreeNode {
sort: number
children?: SortableTreeNode[]
}
// =============== 对象操作 ===============
/**
* 获取对象属性值(类型安全)
* @param obj - 目标对象
* @param key - 属性键名
* @returns 属性值
*/
export function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
/**
* 深拷贝
* @param data - 要拷贝的数据
* @returns 深拷贝后的数据
*/
export function deepClone<T>(data: T): T {
if (typeof data !== 'object' || data === null) return data
const newData: any = Array.isArray(data) ? [] : {}
for (const key in data) {
newData[key] = typeof data[key] === 'object' ? deepClone(data[key]) : data[key]
}
return newData
}
// =============== 字符串操作 ===============
/**
* 去除字符串空格
* @param str - 目标字符串
* @param pos - 去除位置,默认两端
* @returns 处理后的字符串
*/
export function trim(str: string, pos: TrimPosition = 'both'): string {
const trimMap = {
both: /^\s+|\s+$/g,
left: /^\s*/,
right: /(\s*$)/g,
all: /\s+/g
}
return str.replace(trimMap[pos] || trimMap.both, '')
}
/**
* 字符串大小写转换
* @param str - 目标字符串
* @param type - 转换类型:1-全大写 2-全小写 3-首字母大写
* @returns 转换后的字符串
*/
export function toCase(str: string, type: 1 | 2 | 3): string {
const caseMap = {
1: () => str.toUpperCase(),
2: () => str.toLowerCase(),
3: () => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}
return caseMap[type]?.() || str
}
/**
* 格式化电话号码
* @param mobile - 手机号
* @param separator - 分隔符
* @returns 格式化后的电话号码,如:183-7983-6654
*/
export function formatPhone(mobile: string, separator = '-'): string {
return mobile.replace(/(?=(\d{4})+$)/g, separator)
}
/**
* 手机号脱敏处理
* @param phone - 手机号
* @returns 脱敏后的手机号,如:155****8810
*/
export function hidePhone(phone: string): string {
return phone.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2')
}
// =============== 数字操作 ===============
/**
* 获取指定范围内的随机整数
* @param min - 最小值
* @param max - 最大值
* @returns 随机整数
*/
export function getRandomInteger(min = 0, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
/**
* 数组计算工具
*/
export const arrayMath = {
/** 获取数组最大值 */
max: (arr: number[]): number => Math.max(...arr),
/** 获取数组最小值 */
min: (arr: number[]): number => Math.min(...arr),
/** 计算数组总和 */
sum: (arr: number[]): number => arr.reduce((a, b) => a + b, 0),
/** 计算数组平均值 */
average: (arr: number[]): number => arrayMath.sum(arr) / arr.length
}
// =============== 数据验证 ===============
/**
* 检测数据是否为空
* @param data - 要检测的数据
* @returns 是否为空
*/
export function isEmpty(data: unknown): boolean {
if (data === '' || data == null || data === 'undefined' || data === 'null') return true
if (typeof data === 'object') {
return JSON.stringify(data) === '{}'
|| JSON.stringify(data) === '[]'
|| JSON.stringify(data) === '[{}]'
}
return false
}
/**
* 获取数据类型
* @param value - 要检测的值
* @returns 数据类型字符串
*/
export function getTypeOf(value: unknown): string {
return Object.prototype.toString.call(value).slice(8, -1).toLowerCase()
}
// =============== 日期时间 ===============
/**
* 判断是否为闰年
* @param year - 年份
* @returns 是否闰年
*/
export function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
}
/**
* 获取问候语
* @returns 根据当前时间返回问候语
*/
export function goodTimeText(): string {
const hour = new Date().getHours()
if (hour < 9) return '早上好'
if (hour <= 11) return '上午好'
if (hour <= 13) return '中午好'
if (hour < 20) return '下午好'
return '晚上好'
}
// =============== 文件处理 ===============
/**
* 格式化文件大小
* @param size - 文件大小(字节)
* @returns 格式化后的文件大小
*/
export function formatFileSize(size: number): string {
const units = ['B', 'KB', 'MB', 'GB']
let index = 0
while (size > 1024 && index < units.length - 1) {
size /= 1024
index++
}
return `${Math.round(size * 100) / 100}${units[index]}`
}
// =============== 颜色处理 ===============
/**
* RGB转十六进制
* @param r - 红色值(0-255)
* @param g - 绿色值(0-255)
* @param b - 蓝色值(0-255)
* @returns 十六进制颜色值
*/
export function rgbToHex(r: number, g: number, b: number): string {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
/**
* 十六进制颜色转 RGB
* @param {string} hex - 十六进制颜色,如 #377DFF、377DFF、#FFF
* @returns {string} rgb(r, g, b) 格式字符串
*/
export function hexToRgb(hex: string): string {
hex = hex.replace(/^#/, '')
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
}
const r = Number.parseInt(hex.substring(0, 2), 16)
const g = Number.parseInt(hex.substring(2, 4), 16)
const b = Number.parseInt(hex.substring(4, 6), 16)
return `${r}, ${g}, ${b}`
}
/**
* 获取随机十六进制颜色
* @returns 随机颜色值
*/
export function randomHex(): string {
return `#${Math.floor(Math.random() * 0xFFFFFF).toString(16).padEnd(6, '0')}`
}
// =============== 路由工具 ===============
/**
* 转换路由路径为名称
* @param path - 路由路径
* @returns 转换后的名称
* @example
* /system => System
* /system/menu => SystemMenu
* /crud/detail => CrudDetail
*/
export function transformPathToName(path: string): string {
if (!path || isExternal(path)) return ''
return upperFirst(camelCase(path))
}
// =============== 树形结构处理 ===============
/**
* 过滤树形结构
* @param array - 树形数组
* @param predicate - 过滤函数
* @returns 过滤后的树形数组
*/
export function filterTree(array: TreeNode[], predicate: (node: TreeNode) => boolean): TreeNode[] {
const filtered = array.filter(predicate)
return mapTree(filtered, (item) => {
if (item.children?.length) {
item.children = item.children.filter(predicate)
}
return item
})
}
/**
* 排序树形结构
* @param array - 可排序的树形数组
* @returns 排序后的树形数组
*/
export function sortTree(array: SortableTreeNode[]): SortableTreeNode[] {
const sort = (a: SortableTreeNode, b: SortableTreeNode) => (a.sort ?? 0) - (b.sort ?? 0)
array.sort(sort)
return mapTree(array, (item) => {
if (item.children?.length) {
item.children.sort(sort)
}
return item
})
}
// =============== 环境检测 ===============
/**
* 检测是否为移动端环境
* @returns 是否为移动端
*/
export function isMobile(): boolean {
return browse().isMobile
}
+10
View File
@@ -0,0 +1,10 @@
import mitt from 'mitt'
type Events = {
// 自定义事件名称
changeText: string
}
const mittBus = mitt<Events>()
export default mittBus
@@ -0,0 +1,46 @@
/** F: 表单数据 T: 表单数据排除的属性 V: 表单数据里-时间范围属性拆分成的新属性 */
type FormatItem<F, T, V> = {
field: keyof T | keyof F
start: keyof V
end: keyof V
}
/**
* 合并有时间范围的表单
* @param form 表单数据
* @param formatList 需要合并的时间范围属性
* @returns 合并后的表单数据
* @example
* const form = {
name: '李四',
startTime1: '2024-01-01',
endTime1: '2024-01-30',
startTime2: '2025-01-01',
endTime2: '2025-01-30'
}
*
* const data = getFieldToRangTimeForm(form, [
{ field: 'rangTime1', start: 'startTime1', end: 'endTime1' },
{ field: 'rangTime2', start: 'startTime2', end: 'endTime2' }
])
*
* // data的结果为:
* // {
// name: '李四',
// rangTime1: ['2024-01-01', '2024-01-30'],
// rangTime2: ['2025-01-01', '2025-01-30']
// }
*/
export const getFieldToRangTimeForm = <F, T, V>(form: F, formatList: FormatItem<F, T, V>[]) => {
const obj = { ...form } as Omit<F, keyof V> & T
for (const i of formatList) {
const startTime = obj[i.start as never] as string
const endTime = obj[i.end as never] as string
if (startTime && endTime) {
(obj[i.field as never] as [string, string]) = [startTime, endTime]
}
delete obj[i.start as never]
delete obj[i.end as never]
}
return obj
}
@@ -0,0 +1,45 @@
/** F: 表单数据 T: 表单数据排除的属性 V: 表单数据里-时间范围属性拆分成的新属性 */
type FormatItem<F, T, V> = {
field: keyof T | keyof F
start: keyof V
end: keyof V
}
/**
* 拆分有时间范围的表单
* @param form 表单数据
* @param formatList 需要拆分的时间范围属性
* @returns 时间范围转成指定字段的表单
* @example
* const form = {
name: '张三',
rangTime1: ['2024-01-01', '2024-01-30'] as [string, string],
rangTime2: ['2025-01-01', '2025-01-30'] as [string, string]
}
*
* const data = getRangeTimeToFieldForm(form, [
{ field: 'rangTime1', start: 'startTime1', end: 'endTime1' },
{ field: 'rangTime2', start: 'startTime2', end: 'endTime2' }
])
*
* // data的结果为:
// {
// name: '张三',
// startTime1: '2024-01-01',
// endTime1: '2024-01-30',
// startTime2: '2025-01-01',
// endTime2: '2025-01-30'
// }
*/
export const getRangeTimeToFieldForm = <F, T, V>(form: F, formatList: FormatItem<F, T, V>[]) => {
const obj = { ...form } as Omit<F, keyof T> & V
for (const i of formatList) {
const value = obj[i.field as never] as [string, string]
if (Array.isArray(value)) {
delete obj[i.field as never];
(obj[i.start] as string) = value[0] || '';
(obj[i.end] as string) = value[1] || ''
}
}
return obj
}
+80
View File
@@ -0,0 +1,80 @@
import { useUserStore } from '@/stores'
/** 超级管理员权限标识 */
const SUPER_ADMIN_PERMISSION = '*:*:*'
/** 超级管理员角色标识 */
const SUPER_ADMIN_ROLE = 'role_admin'
/**
* 判断是否拥有指定权限
* @description 检查用户是否拥有指定的权限标识,超级管理员拥有所有权限
* @param permission - 权限标识
* @returns {boolean} 是否拥有权限
*/
export function hasPerm(permission: string): boolean {
if (!permission) return false
const permissions = useUserStore().permissions
return permissions.some((v) => v === SUPER_ADMIN_PERMISSION || v === permission)
}
/**
* 判断是否拥有多个权限中的任意一个
* @description 检查用户是否拥有指定权限列表中的任意一个权限
* @param permissions - 权限标识数组
* @returns {boolean} 是否拥有任意一个权限
*/
export function hasPermOr(permissions: string[]): boolean {
return permissions.some((permission) => hasPerm(permission))
}
/**
* 判断是否拥有所有指定权限
* @description 检查用户是否同时拥有指定权限列表中的所有权限
* @param permissions - 权限标识数组
* @returns {boolean} 是否拥有所有权限
*/
export function hasPermAnd(permissions: string[]): boolean {
return permissions.every((permission) => hasPerm(permission))
}
/**
* 判断是否拥有指定角色
* @description 检查用户是否拥有指定的角色标识,超级管理员拥有所有角色
* @param role - 角色标识
* @returns {boolean} 是否拥有角色
*/
export function hasRole(role: string): boolean {
if (!role) return false
const roles = useUserStore().roles
return roles.some((v) => v === SUPER_ADMIN_ROLE || v === role)
}
/**
* 判断是否拥有多个角色中的任意一个
* @description 检查用户是否拥有指定角色列表中的任意一个角色
* @param roles - 角色标识数组
* @returns {boolean} 是否拥有任意一个角色
*/
export function hasRoleOr(roles: string[]): boolean {
return roles.some((role) => hasRole(role))
}
/**
* 判断是否拥有所有指定角色
* @description 检查用户是否同时拥有指定角色列表中的所有角色
* @param roles - 角色标识数组
* @returns {boolean} 是否拥有所有角色
*/
export function hasRoleAnd(roles: string[]): boolean {
return roles.every((role) => hasRole(role))
}
/** 权限判断工具对象 */
export default {
hasPerm,
hasPermOr,
hasPermAnd,
hasRole,
hasRoleOr,
hasRoleAnd
}
+127
View File
@@ -0,0 +1,127 @@
/**
* @description 常用正则表达式集合
*/
/**
* 手机号码正则
* @description 匹配中国大陆手机号码(13-19号段)
* @example
* Phone.test('13812345678') // true
* Phone.test('12345678') // false
*/
export const Phone = /^1[3-9]\d{9}$/
/**
* 邮箱地址正则
* @description 匹配标准邮箱地址格式
* @example
* Email.test('user@example.com') // true
* Email.test('invalid.email') // false
*/
export const Email = /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/
/**
* 密码强度正则(6位数字)
* @description 匹配6位纯数字密码
* @example
* Password.test('123456') // true
* Password.test('12345') // false
*/
export const Password = /^\d{6}$/
/**
* 6位数字验证码正则
* @description 匹配6位纯数字验证码
* @example
* Code_6.test('123456') // true
* Code_6.test('12345') // false
*/
export const Code_6 = /^\d{6}$/
/**
* 4位数字验证码正则
* @description 匹配4位纯数字验证码
* @example
* Code_4.test('1234') // true
* Code_4.test('123') // false
*/
export const Code_4 = /^\d{4}$/
/**
* URL链接正则
* @description 匹配标准URL格式,支持http(s)和www开头的链接
* @example
* Url.test('https://www.example.com') // true
* Url.test('http://localhost:3000') // true
* Url.test('invalid-url') // false
*/
export const Url = /^(https?:\/\/)?([a-z\d]([a-z\d-]*[a-z\d])?\.)*[a-z\d]([a-z\d-]*[a-z\d])?\.[a-z]{2,6}(\/[\w .-]*)?$/i
/**
* 十六进制颜色值正则
* @description 匹配3位或6位十六进制颜色值,支持可选的#前缀
* @example
* ColorRegex.test('#333') // true
* ColorRegex.test('#8c8c8c') // true
* ColorRegex.test('invalid') // false
*/
export const ColorRegex = /^#?([a-f0-9]{6}|[a-f0-9]{3})$/i
/**
* 中文字符正则
* @description 匹配中文字符(不包含标点符号)
* @example
* OnlyCh.test('中文') // true
* OnlyCh.test('Chinese') // false
*/
export const OnlyCh = /^[\u4E00-\u9FA5]+$/
/**
* 英文字符正则
* @description 匹配英文字母(不区分大小写)
* @example
* OnlyEn.test('English') // true
* OnlyEn.test('英文') // false
*/
export const OnlyEn = /^[a-z]+$/i
/**
* 强密码正则(已注释,供参考)
* @description 密码为8-18位数字/字符/符号的组合
* - 不能是纯数字
* - 不能是纯小写字母
* - 不能是纯大写字母
* - 不能是纯符号
* - 不能包含中文
* @example
* StrongPassword.test('Abc123!@#') // true
* StrongPassword.test('12345678') // false
*/
// export const StrongPassword = /^(?![0-9]+$)(?![a-z]+$)(?![A-Z]+$)(?!([^(0-9a-zA-Z)]|[()])+$)(?!^.*[\u4E00-\u9FA5].*$)([^(0-9a-zA-Z)]|[()]|[a-z]|[A-Z]|[0-9]){8,18}$/
/**
* 身份证号码正则
* @description 匹配18位身份证号码
* @example
* IdCard.test('110101199003077758') // true
* IdCard.test('invalid') // false
*/
export const IdCard = /^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]$/i
/**
* 金额正则
* @description 匹配金额格式,支持两位小数
* @example
* Money.test('123.45') // true
* Money.test('123.456') // false
*/
export const Money = /^[1-9]\d*(\.\d{1,2})?$|^0(\.\d{1,2})?$/
/**
* 中国大陆座机号码正则
* @description 匹配座机号码,支持区号和分机号
* @example
* TelPhone.test('010-12345678') // true
* TelPhone.test('010-1234-5678') // true
*/
export const TelPhone = /^(0\d{2,3}-)?\d{7,8}(-\d{1,4})?$/
+57
View File
@@ -0,0 +1,57 @@
/**
* 登录密码加密(RSA)
*
* 公钥从后端 `GET /auth/public-key` 拉取并缓存——这样**轮换密钥不需要重新构建前端**。
* 否则"后端换了密钥、前端还是旧的"会表现为"所有人密码都错",而且极难定位。
*
* ⚠️ 算法必须与服务端 RsaCipher 一致:**RSA/ECB/PKCS1Padding**,
* 与 jsencrypt 的默认行为相同。换成 OAEP 会表现为"密码永远错",两边都改才能换。
*
* ⚠️ 这不是用来替代 HTTPS 的。它的唯一目的是**防止明文密码进日志**——
* 网关、Nginx、APM、链路追踪都会记录请求体,明文密码一旦落进日志就是永久泄露。
*/
import JSEncrypt from 'jsencrypt'
// 直接用 http 实例,不经 @/apis/xt —— 否则会形成
// xt/index → http → router → stores → xt/index 的循环引用
import http from '@/utils/http'
let cachedKey: string | null = null
let inflight: Promise<string> | null = null
/** 拉取公钥(并发调用只会真正请求一次) */
export async function fetchPublicKey(): Promise<string> {
if (cachedKey) {
return cachedKey
}
if (inflight) {
return inflight
}
inflight = http
.get<string>('/auth/public-key')
.then((res) => {
cachedKey = res.data
return res.data
})
.finally(() => {
inflight = null
})
return inflight
}
/** 加密密码,返回 Base64 密文 */
export async function encryptPassword(plain: string): Promise<string> {
const pem = await fetchPublicKey()
const engine = new JSEncrypt()
engine.setPublicKey(pem)
const cipher = engine.encrypt(plain)
if (!cipher) {
// jsencrypt 在密钥格式不对时返回 false 而不是抛异常,必须自己判
throw new Error('密码加密失败,请刷新页面重试')
}
return cipher
}
/** 密钥轮换后调用 */
export function resetPublicKey(): void {
cachedKey = null
}
+179
View File
@@ -0,0 +1,179 @@
/**
* @description 类型判断工具函数集合
* 提供比 typeof 更准确的类型判断方法
*/
/** 获取值的具体类型 */
const getType = (value: unknown): string => Object.prototype.toString.call(value)
/**
* 判断是否为数字类型
* @param value - 要判断的值
* @example
* isNumber(123) // true
* isNumber('123') // false
* isNumber(NaN) // true
* isNumber(Infinity) // true
*/
export function isNumber(value: unknown): value is number {
return getType(value) === '[object Number]'
}
/**
* 判断是否为字符串类型
* @param value - 要判断的值
* @example
* isString('abc') // true
* isString(123) // false
*/
export function isString(value: unknown): value is string {
return getType(value) === '[object String]'
}
/**
* 判断是否为布尔类型
* @param value - 要判断的值
* @example
* isBoolean(true) // true
* isBoolean(0) // false
*/
export function isBoolean(value: unknown): value is boolean {
return getType(value) === '[object Boolean]'
}
/**
* 判断是否为 null
* @param value - 要判断的值
* @example
* isNull(null) // true
* isNull(undefined) // false
*/
export function isNull(value: unknown): value is null {
return getType(value) === '[object Null]'
}
/**
* 判断是否为 undefined
* @param value - 要判断的值
* @example
* isUndefined(undefined) // true
* isUndefined(null) // false
*/
export function isUndefined(value: unknown): value is undefined {
return getType(value) === '[object Undefined]'
}
/**
* 判断是否为普通对象
* @param value - 要判断的值
* @example
* isObject({}) // true
* isObject([]) // false
* isObject(null) // false
*/
export function isObject(value: unknown): value is Record<string, any> {
return getType(value) === '[object Object]'
}
/**
* 判断是否为数组
* @param value - 要判断的值
* @example
* isArray([]) // true
* isArray({}) // false
* @recommend 推荐使用 Array.isArray() 代替
*/
export function isArray(value: unknown): value is any[] {
return getType(value) === '[object Array]'
}
/**
* 判断是否为日期对象
* @param value - 要判断的值
* @example
* isDate(new Date()) // true
* isDate('2024-01-01') // false
*/
export function isDate(value: unknown): value is Date {
return getType(value) === '[object Date]'
}
/**
* 判断是否为正则表达式
* @param value - 要判断的值
* @example
* isRegExp(/abc/) // true
* isRegExp('/abc/') // false
*/
export function isRegExp(value: unknown): value is RegExp {
return getType(value) === '[object RegExp]'
}
/**
* 判断是否为 Set 对象
* @param value - 要判断的值
* @example
* isSet(new Set()) // true
* isSet([]) // false
*/
export function isSet(value: unknown): value is Set<any> {
return getType(value) === '[object Set]'
}
/**
* 判断是否为 Map 对象
* @param value - 要判断的值
* @example
* isMap(new Map()) // true
* isMap({}) // false
*/
export function isMap(value: unknown): value is Map<any, any> {
return getType(value) === '[object Map]'
}
/**
* 判断是否为 Promise 对象
* @param value - 要判断的值
* @example
* isPromise(Promise.resolve()) // true
* isPromise({ then: () => {} }) // false
*/
export function isPromise(value: unknown): value is Promise<any> {
return getType(value) === '[object Promise]'
}
/**
* 判断是否为函数
* @param value - 要判断的值
* @example
* isFunction(() => {}) // true
* isFunction(class {}) // true
* isFunction({}) // false
*/
export function isFunction(value: unknown): value is (...args: any[]) => any {
return typeof value === 'function'
}
/**
* 判断是否为有效数字(排除 NaN 和 Infinity)
* @param value - 要判断的值
* @example
* isValidNumber(123) // true
* isValidNumber(NaN) // false
* isValidNumber(Infinity) // false
*/
export function isValidNumber(value: unknown): value is number {
return isNumber(value) && !Number.isNaN(value) && Number.isFinite(value)
}
/**
* 判断是否为空值(null 或 undefined)
* @param value - 要判断的值
* @example
* isNil(null) // true
* isNil(undefined) // true
* isNil('') // false
*/
export function isNil(value: unknown): value is null | undefined {
return isNull(value) || isUndefined(value)
}
+10
View File
@@ -0,0 +1,10 @@
/** 判断 path 是否为外链 */
export const isExternal = (path: string) => {
const reg = /^(?:https?:|mailto:|tel:)/
return reg.test(path)
}
/** 判断 url 是否是 http 或 https */
export function isHttp(url: string) {
return url.includes('http://') || url.includes('https://')
}