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,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 }
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})?$/
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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://')
|
||||
}
|
||||
Reference in New Issue
Block a user