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:
2026-10-10 16:48:46 +08:00
parent 0bbac7d228
commit 5b877451eb
270 changed files with 12964 additions and 25889 deletions
+24 -32
View File
@@ -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 }
-103
View File
@@ -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
}
})
}
}
-76
View File
@@ -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)
}
})
}
-71
View File
@@ -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']
+45
View File
@@ -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
View File
@@ -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 }
-297
View File
@@ -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
}
-10
View File
@@ -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
}
+17
View File
@@ -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 }
-80
View File
@@ -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
}
-127
View File
@@ -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})?$/
+24 -12
View File
@@ -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)
+55
View File
@@ -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) }
}
-179
View File
@@ -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)
}
-10
View File
@@ -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://')
}