refactor(pc): 从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI
旧模板的 193 个文件整体删除,换成纯 Vue 3.5 + Naive UI 2.42 的树。
- 去掉 auto-import(组件与 API 全显式 import)、v-hasPerm(改 user.has('码'),全库 8 处)、
Stylelint;n-tag 的 type 统一过 utils/tag.ts 转换(Arco 色词直接透传会静默变灰)
- 图标改为构建期抽取:scripts/extract-pc-icons.py -> icons/icon-data.ts(37 个),
内网离线部署不会白屏
- 重搭后修掉 6 类"编译 / lint / typecheck 全绿、界面上就是坏的"故障:
① NGrid 的 cols 写成像素值(不是断点名 -> 永远只 1 列,工作台因此错乱)
② n-tab-pane 用 tab 属性(只渲染空注释节点,整排 tab 一个字都不显示)
③ 漏 import 被当未知自定义元素(样式整块丢) ④ .n-card__content 类名不存在
(高度链第一环失效,表格行在 DOM 里但屏幕上没有) ⑤ 填报单状态映射错位一格
⑥ 有标题的卡片没有上内边距(naive 的 padding-top 只给"内容区是长子"的卡片)
- 卡片式版面提成全局基线:styles/index.scss 的 xt-* 令牌 + .page-card / .toolbar /
.section-title,页面只引用、不再各写一套
- 品牌:改名「深学在线」,文字标换成几何标(方 = 卷宗 / 环 = 在线,不用文字字形);
index.html 的页签图标从 404 的 /favicon.ico 改指真实存在的 /favicon.svg
This commit is contained in:
@@ -1,39 +1,31 @@
|
||||
/** Token 存储键名 */
|
||||
const TOKEN_KEY = 'token'
|
||||
|
||||
/**
|
||||
* 检查用户是否已登录
|
||||
* @description 通过检查 localStorage 中是否存在 token 来判断用户登录状态
|
||||
* @returns {boolean} 是否已登录
|
||||
* token 的本地存取。
|
||||
*
|
||||
* 只用 localStorage 一个来源:以前那份实现把 token 同时放在 pinia 与 localStorage 里,
|
||||
* 两处不一致时表现为"刷新就掉登录",排查成本远大于省下的那点读写。
|
||||
*/
|
||||
const isLogin = () => {
|
||||
return !!localStorage.getItem(TOKEN_KEY)
|
||||
const TOKEN_KEY = 'xt-token'
|
||||
|
||||
export function getToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户 Token
|
||||
* @description 从 localStorage 中获取存储的 token
|
||||
* @returns {string | null} 用户 token,未登录时返回 null
|
||||
*/
|
||||
const getToken = () => {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
export function setToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
} catch {
|
||||
// 隐私模式下 localStorage 可能不可写 —— 不抛,只是这次会话留不住登录态
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户 Token
|
||||
* @description 将用户 token 存储到 localStorage 中
|
||||
* @param token - 用户 token
|
||||
*/
|
||||
const setToken = (token: string) => {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
export function clearToken(): void {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户 Token
|
||||
* @description 从 localStorage 中移除用户 token
|
||||
*/
|
||||
const clearToken = () => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export { clearToken, getToken, isLogin, setToken }
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* 根据文件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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/** 文件类型列表项接口 */
|
||||
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,45 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/** 时间格式化(后端返回 ISO 串;空值显示成 `—`,不要显示 "Invalid Date") */
|
||||
export function fmtTime(value?: string | null, pattern = 'YYYY-MM-DD HH:mm'): string {
|
||||
if (!value) {
|
||||
return '—'
|
||||
}
|
||||
const d = dayjs(value)
|
||||
return d.isValid() ? d.format(pattern) : String(value)
|
||||
}
|
||||
|
||||
/** 只要日期 */
|
||||
export function fmtDate(value?: string | null): string {
|
||||
return fmtTime(value, 'YYYY-MM-DD')
|
||||
}
|
||||
|
||||
/** 体积(人类可读) */
|
||||
export function fmtSize(bytes?: number | null): string {
|
||||
if (bytes === null || bytes === undefined) {
|
||||
return '—'
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** 百分比(后端给 0~100 的数或 0~1 的小数都能用;null 表示"没有样本") */
|
||||
export function fmtPercent(value?: number | null, digits = 0): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—'
|
||||
}
|
||||
return `${value.toFixed(digits)}%`
|
||||
}
|
||||
|
||||
/** 空值显示 */
|
||||
export function dash(value?: string | number | null): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
+121
-170
@@ -1,220 +1,171 @@
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { Message, Notification } from '@arco-design/web-vue'
|
||||
import type { BizError } from '@/apis/types'
|
||||
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 })
|
||||
import { message } from '@/utils/naive'
|
||||
|
||||
/**
|
||||
* 本项目后端响应契约(《03-接口设计》§1.3):
|
||||
* 后端响应契约(《03-接口设计》§1.3):`{ code, msg, data }`。
|
||||
*
|
||||
* { code: 0 | 4xxxx | 5xxxx, msg: string, data: T }
|
||||
* 这一层做三件事,做完之后业务代码里几乎不再出现 `code`:
|
||||
* 1. 成功(`code === 0`)时**直接 resolve `data`**(`await` 拿到的就是数据);
|
||||
* 2. 失败时**弹一次提示**并 reject 一个带 `bizCode` 的错误(想按码分支也能拿);
|
||||
* 3. 带上 `Authorization: Bearer` 与平台代管头 `X-Tenant-Id`。
|
||||
*
|
||||
* 与模板内部的 ApiRes 契约不同,差别有三处,都在下面这层适配里统一处理:
|
||||
*
|
||||
* | 维度 | 本项目后端 | 模板内部约定 |
|
||||
* |----------|--------------------|--------------------|
|
||||
* | 成功判定 | code === 0 | success: boolean |
|
||||
* | 提示文案 | msg | message |
|
||||
* | 分页列表 | { list, total } | { records, total } |
|
||||
*
|
||||
* ⚠️ 适配放在这里而不是逐个改 api 文件:这三条是全后端统一的契约,
|
||||
* 分散到每个 listAPI 去转换,漏一个就是一个"列表永远空着"的 bug。
|
||||
* ⚠️ 401/403/429 **不走业务码**:后端对它们用真实 HTTP 状态码 ——
|
||||
* 所以下面的错误分支才是它们的归属地。别在业务码里找 40301。
|
||||
*/
|
||||
|
||||
/** 本项目后端的成功码 */
|
||||
const CODE_SUCCESS = 0
|
||||
/**
|
||||
* 未登录 / token 失效。
|
||||
*
|
||||
* 只有这一个业务码需要在这里特殊处理(清 token + 跳登录)。
|
||||
* 40301 无权限与 42901 限流<b>不在这里判</b> —— 后端对这两个用的是**真实 HTTP 状态码**
|
||||
* (403 / 429,见《03-接口设计》§1.3),由下面的传输层错误分支统一处理。
|
||||
* 所以这里不要再补 40301 / 42901 的常量:加了也不会被走到,
|
||||
* 反而会让人以为"业务码里也会出现 40301",从而在错的地方排查问题。
|
||||
*/
|
||||
/** 未登录 / token 失效(唯一需要在业务码里特殊处理的一个) */
|
||||
const CODE_UNAUTHORIZED = 40101
|
||||
|
||||
/** 传输层 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)'
|
||||
const STATUS_TEXT: Record<number, string> = {
|
||||
400: '请求参数有误(400)',
|
||||
401: '登录已失效,请重新登录',
|
||||
403: '无权限访问(403)',
|
||||
404: '接口不存在(404)',
|
||||
408: '请求超时(408)',
|
||||
429: '操作过于频繁,请稍后再试',
|
||||
500: '服务器错误(500)',
|
||||
502: '网关错误(502)',
|
||||
503: '服务不可用(503)',
|
||||
504: '网关超时(504)'
|
||||
}
|
||||
|
||||
/** 创建 axios 实例 */
|
||||
const http: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_PREFIX,
|
||||
timeout: 30 * 1000
|
||||
timeout: 60 * 1000
|
||||
})
|
||||
|
||||
/**
|
||||
* 把分页字段补一份 records。
|
||||
* 后端返回 { list, total },模板的 useTable / PageRes 读 records。
|
||||
* 补而不替:list 原样保留,两边都能取到,避免破坏原有调用。
|
||||
* 平台运营的「当前学校」——请求头 `X-Tenant-Id`。
|
||||
*
|
||||
* 放在模块级变量而不是从 store 读:store 引 http 会成环。由 store 在变更时调 setter
|
||||
* 推过来,方向永远是 store → http,环就断了。
|
||||
*/
|
||||
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 actingTenantId: number | null = null
|
||||
|
||||
export function setActingTenantHeader(tenantId: number | null): void {
|
||||
actingTenantId = tenantId
|
||||
}
|
||||
|
||||
/** 未登录时的处理(由 router 注册,避免 http → router → views → http 的循环引用) */
|
||||
type UnauthorizedHandler = () => void
|
||||
let onUnauthorized: UnauthorizedHandler | null = null
|
||||
|
||||
export function setUnauthorizedHandler(handler: UnauthorizedHandler): void {
|
||||
onUnauthorized = handler
|
||||
}
|
||||
|
||||
/** 跳登录页(只跳一次,避免一个页面多个请求同时失效时反复跳) */
|
||||
let redirecting = false
|
||||
function toLogin() {
|
||||
if (redirecting) return
|
||||
function handleUnauthorized(): void {
|
||||
if (redirecting) {
|
||||
return
|
||||
}
|
||||
redirecting = true
|
||||
const back = encodeURIComponent(router.currentRoute.value.fullPath)
|
||||
router.replace(`/login?redirect=${back}`).finally(() => {
|
||||
try {
|
||||
onUnauthorized?.()
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
redirecting = false
|
||||
}, 800)
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
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)
|
||||
}
|
||||
)
|
||||
function makeError(text: string, bizCode?: number, bizData?: unknown, status?: number): BizError {
|
||||
const err = new Error(text) as BizError
|
||||
err.bizCode = bizCode
|
||||
err.bizData = bizData
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
if (actingTenantId) {
|
||||
config.headers['X-Tenant-Id'] = String(actingTenantId)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器
|
||||
http.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
NProgress.done()
|
||||
const raw = response.data
|
||||
const envelope = response.data
|
||||
|
||||
// 非信封响应(文件流等)直接放行
|
||||
if (!raw || typeof raw.code !== 'number') {
|
||||
// 非信封响应(文件流 / 第三方回调)原样放行
|
||||
if (!envelope || typeof envelope.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)
|
||||
}
|
||||
if (envelope.code === 0) {
|
||||
response.data = envelope.data
|
||||
return response
|
||||
}
|
||||
|
||||
// 业务失败:按文案长度选择 Message(短)或 Notification(长),与原实现一致
|
||||
const message = raw.msg || '服务器端错误'
|
||||
if (message.length <= 15) {
|
||||
Message.error(message)
|
||||
} else {
|
||||
Notification.error(message)
|
||||
if (envelope.code === CODE_UNAUTHORIZED) {
|
||||
handleUnauthorized()
|
||||
return Promise.reject(makeError(envelope.msg || '登录已失效,请重新登录', envelope.code, envelope.data))
|
||||
}
|
||||
// 把后端业务码附在错误上,便于调用方按码分支(如租户重名)
|
||||
const err = new Error(message) as Error & { bizCode?: number, bizData?: unknown }
|
||||
err.bizCode = raw.code
|
||||
err.bizData = raw.data
|
||||
return Promise.reject(err)
|
||||
|
||||
const text = envelope.msg || '操作失败'
|
||||
message.error(text)
|
||||
return Promise.reject(makeError(text, envelope.code, envelope.data))
|
||||
},
|
||||
(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 status: number | undefined = error?.response?.status
|
||||
if (status === 401) {
|
||||
handleUnauthorized()
|
||||
return Promise.reject(makeError('登录已失效,请重新登录', undefined, undefined, 401))
|
||||
}
|
||||
|
||||
const err = new Error(error.message) as Error & { status?: number }
|
||||
err.status = response?.status
|
||||
return Promise.reject(err)
|
||||
const text = STATUS_TEXT[status ?? 0] || '网络异常,请检查网络后重试'
|
||||
message.error(text)
|
||||
return Promise.reject(makeError(text, undefined, undefined, status))
|
||||
}
|
||||
)
|
||||
|
||||
/** 封装请求方法 */
|
||||
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))
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 把 axios 的响应**在运行时**取出 `data`。
|
||||
*
|
||||
* ⚠️ 这一句是整个请求层最容易写错的地方,这里踩过一次:
|
||||
* 上面的响应拦截器是按 axios 的约定写的 —— 它**只能返回一个"响应对象"**,
|
||||
* 于是 `response.data = envelope.data` 只是把信封里的 `data` 换成了业务数据,
|
||||
* **promise 本身仍然 resolve 成那个响应对象**。
|
||||
* 如果这里只做类型断言(`as unknown as Promise<T>`)而不真的取 `.data`,
|
||||
* 那么所有 `await getXxx()` 拿到的都是 AxiosResponse ——
|
||||
* 表现是"接口明明返回了数据,页面却什么也没有"(`page.list` 是 undefined),
|
||||
* 而且**不报任何错**。登录页是第一个撞上的:它拿公钥去加密,拿到的是个对象。
|
||||
*
|
||||
* 所以:拦截器负责"拆信封并弹提示",这里负责"把 data 交出去",
|
||||
* 两件事都必须做,缺一不可。
|
||||
*/
|
||||
const unwrap = <T>(promise: Promise<AxiosResponse>): Promise<T> =>
|
||||
promise.then((response) => response.data as T)
|
||||
|
||||
/** GET 请求 */
|
||||
const get = <T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<ApiRes<T>> => {
|
||||
return request({
|
||||
method: 'get',
|
||||
url,
|
||||
params,
|
||||
...config
|
||||
})
|
||||
}
|
||||
const get = <T = unknown>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> =>
|
||||
unwrap<T>(http.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
|
||||
})
|
||||
}
|
||||
const post = <T = unknown>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> =>
|
||||
unwrap<T>(http.post(url, data, 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
|
||||
})
|
||||
}
|
||||
const put = <T = unknown>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> =>
|
||||
unwrap<T>(http.put(url, data, config))
|
||||
|
||||
/** DELETE 请求 */
|
||||
const del = <T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiRes<T>> => {
|
||||
return request({
|
||||
method: 'delete',
|
||||
url,
|
||||
...config
|
||||
})
|
||||
}
|
||||
const del = <T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> =>
|
||||
unwrap<T>(http.delete(url, config))
|
||||
|
||||
/**
|
||||
* 任意方法的原样请求(上传 FormData、要自定义 timeout 时用)。
|
||||
*
|
||||
* ⚠️ 与上面四个一样,返回的是**拆过信封的 data** —— 调用方不要再写 `res.data`。
|
||||
* 要拿原始响应(文件流)请用下面导出的 `raw`。
|
||||
*/
|
||||
const request = <T = unknown>(config: AxiosRequestConfig): Promise<T> =>
|
||||
unwrap<T>(http.request(config))
|
||||
|
||||
/** 需要拿原始响应(文件流)时用它,例如导出包下载 */
|
||||
export const raw = http
|
||||
|
||||
export default { get, post, put, delete: del, request }
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import mitt from 'mitt'
|
||||
|
||||
type Events = {
|
||||
// 自定义事件名称
|
||||
changeText: string
|
||||
}
|
||||
|
||||
const mittBus = mitt<Events>()
|
||||
|
||||
export default mittBus
|
||||
@@ -1,46 +0,0 @@
|
||||
/** 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
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/** 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,17 @@
|
||||
/**
|
||||
* 组件树之外的提示能力(`useMessage` 只能在组件里用)。
|
||||
*
|
||||
* http 拦截器、路由守卫这些地方都要弹提示,用 `createDiscreteApi` 建一套离屏实例,
|
||||
* 全应用共用一份 —— 每次调用新建实例会导致"弹出一堆重复提示"且不消失。
|
||||
*/
|
||||
import { createDiscreteApi } from 'naive-ui'
|
||||
|
||||
const { message, dialog, notification, loadingBar } = createDiscreteApi(
|
||||
['message', 'dialog', 'notification', 'loadingBar'],
|
||||
{
|
||||
messageProviderProps: { max: 3, duration: 3000 },
|
||||
notificationProviderProps: { max: 3 }
|
||||
}
|
||||
)
|
||||
|
||||
export { dialog, loadingBar, message, notification }
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* @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})?$/
|
||||
@@ -1,20 +1,22 @@
|
||||
/**
|
||||
* 登录密码加密(RSA)
|
||||
* 登录密码加密(RSA)。
|
||||
*
|
||||
* 公钥从后端 `GET /auth/public-key` 拉取并缓存——这样**轮换密钥不需要重新构建前端**。
|
||||
* 否则"后端换了密钥、前端还是旧的"会表现为"所有人密码都错",而且极难定位。
|
||||
* 公钥从后端 `GET /auth/public-key` 拉取并缓存 —— 这样**轮换密钥不需要重新构建前端**。
|
||||
* 否则"后端换了密钥、前端还是旧的"会表现为"所有人密码都错",且极难定位。
|
||||
*
|
||||
* ⚠️ 算法必须与服务端 RsaCipher 一致:**RSA/ECB/PKCS1Padding**,
|
||||
* 与 jsencrypt 的默认行为相同。换成 OAEP 会表现为"密码永远错",两边都改才能换。
|
||||
* ⚠️ 算法必须与服务端 `RsaCipher` 一致:**RSA/ECB/PKCS1Padding**(jsencrypt 的默认行为)。
|
||||
* 换成 OAEP 会表现为"密码永远错"。
|
||||
*
|
||||
* ⚠️ 这不是用来替代 HTTPS 的。它的唯一目的是**防止明文密码进日志**——
|
||||
* 网关、Nginx、APM、链路追踪都会记录请求体,明文密码一旦落进日志就是永久泄露。
|
||||
* ⚠️ 它不是用来替代 HTTPS 的:唯一目的是**防止明文密码进日志** ——
|
||||
* 网关、Nginx、APM 都会记录请求体,明文一旦落进日志就是永久泄露。
|
||||
*/
|
||||
import JSEncrypt from 'jsencrypt'
|
||||
// 直接用 http 实例,不经 @/apis/xt —— 否则会形成
|
||||
// xt/index → http → router → stores → xt/index 的循环引用
|
||||
// 直接用 http,不经 @/apis/xt —— 否则形成 xt → http → … → xt 的环
|
||||
import http from '@/utils/http'
|
||||
|
||||
/** 折叠空白用(提到模块级:每次调用都新建正则纯属浪费,lint 也会拦) */
|
||||
const WHITESPACE_RE = /\s+/g
|
||||
|
||||
let cachedKey: string | null = null
|
||||
let inflight: Promise<string> | null = null
|
||||
|
||||
@@ -28,9 +30,9 @@ export async function fetchPublicKey(): Promise<string> {
|
||||
}
|
||||
inflight = http
|
||||
.get<string>('/auth/public-key')
|
||||
.then((res) => {
|
||||
cachedKey = res.data
|
||||
return res.data
|
||||
.then((pem) => {
|
||||
cachedKey = pem
|
||||
return pem
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
@@ -41,6 +43,16 @@ export async function fetchPublicKey(): Promise<string> {
|
||||
/** 加密密码,返回 Base64 密文 */
|
||||
export async function encryptPassword(plain: string): Promise<string> {
|
||||
const pem = await fetchPublicKey()
|
||||
if (typeof pem !== 'string' || !pem.includes('BEGIN PUBLIC KEY')) {
|
||||
// ⚠️ 这一条是**踩过的坑**:`/auth/public-key` 若没走到后端(例如 baseURL 少了
|
||||
// `/api/v1`,请求落到 SPA 兜底上),拿到的是 **index.html** 而不是公钥。
|
||||
// 此时 jsencrypt 只会返回 false,界面显示"密码加密失败" —— 排查方向会完全跑偏
|
||||
// (去查 jsencrypt、去查密钥轮换,而真正的原因是请求 URL)。
|
||||
// 所以这里把"到底拿到了什么"直接说出来。
|
||||
throw new Error(`公钥格式不对(拿到 ${typeof pem === 'string' ? `${pem.length} 字节` : typeof pem}:`
|
||||
+ `${String(pem).slice(0, 30).replace(WHITESPACE_RE, ' ')}…)`)
|
||||
}
|
||||
|
||||
const engine = new JSEncrypt()
|
||||
engine.setPublicKey(pem)
|
||||
const cipher = engine.encrypt(plain)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 状态色词 → Naive UI 标签色。
|
||||
*
|
||||
* 为什么需要这一层:`apis/xt/*.ts` 里的展示映射(`TASK_STATUS` / `AUDIT_*` /
|
||||
* `EXPORT_STATUS` / `NOTIFY_STATUS` …)是从上一版(Arco)带过来的,
|
||||
* 它们的 `color` 值是 **Arco 的色词**(`green` / `arcoblue` / `gold` / `gray`),
|
||||
* 而 Naive UI 的 `n-tag` 要的是 `type`(`success` / `info` / `warning` / `error` / `default`)。
|
||||
* 直接把它们透传给 `n-tag` 会**静默变成默认灰色**——状态色全丢,且不报错。
|
||||
*
|
||||
* ⚠️ 不要把这些映射的 color 值改成 naive 的 type:那份映射同时被别处
|
||||
* (例如导出、日志页的文案)引用,改它会牵动一片。只在这里做一次转换。
|
||||
*/
|
||||
|
||||
export type TagType = 'default' | 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
const COLOR_MAP: Record<string, TagType> = {
|
||||
green: 'success',
|
||||
success: 'success',
|
||||
red: 'error',
|
||||
danger: 'error',
|
||||
error: 'error',
|
||||
orange: 'warning',
|
||||
gold: 'warning',
|
||||
warning: 'warning',
|
||||
yellow: 'warning',
|
||||
blue: 'info',
|
||||
arcoblue: 'info',
|
||||
cyan: 'info',
|
||||
info: 'info',
|
||||
purple: 'info',
|
||||
gray: 'default',
|
||||
grey: 'default',
|
||||
default: 'default'
|
||||
}
|
||||
|
||||
/** 把色词转成 `n-tag` 的 type(不认识的色词按 default,不猜) */
|
||||
export function tagType(color?: string | null): TagType {
|
||||
if (!color) {
|
||||
return 'default'
|
||||
}
|
||||
return COLOR_MAP[color.toLowerCase()] ?? 'default'
|
||||
}
|
||||
|
||||
/** 从一个 `{ text, color }` 映射里取 type 与文案,省掉每页各写一遍 */
|
||||
export function tagOf(
|
||||
map: Record<string | number, { text: string, color: string } | undefined>,
|
||||
key: string | number | null | undefined,
|
||||
fallback = '—'
|
||||
): { text: string, type: TagType } {
|
||||
const hit = key === null || key === undefined ? undefined : map[key]
|
||||
if (!hit) {
|
||||
return { text: fallback, type: 'default' }
|
||||
}
|
||||
return { text: hit.text, type: tagType(hit.color) }
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* @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)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/** 判断 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