项目初始化

This commit is contained in:
2026-03-18 15:54:43 +08:00
commit dfcd762e23
601 changed files with 57883 additions and 0 deletions

383
web/src/utils/axios.ts Normal file
View 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
View 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
View 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
View 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
View 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
}
}
},
})
}

View 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
View 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
View 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
View 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
View 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)
}

View 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
View 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
View 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
View 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()
},
}

View 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
View 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
View 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
View 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 []
}
},
}
}