项目初始化
This commit is contained in:
383
web/src/utils/axios.ts
Normal file
383
web/src/utils/axios.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import type { AxiosRequestConfig, Method } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { ElLoading, ElNotification, type LoadingOptions } from 'element-plus'
|
||||
import { refreshToken } from '/@/api/common'
|
||||
import { i18n } from '/@/lang/index'
|
||||
import router from '/@/router/index'
|
||||
import adminBaseRoute from '/@/router/static/adminBase'
|
||||
import { memberCenterBaseRoutePath } from '/@/router/static/memberCenterBase'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
|
||||
import { useUserInfo } from '/@/stores/userInfo'
|
||||
import { isAdminApp } from '/@/utils/common'
|
||||
|
||||
window.requests = []
|
||||
window.tokenRefreshing = false
|
||||
const pendingMap = new Map()
|
||||
const loadingInstance: LoadingInstance = {
|
||||
target: null,
|
||||
count: 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据运行环境获取基础请求URL
|
||||
*/
|
||||
export const getUrl = (): string => {
|
||||
const value: string = import.meta.env.VITE_AXIOS_BASE_URL as string
|
||||
return value == 'getCurrentDomain' ? window.location.protocol + '//' + window.location.host : value
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据运行环境获取基础请求URL的端口
|
||||
*/
|
||||
export const getUrlPort = (): string => {
|
||||
const url = getUrl()
|
||||
return new URL(url).port
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建`Axios`
|
||||
* 默认开启`reductDataFormat(简洁响应)`,返回类型为`ApiPromise`
|
||||
* 关闭`reductDataFormat`,返回类型则为`AxiosPromise`
|
||||
*/
|
||||
function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequestConfig, options: Options = {}, loading: LoadingOptions = {}): T {
|
||||
const config = useConfig()
|
||||
const adminInfo = useAdminInfo()
|
||||
const userInfo = useUserInfo()
|
||||
|
||||
const Axios = axios.create({
|
||||
baseURL: getUrl(),
|
||||
timeout: 1000 * 10,
|
||||
headers: {
|
||||
'think-lang': config.lang.defaultLang,
|
||||
server: true,
|
||||
},
|
||||
responseType: 'json',
|
||||
})
|
||||
|
||||
// 自定义后台入口
|
||||
if (adminBaseRoute.path != '/admin' && isAdminApp() && /^\/admin\//.test(axiosConfig.url!)) {
|
||||
axiosConfig.url = axiosConfig.url!.replace(/^\/admin\//, adminBaseRoute.path + '.php/')
|
||||
}
|
||||
|
||||
// 合并默认请求选项
|
||||
options = Object.assign(
|
||||
{
|
||||
cancelDuplicateRequest: true, // 是否开启取消重复请求, 默认为 true
|
||||
loading: false, // 是否开启loading层效果, 默认为false
|
||||
reductDataFormat: true, // 是否开启简洁的数据结构响应, 默认为true
|
||||
showErrorMessage: true, // 是否开启接口错误信息展示,默认为true
|
||||
showCodeMessage: true, // 是否开启code不为1时的信息提示, 默认为true
|
||||
showSuccessMessage: false, // 是否开启code为1时的信息提示, 默认为false
|
||||
anotherToken: '', // 当前请求使用另外的用户token
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
// 请求拦截
|
||||
Axios.interceptors.request.use(
|
||||
(config) => {
|
||||
removePending(config)
|
||||
options.cancelDuplicateRequest && addPending(config)
|
||||
// 创建loading实例
|
||||
if (options.loading) {
|
||||
loadingInstance.count++
|
||||
if (loadingInstance.count === 1) {
|
||||
loadingInstance.target = ElLoading.service(loading)
|
||||
}
|
||||
}
|
||||
|
||||
// 自动携带token和语言(确保每次请求使用当前语言,后端据此返回对应语言的 remark 等)
|
||||
if (config.headers) {
|
||||
const token = adminInfo.getToken()
|
||||
if (token) (config.headers as anyObj).batoken = token
|
||||
const userToken = options.anotherToken || userInfo.getToken()
|
||||
if (userToken) (config.headers as anyObj)['ba-user-token'] = userToken
|
||||
;(config.headers as anyObj)['think-lang'] = useConfig().lang.defaultLang
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截
|
||||
Axios.interceptors.response.use(
|
||||
(response) => {
|
||||
removePending(response.config)
|
||||
options.loading && closeLoading(options) // 关闭loading
|
||||
|
||||
if (response.config.responseType == 'json') {
|
||||
if (response.data && response.data.code !== 1) {
|
||||
if (response.data.code == 409) {
|
||||
if (!window.tokenRefreshing) {
|
||||
window.tokenRefreshing = true
|
||||
return refreshToken()
|
||||
.then((res) => {
|
||||
if (res.data.type == 'admin-refresh') {
|
||||
adminInfo.setToken(res.data.token, 'auth')
|
||||
response.headers.batoken = `${res.data.token}`
|
||||
window.requests.forEach((cb) => cb(res.data.token, 'admin-refresh'))
|
||||
} else if (res.data.type == 'user-refresh') {
|
||||
userInfo.setToken(res.data.token, 'auth')
|
||||
response.headers['ba-user-token'] = `${res.data.token}`
|
||||
window.requests.forEach((cb) => cb(res.data.token, 'user-refresh'))
|
||||
}
|
||||
window.requests = []
|
||||
return Axios(response.config)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isAdminApp()) {
|
||||
adminInfo.removeToken()
|
||||
if (router.currentRoute.value.name != 'adminLogin') {
|
||||
router.push({ name: 'adminLogin' })
|
||||
return Promise.reject(err)
|
||||
} else {
|
||||
response.headers.batoken = ''
|
||||
window.requests.forEach((cb) => cb('', 'admin-refresh'))
|
||||
window.requests = []
|
||||
return Axios(response.config)
|
||||
}
|
||||
} else {
|
||||
userInfo.removeToken()
|
||||
if (router.currentRoute.value.name != 'userLogin') {
|
||||
router.push({ name: 'userLogin' })
|
||||
return Promise.reject(err)
|
||||
} else {
|
||||
response.headers['ba-user-token'] = ''
|
||||
window.requests.forEach((cb) => cb('', 'user-refresh'))
|
||||
window.requests = []
|
||||
return Axios(response.config)
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
window.tokenRefreshing = false
|
||||
})
|
||||
} else {
|
||||
return new Promise((resolve) => {
|
||||
// 用函数形式将 resolve 存入,等待刷新后再执行
|
||||
window.requests.push((token: string, type: string) => {
|
||||
if (type == 'admin-refresh') {
|
||||
response.headers.batoken = `${token}`
|
||||
} else {
|
||||
response.headers['ba-user-token'] = `${token}`
|
||||
}
|
||||
resolve(Axios(response.config))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
if (options.showCodeMessage) {
|
||||
ElNotification({
|
||||
type: 'error',
|
||||
message: response.data.msg,
|
||||
zIndex: SYSTEM_ZINDEX,
|
||||
})
|
||||
}
|
||||
// 自动跳转到路由name或path
|
||||
if (response.data.code == 302) {
|
||||
router.push({ path: response.data.data.routePath ?? '', name: response.data.data.routeName ?? '' })
|
||||
}
|
||||
if (response.data.code == 303) {
|
||||
const isAdminAppFlag = isAdminApp()
|
||||
let routerPath = isAdminAppFlag ? adminBaseRoute.path : memberCenterBaseRoutePath
|
||||
|
||||
// 需要登录,清理 token,转到登录页
|
||||
if (response.data.data.type == 'need login') {
|
||||
if (isAdminAppFlag) {
|
||||
adminInfo.removeToken()
|
||||
} else {
|
||||
userInfo.removeToken()
|
||||
}
|
||||
routerPath += '/login'
|
||||
}
|
||||
router.push({ path: routerPath })
|
||||
}
|
||||
// code不等于1, 页面then内的具体逻辑就不执行了
|
||||
return Promise.reject(response.data)
|
||||
} else if (options.showSuccessMessage && response.data && response.data.code == 1) {
|
||||
ElNotification({
|
||||
message: response.data.msg ? response.data.msg : i18n.global.t('axios.Operation successful'),
|
||||
type: 'success',
|
||||
zIndex: SYSTEM_ZINDEX,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return options.reductDataFormat ? response.data : response
|
||||
},
|
||||
(error) => {
|
||||
error.config && removePending(error.config)
|
||||
options.loading && closeLoading(options) // 关闭loading
|
||||
options.showErrorMessage && httpErrorStatusHandle(error) // 处理错误状态码
|
||||
return Promise.reject(error) // 错误继续返回给到具体页面
|
||||
}
|
||||
)
|
||||
return Axios(axiosConfig) as T
|
||||
}
|
||||
|
||||
export default createAxios
|
||||
|
||||
/**
|
||||
* 处理异常
|
||||
* @param {*} error
|
||||
*/
|
||||
function httpErrorStatusHandle(error: any) {
|
||||
// 处理被取消的请求
|
||||
if (axios.isCancel(error)) return console.error(i18n.global.t('axios.Automatic cancellation due to duplicate request:') + error.message)
|
||||
let message = ''
|
||||
if (error && error.response) {
|
||||
switch (error.response.status) {
|
||||
case 302:
|
||||
message = i18n.global.t('axios.Interface redirected!')
|
||||
break
|
||||
case 400:
|
||||
message = i18n.global.t('axios.Incorrect parameter!')
|
||||
break
|
||||
case 401:
|
||||
message = i18n.global.t('axios.You do not have permission to operate!')
|
||||
break
|
||||
case 403:
|
||||
message = i18n.global.t('axios.You do not have permission to operate!')
|
||||
break
|
||||
case 404:
|
||||
message = i18n.global.t('axios.Error requesting address:') + error.response.config.url
|
||||
break
|
||||
case 408:
|
||||
message = i18n.global.t('axios.Request timed out!')
|
||||
break
|
||||
case 409:
|
||||
message = i18n.global.t('axios.The same data already exists in the system!')
|
||||
break
|
||||
case 500:
|
||||
message = i18n.global.t('axios.Server internal error!')
|
||||
break
|
||||
case 501:
|
||||
message = i18n.global.t('axios.Service not implemented!')
|
||||
break
|
||||
case 502:
|
||||
message = i18n.global.t('axios.Gateway error!')
|
||||
break
|
||||
case 503:
|
||||
message = i18n.global.t('axios.Service unavailable!')
|
||||
break
|
||||
case 504:
|
||||
message = i18n.global.t('axios.The service is temporarily unavailable Please try again later!')
|
||||
break
|
||||
case 505:
|
||||
message = i18n.global.t('axios.HTTP version is not supported!')
|
||||
break
|
||||
default:
|
||||
message = i18n.global.t('axios.Abnormal problem, please contact the website administrator!')
|
||||
break
|
||||
}
|
||||
}
|
||||
if (error.message.includes('timeout')) message = i18n.global.t('axios.Network request timeout!')
|
||||
if (error.message.includes('Network'))
|
||||
message = window.navigator.onLine ? i18n.global.t('axios.Server exception!') : i18n.global.t('axios.You are disconnected!')
|
||||
|
||||
ElNotification({
|
||||
type: 'error',
|
||||
message,
|
||||
zIndex: SYSTEM_ZINDEX,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭Loading层实例
|
||||
*/
|
||||
function closeLoading(options: Options) {
|
||||
if (options.loading && loadingInstance.count > 0) loadingInstance.count--
|
||||
if (loadingInstance.count === 0) {
|
||||
loadingInstance.target.close()
|
||||
loadingInstance.target = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 储存每个请求的唯一cancel回调, 以此为标识
|
||||
*/
|
||||
function addPending(config: AxiosRequestConfig) {
|
||||
const pendingKey = getPendingKey(config)
|
||||
config.cancelToken =
|
||||
config.cancelToken ||
|
||||
new axios.CancelToken((cancel) => {
|
||||
if (!pendingMap.has(pendingKey)) {
|
||||
pendingMap.set(pendingKey, cancel)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除重复的请求
|
||||
*/
|
||||
function removePending(config: AxiosRequestConfig) {
|
||||
const pendingKey = getPendingKey(config)
|
||||
if (pendingMap.has(pendingKey)) {
|
||||
const cancelToken = pendingMap.get(pendingKey)
|
||||
cancelToken(pendingKey)
|
||||
pendingMap.delete(pendingKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成每个请求的唯一key
|
||||
*/
|
||||
function getPendingKey(config: AxiosRequestConfig) {
|
||||
let { data } = config
|
||||
const { url, method, params, headers } = config
|
||||
if (typeof data === 'string') data = JSON.parse(data) // response里面返回的config.data是个字符串对象
|
||||
return [
|
||||
url,
|
||||
method,
|
||||
headers && (headers as anyObj).batoken ? (headers as anyObj).batoken : '',
|
||||
headers && (headers as anyObj)['ba-user-token'] ? (headers as anyObj)['ba-user-token'] : '',
|
||||
JSON.stringify(params),
|
||||
JSON.stringify(data),
|
||||
].join('&')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求方法组装请求数据/参数
|
||||
*/
|
||||
export function requestPayload(method: Method, data: anyObj) {
|
||||
if (method == 'GET') {
|
||||
return {
|
||||
params: data,
|
||||
}
|
||||
} else if (method == 'POST') {
|
||||
return {
|
||||
data: data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface LoadingInstance {
|
||||
target: any
|
||||
count: number
|
||||
}
|
||||
interface Options {
|
||||
// 是否开启取消重复请求, 默认为 true
|
||||
cancelDuplicateRequest?: boolean
|
||||
// 是否开启loading层效果, 默认为false
|
||||
loading?: boolean
|
||||
// 是否开启简洁的数据结构响应, 默认为true
|
||||
reductDataFormat?: boolean
|
||||
// 是否开启接口错误信息展示,默认为true
|
||||
showErrorMessage?: boolean
|
||||
// 是否开启code不为1时的信息提示, 默认为true
|
||||
showCodeMessage?: boolean
|
||||
// 是否开启code为1时的信息提示, 默认为false
|
||||
showSuccessMessage?: boolean
|
||||
// 当前请求使用另外的用户token
|
||||
anotherToken?: string
|
||||
}
|
||||
|
||||
/*
|
||||
* 感谢掘金@橙某人提供的思路和分享
|
||||
* 本axios封装详细解释请参考:https://juejin.cn/post/6968630178163458084?share_token=7831c9e0-bea0-469e-8028-b587e13681a8#heading-27
|
||||
*/
|
||||
684
web/src/utils/baTable.ts
Normal file
684
web/src/utils/baTable.ts
Normal file
@@ -0,0 +1,684 @@
|
||||
import type { FormInstance, TableColumnCtx } from 'element-plus'
|
||||
import { ElNotification, dayjs } from 'element-plus'
|
||||
import { cloneDeep, isArray, isEmpty } from 'lodash-es'
|
||||
import Sortable from 'sortablejs'
|
||||
import { reactive } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { baTableApi } from '/@/api/common'
|
||||
import { findIndexRow } from '/@/components/table'
|
||||
import { i18n } from '/@/lang/index'
|
||||
import { auth, getArrayKey } from '/@/utils/common'
|
||||
|
||||
/**
|
||||
* 表格管家类
|
||||
*/
|
||||
export default class baTable {
|
||||
/** baTableApi 类的实例,开发者可重写该类 */
|
||||
public api: baTableApi
|
||||
|
||||
/** 表格状态,属性对应含义请查阅 BaTable 的类型定义 */
|
||||
public table: BaTable = reactive({
|
||||
ref: undefined,
|
||||
pk: 'id',
|
||||
data: [],
|
||||
remark: null,
|
||||
loading: false,
|
||||
selection: [],
|
||||
column: [],
|
||||
total: 0,
|
||||
filter: {},
|
||||
dragSortLimitField: 'pid',
|
||||
acceptQuery: true,
|
||||
showComSearch: false,
|
||||
dblClickNotEditColumn: [undefined],
|
||||
expandAll: false,
|
||||
extend: {},
|
||||
})
|
||||
|
||||
/** 表单状态,属性对应含义请查阅 BaTableForm 的类型定义 */
|
||||
public form: BaTableForm = reactive({
|
||||
ref: undefined,
|
||||
labelWidth: 160,
|
||||
operate: '',
|
||||
operateIds: [],
|
||||
items: {},
|
||||
submitLoading: false,
|
||||
defaultItems: {},
|
||||
loading: false,
|
||||
extend: {},
|
||||
})
|
||||
|
||||
/** BaTable 前置处理函数列表(前置埋点) */
|
||||
public before: BaTableBefore
|
||||
|
||||
/** BaTable 后置处理函数列表(后置埋点) */
|
||||
public after: BaTableAfter
|
||||
|
||||
/** 公共搜索数据 */
|
||||
public comSearch: ComSearch = reactive({
|
||||
form: {},
|
||||
fieldData: new Map(),
|
||||
})
|
||||
|
||||
constructor(api: baTableApi, table: BaTable, form: BaTableForm = {}, before: BaTableBefore = {}, after: BaTableAfter = {}) {
|
||||
this.api = api
|
||||
this.form = Object.assign(this.form, form)
|
||||
this.table = Object.assign(this.table, table)
|
||||
this.before = before
|
||||
this.after = after
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格内部鉴权方法
|
||||
* 此方法在表头或表行组件内部自动调用,传递权限节点名,如:add、edit
|
||||
* 若需自定义表格内部鉴权,重写此方法即可
|
||||
*/
|
||||
auth(node: string) {
|
||||
return auth(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行前置函数
|
||||
* @param funName 函数名
|
||||
* @param args 参数
|
||||
*/
|
||||
runBefore(funName: string, args: any = {}) {
|
||||
if (this.before && this.before[funName] && typeof this.before[funName] == 'function') {
|
||||
return this.before[funName]!({ ...args }) === false ? false : true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行后置函数
|
||||
* @param funName 函数名
|
||||
* @param args 参数
|
||||
*/
|
||||
runAfter(funName: string, args: any = {}) {
|
||||
if (this.after && this.after[funName] && typeof this.after[funName] == 'function') {
|
||||
return this.after[funName]!({ ...args }) === false ? false : true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格数据获取(请求表格对应控制器的查看方法)
|
||||
* @alias getIndex
|
||||
*/
|
||||
getData = () => {
|
||||
if (this.runBefore('getData') === false) return
|
||||
if (this.runBefore('getIndex') === false) return
|
||||
this.table.loading = true
|
||||
return this.api
|
||||
.index(this.table.filter)
|
||||
.then((res) => {
|
||||
this.table.data = res.data.list
|
||||
this.table.total = res.data.total
|
||||
this.table.remark = res.data.remark
|
||||
this.runAfter('getData', { res })
|
||||
this.runAfter('getIndex', { res })
|
||||
})
|
||||
.catch((err) => {
|
||||
this.runAfter('getData', { err })
|
||||
this.runAfter('getIndex', { err })
|
||||
})
|
||||
.finally(() => {
|
||||
this.table.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除数据
|
||||
*/
|
||||
postDel = (ids: string[]) => {
|
||||
if (this.runBefore('postDel', { ids }) === false) return
|
||||
this.api.del(ids).then((res) => {
|
||||
this.onTableHeaderAction('refresh', { event: 'delete', ids })
|
||||
this.runAfter('postDel', { res })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取被编辑行数据
|
||||
* @alias requestEdit
|
||||
*/
|
||||
getEditData = (id: string) => {
|
||||
if (this.runBefore('getEditData', { id }) === false) return
|
||||
if (this.runBefore('requestEdit', { id }) === false) return
|
||||
this.form.loading = true
|
||||
this.form.items = {}
|
||||
return this.api
|
||||
.edit({
|
||||
[this.table.pk!]: id,
|
||||
})
|
||||
.then((res) => {
|
||||
this.form.items = res.data.row
|
||||
this.runAfter('getEditData', { res })
|
||||
this.runAfter('requestEdit', { res })
|
||||
})
|
||||
.catch((err) => {
|
||||
this.toggleForm()
|
||||
this.runAfter('getEditData', { err })
|
||||
this.runAfter('requestEdit', { err })
|
||||
})
|
||||
.finally(() => {
|
||||
this.form.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 双击表格
|
||||
* @param row 行数据
|
||||
* @param column 列上下文数据
|
||||
*/
|
||||
onTableDblclick = (row: TableRow, column: TableColumnCtx<TableRow>) => {
|
||||
if (!this.table.dblClickNotEditColumn!.includes('all') && !this.table.dblClickNotEditColumn!.includes(column.property)) {
|
||||
if (this.runBefore('onTableDblclick', { row, column }) === false) return
|
||||
this.toggleForm('Edit', [row[this.table.pk!]])
|
||||
this.runAfter('onTableDblclick', { row, column })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开表单
|
||||
* @param operate 操作:Add=添加,Edit=编辑
|
||||
* @param operateIds 被操作项的数组:Add=[],Edit=[1,2,...]
|
||||
*/
|
||||
toggleForm = (operate = '', operateIds: string[] = []) => {
|
||||
if (this.runBefore('toggleForm', { operate, operateIds }) === false) return
|
||||
if (operate == 'Edit') {
|
||||
if (!operateIds.length) {
|
||||
return false
|
||||
}
|
||||
this.getEditData(operateIds[0])
|
||||
} else if (operate == 'Add') {
|
||||
this.form.items = cloneDeep(this.form.defaultItems)
|
||||
}
|
||||
this.form.operate = operate
|
||||
this.form.operateIds = operateIds
|
||||
this.runAfter('toggleForm', { operate, operateIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交表单
|
||||
* @param formEl 表单组件ref
|
||||
*/
|
||||
onSubmit = (formEl?: FormInstance | null) => {
|
||||
// 当前操作的首字母小写
|
||||
const operate = this.form.operate!.replace(this.form.operate![0], this.form.operate![0].toLowerCase())
|
||||
|
||||
if (this.runBefore('onSubmit', { formEl: formEl, operate: operate, items: this.form.items! }) === false) return
|
||||
|
||||
// 表单验证通过后执行的 api 请求操作
|
||||
const submitCallback = () => {
|
||||
this.form.submitLoading = true
|
||||
this.api
|
||||
.postData(operate, this.form.items!)
|
||||
.then((res) => {
|
||||
this.onTableHeaderAction('refresh', { event: 'submit', operate, items: this.form.items })
|
||||
this.form.operateIds?.shift()
|
||||
if (this.form.operateIds!.length > 0) {
|
||||
this.toggleForm('Edit', this.form.operateIds)
|
||||
} else {
|
||||
this.toggleForm()
|
||||
}
|
||||
this.runAfter('onSubmit', { res })
|
||||
})
|
||||
.finally(() => {
|
||||
this.form.submitLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
if (formEl) {
|
||||
this.form.ref = formEl
|
||||
formEl.validate((valid: boolean) => {
|
||||
if (valid) {
|
||||
submitCallback()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
submitCallback()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表格选择项的主键数组
|
||||
*/
|
||||
getSelectionIds() {
|
||||
const ids: string[] = []
|
||||
this.table.selection?.forEach((item) => {
|
||||
ids.push(item[this.table.pk!])
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格内的事件统一响应
|
||||
* @param event 事件名称,含义请参考其类型定义
|
||||
* @param data 携带数据
|
||||
*/
|
||||
onTableAction = (event: BaTableActionEventName, data: anyObj) => {
|
||||
if (this.runBefore('onTableAction', { event, data }) === false) return
|
||||
const actionFun = new Map([
|
||||
[
|
||||
'selection-change',
|
||||
() => {
|
||||
this.table.selection = data as TableRow[]
|
||||
},
|
||||
],
|
||||
[
|
||||
'page-size-change',
|
||||
() => {
|
||||
this.table.filter!.limit = data.size
|
||||
this.onTableHeaderAction('refresh', { event: 'page-size-change', ...data })
|
||||
},
|
||||
],
|
||||
[
|
||||
'current-page-change',
|
||||
() => {
|
||||
this.table.filter!.page = data.page
|
||||
this.onTableHeaderAction('refresh', { event: 'current-page-change', ...data })
|
||||
},
|
||||
],
|
||||
[
|
||||
'sort-change',
|
||||
() => {
|
||||
let newOrder: string | undefined
|
||||
if (data.prop && data.order) {
|
||||
newOrder = data.prop + ',' + data.order
|
||||
}
|
||||
if (newOrder != this.table.filter!.order) {
|
||||
this.table.filter!.order = newOrder
|
||||
this.onTableHeaderAction('refresh', { event: 'sort-change', ...data })
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
'edit',
|
||||
() => {
|
||||
this.toggleForm('Edit', [data.row[this.table.pk!]])
|
||||
},
|
||||
],
|
||||
[
|
||||
'delete',
|
||||
() => {
|
||||
this.postDel([data.row[this.table.pk!]])
|
||||
},
|
||||
],
|
||||
[
|
||||
'field-change',
|
||||
() => {
|
||||
if (data.field && data.field.prop && this.table.data![data.index]) {
|
||||
this.table.data![data.index][data.field.prop!] = data.value
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
'com-search',
|
||||
() => {
|
||||
// 主动触发公共搜索,采用覆盖模式设定请求筛选数据
|
||||
this.setFilterSearchData(this.getComSearchData(), 'cover')
|
||||
|
||||
// 刷新表格
|
||||
this.onTableHeaderAction('refresh', { event: 'com-search', data: this.table.filter!.search })
|
||||
},
|
||||
],
|
||||
[
|
||||
'default',
|
||||
() => {
|
||||
console.warn('No action defined')
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const action = actionFun.get(event) || actionFun.get('default')
|
||||
action!.call(this)
|
||||
return this.runAfter('onTableAction', { event, data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格顶栏按钮事件统一响应
|
||||
* @param event 事件名称,含义参考其类型定义
|
||||
* @param data 携带数据
|
||||
*/
|
||||
onTableHeaderAction = (event: BaTableHeaderActionEventName, data: anyObj) => {
|
||||
if (this.runBefore('onTableHeaderAction', { event, data }) === false) return
|
||||
const actionFun = new Map([
|
||||
[
|
||||
'refresh',
|
||||
() => {
|
||||
// 刷新表格在大多数情况下无需置空 data,但任需防范表格列组件的 :key 不会被更新的问题,比如关联表的数据列
|
||||
this.table.data = []
|
||||
this.getData()
|
||||
},
|
||||
],
|
||||
[
|
||||
'add',
|
||||
() => {
|
||||
this.toggleForm('Add')
|
||||
},
|
||||
],
|
||||
[
|
||||
'edit',
|
||||
() => {
|
||||
this.toggleForm('Edit', this.getSelectionIds())
|
||||
},
|
||||
],
|
||||
[
|
||||
'delete',
|
||||
() => {
|
||||
this.postDel(this.getSelectionIds())
|
||||
},
|
||||
],
|
||||
[
|
||||
'unfold',
|
||||
() => {
|
||||
if (!this.table.ref) {
|
||||
console.warn('Collapse/expand failed because table ref is not defined. Please assign table ref when onMounted')
|
||||
return
|
||||
}
|
||||
this.table.expandAll = data.unfold
|
||||
this.table.ref.unFoldAll(data.unfold)
|
||||
},
|
||||
],
|
||||
[
|
||||
'quick-search',
|
||||
() => {
|
||||
this.onTableHeaderAction('refresh', { event: 'quick-search', ...data })
|
||||
},
|
||||
],
|
||||
[
|
||||
'change-show-column',
|
||||
() => {
|
||||
const columnKey = getArrayKey(this.table.column, 'prop', data.field)
|
||||
this.table.column[columnKey].show = data.value
|
||||
},
|
||||
],
|
||||
[
|
||||
'default',
|
||||
() => {
|
||||
console.warn('No action defined')
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const action = actionFun.get(event) || actionFun.get('default')
|
||||
action!.call(this)
|
||||
return this.runAfter('onTableHeaderAction', { event, data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化默认排序
|
||||
* el-table 的 `default-sort` 在自定义排序时无效
|
||||
* 此方法只有在表格数据请求结束后执行有效
|
||||
*/
|
||||
initSort = () => {
|
||||
if (this.table.defaultOrder && this.table.defaultOrder.prop) {
|
||||
if (!this.table.ref) {
|
||||
console.warn('Failed to initialize default sorting because table ref is not defined. Please assign table ref when onMounted')
|
||||
return
|
||||
}
|
||||
|
||||
const defaultOrder = this.table.defaultOrder.prop + ',' + this.table.defaultOrder.order
|
||||
if (this.table.filter && this.table.filter.order != defaultOrder) {
|
||||
this.table.filter.order = defaultOrder
|
||||
this.table.ref.getRef()?.sort(this.table.defaultOrder.prop, this.table.defaultOrder.order == 'desc' ? 'descending' : 'ascending')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化表格拖动排序
|
||||
*/
|
||||
dragSort = () => {
|
||||
const buttonsKey = getArrayKey(this.table.column, 'render', 'buttons')
|
||||
if (buttonsKey === false) return
|
||||
const moveButton = getArrayKey(this.table.column[buttonsKey]?.buttons, 'render', 'moveButton')
|
||||
if (moveButton === false) return
|
||||
if (!this.table.ref) {
|
||||
console.warn('Failed to initialize drag sort because table ref is not defined. Please assign table ref when onMounted')
|
||||
return
|
||||
}
|
||||
|
||||
const el = this.table.ref.getRef()?.$el.querySelector('.el-table__body-wrapper .el-table__body tbody')
|
||||
const disabledTip = this.table.column[buttonsKey].buttons![moveButton].disabledTip
|
||||
Sortable.create(el, {
|
||||
animation: 200,
|
||||
handle: '.table-row-weigh-sort',
|
||||
ghostClass: 'ba-table-row',
|
||||
onStart: () => {
|
||||
this.table.column[buttonsKey].buttons![moveButton].disabledTip = true
|
||||
},
|
||||
onEnd: (evt: Sortable.SortableEvent) => {
|
||||
this.table.column[buttonsKey].buttons![moveButton].disabledTip = disabledTip
|
||||
|
||||
// 目标位置不变
|
||||
if (evt.oldIndex == evt.newIndex || typeof evt.newIndex == 'undefined' || typeof evt.oldIndex == 'undefined') return
|
||||
|
||||
// 找到对应行id
|
||||
const moveRow = findIndexRow(this.table.data!, evt.oldIndex) as TableRow
|
||||
const targetRow = findIndexRow(this.table.data!, evt.newIndex) as TableRow
|
||||
|
||||
const eventData = {
|
||||
move: moveRow[this.table.pk!],
|
||||
target: targetRow[this.table.pk!],
|
||||
order: this.table.filter?.order,
|
||||
direction: evt.newIndex > evt.oldIndex ? 'down' : 'up',
|
||||
}
|
||||
|
||||
if (this.table.dragSortLimitField && moveRow[this.table.dragSortLimitField] != targetRow[this.table.dragSortLimitField]) {
|
||||
this.onTableHeaderAction('refresh', { event: 'sort', ...eventData })
|
||||
ElNotification({
|
||||
type: 'error',
|
||||
message: i18n.global.t('utils.The moving position is beyond the movable range!'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.api.sortable(eventData).finally(() => {
|
||||
this.onTableHeaderAction('refresh', { event: 'sort', ...eventData })
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格初始化
|
||||
*/
|
||||
mount = () => {
|
||||
if (this.runBefore('mount') === false) return
|
||||
|
||||
// 记录表格的路由路径
|
||||
const route = useRoute()
|
||||
this.table.routePath = route.fullPath
|
||||
|
||||
// 按需初始化公共搜索表单数据和字段Map
|
||||
if (this.comSearch.fieldData.size === 0) {
|
||||
this.initComSearch()
|
||||
}
|
||||
|
||||
if (this.table.acceptQuery && !isEmpty(route.query)) {
|
||||
// 根据当前 URL 的 query 初始化公共搜索默认值
|
||||
this.setComSearchData(route.query)
|
||||
|
||||
// 获取公共搜索数据合并至表格筛选条件
|
||||
this.setFilterSearchData(this.getComSearchData(), 'merge')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共搜索初始化
|
||||
*/
|
||||
initComSearch = () => {
|
||||
const form: anyObj = {}
|
||||
const field = this.table.column
|
||||
|
||||
if (field.length <= 0) return
|
||||
|
||||
for (const key in field) {
|
||||
// 关闭搜索的字段
|
||||
if (field[key].operator === false) continue
|
||||
|
||||
// 取默认操作符号
|
||||
if (typeof field[key].operator == 'undefined') {
|
||||
field[key].operator = 'eq'
|
||||
}
|
||||
|
||||
// 公共搜索表单字段初始化
|
||||
const prop = field[key].prop
|
||||
if (prop) {
|
||||
if (field[key].operator == 'RANGE' || field[key].operator == 'NOT RANGE') {
|
||||
// 范围查询
|
||||
form[prop] = ''
|
||||
form[prop + '-start'] = ''
|
||||
form[prop + '-end'] = ''
|
||||
} else if (field[key].operator == 'NULL' || field[key].operator == 'NOT NULL') {
|
||||
// 复选框
|
||||
form[prop] = false
|
||||
} else {
|
||||
// 普通文本框
|
||||
form[prop] = ''
|
||||
}
|
||||
|
||||
// 初始化字段的公共搜索数据
|
||||
this.comSearch.fieldData.set(prop, {
|
||||
operator: field[key].operator,
|
||||
render: field[key].render,
|
||||
comSearchRender: field[key].comSearchRender,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.comSearch.form = Object.assign(this.comSearch.form, form)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置公共搜索表单数据
|
||||
*/
|
||||
setComSearchData = (query: anyObj) => {
|
||||
// 必需已经完成公共搜索数据的初始化
|
||||
if (this.comSearch.fieldData.size === 0) {
|
||||
this.initComSearch()
|
||||
}
|
||||
|
||||
for (const key in this.table.column) {
|
||||
const prop = this.table.column[key].prop
|
||||
if (prop && typeof query[prop] !== 'undefined') {
|
||||
const queryProp = query[prop] ?? ''
|
||||
if (this.table.column[key].operator == 'RANGE' || this.table.column[key].operator == 'NOT RANGE') {
|
||||
const range = queryProp.split(',')
|
||||
if (this.table.column[key].render == 'datetime' || this.table.column[key].comSearchRender == 'date') {
|
||||
if (range && range.length >= 2) {
|
||||
const rangeDayJs = [dayjs(range[0]), dayjs(range[1])]
|
||||
if (rangeDayJs[0].isValid() && rangeDayJs[1].isValid()) {
|
||||
if (this.table.column[key].comSearchRender == 'date') {
|
||||
this.comSearch.form[prop] = [rangeDayJs[0].format('YYYY-MM-DD'), rangeDayJs[1].format('YYYY-MM-DD')]
|
||||
} else {
|
||||
this.comSearch.form[prop] = [
|
||||
rangeDayJs[0].format('YYYY-MM-DD HH:mm:ss'),
|
||||
rangeDayJs[1].format('YYYY-MM-DD HH:mm:ss'),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.table.column[key].comSearchRender == 'time') {
|
||||
if (range && range.length >= 2) {
|
||||
this.comSearch.form[prop] = [range[0], range[1]]
|
||||
}
|
||||
} else {
|
||||
this.comSearch.form[prop + '-start'] = range[0] ?? ''
|
||||
this.comSearch.form[prop + '-end'] = range[1] ?? ''
|
||||
}
|
||||
} else if (this.table.column[key].operator == 'NULL' || this.table.column[key].operator == 'NOT NULL') {
|
||||
this.comSearch.form[prop] = queryProp ? true : false
|
||||
} else if (this.table.column[key].render == 'datetime' || this.table.column[key].comSearchRender == 'date') {
|
||||
const propDayJs = dayjs(queryProp)
|
||||
if (propDayJs.isValid()) {
|
||||
this.comSearch.form[prop] = propDayJs.format(
|
||||
this.table.column[key].comSearchRender == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.comSearch.form[prop] = queryProp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公共搜索表单数据
|
||||
*/
|
||||
getComSearchData = () => {
|
||||
// 必需已经完成公共搜索数据的初始化
|
||||
if (this.comSearch.fieldData.size === 0) {
|
||||
this.initComSearch()
|
||||
}
|
||||
|
||||
const comSearchData: ComSearchData[] = []
|
||||
|
||||
for (const key in this.comSearch.form) {
|
||||
if (!this.comSearch.fieldData.has(key)) continue
|
||||
|
||||
let val = null
|
||||
const fieldDataTemp = this.comSearch.fieldData.get(key)
|
||||
if (
|
||||
(fieldDataTemp.render == 'datetime' || ['datetime', 'date', 'time'].includes(fieldDataTemp.comSearchRender)) &&
|
||||
(fieldDataTemp.operator == 'RANGE' || fieldDataTemp.operator == 'NOT RANGE')
|
||||
) {
|
||||
if (this.comSearch.form[key] && this.comSearch.form[key].length >= 2) {
|
||||
// 日期范围
|
||||
if (fieldDataTemp.comSearchRender == 'date') {
|
||||
val = this.comSearch.form[key][0] + ' 00:00:00' + ',' + this.comSearch.form[key][1] + ' 23:59:59'
|
||||
} else {
|
||||
// 时间范围、时间日期范围
|
||||
val = this.comSearch.form[key][0] + ',' + this.comSearch.form[key][1]
|
||||
}
|
||||
}
|
||||
} else if (fieldDataTemp.operator == 'RANGE' || fieldDataTemp.operator == 'NOT RANGE') {
|
||||
// 普通的范围筛选,公共搜索初始化时已准备好 start 和 end 字段
|
||||
if (!this.comSearch.form[key + '-start'] && !this.comSearch.form[key + '-end']) {
|
||||
continue
|
||||
}
|
||||
val = this.comSearch.form[key + '-start'] + ',' + this.comSearch.form[key + '-end']
|
||||
} else if (this.comSearch.form[key]) {
|
||||
val = this.comSearch.form[key]
|
||||
}
|
||||
|
||||
if (val === null) continue
|
||||
if (isArray(val) && !val.length) continue
|
||||
|
||||
comSearchData.push({
|
||||
field: key,
|
||||
val: val,
|
||||
operator: fieldDataTemp.operator,
|
||||
render: fieldDataTemp.render,
|
||||
})
|
||||
}
|
||||
|
||||
return comSearchData
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 getData 请求时的过滤条件(搜索数据)
|
||||
* @param search 新的搜索数据
|
||||
* @param mode 模式:cover=覆盖到已有搜索数据,merge=合并到已有搜索数据
|
||||
*/
|
||||
setFilterSearchData = (search: ComSearchData[], mode: 'cover' | 'merge' = 'merge') => {
|
||||
if (mode == 'cover' || !this.table.filter?.search) {
|
||||
this.table.filter!.search = search
|
||||
} else {
|
||||
const merged = this.table.filter!.search.concat(search)
|
||||
const fieldMap = new Map<string, ComSearchData>()
|
||||
|
||||
merged.forEach((item) => {
|
||||
fieldMap.set(item.field, item)
|
||||
})
|
||||
|
||||
this.table.filter!.search = Array.from(fieldMap.values())
|
||||
}
|
||||
}
|
||||
|
||||
// 方法别名
|
||||
getIndex = this.getData
|
||||
requestEdit = this.getEditData
|
||||
}
|
||||
37
web/src/utils/build.ts
Normal file
37
web/src/utils/build.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { readdirSync, writeFile } from 'fs'
|
||||
import { trimEnd } from 'lodash-es'
|
||||
|
||||
function getFileNames(dir: string) {
|
||||
const dirents = readdirSync(dir, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
const fileNames: string[] = []
|
||||
for (const dirent of dirents) {
|
||||
if (!dirent.isDirectory()) fileNames.push(dirent.name.replace('.vue', ''))
|
||||
}
|
||||
return fileNames
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 ./types/tableRenderer.d.ts 文件
|
||||
*/
|
||||
const buildTableRendererType = () => {
|
||||
let tableRenderer = getFileNames('./src/components/table/fieldRender/')
|
||||
|
||||
// 增加 slot,去除 default
|
||||
tableRenderer.push('slot')
|
||||
tableRenderer = tableRenderer.filter((item) => item !== 'default')
|
||||
|
||||
let tableRendererContent =
|
||||
'/** 可用的表格单元格渲染器,以 ./src/components/table/fieldRender/ 目录中的文件名自动生成 */\ntype TableRenderer =\n | '
|
||||
for (const key in tableRenderer) {
|
||||
tableRendererContent += `'${tableRenderer[key]}'\n | `
|
||||
}
|
||||
tableRendererContent = trimEnd(tableRendererContent, ' | ')
|
||||
|
||||
writeFile('./types/tableRenderer.d.ts', tableRendererContent, 'utf-8', (err) => {
|
||||
if (err) throw err
|
||||
})
|
||||
}
|
||||
|
||||
buildTableRendererType()
|
||||
404
web/src/utils/common.ts
Normal file
404
web/src/utils/common.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import * as elIcons from '@element-plus/icons-vue'
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { isArray, isNull, trim, trimStart } from 'lodash-es'
|
||||
import type { App } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import type { TranslateOptions } from 'vue-i18n'
|
||||
import { i18n } from '../lang'
|
||||
import { useSiteConfig } from '../stores/siteConfig'
|
||||
import { getUrl } from './axios'
|
||||
import Icon from '/@/components/icon/index.vue'
|
||||
import router from '/@/router/index'
|
||||
import { adminBaseRoutePath } from '/@/router/static/adminBase'
|
||||
import { useMemberCenter } from '/@/stores/memberCenter'
|
||||
import { useNavTabs } from '/@/stores/navTabs'
|
||||
|
||||
export function registerIcons(app: App) {
|
||||
/*
|
||||
* 全局注册 Icon
|
||||
* 使用方式: <Icon name="name" size="size" color="color" />
|
||||
* 详见<待完善>
|
||||
*/
|
||||
app.component('Icon', Icon)
|
||||
|
||||
/*
|
||||
* 全局注册element Plus的icon
|
||||
*/
|
||||
const icons = elIcons as any
|
||||
for (const i in icons) {
|
||||
app.component(`el-icon-${icons[i].name}`, icons[i])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载网络css文件
|
||||
* @param url css资源url
|
||||
*/
|
||||
export function loadCss(url: string): void {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = url
|
||||
link.crossOrigin = 'anonymous'
|
||||
document.getElementsByTagName('head')[0].appendChild(link)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载网络js文件
|
||||
* @param url js资源url
|
||||
*/
|
||||
export function loadJs(url: string): void {
|
||||
const link = document.createElement('script')
|
||||
link.src = url
|
||||
document.body.appendChild(link)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路由 meta.title 设置浏览器标题
|
||||
*/
|
||||
export function setTitleFromRoute() {
|
||||
nextTick(() => {
|
||||
if (typeof router.currentRoute.value.meta.title != 'string') {
|
||||
return
|
||||
}
|
||||
const webTitle = i18n.global.te(router.currentRoute.value.meta.title)
|
||||
? i18n.global.t(router.currentRoute.value.meta.title)
|
||||
: router.currentRoute.value.meta.title
|
||||
const title = useTitle()
|
||||
const siteConfig = useSiteConfig()
|
||||
title.value = `${webTitle}${siteConfig.siteName ? ' - ' + siteConfig.siteName : ''}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置浏览器标题
|
||||
* @param webTitle 新的标题
|
||||
*/
|
||||
export function setTitle(webTitle: string) {
|
||||
if (router.currentRoute.value) {
|
||||
router.currentRoute.value.meta.title = webTitle
|
||||
}
|
||||
nextTick(() => {
|
||||
const title = useTitle()
|
||||
const siteConfig = useSiteConfig()
|
||||
title.value = `${webTitle}${siteConfig.siteName ? ' - ' + siteConfig.siteName : ''}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是外部链接
|
||||
* @param path
|
||||
*/
|
||||
export function isExternal(path: string): boolean {
|
||||
return /^(https?|ftp|mailto|tel):/.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局防抖
|
||||
* 与 _.debounce 不同的是,间隔期间如果再次传递不同的函数,两个函数也只会执行一次
|
||||
* @param fn 执行函数
|
||||
* @param ms 间隔毫秒数
|
||||
*/
|
||||
export const debounce = (fn: Function, ms: number) => {
|
||||
return (...args: any[]) => {
|
||||
if (window.lazy) {
|
||||
clearTimeout(window.lazy)
|
||||
}
|
||||
window.lazy = window.setTimeout(() => {
|
||||
fn(...args)
|
||||
}, ms)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据pk字段的值从数组中获取key
|
||||
* @param arr
|
||||
* @param pk
|
||||
* @param value
|
||||
*/
|
||||
export const getArrayKey = (arr: any, pk: string, value: any): any => {
|
||||
for (const key in arr) {
|
||||
if (arr[key][pk] == value) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单重置
|
||||
* @param formEl
|
||||
*/
|
||||
export const onResetForm = (formEl?: FormInstance | null) => {
|
||||
typeof formEl?.resetFields == 'function' && formEl.resetFields()
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据构建为ElTree的data {label:'', children: []}
|
||||
* @param data
|
||||
*/
|
||||
export const buildJsonToElTreeData = (data: any): ElTreeData[] => {
|
||||
if (typeof data == 'object') {
|
||||
const childrens = []
|
||||
for (const key in data) {
|
||||
childrens.push({
|
||||
label: key + ': ' + data[key],
|
||||
children: buildJsonToElTreeData(data[key]),
|
||||
})
|
||||
}
|
||||
return childrens
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否在后台应用内
|
||||
* @param path 不传递则通过当前路由 path 检查
|
||||
*/
|
||||
export const isAdminApp = (path = '') => {
|
||||
const regex = new RegExp(`^${adminBaseRoutePath}`)
|
||||
if (path) {
|
||||
return regex.test(path)
|
||||
}
|
||||
if (regex.test(getCurrentRoutePath())) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为手机设备
|
||||
*/
|
||||
export const isMobile = () => {
|
||||
return !!navigator.userAgent.match(
|
||||
/android|webos|ip(hone|ad|od)|opera (mini|mobi|tablet)|iemobile|windows.+(phone|touch)|mobile|fennec|kindle (Fire)|Silk|maemo|blackberry|playbook|bb10\; (touch|kbd)|Symbian(OS)|Ubuntu Touch/i
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一个文件路径中获取文件名
|
||||
* @param path 文件路径
|
||||
*/
|
||||
export const getFileNameFromPath = (path: string) => {
|
||||
const paths = path.split('/')
|
||||
return paths[paths.length - 1]
|
||||
}
|
||||
|
||||
export function auth(node: string): boolean
|
||||
export function auth(node: { name: string; subNodeName?: string }): boolean
|
||||
|
||||
/**
|
||||
* 鉴权
|
||||
* 提供 string 将根据当前路由 path 自动拼接和鉴权,还可以提供路由的 name 对象进行鉴权
|
||||
* @param node
|
||||
*/
|
||||
export function auth(node: string | { name: string; subNodeName?: string }) {
|
||||
const store = isAdminApp() ? useNavTabs() : useMemberCenter()
|
||||
if (typeof node === 'string') {
|
||||
const path = getCurrentRoutePath()
|
||||
if (store.state.authNode.has(path)) {
|
||||
const subNodeName = path + (path == '/' ? '' : '/') + node
|
||||
if (store.state.authNode.get(path)!.some((v: string) => v == subNodeName)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 节点列表中没有找到 name
|
||||
if (!node.name || !store.state.authNode.has(node.name)) return false
|
||||
|
||||
// 无需继续检查子节点或未找到子节点
|
||||
if (!node.subNodeName || store.state.authNode.get(node.name)?.includes(node.subNodeName)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源完整地址
|
||||
* @param relativeUrl 资源相对地址
|
||||
* @param domain 指定域名
|
||||
*/
|
||||
export const fullUrl = (relativeUrl: string, domain = '') => {
|
||||
const siteConfig = useSiteConfig()
|
||||
if (!domain) {
|
||||
domain = siteConfig.cdnUrl ? siteConfig.cdnUrl : getUrl()
|
||||
}
|
||||
if (!relativeUrl) return domain
|
||||
|
||||
const regUrl = new RegExp(/^http(s)?:\/\//)
|
||||
const regexImg = new RegExp(/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i)
|
||||
if (!domain || regUrl.test(relativeUrl) || regexImg.test(relativeUrl)) {
|
||||
return relativeUrl
|
||||
}
|
||||
|
||||
let url = domain + relativeUrl
|
||||
if (domain === siteConfig.cdnUrl && siteConfig.cdnUrlParams) {
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
url += separator + siteConfig.cdnUrlParams
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路由 path
|
||||
*/
|
||||
export const getCurrentRoutePath = () => {
|
||||
let path = router.currentRoute.value.path
|
||||
if (path == '/') path = trimStart(window.location.hash, '#')
|
||||
if (path.indexOf('?') !== -1) path = path.replace(/\?.*/, '')
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取根据当前路由路径动态加载的语言翻译
|
||||
* @param key 无需语言路径的翻译key,亦可使用完整路径
|
||||
* @param named — 命名插值的值
|
||||
* @param options — 其他翻译选项
|
||||
* @returns — Translated message
|
||||
*/
|
||||
export const __ = (key: string, named?: Record<string, unknown>, options?: TranslateOptions<string>) => {
|
||||
let langPath = ''
|
||||
const path = getCurrentRoutePath()
|
||||
if (isAdminApp()) {
|
||||
langPath = path.slice(path.indexOf(adminBaseRoutePath) + adminBaseRoutePath.length)
|
||||
langPath = trim(langPath, '/').replaceAll('/', '.')
|
||||
} else {
|
||||
langPath = trim(path, '/').replaceAll('/', '.')
|
||||
}
|
||||
langPath = langPath ? langPath + '.' + key : key
|
||||
return i18n.global.te(langPath)
|
||||
? i18n.global.t(langPath, named ?? {}, options ? options : {})
|
||||
: i18n.global.t(key, named ?? {}, options ? options : {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件类型效验,前端根据服务端配置进行初步检查
|
||||
* @param fileName 文件名
|
||||
* @param fileType 文件 mimeType,不一定存在
|
||||
*/
|
||||
export const checkFileMimetype = (fileName: string, fileType: string) => {
|
||||
if (!fileName) return false
|
||||
const siteConfig = useSiteConfig()
|
||||
const allowedSuffixes = isArray(siteConfig.upload.allowedSuffixes)
|
||||
? siteConfig.upload.allowedSuffixes
|
||||
: siteConfig.upload.allowedSuffixes.toLowerCase().split(',')
|
||||
|
||||
const allowedMimeTypes = isArray(siteConfig.upload.allowedMimeTypes)
|
||||
? siteConfig.upload.allowedMimeTypes
|
||||
: siteConfig.upload.allowedMimeTypes.toLowerCase().split(',')
|
||||
|
||||
const fileSuffix = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
|
||||
if (allowedSuffixes.includes(fileSuffix) || allowedSuffixes.includes('.' + fileSuffix)) {
|
||||
return true
|
||||
}
|
||||
if (fileType && allowedMimeTypes.includes(fileType)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一组资源的完整地址
|
||||
* @param relativeUrls 资源相对地址
|
||||
* @param domain 指定域名
|
||||
*/
|
||||
export const arrayFullUrl = (relativeUrls: string | string[], domain = '') => {
|
||||
if (typeof relativeUrls === 'string') {
|
||||
relativeUrls = relativeUrls == '' ? [] : relativeUrls.split(',')
|
||||
}
|
||||
for (const key in relativeUrls) {
|
||||
relativeUrls[key] = fullUrl(relativeUrls[key], domain)
|
||||
}
|
||||
return relativeUrls
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳
|
||||
* @param dateTime 时间戳,默认使用当前时间戳
|
||||
* @param fmt 格式化方式,默认:yyyy-mm-dd hh:MM:ss
|
||||
*/
|
||||
export const timeFormat = (dateTime: string | number | null = null, fmt = 'yyyy-mm-dd hh:MM:ss') => {
|
||||
if (dateTime == 'none') {
|
||||
return i18n.global.t('None')
|
||||
}
|
||||
|
||||
if (isNull(dateTime)) {
|
||||
dateTime = Number(new Date())
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. 秒级时间戳(10位)需要转换为毫秒级,才能供 Date 对象直接使用
|
||||
* 2. yyyy-mm-dd 也是10位,使用 isFinite 进行排除
|
||||
*/
|
||||
if (String(dateTime).length === 10 && isFinite(Number(dateTime))) {
|
||||
dateTime = +dateTime * 1000
|
||||
}
|
||||
|
||||
let date = new Date(dateTime)
|
||||
if (isNaN(date.getTime())) {
|
||||
date = new Date(Number(dateTime))
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid Date'
|
||||
}
|
||||
}
|
||||
|
||||
let ret
|
||||
const opt: anyObj = {
|
||||
'y+': date.getFullYear().toString(), // 年
|
||||
'm+': (date.getMonth() + 1).toString(), // 月
|
||||
'd+': date.getDate().toString(), // 日
|
||||
'h+': date.getHours().toString(), // 时
|
||||
'M+': date.getMinutes().toString(), // 分
|
||||
's+': date.getSeconds().toString(), // 秒
|
||||
}
|
||||
for (const k in opt) {
|
||||
ret = new RegExp('(' + k + ')').exec(fmt)
|
||||
if (ret) {
|
||||
fmt = fmt.replace(ret[1], ret[1].length == 1 ? opt[k] : padStart(opt[k], ret[1].length, '0'))
|
||||
}
|
||||
}
|
||||
return fmt
|
||||
}
|
||||
|
||||
/**
|
||||
* 字符串补位
|
||||
*/
|
||||
const padStart = (str: string, maxLength: number, fillString = ' ') => {
|
||||
if (str.length >= maxLength) return str
|
||||
|
||||
const fillLength = maxLength - str.length
|
||||
let times = Math.ceil(fillLength / fillString.length)
|
||||
while ((times >>= 1)) {
|
||||
fillString += fillString
|
||||
if (times === 1) {
|
||||
fillString += fillString
|
||||
}
|
||||
}
|
||||
return fillString.slice(0, fillLength) + str
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前时间生成问候语
|
||||
*/
|
||||
export const getGreet = () => {
|
||||
const now = new Date()
|
||||
const hour = now.getHours()
|
||||
let greet = ''
|
||||
|
||||
if (hour < 5) {
|
||||
greet = i18n.global.t('utils.Late at night, pay attention to your body!')
|
||||
} else if (hour < 9) {
|
||||
greet = i18n.global.t('utils.good morning!') + i18n.global.t('utils.welcome back')
|
||||
} else if (hour < 12) {
|
||||
greet = i18n.global.t('utils.Good morning!') + i18n.global.t('utils.welcome back')
|
||||
} else if (hour < 14) {
|
||||
greet = i18n.global.t('utils.Good noon!') + i18n.global.t('utils.welcome back')
|
||||
} else if (hour < 18) {
|
||||
greet = i18n.global.t('utils.good afternoon') + i18n.global.t('utils.welcome back')
|
||||
} else if (hour < 24) {
|
||||
greet = i18n.global.t('utils.Good evening') + i18n.global.t('utils.welcome back')
|
||||
} else {
|
||||
greet = i18n.global.t('utils.Hello!') + i18n.global.t('utils.welcome back')
|
||||
}
|
||||
return greet
|
||||
}
|
||||
224
web/src/utils/directives.ts
Normal file
224
web/src/utils/directives.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { App } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import horizontalScroll from '/@/utils/horizontalScroll'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { isString } from 'lodash-es'
|
||||
import { auth } from '/@/utils/common'
|
||||
|
||||
export function directives(app: App) {
|
||||
// 鉴权指令
|
||||
authDirective(app)
|
||||
// 拖动指令
|
||||
dragDirective(app)
|
||||
// 缩放指令
|
||||
zoomDirective(app)
|
||||
// 点击后自动失焦指令
|
||||
blurDirective(app)
|
||||
// 表格横向拖动指令
|
||||
tableLateralDragDirective(app)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面按钮鉴权指令
|
||||
* @description v-auth="'name'",name可以为:index,add,edit,del,...
|
||||
*/
|
||||
function authDirective(app: App) {
|
||||
app.directive('auth', {
|
||||
mounted(el, binding) {
|
||||
if (!binding.value) return false
|
||||
if (!auth(binding.value)) el.parentNode.removeChild(el)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格横向滚动指令
|
||||
* @description v-table-lateral-drag
|
||||
*/
|
||||
function tableLateralDragDirective(app: App) {
|
||||
app.directive('tableLateralDrag', {
|
||||
created(el) {
|
||||
new horizontalScroll(el.querySelector('.el-table__body-wrapper .el-scrollbar .el-scrollbar__wrap'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击后自动失焦指令
|
||||
* @description v-blur
|
||||
*/
|
||||
function blurDirective(app: App) {
|
||||
app.directive('blur', {
|
||||
mounted(el) {
|
||||
useEventListener(el, 'focus', () => el.blur())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* el-dialog 的缩放指令
|
||||
* 可以传递字符串和数组
|
||||
* 当为字符串时,传递dialog的class即可,实际被缩放的元素为'.el-dialog__body'
|
||||
* 当为数组时,参数一为句柄,参数二为实际被缩放的元素
|
||||
* @description v-zoom="'.handle-class-name'"
|
||||
* @description v-zoom="['.handle-class-name', '.zoom-dom-class-name', 句柄元素高度是否跟随缩放:默认false,句柄元素宽度是否跟随缩放:默认true]"
|
||||
*/
|
||||
function zoomDirective(app: App) {
|
||||
app.directive('zoom', {
|
||||
mounted(el, binding) {
|
||||
if (!binding.value) return false
|
||||
const zoomDomBindData = isString(binding.value) ? [binding.value, '.el-dialog__body', false, true] : binding.value
|
||||
zoomDomBindData[1] = zoomDomBindData[1] ? zoomDomBindData[1] : '.el-dialog__body'
|
||||
zoomDomBindData[2] = typeof zoomDomBindData[2] == 'undefined' ? false : zoomDomBindData[2]
|
||||
zoomDomBindData[3] = typeof zoomDomBindData[3] == 'undefined' ? true : zoomDomBindData[3]
|
||||
|
||||
nextTick(() => {
|
||||
const zoomDom = document.querySelector(zoomDomBindData[1]) as HTMLElement // 实际被缩放的元素
|
||||
const zoomDomBox = document.querySelector(zoomDomBindData[0]) as HTMLElement // 动态添加缩放句柄的元素
|
||||
const zoomHandleEl = document.createElement('div') // 缩放句柄
|
||||
zoomHandleEl.className = 'zoom-handle'
|
||||
zoomHandleEl.onmouseenter = () => {
|
||||
zoomHandleEl.onmousedown = (e: MouseEvent) => {
|
||||
const x = e.clientX
|
||||
const y = e.clientY
|
||||
const zoomDomWidth = zoomDom.offsetWidth
|
||||
const zoomDomHeight = zoomDom.offsetHeight
|
||||
const zoomDomBoxWidth = zoomDomBox.offsetWidth
|
||||
const zoomDomBoxHeight = zoomDomBox.offsetHeight
|
||||
document.onmousemove = (e: MouseEvent) => {
|
||||
e.preventDefault() // 移动时禁用默认事件
|
||||
const w = zoomDomWidth + (e.clientX - x) * 2
|
||||
const h = zoomDomHeight + (e.clientY - y)
|
||||
|
||||
zoomDom.style.width = `${w}px`
|
||||
zoomDom.style.height = `${h}px`
|
||||
|
||||
if (zoomDomBindData[2]) {
|
||||
const boxH = zoomDomBoxHeight + (e.clientY - y)
|
||||
zoomDomBox.style.height = `${boxH}px`
|
||||
}
|
||||
if (zoomDomBindData[3]) {
|
||||
const boxW = zoomDomBoxWidth + (e.clientX - x) * 2
|
||||
zoomDomBox.style.width = `${boxW}px`
|
||||
}
|
||||
}
|
||||
|
||||
document.onmouseup = function () {
|
||||
document.onmousemove = null
|
||||
document.onmouseup = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
zoomDomBox.appendChild(zoomHandleEl)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖动指令
|
||||
* @description v-drag="[domEl,handleEl]"
|
||||
* @description domEl=被拖动的元素,handleEl=在此元素内可以拖动`dom`
|
||||
*/
|
||||
interface downReturn {
|
||||
[key: string]: number
|
||||
}
|
||||
function dragDirective(app: App) {
|
||||
app.directive('drag', {
|
||||
mounted(el, binding) {
|
||||
if (!binding.value) return false
|
||||
|
||||
const dragDom = document.querySelector(binding.value[0]) as HTMLElement
|
||||
const dragHandle = document.querySelector(binding.value[1]) as HTMLElement
|
||||
|
||||
if (!dragHandle || !dragDom) {
|
||||
return false
|
||||
}
|
||||
|
||||
function down(e: MouseEvent | TouchEvent, type: string): downReturn {
|
||||
// 鼠标按下,记录鼠标位置
|
||||
const disX = type === 'pc' ? (e as MouseEvent).clientX : (e as TouchEvent).touches[0].clientX
|
||||
const disY = type === 'pc' ? (e as MouseEvent).clientY : (e as TouchEvent).touches[0].clientY
|
||||
|
||||
// body宽度
|
||||
const screenWidth = document.body.clientWidth
|
||||
const screenHeight = document.body.clientHeight || document.documentElement.clientHeight
|
||||
|
||||
// 被拖动元素宽度
|
||||
const dragDomWidth = dragDom.offsetWidth
|
||||
// 被拖动元素高度
|
||||
const dragDomheight = dragDom.offsetHeight
|
||||
|
||||
// 拖动限位
|
||||
const minDragDomLeft = dragDom.offsetLeft
|
||||
const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth
|
||||
const minDragDomTop = dragDom.offsetTop
|
||||
const maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomheight
|
||||
|
||||
// 获取到的值带px 正则匹配替换
|
||||
let styL: string | number = getComputedStyle(dragDom).left
|
||||
let styT: string | number = getComputedStyle(dragDom).top
|
||||
styL = +styL.replace(/\px/g, '')
|
||||
styT = +styT.replace(/\px/g, '')
|
||||
|
||||
return {
|
||||
disX,
|
||||
disY,
|
||||
minDragDomLeft,
|
||||
maxDragDomLeft,
|
||||
minDragDomTop,
|
||||
maxDragDomTop,
|
||||
styL,
|
||||
styT,
|
||||
}
|
||||
}
|
||||
|
||||
function move(e: MouseEvent | TouchEvent, type: string, obj: downReturn) {
|
||||
const { disX, disY, minDragDomLeft, maxDragDomLeft, minDragDomTop, maxDragDomTop, styL, styT } = obj
|
||||
|
||||
// 通过事件委托,计算移动的距离
|
||||
let left = type === 'pc' ? (e as MouseEvent).clientX - disX : (e as TouchEvent).touches[0].clientX - disX
|
||||
let top = type === 'pc' ? (e as MouseEvent).clientY - disY : (e as TouchEvent).touches[0].clientY - disY
|
||||
|
||||
// 边界处理
|
||||
if (-left > minDragDomLeft) {
|
||||
left = -minDragDomLeft
|
||||
} else if (left > maxDragDomLeft) {
|
||||
left = maxDragDomLeft
|
||||
}
|
||||
|
||||
if (-top > minDragDomTop) {
|
||||
top = -minDragDomTop
|
||||
} else if (top > maxDragDomTop) {
|
||||
top = maxDragDomTop
|
||||
}
|
||||
|
||||
// 移动当前元素
|
||||
dragDom.style.cssText += `;left:${left + styL}px;top:${top + styT}px;`
|
||||
}
|
||||
|
||||
dragHandle.onmouseover = () => (dragHandle.style.cursor = `move`)
|
||||
dragHandle.onmousedown = (e) => {
|
||||
const obj = down(e, 'pc')
|
||||
document.onmousemove = (e) => {
|
||||
move(e, 'pc', obj)
|
||||
}
|
||||
document.onmouseup = () => {
|
||||
document.onmousemove = null
|
||||
document.onmouseup = null
|
||||
}
|
||||
}
|
||||
dragHandle.ontouchstart = (e) => {
|
||||
const obj = down(e, 'app')
|
||||
document.ontouchmove = (e) => {
|
||||
move(e, 'app', obj)
|
||||
}
|
||||
document.ontouchend = () => {
|
||||
document.ontouchmove = null
|
||||
document.ontouchend = null
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
33
web/src/utils/horizontalScroll.ts
Normal file
33
web/src/utils/horizontalScroll.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 横向滚动条
|
||||
*/
|
||||
export default class horizontalScroll {
|
||||
private el: HTMLElement
|
||||
|
||||
constructor(nativeElement: HTMLElement) {
|
||||
this.el = nativeElement
|
||||
this.handleWheelEvent()
|
||||
}
|
||||
|
||||
handleWheelEvent() {
|
||||
let wheel = ''
|
||||
|
||||
if ('onmousewheel' in this.el) {
|
||||
wheel = 'mousewheel'
|
||||
} else if ('onwheel' in this.el) {
|
||||
wheel = 'wheel'
|
||||
} else if ('attachEvent' in window) {
|
||||
wheel = 'onmousewheel'
|
||||
} else {
|
||||
wheel = 'DOMMouseScroll'
|
||||
}
|
||||
this.el['addEventListener'](wheel, this.scroll, { passive: true })
|
||||
}
|
||||
|
||||
scroll = (event: any) => {
|
||||
if (this.el.clientWidth >= this.el.scrollWidth) {
|
||||
return
|
||||
}
|
||||
this.el.scrollLeft += event.deltaY ? event.deltaY : event.detail && event.detail !== 0 ? event.detail : -event.wheelDelta
|
||||
}
|
||||
}
|
||||
170
web/src/utils/iconfont.ts
Normal file
170
web/src/utils/iconfont.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { loadCss, loadJs } from './common'
|
||||
import * as elIcons from '@element-plus/icons-vue'
|
||||
import { getUrl } from '/@/utils/axios'
|
||||
|
||||
/**
|
||||
* 动态加载的 css 和 js
|
||||
*/
|
||||
const cssUrls: Array<string> = ['//at.alicdn.com/t/font_3135462_5axiswmtpj.css']
|
||||
const jsUrls: Array<string> = []
|
||||
|
||||
/*
|
||||
* 加载预设的字体图标资源
|
||||
*/
|
||||
export default function init() {
|
||||
if (cssUrls.length > 0) {
|
||||
cssUrls.map((v) => {
|
||||
loadCss(v)
|
||||
})
|
||||
}
|
||||
|
||||
if (jsUrls.length > 0) {
|
||||
jsUrls.map((v) => {
|
||||
loadJs(v)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 获取当前页面中从指定域名加载到的样式表内容
|
||||
* 样式表未载入前无法获取
|
||||
*/
|
||||
function getStylesFromDomain(domain: string) {
|
||||
const sheets = []
|
||||
const styles: StyleSheetList = document.styleSheets
|
||||
for (const key in styles) {
|
||||
if (styles[key].href && (styles[key].href as string).indexOf(domain) > -1) {
|
||||
sheets.push(styles[key])
|
||||
}
|
||||
}
|
||||
return sheets
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Vite开发服务/编译后的样式表内容
|
||||
* @param devID style 标签的 viteDevId,只开发服务有
|
||||
*/
|
||||
function getStylesFromVite(devId: string) {
|
||||
const sheets = []
|
||||
const styles: StyleSheetList = document.styleSheets
|
||||
if (import.meta.env.MODE == 'production') {
|
||||
const url = getUrl()
|
||||
for (const key in styles) {
|
||||
if (styles[key].href && styles[key].href?.indexOf(url) === 0) {
|
||||
sheets.push(styles[key])
|
||||
}
|
||||
}
|
||||
return sheets
|
||||
}
|
||||
for (const key in styles) {
|
||||
const ownerNode = styles[key].ownerNode as HTMLMapElement
|
||||
if (ownerNode && ownerNode.dataset?.viteDevId && ownerNode.dataset.viteDevId!.indexOf(devId) > -1) {
|
||||
sheets.push(styles[key])
|
||||
}
|
||||
}
|
||||
return sheets
|
||||
}
|
||||
|
||||
/*
|
||||
* 获取本地自带的图标
|
||||
* /src/assets/icons文件夹内的svg文件
|
||||
*/
|
||||
export function getLocalIconfontNames() {
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
nextTick(() => {
|
||||
let iconfonts: string[] = []
|
||||
|
||||
const svgEl = document.getElementById('local-icon')
|
||||
if (svgEl?.dataset.iconName) {
|
||||
iconfonts = (svgEl?.dataset.iconName as string).split(',')
|
||||
}
|
||||
|
||||
if (iconfonts.length > 0) {
|
||||
resolve(iconfonts)
|
||||
} else {
|
||||
reject('No Local Icons')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* 获取 Awesome-Iconfont 的 name 列表
|
||||
*/
|
||||
export function getAwesomeIconfontNames() {
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
nextTick(() => {
|
||||
const iconfonts = []
|
||||
const sheets = getStylesFromVite('font-awesome.min.css')
|
||||
for (const key in sheets) {
|
||||
const rules: any = sheets[key].cssRules
|
||||
for (const k in rules) {
|
||||
if (!rules[k].selectorText || rules[k].selectorText.indexOf('.fa-') !== 0) {
|
||||
continue
|
||||
}
|
||||
if (/^\.fa-(.*)::before$/g.test(rules[k].selectorText)) {
|
||||
if (rules[k].selectorText.indexOf(', ') > -1) {
|
||||
const iconNames = rules[k].selectorText.split(', ')
|
||||
iconfonts.push(`${iconNames[0].substring(1, iconNames[0].length).replace(/\:\:before/gi, '')}`)
|
||||
} else {
|
||||
iconfonts.push(`${rules[k].selectorText.substring(1, rules[k].selectorText.length).replace(/\:\:before/gi, '')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (iconfonts.length > 0) {
|
||||
resolve(iconfonts)
|
||||
} else {
|
||||
reject('No AwesomeIcon style sheet')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* 获取 Iconfont 的 name 列表
|
||||
*/
|
||||
export function getIconfontNames() {
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
nextTick(() => {
|
||||
const iconfonts = []
|
||||
const sheets = getStylesFromDomain('at.alicdn.com')
|
||||
for (const key in sheets) {
|
||||
const rules: any = sheets[key].cssRules
|
||||
for (const k in rules) {
|
||||
if (rules[k].selectorText && /^\.icon-(.*)::before$/g.test(rules[k].selectorText)) {
|
||||
iconfonts.push(`${rules[k].selectorText.substring(1, rules[k].selectorText.length).replace(/\:\:before/gi, '')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (iconfonts.length > 0) {
|
||||
resolve(iconfonts)
|
||||
} else {
|
||||
reject('No Iconfont style sheet')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* 获取element plus 自带的图标
|
||||
*/
|
||||
export function getElementPlusIconfontNames() {
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
nextTick(() => {
|
||||
const iconfonts = []
|
||||
const icons = elIcons as any
|
||||
for (const i in icons) {
|
||||
iconfonts.push(`el-icon-${icons[i].name}`)
|
||||
}
|
||||
if (iconfonts.length > 0) {
|
||||
resolve(iconfonts)
|
||||
} else {
|
||||
reject('No ElementPlus Icons')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
60
web/src/utils/layout.ts
Normal file
60
web/src/utils/layout.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import { useMemberCenter } from '/@/stores/memberCenter'
|
||||
import { useNavTabs } from '/@/stores/navTabs'
|
||||
import { isAdminApp } from '/@/utils/common'
|
||||
|
||||
/**
|
||||
* 管理员后台各个布局顶栏高度
|
||||
*/
|
||||
export const adminLayoutHeaderBarHeight = {
|
||||
Default: 70,
|
||||
Classic: 50,
|
||||
Streamline: 60,
|
||||
Double: 60,
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台会员中心各个布局顶栏高度
|
||||
*/
|
||||
export const userLayoutHeaderBarHeight = {
|
||||
Default: 60,
|
||||
Disable: 60,
|
||||
}
|
||||
|
||||
/**
|
||||
* main高度
|
||||
* @param extra main高度额外减去的px数,可以实现隐藏原有的滚动条
|
||||
* @returns CSSProperties
|
||||
*/
|
||||
export function mainHeight(extra = 0): CSSProperties {
|
||||
let height = extra
|
||||
if (isAdminApp()) {
|
||||
const config = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
if (!navTabs.state.tabFullScreen) {
|
||||
height += adminLayoutHeaderBarHeight[config.layout.layoutMode as keyof typeof adminLayoutHeaderBarHeight]
|
||||
}
|
||||
} else {
|
||||
const memberCenter = useMemberCenter()
|
||||
height += userLayoutHeaderBarHeight[memberCenter.state.layoutMode as keyof typeof userLayoutHeaderBarHeight]
|
||||
}
|
||||
return {
|
||||
height: 'calc(100vh - ' + height.toString() + 'px)',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置导航栏宽度
|
||||
* @returns
|
||||
*/
|
||||
export function setNavTabsWidth() {
|
||||
const navTabs = document.querySelector('.nav-tabs') as HTMLElement
|
||||
if (!navTabs) {
|
||||
return
|
||||
}
|
||||
const navBar = document.querySelector('.nav-bar') as HTMLElement
|
||||
const navMenus = document.querySelector('.nav-menus') as HTMLElement
|
||||
const minWidth = navBar.offsetWidth - (navMenus.offsetWidth + 20)
|
||||
navTabs.style.width = minWidth.toString() + 'px'
|
||||
}
|
||||
34
web/src/utils/loading.ts
Normal file
34
web/src/utils/loading.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { nextTick } from 'vue'
|
||||
import '/@/styles/loading.scss'
|
||||
|
||||
export const loading = {
|
||||
show: () => {
|
||||
const bodys: Element = document.body
|
||||
const div = document.createElement('div')
|
||||
div.className = 'block-loading'
|
||||
div.innerHTML = `
|
||||
<div class="block-loading-box">
|
||||
<div class="block-loading-box-warp">
|
||||
<div class="block-loading-box-item"></div>
|
||||
<div class="block-loading-box-item"></div>
|
||||
<div class="block-loading-box-item"></div>
|
||||
<div class="block-loading-box-item"></div>
|
||||
<div class="block-loading-box-item"></div>
|
||||
<div class="block-loading-box-item"></div>
|
||||
<div class="block-loading-box-item"></div>
|
||||
<div class="block-loading-box-item"></div>
|
||||
<div class="block-loading-box-item"></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
bodys.insertBefore(div, bodys.childNodes[0])
|
||||
},
|
||||
hide: () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
const el = document.querySelector('.block-loading')
|
||||
el && el.parentNode?.removeChild(el)
|
||||
}, 1000)
|
||||
})
|
||||
},
|
||||
}
|
||||
104
web/src/utils/pageBubble.ts
Normal file
104
web/src/utils/pageBubble.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// 页面气泡效果
|
||||
|
||||
const bubble: {
|
||||
width: number
|
||||
height: number
|
||||
bubbleEl: any
|
||||
canvas: any
|
||||
ctx: any
|
||||
circles: any[]
|
||||
animate: boolean
|
||||
requestId: any
|
||||
} = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
bubbleEl: null,
|
||||
canvas: null,
|
||||
ctx: {},
|
||||
circles: [],
|
||||
animate: true,
|
||||
requestId: null,
|
||||
}
|
||||
|
||||
export const init = function (): void {
|
||||
bubble.width = window.innerWidth
|
||||
bubble.height = window.innerHeight
|
||||
|
||||
bubble.bubbleEl = document.getElementById('bubble')
|
||||
bubble.bubbleEl.style.height = bubble.height + 'px'
|
||||
|
||||
bubble.canvas = document.getElementById('bubble-canvas')
|
||||
bubble.canvas.width = bubble.width
|
||||
bubble.canvas.height = bubble.height
|
||||
bubble.ctx = bubble.canvas.getContext('2d')
|
||||
|
||||
// create particles
|
||||
bubble.circles = []
|
||||
for (let x = 0; x < bubble.width * 0.5; x++) {
|
||||
const c = new Circle()
|
||||
bubble.circles.push(c)
|
||||
}
|
||||
animate()
|
||||
addListeners()
|
||||
}
|
||||
|
||||
function scrollCheck() {
|
||||
bubble.animate = document.body.scrollTop > bubble.height ? false : true
|
||||
}
|
||||
|
||||
function resize() {
|
||||
bubble.width = window.innerWidth
|
||||
bubble.height = window.innerHeight
|
||||
bubble.bubbleEl.style.height = bubble.height + 'px'
|
||||
bubble.canvas.width = bubble.width
|
||||
bubble.canvas.height = bubble.height
|
||||
}
|
||||
|
||||
function animate() {
|
||||
if (bubble.animate) {
|
||||
bubble.ctx.clearRect(0, 0, bubble.width, bubble.height)
|
||||
for (const i in bubble.circles) {
|
||||
bubble.circles[i].draw()
|
||||
}
|
||||
}
|
||||
bubble.requestId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
class Circle {
|
||||
pos: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
alpha: number
|
||||
scale: number
|
||||
velocity: number
|
||||
draw: () => void
|
||||
constructor() {
|
||||
this.pos = {
|
||||
x: Math.random() * bubble.width,
|
||||
y: bubble.height + Math.random() * 100,
|
||||
}
|
||||
this.alpha = 0.1 + Math.random() * 0.3
|
||||
this.scale = 0.1 + Math.random() * 0.3
|
||||
this.velocity = Math.random()
|
||||
this.draw = function () {
|
||||
this.pos.y -= this.velocity
|
||||
this.alpha -= 0.0005
|
||||
bubble.ctx.beginPath()
|
||||
bubble.ctx.arc(this.pos.x, this.pos.y, this.scale * 10, 0, 2 * Math.PI, false)
|
||||
bubble.ctx.fillStyle = 'rgba(255,255,255,' + this.alpha + ')'
|
||||
bubble.ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addListeners() {
|
||||
window.addEventListener('scroll', scrollCheck)
|
||||
window.addEventListener('resize', resize)
|
||||
}
|
||||
|
||||
export function removeListeners() {
|
||||
window.removeEventListener('scroll', scrollCheck)
|
||||
window.removeEventListener('resize', resize)
|
||||
cancelAnimationFrame(bubble.requestId)
|
||||
}
|
||||
22
web/src/utils/pageShade.ts
Normal file
22
web/src/utils/pageShade.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
/*
|
||||
* 显示页面遮罩
|
||||
*/
|
||||
export const showShade = function (className = 'shade', closeCallBack: Function): void {
|
||||
const containerEl = document.querySelector('.layout-container') as HTMLElement
|
||||
const shadeDiv = document.createElement('div')
|
||||
shadeDiv.setAttribute('class', 'ba-layout-shade ' + className)
|
||||
containerEl.appendChild(shadeDiv)
|
||||
useEventListener(shadeDiv, 'click', () => closeShade(closeCallBack))
|
||||
}
|
||||
|
||||
/*
|
||||
* 隐藏页面遮罩
|
||||
*/
|
||||
export const closeShade = function (closeCallBack: Function = () => {}): void {
|
||||
const shadeEl = document.querySelector('.ba-layout-shade') as HTMLElement
|
||||
shadeEl && shadeEl.remove()
|
||||
|
||||
closeCallBack()
|
||||
}
|
||||
57
web/src/utils/random.ts
Normal file
57
web/src/utils/random.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
const hexList: string[] = []
|
||||
for (let i = 0; i <= 15; i++) {
|
||||
hexList[i] = i.toString(16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机数
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns 生成的随机数
|
||||
*/
|
||||
export function randomNum(min: number, max: number) {
|
||||
switch (arguments.length) {
|
||||
case 1:
|
||||
return parseInt((Math.random() * min + 1).toString(), 10)
|
||||
break
|
||||
case 2:
|
||||
return parseInt((Math.random() * (max - min + 1) + min).toString(), 10)
|
||||
break
|
||||
default:
|
||||
return 0
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成全球唯一标识
|
||||
* @returns uuid
|
||||
*/
|
||||
export function uuid(): string {
|
||||
let uuid = ''
|
||||
for (let i = 1; i <= 36; i++) {
|
||||
if (i === 9 || i === 14 || i === 19 || i === 24) {
|
||||
uuid += '-'
|
||||
} else if (i === 15) {
|
||||
uuid += 4
|
||||
} else if (i === 20) {
|
||||
uuid += hexList[(Math.random() * 4) | 8]
|
||||
} else {
|
||||
uuid += hexList[(Math.random() * 16) | 0]
|
||||
}
|
||||
}
|
||||
return uuid
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一标识
|
||||
* @param prefix 前缀
|
||||
* @returns 唯一标识
|
||||
*/
|
||||
export function shortUuid(prefix = ''): string {
|
||||
const time = Date.now()
|
||||
const random = Math.floor(Math.random() * 1000000000)
|
||||
if (!window.unique) window.unique = 0
|
||||
window.unique++
|
||||
return prefix + '_' + random + window.unique + String(time)
|
||||
}
|
||||
318
web/src/utils/router.ts
Normal file
318
web/src/utils/router.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { compact, isEmpty, reverse } from 'lodash-es'
|
||||
import type { RouteLocationRaw, RouteRecordRaw } from 'vue-router'
|
||||
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
|
||||
import { i18n } from '/@/lang/index'
|
||||
import router from '/@/router/index'
|
||||
import adminBaseRoute from '/@/router/static/adminBase'
|
||||
import memberCenterBaseRoute from '/@/router/static/memberCenterBase'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import { useMemberCenter } from '/@/stores/memberCenter'
|
||||
import { useNavTabs } from '/@/stores/navTabs'
|
||||
import { useSiteConfig } from '/@/stores/siteConfig'
|
||||
import { isAdminApp } from '/@/utils/common'
|
||||
import { closeShade } from '/@/utils/pageShade'
|
||||
|
||||
/**
|
||||
* 导航失败有错误消息的路由push
|
||||
* @param to — 导航位置,同 router.push
|
||||
*/
|
||||
export const routePush = async (to: RouteLocationRaw) => {
|
||||
try {
|
||||
const failure = await router.push(to)
|
||||
if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
|
||||
ElNotification({
|
||||
message: i18n.global.t('utils.Navigation failed, navigation guard intercepted!'),
|
||||
type: 'error',
|
||||
})
|
||||
} else if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
||||
ElNotification({
|
||||
message: i18n.global.t('utils.Navigation failed, it is at the navigation target position!'),
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
ElNotification({
|
||||
message: i18n.global.t('utils.Navigation failed, invalid route!'),
|
||||
type: 'error',
|
||||
})
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第一个菜单
|
||||
*/
|
||||
export const getFirstRoute = (routes: RouteRecordRaw[]): false | RouteRecordRaw => {
|
||||
const routerPaths: string[] = []
|
||||
const routers = router.getRoutes()
|
||||
routers.forEach((item) => {
|
||||
if (item.path) routerPaths.push(item.path)
|
||||
})
|
||||
let find: boolean | RouteRecordRaw = false
|
||||
for (const key in routes) {
|
||||
if (routes[key].meta?.type == 'menu' && routerPaths.indexOf(routes[key].path) !== -1) {
|
||||
return routes[key]
|
||||
} else if (routes[key].children && routes[key].children?.length) {
|
||||
find = getFirstRoute(routes[key].children!)
|
||||
if (find) return find
|
||||
}
|
||||
}
|
||||
return find
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开侧边菜单
|
||||
* @param menu 菜单数据
|
||||
*/
|
||||
export const onClickMenu = (menu: RouteRecordRaw) => {
|
||||
switch (menu.meta?.menu_type) {
|
||||
case 'iframe':
|
||||
case 'tab':
|
||||
routePush(menu.path)
|
||||
break
|
||||
case 'link':
|
||||
window.open(menu.path, '_blank')
|
||||
break
|
||||
|
||||
default:
|
||||
ElNotification({
|
||||
message: i18n.global.t('utils.Navigation failed, the menu type is unrecognized!'),
|
||||
type: 'error',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const config = useConfig()
|
||||
if (config.layout.shrink) {
|
||||
closeShade(() => {
|
||||
config.setLayout('menuCollapse', true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理前台的路由
|
||||
* @param routes 路由规则
|
||||
* @param menus 会员中心菜单路由规则
|
||||
*/
|
||||
export const handleFrontendRoute = (routes: any, menus: any) => {
|
||||
const siteConfig = useSiteConfig()
|
||||
const memberCenter = useMemberCenter()
|
||||
const viewsComponent = import.meta.glob('/src/views/frontend/**/*.vue')
|
||||
|
||||
if (routes.length) {
|
||||
addRouteAll(viewsComponent, routes, '', true)
|
||||
memberCenter.mergeAuthNode(handleAuthNode(routes, '/'))
|
||||
siteConfig.setHeadNav(handleMenuRule(routes, '/', ['nav']))
|
||||
memberCenter.mergeNavUserMenus(handleMenuRule(routes, '/', ['nav_user_menu']))
|
||||
}
|
||||
if (menus.length && isEmpty(memberCenter.state.viewRoutes)) {
|
||||
addRouteAll(viewsComponent, menus, memberCenterBaseRoute.name as string)
|
||||
const menuMemberCenterBaseRoute = (memberCenterBaseRoute.path as string) + '/'
|
||||
memberCenter.mergeAuthNode(handleAuthNode(menus, menuMemberCenterBaseRoute))
|
||||
|
||||
memberCenter.mergeNavUserMenus(handleMenuRule(menus, '/', ['nav_user_menu']))
|
||||
memberCenter.setShowHeadline(menus.length > 1)
|
||||
memberCenter.setViewRoutes(handleMenuRule(menus, menuMemberCenterBaseRoute))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理后台的路由
|
||||
*/
|
||||
export const handleAdminRoute = (routes: any) => {
|
||||
const viewsComponent = import.meta.glob('/src/views/backend/**/*.vue')
|
||||
addRouteAll(viewsComponent, routes, adminBaseRoute.name as string)
|
||||
const menuAdminBaseRoute = (adminBaseRoute.path as string) + '/'
|
||||
|
||||
// 更新stores中的路由菜单数据
|
||||
const navTabs = useNavTabs()
|
||||
navTabs.setTabsViewRoutes(handleMenuRule(routes, menuAdminBaseRoute))
|
||||
navTabs.fillAuthNode(handleAuthNode(routes, menuAdminBaseRoute))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单的paths
|
||||
*/
|
||||
export const getMenuPaths = (menus: RouteRecordRaw[]): string[] => {
|
||||
let menuPaths: string[] = []
|
||||
menus.forEach((item) => {
|
||||
menuPaths.push(item.path)
|
||||
if (item.children && item.children.length > 0) {
|
||||
menuPaths = menuPaths.concat(getMenuPaths(item.children))
|
||||
}
|
||||
})
|
||||
return menuPaths
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单唯一标识
|
||||
* @param menu 菜单数据
|
||||
* @param prefix 前缀
|
||||
*/
|
||||
export const getMenuKey = (menu: RouteRecordRaw, prefix = '') => {
|
||||
if (prefix === '') {
|
||||
prefix = menu.path
|
||||
}
|
||||
return `${prefix}-${menu.name as string}-${menu.meta && menu.meta.id ? menu.meta.id : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员中心和后台的菜单处理
|
||||
*/
|
||||
const handleMenuRule = (routes: any, pathPrefix = '/', type = ['menu', 'menu_dir']) => {
|
||||
const menuRule: RouteRecordRaw[] = []
|
||||
for (const key in routes) {
|
||||
if (routes[key].extend == 'add_rules_only') {
|
||||
continue
|
||||
}
|
||||
if (!type.includes(routes[key].type)) {
|
||||
continue
|
||||
}
|
||||
if (routes[key].type == 'menu_dir' && routes[key].children && !routes[key].children.length) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
['route', 'menu', 'nav_user_menu', 'nav'].includes(routes[key].type) &&
|
||||
((routes[key].menu_type == 'tab' && !routes[key].component) || (['link', 'iframe'].includes(routes[key].menu_type) && !routes[key].url))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const currentPath = ['link', 'iframe'].includes(routes[key].menu_type) ? routes[key].url : pathPrefix + routes[key].path
|
||||
let children: RouteRecordRaw[] = []
|
||||
if (routes[key].children && routes[key].children.length > 0) {
|
||||
children = handleMenuRule(routes[key].children, pathPrefix, type)
|
||||
}
|
||||
menuRule.push({
|
||||
path: currentPath,
|
||||
name: routes[key].name,
|
||||
component: routes[key].component,
|
||||
meta: {
|
||||
id: routes[key].id,
|
||||
title: routes[key].title,
|
||||
icon: routes[key].icon,
|
||||
keepalive: routes[key].keepalive,
|
||||
menu_type: routes[key].menu_type,
|
||||
type: routes[key].type,
|
||||
},
|
||||
children: children,
|
||||
})
|
||||
}
|
||||
return menuRule
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理权限节点
|
||||
* @param routes 路由数据
|
||||
* @param prefix 节点前缀
|
||||
* @returns 组装好的权限节点
|
||||
*/
|
||||
const handleAuthNode = (routes: any, prefix = '/') => {
|
||||
const authNode: Map<string, string[]> = new Map([])
|
||||
assembleAuthNode(routes, authNode, prefix, prefix)
|
||||
return authNode
|
||||
}
|
||||
const assembleAuthNode = (routes: any, authNode: Map<string, string[]>, prefix = '/', parent = '/') => {
|
||||
const authNodeTemp = []
|
||||
for (const key in routes) {
|
||||
if (routes[key].type == 'button') authNodeTemp.push(prefix + routes[key].name)
|
||||
if (routes[key].children && routes[key].children.length > 0) {
|
||||
assembleAuthNode(routes[key].children, authNode, prefix, prefix + routes[key].name)
|
||||
}
|
||||
}
|
||||
if (authNodeTemp && authNodeTemp.length > 0) {
|
||||
authNode.set(parent, authNodeTemp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态添加路由-带子路由
|
||||
* @param viewsComponent
|
||||
* @param routes
|
||||
* @param parentName
|
||||
* @param analyticRelation 根据 name 从已注册路由分析父级路由
|
||||
*/
|
||||
export const addRouteAll = (viewsComponent: Record<string, any>, routes: any, parentName: string, analyticRelation = false) => {
|
||||
for (const idx in routes) {
|
||||
if (routes[idx].extend == 'add_menu_only') {
|
||||
continue
|
||||
}
|
||||
if ((routes[idx].menu_type == 'tab' && viewsComponent[routes[idx].component]) || routes[idx].menu_type == 'iframe') {
|
||||
addRouteItem(viewsComponent, routes[idx], parentName, analyticRelation)
|
||||
}
|
||||
|
||||
if (routes[idx].children && routes[idx].children.length > 0) {
|
||||
addRouteAll(viewsComponent, routes[idx].children, parentName, analyticRelation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态添加路由
|
||||
* @param viewsComponent
|
||||
* @param route
|
||||
* @param parentName
|
||||
* @param analyticRelation 根据 name 从已注册路由分析父级路由
|
||||
*/
|
||||
export const addRouteItem = (viewsComponent: Record<string, any>, route: any, parentName: string, analyticRelation: boolean) => {
|
||||
let path = '',
|
||||
component
|
||||
if (route.menu_type == 'iframe') {
|
||||
path = (isAdminApp() ? adminBaseRoute.path : memberCenterBaseRoute.path) + '/iframe/' + encodeURIComponent(route.url)
|
||||
component = () => import('/@/layouts/common/router-view/iframe.vue')
|
||||
} else {
|
||||
path = parentName ? route.path : '/' + route.path
|
||||
component = viewsComponent[route.component]
|
||||
}
|
||||
|
||||
if (route.menu_type == 'tab' && analyticRelation) {
|
||||
const parentNames = getParentNames(route.name)
|
||||
if (parentNames.length) {
|
||||
for (const key in parentNames) {
|
||||
if (router.hasRoute(parentNames[key])) {
|
||||
parentName = parentNames[key]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const routeBaseInfo: RouteRecordRaw = {
|
||||
path: path,
|
||||
name: route.name,
|
||||
component: component,
|
||||
meta: {
|
||||
title: route.title,
|
||||
extend: route.extend,
|
||||
icon: route.icon,
|
||||
keepalive: route.keepalive,
|
||||
menu_type: route.menu_type,
|
||||
type: route.type,
|
||||
url: route.url,
|
||||
addtab: true,
|
||||
},
|
||||
}
|
||||
if (parentName) {
|
||||
router.addRoute(parentName, routeBaseInfo)
|
||||
} else {
|
||||
router.addRoute(routeBaseInfo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据name字符串,获取父级name组合的数组
|
||||
* @param name
|
||||
*/
|
||||
const getParentNames = (name: string) => {
|
||||
const names = compact(name.split('/'))
|
||||
const tempNames = []
|
||||
const parentNames = []
|
||||
for (const key in names) {
|
||||
tempNames.push(names[key])
|
||||
if (parseInt(key) != names.length - 1) {
|
||||
parentNames.push(tempNames.join('/'))
|
||||
}
|
||||
}
|
||||
return reverse(parentNames)
|
||||
}
|
||||
45
web/src/utils/storage.ts
Normal file
45
web/src/utils/storage.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* window.localStorage
|
||||
* @method set 设置
|
||||
* @method get 获取
|
||||
* @method remove 移除
|
||||
* @method clear 移除全部
|
||||
*/
|
||||
export const Local = {
|
||||
set(key: string, val: any) {
|
||||
window.localStorage.setItem(key, JSON.stringify(val))
|
||||
},
|
||||
get(key: string) {
|
||||
const json: any = window.localStorage.getItem(key)
|
||||
return JSON.parse(json)
|
||||
},
|
||||
remove(key: string) {
|
||||
window.localStorage.removeItem(key)
|
||||
},
|
||||
clear() {
|
||||
window.localStorage.clear()
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* window.sessionStorage
|
||||
* @method set 设置会话缓存
|
||||
* @method get 获取会话缓存
|
||||
* @method remove 移除会话缓存
|
||||
* @method clear 移除全部会话缓存
|
||||
*/
|
||||
export const Session = {
|
||||
set(key: string, val: any) {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(val))
|
||||
},
|
||||
get(key: string) {
|
||||
const json: any = window.sessionStorage.getItem(key)
|
||||
return JSON.parse(json)
|
||||
},
|
||||
remove(key: string) {
|
||||
window.sessionStorage.removeItem(key)
|
||||
},
|
||||
clear() {
|
||||
window.sessionStorage.clear()
|
||||
},
|
||||
}
|
||||
13
web/src/utils/useCurrentInstance.ts
Normal file
13
web/src/utils/useCurrentInstance.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import type { ComponentInternalInstance } from 'vue'
|
||||
|
||||
export default function useCurrentInstance() {
|
||||
if (!getCurrentInstance()) {
|
||||
throw new Error('useCurrentInstance() can only be used inside setup() or functional components!')
|
||||
}
|
||||
const { appContext } = getCurrentInstance() as ComponentInternalInstance
|
||||
const proxy = appContext.config.globalProperties
|
||||
return {
|
||||
proxy,
|
||||
}
|
||||
}
|
||||
49
web/src/utils/useDark.ts
Normal file
49
web/src/utils/useDark.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const isDark = useDark({
|
||||
onChanged(dark: boolean) {
|
||||
const config = useConfig()
|
||||
updateHtmlDarkClass(dark)
|
||||
config.setLayout('isDark', dark)
|
||||
config.onSetLayoutColor()
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 切换暗黑模式
|
||||
*/
|
||||
const toggleDark = useToggle(isDark)
|
||||
|
||||
/**
|
||||
* 切换当前页面的暗黑模式
|
||||
*/
|
||||
export function togglePageDark(val: boolean) {
|
||||
const config = useConfig()
|
||||
const isDark = ref(config.layout.isDark)
|
||||
onMounted(() => {
|
||||
if (isDark.value !== val) updateHtmlDarkClass(val)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
updateHtmlDarkClass(isDark.value)
|
||||
})
|
||||
watch(
|
||||
() => config.layout.isDark,
|
||||
(newVal) => {
|
||||
isDark.value = newVal
|
||||
if (isDark.value !== val) updateHtmlDarkClass(val)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function updateHtmlDarkClass(val: boolean) {
|
||||
const htmlEl = document.getElementsByTagName('html')[0]
|
||||
if (val) {
|
||||
htmlEl.setAttribute('class', 'dark')
|
||||
} else {
|
||||
htmlEl.setAttribute('class', '')
|
||||
}
|
||||
}
|
||||
|
||||
export default toggleDark
|
||||
169
web/src/utils/validate.ts
Normal file
169
web/src/utils/validate.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { RuleType } from 'async-validator'
|
||||
import type { FormItemRule } from 'element-plus'
|
||||
import { i18n } from '../lang'
|
||||
|
||||
/**
|
||||
* 手机号码验证
|
||||
*/
|
||||
export function validatorMobile(rule: any, mobile: string | number, callback: Function) {
|
||||
// 允许空值,若需必填请添加多验证规则
|
||||
if (!mobile) {
|
||||
return callback()
|
||||
}
|
||||
if (!/^(1[3-9])\d{9}$/.test(mobile.toString())) {
|
||||
return callback(new Error(i18n.global.t('validate.Please enter the correct mobile number')))
|
||||
}
|
||||
return callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份证号验证
|
||||
*/
|
||||
export function validatorIdNumber(rule: any, idNumber: string | number, callback: Function) {
|
||||
if (!idNumber) {
|
||||
return callback()
|
||||
}
|
||||
if (!/(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/.test(idNumber.toString())) {
|
||||
return callback(new Error(i18n.global.t('validate.Please enter the correct ID number')))
|
||||
}
|
||||
return callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户名验证
|
||||
*/
|
||||
export function validatorAccount(rule: any, val: string, callback: Function) {
|
||||
if (!val) {
|
||||
return callback()
|
||||
}
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/.test(val)) {
|
||||
return callback(new Error(i18n.global.t('validate.Please enter the correct account')))
|
||||
}
|
||||
return callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码验证
|
||||
*/
|
||||
export function regularPassword(val: string) {
|
||||
return /^(?!.*[&<>"'\n\r]).{6,32}$/.test(val)
|
||||
}
|
||||
export function validatorPassword(rule: any, val: string, callback: Function) {
|
||||
if (!val) {
|
||||
return callback()
|
||||
}
|
||||
if (!regularPassword(val)) {
|
||||
return callback(new Error(i18n.global.t('validate.Please enter the correct password')))
|
||||
}
|
||||
return callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* 变量名验证
|
||||
*/
|
||||
export function regularVarName(val: string) {
|
||||
return /^([^\x00-\xff]|[a-zA-Z_$])([^\x00-\xff]|[a-zA-Z0-9_$])*$/.test(val)
|
||||
}
|
||||
export function validatorVarName(rule: any, val: string, callback: Function) {
|
||||
if (!val) {
|
||||
return callback()
|
||||
}
|
||||
if (!regularVarName(val)) {
|
||||
return callback(new Error(i18n.global.t('validate.Please enter the correct name')))
|
||||
}
|
||||
return callback()
|
||||
}
|
||||
|
||||
export function validatorEditorRequired(rule: any, val: string, callback: Function) {
|
||||
if (!val || val == '<p><br></p>') {
|
||||
return callback(new Error(i18n.global.t('validate.Content cannot be empty')))
|
||||
}
|
||||
return callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持的表单验证规则
|
||||
*/
|
||||
export const validatorType = {
|
||||
required: i18n.global.t('validate.required'),
|
||||
mobile: i18n.global.t('utils.mobile'),
|
||||
idNumber: i18n.global.t('utils.Id number'),
|
||||
account: i18n.global.t('utils.account'),
|
||||
password: i18n.global.t('utils.password'),
|
||||
varName: i18n.global.t('utils.variable name'),
|
||||
editorRequired: i18n.global.t('validate.editor required'),
|
||||
url: 'URL',
|
||||
email: i18n.global.t('utils.email'),
|
||||
date: i18n.global.t('utils.date'),
|
||||
number: i18n.global.t('validate.number'), // 数字(包括浮点和整数)
|
||||
integer: i18n.global.t('validate.integer'), // 整数(不包括浮点数)
|
||||
float: i18n.global.t('validate.float'), // 浮点数(不包括整数)
|
||||
}
|
||||
|
||||
export interface buildValidatorParams {
|
||||
// 规则名:required=必填,mobile=手机号,idNumber=身份证号,account=账户,password=密码,varName=变量名,editorRequired=富文本必填,number、integer、float、date、url、email
|
||||
name:
|
||||
| 'required'
|
||||
| 'mobile'
|
||||
| 'idNumber'
|
||||
| 'account'
|
||||
| 'password'
|
||||
| 'varName'
|
||||
| 'editorRequired'
|
||||
| 'number'
|
||||
| 'integer'
|
||||
| 'float'
|
||||
| 'date'
|
||||
| 'url'
|
||||
| 'email'
|
||||
// 自定义验证错误消息
|
||||
message?: string
|
||||
// 验证项的标题,这些验证方式不支持:mobile、account、password、varName、editorRequired
|
||||
title?: string
|
||||
// 验证触发方式
|
||||
trigger?: 'change' | 'blur'
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建表单验证规则
|
||||
* @param {buildValidatorParams} paramsObj 参数对象
|
||||
*/
|
||||
export function buildValidatorData({ name, message, title, trigger = 'blur' }: buildValidatorParams): FormItemRule {
|
||||
// 必填
|
||||
if (name == 'required') {
|
||||
return {
|
||||
required: true,
|
||||
message: message ? message : i18n.global.t('Please input field', { field: title }),
|
||||
trigger: trigger,
|
||||
}
|
||||
}
|
||||
|
||||
// 常见类型
|
||||
const validatorType = ['number', 'integer', 'float', 'date', 'url', 'email']
|
||||
if (validatorType.includes(name)) {
|
||||
return {
|
||||
type: name as RuleType,
|
||||
message: message ? message : i18n.global.t('Please enter the correct field', { field: title }),
|
||||
trigger: trigger,
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义验证方法
|
||||
const validatorCustomFun: anyObj = {
|
||||
mobile: validatorMobile,
|
||||
idNumber: validatorIdNumber,
|
||||
account: validatorAccount,
|
||||
password: validatorPassword,
|
||||
varName: validatorVarName,
|
||||
editorRequired: validatorEditorRequired,
|
||||
}
|
||||
if (validatorCustomFun[name]) {
|
||||
return {
|
||||
required: name == 'editorRequired' ? true : false,
|
||||
validator: validatorCustomFun[name],
|
||||
trigger: trigger,
|
||||
message: message,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
184
web/src/utils/vite.ts
Normal file
184
web/src/utils/vite.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { Plugin, ViteDevServer } from 'vite'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
interface HotUpdateState {
|
||||
// 热更新状态
|
||||
switch: boolean
|
||||
// 热更新关闭类型:terminal=WEB终端执行命令,crud=CRUD,modules=模块安装服务,config=修改系统配置
|
||||
closeType: string
|
||||
// 是否有脏文件(热更新 switch 为 false,又触发了热更新就会产生脏文件)
|
||||
dirtyFile: boolean
|
||||
// 监听是否有脏文件
|
||||
listenDirtyFileSwitch: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试模式下的 Vite 热更新相关状态(这些状态均由 Vite 服务器记录并随时同步至客户端)
|
||||
*/
|
||||
export const hotUpdateState = reactive<HotUpdateState>({
|
||||
switch: true,
|
||||
closeType: '',
|
||||
dirtyFile: false,
|
||||
listenDirtyFileSwitch: true,
|
||||
})
|
||||
|
||||
/**
|
||||
* Vite 相关初始化
|
||||
*/
|
||||
export function init() {
|
||||
if (import.meta.hot) {
|
||||
// 监听 Vite 服务器通知热更新相关状态更新
|
||||
import.meta.hot.on('custom:change-hot-update-state', (state: Partial<HotUpdateState>) => {
|
||||
hotUpdateState.switch = state.switch ?? hotUpdateState.switch
|
||||
hotUpdateState.closeType = state.closeType ?? hotUpdateState.closeType
|
||||
hotUpdateState.dirtyFile = state.dirtyFile ?? hotUpdateState.dirtyFile
|
||||
hotUpdateState.listenDirtyFileSwitch = state.listenDirtyFileSwitch ?? hotUpdateState.listenDirtyFileSwitch
|
||||
})
|
||||
|
||||
// 保持脏文件监听功能开启(同时可以从服务端同步一次热更新服务的状态数据)
|
||||
changeListenDirtyFileSwitch(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否在开发环境
|
||||
*/
|
||||
export function isDev(mode: string): boolean {
|
||||
return mode === 'development'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否在生产环境
|
||||
*/
|
||||
export function isProd(mode: string | undefined): boolean {
|
||||
return mode === 'production'
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试模式下关闭热更新
|
||||
*/
|
||||
export const closeHotUpdate = (type: string) => {
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.send('custom:close-hot', { type })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试模式下开启热更新
|
||||
*/
|
||||
export const openHotUpdate = (type: string) => {
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.send('custom:open-hot', { type })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试模式下重启服务并刷新网页
|
||||
*/
|
||||
export const reloadServer = (type: string) => {
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.send('custom:reload-server', { type })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 改变脏文件监听功能的开关
|
||||
*/
|
||||
export const changeListenDirtyFileSwitch = (status: boolean) => {
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.send('custom:change-listen-dirty-file-switch', status)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义热更新/热替换处理的 Vite 插件
|
||||
*/
|
||||
export const customHotUpdate = (): Plugin => {
|
||||
type Listeners = ((...args: any[]) => void)[]
|
||||
|
||||
let addFunctionBack: Listeners = []
|
||||
let unlinkFunctionBack: Listeners = []
|
||||
|
||||
// 本服务端的热更新状态数据
|
||||
const hotUpdateState: HotUpdateState = {
|
||||
switch: true,
|
||||
closeType: '',
|
||||
dirtyFile: false,
|
||||
listenDirtyFileSwitch: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步所有热更新状态数据至客户端
|
||||
*/
|
||||
const syncHotUpdateState = (server: ViteDevServer) => {
|
||||
server.ws.send('custom:change-hot-update-state', hotUpdateState)
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-custom-hot-update',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
// 关闭热更新
|
||||
server.ws.on('custom:close-hot', ({ type }) => {
|
||||
hotUpdateState.switch = false
|
||||
hotUpdateState.closeType = type
|
||||
|
||||
// 备份文件添加和删除时的函数列表
|
||||
addFunctionBack = server.watcher.listeners('add') as Listeners
|
||||
unlinkFunctionBack = server.watcher.listeners('unlink') as Listeners
|
||||
|
||||
// 关闭文件添加和删除的监听
|
||||
server.watcher.removeAllListeners('add')
|
||||
server.watcher.removeAllListeners('unlink')
|
||||
|
||||
syncHotUpdateState(server)
|
||||
|
||||
// 文件添加时通知客户端新增了脏文件(文件删除无需记录为脏文件)
|
||||
server.watcher.on('add', () => {
|
||||
if (hotUpdateState.listenDirtyFileSwitch) {
|
||||
hotUpdateState.dirtyFile = true
|
||||
syncHotUpdateState(server)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 开启热更新
|
||||
server.ws.on('custom:open-hot', () => {
|
||||
hotUpdateState.switch = true
|
||||
server.watcher.removeAllListeners('add')
|
||||
server.watcher.removeAllListeners('unlink')
|
||||
|
||||
// 恢复备份的函数列表
|
||||
for (const key in addFunctionBack) {
|
||||
server.watcher.on('add', addFunctionBack[key])
|
||||
}
|
||||
for (const key in unlinkFunctionBack) {
|
||||
server.watcher.on('unlink', unlinkFunctionBack[key])
|
||||
}
|
||||
|
||||
syncHotUpdateState(server)
|
||||
})
|
||||
|
||||
// 重启热更新
|
||||
server.ws.on('custom:reload-server', () => {
|
||||
server.restart()
|
||||
})
|
||||
|
||||
// 客户端可从本服务端获取热更新服务状态数据
|
||||
server.ws.on('custom:get-hot-update-state', () => {
|
||||
syncHotUpdateState(server)
|
||||
})
|
||||
|
||||
// 修改监听脏文件的开关
|
||||
server.ws.on('custom:change-listen-dirty-file-switch', (status: boolean) => {
|
||||
hotUpdateState.listenDirtyFileSwitch = status
|
||||
syncHotUpdateState(server)
|
||||
})
|
||||
},
|
||||
handleHotUpdate() {
|
||||
if (!hotUpdateState.switch) {
|
||||
return []
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user